vendor: import KillerPDF 1.7.5 source (GPL-3.0) as the base for MMD PDF
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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="" 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="" 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="" ToolTip="{DynamicResource Str_TT_ShowHidden}" Click="ShowHidden_Click" Margin="0,0,6,0"/>
|
||||
<Button x:Name="ViewListBtn" Style="{StaticResource PickerViewBtn}" Content="" ToolTip="{DynamicResource Str_TT_ViewList}" Click="ViewList_Click"/>
|
||||
<Button x:Name="ViewIconsBtn" Style="{StaticResource PickerViewBtn}" Content="" ToolTip="{DynamicResource Str_TT_ViewIcons}" Click="ViewIcons_Click" Margin="2,0"/>
|
||||
<Button x:Name="ViewDetailsBtn" Style="{StaticResource PickerViewBtn}" Content="" 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="" 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="" 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="" 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=""
|
||||
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
@@ -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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="" 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="" 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="" 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="" 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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,213 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using PdfSharpCore.Pdf;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// The viewer's outward surface: what the window still calls into.
|
||||
///
|
||||
/// WHY A FACADE RATHER THAN WIDENING. Roughly 55 of these are private members of the seven
|
||||
/// moved files. Making them internal in place would mean 55 edits scattered through code that
|
||||
/// is otherwise VERBATIM - and "verbatim" is the property that makes the move reviewable by
|
||||
/// diff. This file is part of the same partial class, so it can see those privates and
|
||||
/// re-expose them without touching a line of the moved code.
|
||||
///
|
||||
/// The `Ext` suffix exists only because a wrapper cannot share a name with the member it wraps
|
||||
/// inside one class. Members that were already internal (RenderAllAnnotations, ClearSelection,
|
||||
/// ClearTextSelection, AccentBrush) are absent here - the window calls those directly.
|
||||
///
|
||||
/// Every entry is one call site away from deletion: when a caller moves into the viewer, its
|
||||
/// line here goes with it.
|
||||
/// </summary>
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ── Annotations: selection, hit-testing, geometry ────────────────────────────────────
|
||||
internal void AddAnnotationExt(PageAnnotation a) => AddAnnotation(a);
|
||||
internal Rect AnnotBoundsExt(PageAnnotation a) => AnnotBounds(a);
|
||||
internal static Point AnnotGetPosExt(PageAnnotation a) => AnnotGetPos(a);
|
||||
internal static void AnnotSetPosExt(PageAnnotation a, Point pos) => AnnotSetPos(a, pos);
|
||||
internal Point ClampAnnotPosExt(PageAnnotation a) => ClampAnnotPos(a);
|
||||
internal bool HitTestAnnotationExt(PageAnnotation a, Point pos, out Rect bounds)
|
||||
=> HitTestAnnotation(a, pos, out bounds);
|
||||
internal static bool IsDraggableExt(PageAnnotation a) => IsDraggable(a);
|
||||
internal void SelectAnnotationExt(PageAnnotation a, Rect bounds) => SelectAnnotation(a, bounds);
|
||||
internal void ToggleMultiSelectExt(PageAnnotation a, Rect bounds, Canvas canvas)
|
||||
=> ToggleMultiSelect(a, bounds, canvas);
|
||||
internal void SelectGroupExt(PageAnnotation lead) => SelectGroup(lead);
|
||||
internal PageAnnotation? SelectedPairedExt() => SelectedPaired();
|
||||
internal int SelectionCountExt() => SelectionCount();
|
||||
internal void ReattachSelectionVisualsExt() => ReattachSelectionVisuals();
|
||||
internal void UnpairSelectedExt() => UnpairSelected();
|
||||
internal void GroupSelectedExt() => GroupSelected();
|
||||
internal void UngroupAnnotationExt(PageAnnotation a) => UngroupAnnotation(a);
|
||||
internal void RemoveFromGroupExt(PageAnnotation a) => RemoveFromGroup(a);
|
||||
internal void DeleteSelectedExt() => DeleteSelected();
|
||||
internal bool SelectAllAnnotationsExt() => SelectAllAnnotations();
|
||||
internal void HideBrushPreviewExt() => HideBrushPreview();
|
||||
internal void FinishStuckGestureExt() => FinishStuckGesture();
|
||||
internal void RefreshSelectionAccentExt() => RefreshSelectionAccent();
|
||||
|
||||
// ── Page canvases ────────────────────────────────────────────────────────────────────
|
||||
internal Canvas CanvasForPageExt(int page) => CanvasForPage(page);
|
||||
internal Canvas? VisibleCanvasForPageExt(int page) => VisibleCanvasForPage(page);
|
||||
internal IEnumerable<Canvas> AllPageCanvasesExt() => AllPageCanvases();
|
||||
|
||||
// ── Undo / commands bound from MainWindow.xaml and the context menu ──────────────────
|
||||
internal void PushDocUndoExt() => PushDocUndo();
|
||||
internal void PushPageSnapshotUndoExt(int pageIdx) => PushPageSnapshotUndo(pageIdx);
|
||||
internal void UndoClickExt(object sender, RoutedEventArgs e) => Undo_Click(sender, e);
|
||||
internal void RedoClickExt(object sender, RoutedEventArgs e) => Redo_Click(sender, e);
|
||||
internal void ClearAnnotationsClickExt(object sender, RoutedEventArgs e) => ClearAnnotations_Click(sender, e);
|
||||
internal void ClearAllAnnotationsClickExt(object sender, RoutedEventArgs e) => ClearAllAnnotations_Click(sender, e);
|
||||
|
||||
// ── Text editing ─────────────────────────────────────────────────────────────────────
|
||||
internal void CommitActiveTextBoxExt() => CommitActiveTextBox();
|
||||
internal void RemoveTextEditHandlesExt() => RemoveTextEditHandles();
|
||||
internal void EditTextAtPositionExt(Point canvasPos, int pageIdx) => EditTextAtPosition(canvasPos, pageIdx);
|
||||
internal void PlaceTextBoxExt(Point pos, int pageIdx) => PlaceTextBox(pos, pageIdx);
|
||||
internal Brush TextEditBackgroundExt() => TextEditBackground();
|
||||
internal static ControlTemplate FlatTextBoxTemplateExt() => FlatTextBoxTemplate();
|
||||
|
||||
// ── Text selection ───────────────────────────────────────────────────────────────────
|
||||
internal void CopySelectedTextExt() => CopySelectedText();
|
||||
internal void SelectAllTextExt() => SelectAllText();
|
||||
|
||||
// ── Crop ─────────────────────────────────────────────────────────────────────────────
|
||||
internal void ApplyCropExt(int[] pageIndices) => ApplyCrop(pageIndices);
|
||||
internal void HideCropConfirmBarExt() => HideCropConfirmBar();
|
||||
internal void ShowDefaultCropBoxExt() => ShowDefaultCropBox();
|
||||
internal void RebuildCropBarForLocaleExt() => RebuildCropBarForLocale();
|
||||
|
||||
// ── Links ────────────────────────────────────────────────────────────────────────────
|
||||
internal void CloseLinkPdfiumDocExt() => CloseLinkPdfiumDoc();
|
||||
internal void AddLinkMenuItemsExt(ContextMenu menu, object target, int annotIndex, int pageIndex)
|
||||
=> AddLinkMenuItems(menu, target, annotIndex, pageIndex);
|
||||
internal int? ResolveDestExt(PdfItem? destItem) => ResolveDest(destItem);
|
||||
internal bool IsPanning => _isPanning;
|
||||
internal EditTool CurrentToolRef { get => _currentTool; set => _currentTool = value; }
|
||||
internal PdfDocument? DocumentRef { get => _doc; set => _doc = value; }
|
||||
internal string? CurrentFileRef { get => _currentFile; set => _currentFile = value; }
|
||||
internal Dictionary<int, List<PageAnnotation>> AnnotationsRef { get => _annotations; set => _annotations = value; }
|
||||
internal Dictionary<int, (int w, int h)> RenderDimsRef { get => _renderDims; set => _renderDims = value; }
|
||||
internal Dictionary<int, int> PageRotationsRef { get => _pageRotations; set => _pageRotations = value; }
|
||||
internal bool IsDrawingRef { get => _isDrawing; set => _isDrawing = value; }
|
||||
internal Point DrawStartRef { get => _drawStart; set => _drawStart = value; }
|
||||
internal UIElement? ActivePreviewRef { get => _activePreview; set => _activePreview = value; }
|
||||
internal System.Windows.Shapes.Rectangle? CropPreviewRectRef { get => _cropPreviewRect; set => _cropPreviewRect = value; }
|
||||
internal Border? CropConfirmBarRef { get => _cropConfirmBar; set => _cropConfirmBar = value; }
|
||||
internal PageAnnotation? SelectedAnnotationRef { get => _selectedAnnotation; set => _selectedAnnotation = value; }
|
||||
internal Border? SelectionBorderRef { get => _selectionBorder; set => _selectionBorder = value; }
|
||||
internal List<PageAnnotation> SelectedSetRef => _selectedSet;
|
||||
internal List<Border> SelectionOutlinesRef => _selectionOutlines;
|
||||
internal System.Windows.Shapes.Rectangle? PairedCoverOutlineRef { get => _pairedCoverOutline; set => _pairedCoverOutline = value; }
|
||||
internal System.Windows.Shapes.Rectangle? ReeditCoverOutlineRef { get => _reeditCoverOutline; set => _reeditCoverOutline = value; }
|
||||
internal string? SelectedTextRef { get => _selectedText; set => _selectedText = value; }
|
||||
internal List<(PageAnnotation a, Point orig)> DragGroupOrigRef => _dragGroupOrig;
|
||||
internal Color DrawColorRef { get => _drawColor; set => _drawColor = value; }
|
||||
internal double DrawWidthRef { get => _drawWidth; set => _drawWidth = value; }
|
||||
internal byte DrawOpacityRef { get => _drawOpacity; set => _drawOpacity = value; }
|
||||
internal bool LineLevelRef { get => _lineLevel; set => _lineLevel = value; }
|
||||
internal bool HighlightEraseRef { get => _highlightErase; set => _highlightErase = value; }
|
||||
internal bool DrawEraseRef { get => _drawErase; set => _drawErase = value; }
|
||||
internal Color HighlightColorRef { get => _highlightColor; set => _highlightColor = value; }
|
||||
internal Color LineAnnotColorRef { get => _lineAnnotColor; set => _lineAnnotColor = value; }
|
||||
internal InkAnnotation? ActiveInkRef { get => _activeInk; set => _activeInk = value; }
|
||||
internal TextBox? ActiveTextBoxRef { get => _activeTextBox; set => _activeTextBox = value; }
|
||||
internal double TextFontSizeRef { get => _textFontSize; set => _textFontSize = value; }
|
||||
internal string TextFontNameRef { get => _textFontName; set => _textFontName = value; }
|
||||
internal bool TextBoldRef { get => _textBold; set => _textBold = value; }
|
||||
internal bool TextItalicRef { get => _textItalic; set => _textItalic = value; }
|
||||
internal bool TextStrikeRef { get => _textStrike; set => _textStrike = value; }
|
||||
internal bool TextUnderlineRef { get => _textUnderline; set => _textUnderline = value; }
|
||||
internal Color TextColorRef { get => _textColor; set => _textColor = value; }
|
||||
internal byte TextOpacityRef { get => _textOpacity; set => _textOpacity = value; }
|
||||
internal Color TextFillColorRef { get => _textFillColor; set => _textFillColor = value; }
|
||||
internal TextAnnotation? ReeditOriginalRef { get => _reeditOriginal; set => _reeditOriginal = value; }
|
||||
internal CoverAnnotation? PendingCoverRef { get => _pendingCover; set => _pendingCover = value; }
|
||||
internal bool PendingEditWasDirtyRef { get => _pendingEditWasDirty; set => _pendingEditWasDirty = value; }
|
||||
internal Border? TextSettingsBarRef { get => _textSettingsBar; set => _textSettingsBar = value; }
|
||||
internal bool IsResizingSigRef { get => _isResizingSig; set => _isResizingSig = value; }
|
||||
internal Point ResizeSigStartRef { get => _resizeSigStart; set => _resizeSigStart = value; }
|
||||
internal double ResizeSigStartScaleRef { get => _resizeSigStartScale; set => _resizeSigStartScale = value; }
|
||||
internal PlacedAnnotation? ResizeSigAnnotRef { get => _resizeSigAnnot; set => _resizeSigAnnot = value; }
|
||||
internal TextAnnotation? ResizeTextAnnotRef { get => _resizeTextAnnot; set => _resizeTextAnnot = value; }
|
||||
internal HighlightAnnotation? ResizeHlAnnotRef { get => _resizeHlAnnot; set => _resizeHlAnnot = value; }
|
||||
internal InkAnnotation? ResizeInkAnnotRef { get => _resizeInkAnnot; set => _resizeInkAnnot = value; }
|
||||
internal List<Point>? ResizeInkOrigPointsRef { get => _resizeInkOrigPoints; set => _resizeInkOrigPoints = value; }
|
||||
internal Rect ResizeInkOrigBoundsRef { get => _resizeInkOrigBounds; set => _resizeInkOrigBounds = value; }
|
||||
internal List<System.Windows.Shapes.Rectangle> ResizeHandlesRef => _resizeHandles;
|
||||
internal string ResizeCornerRef { get => _resizeCorner; set => _resizeCorner = value; }
|
||||
internal Point ResizeAnchorRef { get => _resizeAnchor; set => _resizeAnchor = value; }
|
||||
internal List<System.Windows.Shapes.Rectangle> TextEditHandlesRef => _textEditHandles;
|
||||
internal bool DraggingTextEditHandleRef { get => _draggingTextEditHandle; set => _draggingTextEditHandle = value; }
|
||||
internal string TehCornerRef { get => _tehCorner; set => _tehCorner = value; }
|
||||
internal Point TehAnchorRef { get => _tehAnchor; set => _tehAnchor = value; }
|
||||
internal TextBox? TehBoxRef { get => _tehBox; set => _tehBox = value; }
|
||||
internal bool IsDraggingAnnotRef { get => _isDraggingAnnot; set => _isDraggingAnnot = value; }
|
||||
internal Point DragAnnotStartRef { get => _dragAnnotStart; set => _dragAnnotStart = value; }
|
||||
internal Point DragAnnotOrigPosRef { get => _dragAnnotOrigPos; set => _dragAnnotOrigPos = value; }
|
||||
internal PageAnnotation? DragAnnotRef { get => _dragAnnot; set => _dragAnnot = value; }
|
||||
internal Rect CropCanvasRectRef { get => _cropCanvasRect; set => _cropCanvasRect = value; }
|
||||
internal System.Windows.Shapes.Rectangle? CropPreviewRectBorderRef { get => _cropPreviewRectBorder; set => _cropPreviewRectBorder = value; }
|
||||
internal List<System.Windows.Shapes.Path> CropBracketsRef => _cropBrackets;
|
||||
internal List<System.Windows.Shapes.Rectangle> CropHandlesRef => _cropHandles;
|
||||
internal string? ActiveCropHandleTagRef { get => _activeCropHandleTag; set => _activeCropHandleTag = value; }
|
||||
internal Point CropHandleDragStartRef { get => _cropHandleDragStart; set => _cropHandleDragStart = value; }
|
||||
internal Rect CropRectAtHandleDragRef { get => _cropRectAtHandleDrag; set => _cropRectAtHandleDrag = value; }
|
||||
internal TextBox? CropXBoxRef { get => _cropXBox; set => _cropXBox = value; }
|
||||
internal TextBox? CropYBoxRef { get => _cropYBox; set => _cropYBox = value; }
|
||||
internal TextBox? CropWBoxRef { get => _cropWBox; set => _cropWBox = value; }
|
||||
internal TextBox? CropHBoxRef { get => _cropHBox; set => _cropHBox = value; }
|
||||
internal TextBox? CropRangeBoxRef { get => _cropRangeBox; set => _cropRangeBox = value; }
|
||||
internal string CropUnitRef { get => _cropUnit; set => _cropUnit = value; }
|
||||
internal bool UpdatingCropInputsRef { get => _updatingCropInputs; set => _updatingCropInputs = value; }
|
||||
internal Dictionary<int, string> FormTextValuesRef { get => _formTextValues; set => _formTextValues = value; }
|
||||
internal Dictionary<int, bool> FormCheckValuesRef { get => _formCheckValues; set => _formCheckValues = value; }
|
||||
internal Dictionary<string, string> FormRadioValuesRef { get => _formRadioValues; set => _formRadioValues = value; }
|
||||
internal Dictionary<int, double> FormFontSizesRef { get => _formFontSizes; set => _formFontSizes = value; }
|
||||
internal Border? FormSizeBarRef { get => _formSizeBar; set => _formSizeBar = value; }
|
||||
internal TextBox? ActiveFormTbRef { get => _activeFormTb; set => _activeFormTb = value; }
|
||||
internal int ActiveFormObjRef { get => _activeFormObj; set => _activeFormObj = value; }
|
||||
internal double ActiveFormScaleRef { get => _activeFormScale; set => _activeFormScale = value; }
|
||||
internal Stack<UndoEntry> UndoStackRef { get => _undoStack; set => _undoStack = value; }
|
||||
internal Stack<UndoEntry> RedoStackRef { get => _redoStack; set => _redoStack = value; }
|
||||
internal bool IsDirtyRef { get => _isDirty; set => _isDirty = value; }
|
||||
internal string? OriginalFileRef { get => _originalFile; set => _originalFile = value; }
|
||||
internal bool OpenedFromProtectedRef { get => _openedFromProtected; set => _openedFromProtected = value; }
|
||||
internal bool AsyncOpenPendingRef { get => _asyncOpenPending; set => _asyncOpenPending = value; }
|
||||
internal Stack<int> NavBackRef => _navBack;
|
||||
internal Stack<int> NavForwardRef => _navForward;
|
||||
internal bool OcrRegionModeRef { get => _ocrRegionMode; set => _ocrRegionMode = value; }
|
||||
internal SavedSignature? PendingSignatureRef { get => _pendingSignature; set => _pendingSignature = value; }
|
||||
internal List<Point> ShapePolyPointsRef => _shapePolyPoints;
|
||||
internal EditTool? AnnotBarToolRef { get => _annotBarTool; set => _annotBarTool = value; }
|
||||
internal bool AnnotBarMinimizedRef { get => _annotBarMinimized; set => _annotBarMinimized = value; }
|
||||
internal List<FrameworkElement> AnnotBarDragInnersRef => _annotBarDragInners;
|
||||
|
||||
// ── Save paths ───────────────────────────────────────────────────────────────────────
|
||||
internal void DrawAnnotationsOnDocumentExt(int? onlyPage = null) => DrawAnnotationsOnDocument(onlyPage);
|
||||
internal void WriteFormValuesToDocumentExt() => WriteFormValuesToDocument();
|
||||
|
||||
// ── Handlers bound from MainWindow.xaml ──────────────────────────────────────────────
|
||||
// WPF resolves Click="X" against the XAML root's code-behind, which is still MainWindow, so
|
||||
// these keep working only because MainWindowViewerStubs.cs re-declares each name and points
|
||||
// it here. Moving them without that would throw XamlParseException at startup.
|
||||
internal void PageJumpBoxKeyDownExt(object sender, KeyEventArgs e) => PageJumpBox_KeyDown(sender, e);
|
||||
internal void PageJumpBoxGotFocusExt(object sender, RoutedEventArgs e) => PageJumpBox_GotFocus(sender, e);
|
||||
internal void PageListSelectionChangedExt(object sender, SelectionChangedEventArgs e)
|
||||
=> PageList_SelectionChanged(sender, e);
|
||||
internal void ShortcutHelpClickExt(object sender, RoutedEventArgs e) => ShortcutHelp_Click(sender, e);
|
||||
internal void ShortcutOverlayMouseDownExt(object sender, MouseButtonEventArgs e)
|
||||
=> ShortcutOverlay_MouseLeftButtonDown(sender, e);
|
||||
internal void ShortcutOverlayCardMouseDownExt(object sender, MouseButtonEventArgs e)
|
||||
=> ShortcutOverlayCard_MouseLeftButtonDown(sender, e);
|
||||
internal void ShortcutOverlayCloseClickExt(object sender, RoutedEventArgs e)
|
||||
=> ShortcutOverlayClose_Click(sender, e);
|
||||
internal void HyperlinkRequestNavigateExt(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
|
||||
=> Hyperlink_RequestNavigate(sender, e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using PdfSharpCore.Pdf;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Transitional forwards used by the moved render pipeline while the remaining bridge surface
|
||||
/// is converted to IViewerHost and per-document state.
|
||||
///
|
||||
/// WHY THIS FILE EXISTS. PdfViewer.Viewport.cs and PdfViewer.Zoom.cs were moved across VERBATIM -
|
||||
/// roughly 2,100 lines carrying about 700 references to window members spelled bare (PageList,
|
||||
/// _doc, Loc, RenderAllAnnotations...). Rewriting those 700 sites in the same change that moved
|
||||
/// the files would have been unreviewable. Declaring the names here instead means the moved
|
||||
/// files did not change a character of logic, and the entire coupling surface between viewer
|
||||
/// and window is one readable list.
|
||||
///
|
||||
/// THIS IS SCAFFOLDING, NOT THE DESTINATION. Read the groups below as a to-do list:
|
||||
/// - Group B is per-DOCUMENT state that belongs in DocumentSession. When the viewer holds
|
||||
/// its own active session, that block deletes itself.
|
||||
/// - Group C members live in files that have not moved into the viewer. Each deletes itself
|
||||
/// as its defining file arrives; the defining file is named against every one.
|
||||
/// - Group A is the only group meant to survive, and it should end up expressed as
|
||||
/// IViewerHost rather than as raw Owner reach.
|
||||
///
|
||||
/// Host is null only between construction and the window wiring it up, which happens in the
|
||||
/// MainWindow constructor before any of this can run. The null-forgiving operator is therefore
|
||||
/// deliberate: a null here is a wiring bug and should throw loudly, not render nothing.
|
||||
/// </summary>
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ── The view's own state ─────────────────────────────────────────────────────────────
|
||||
// Not forwards: this viewer OWNS its ViewerState (see PdfViewer.xaml.cs). These mirror the
|
||||
// window's forwarding properties one for one, so the moved code reads identically.
|
||||
private ViewMode _viewMode { get => State.Mode; set => State.Mode = value; }
|
||||
private ViewMode? _pendingViewMode { get => State.Pending; set => State.Pending = value; }
|
||||
private double _zoomLevel { get => State.ZoomLevel; set => State.ZoomLevel = value; }
|
||||
private double _lastRenderZoom { get => State.LastRenderZoom; set => State.LastRenderZoom = value; }
|
||||
private int _renderedPrimaryPage { get => State.RenderedPrimaryPage; set => State.RenderedPrimaryPage = value; }
|
||||
private FitMode _fitMode { get => State.Fit; set => State.Fit = value; }
|
||||
private System.Windows.Threading.DispatcherTimer? _rerenderTimer { get => State.RerenderTimer; set => State.RerenderTimer = value; }
|
||||
private System.Threading.CancellationTokenSource? _secondaryRenderCts { get => State.SecondaryRenderCts; set => State.SecondaryRenderCts = value; }
|
||||
private System.Threading.CancellationTokenSource? _continuousRenderCts { get => State.ContinuousRenderCts; set => State.ContinuousRenderCts = value; }
|
||||
private System.Threading.CancellationTokenSource? _continuousSharpenCts { get => State.ContinuousSharpenCts; set => State.ContinuousSharpenCts = value; }
|
||||
private HashSet<int> _continuousSharpPages => State.ContinuousSharpPages;
|
||||
private int _continuousSharpW { get => State.ContinuousSharpW; set => State.ContinuousSharpW = value; }
|
||||
private List<double> _continuousTops => State.ContinuousTops;
|
||||
private int _gridScrollToPage { get => State.GridScrollToPage; set => State.GridScrollToPage = value; }
|
||||
private int _continuousScrollTarget { get => State.ContinuousScrollTarget; set => State.ContinuousScrollTarget = value; }
|
||||
private double _continuousPageW { get => State.ContinuousPageW; set => State.ContinuousPageW = value; }
|
||||
private Dictionary<int, Canvas> _pages => State.Pages;
|
||||
private Dictionary<int, Canvas> _continuousCanvases => State.ContinuousCanvases;
|
||||
private Canvas _annotationCanvas { get => State.AnnotationCanvas; set => State.AnnotationCanvas = value; }
|
||||
private Canvas _activeCanvas { get => State.ActiveCanvas; set => State.ActiveCanvas = value; }
|
||||
private Canvas? _gestureCanvas { get => State.GestureCanvas; set => State.GestureCanvas = value; }
|
||||
private int _gesturePage { get => State.GesturePage; set => State.GesturePage = value; }
|
||||
private Image PageImage { get => State.PageImage; set => State.PageImage = value; }
|
||||
private StackPanel _continuousPanel { get => State.ContinuousPanel; set => State.ContinuousPanel = value; }
|
||||
private WrapPanel _pageContentPanel { get => State.PageContentPanel; set => State.PageContentPanel = value; }
|
||||
private Grid _pageContentGrid { get => State.PageContentGrid; set => State.PageContentGrid = value; }
|
||||
private int _currentPage
|
||||
{
|
||||
get => State.CurrentPage;
|
||||
set
|
||||
{
|
||||
State.CurrentPage = value;
|
||||
Host?.ViewerPageChanged(this, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group A: host chrome and services ────────────────────────────────────────────────
|
||||
// One toolbar, one sidebar, one status line serving both panes. These are the forwards
|
||||
// meant to survive, and they should become direct IViewerHost calls.
|
||||
|
||||
private string Loc(string key) => Host!.Loc(key);
|
||||
private void SetStatus(string text) => Host!.SetStatus(text);
|
||||
private void RepositionAnnotationBars() => Host!.RepositionAnnotationBars();
|
||||
|
||||
private EditTool _currentTool = EditTool.Select;
|
||||
private bool _fullScreen => Host!.FullScreen;
|
||||
private bool _vScrollVisible { get => Host!.VerticalScrollVisible; set => Host!.VerticalScrollVisible = value; }
|
||||
private bool _spaceHeld => Host!.SpaceHeld;
|
||||
|
||||
// Zoom limits stay defined on the window: MainWindow.xaml.cs and KeyboardShortcuts.cs read
|
||||
// them too, and a const aliases at no cost rather than being duplicated.
|
||||
private const double ZoomMin = MainWindow.ZoomMin;
|
||||
private const double ZoomMax = MainWindow.ZoomMax;
|
||||
private const double ZoomStep = MainWindow.ZoomStep;
|
||||
|
||||
// ── Group B: per-document state ──────────────────────────────────────────────────────
|
||||
// NOT host services. Every one of these already rides in DocumentSession, which tab
|
||||
// switching swaps by reference. They forward for now because the window still owns the
|
||||
// active session; when the viewer holds its own, this whole block goes.
|
||||
private PdfDocument? _doc;
|
||||
private string? _currentFile;
|
||||
// Settable: the tab switch rebinds all three by reference.
|
||||
private Dictionary<int, List<PageAnnotation>> _annotations = [];
|
||||
private Dictionary<int, (int w, int h)> _renderDims = [];
|
||||
private Dictionary<int, int> _pageRotations = [];
|
||||
// _active is NOT forwarded: the session list lives in this class, so this pane owns its own
|
||||
// active document. The window reads it back via ActiveSession.
|
||||
private readonly List<Canvas> _linkOverlays = [];
|
||||
// _continuousLinks is no longer forwarded - the field itself arrived with Links.cs and the
|
||||
// viewer owns it now. ContextMenu.cs and FileOperations.cs read it from the window side
|
||||
// through MainWindowViewerBridge's ContinuousLinksRef, which points back here.
|
||||
|
||||
// Live gesture state shared with the annotation and crop tools, which have not moved yet.
|
||||
private bool _isPanning;
|
||||
private Point _panStart;
|
||||
private double _panScrollH;
|
||||
private double _panScrollV;
|
||||
private bool _isDrawing;
|
||||
private Point _drawStart;
|
||||
private UIElement? _activePreview;
|
||||
private bool _isSelecting;
|
||||
private Point _selectStart;
|
||||
private Rectangle? _selectRect;
|
||||
private int _cropPageIndex = -1;
|
||||
// Crop.cs and Annotations.cs ASSIGN both, so these go through the settable pair on the
|
||||
// window side rather than a get-only forward.
|
||||
private Rectangle? _cropPreviewRect;
|
||||
private Border? _cropConfirmBar;
|
||||
|
||||
// ── Group C: methods in partials that have NOT moved yet ─────────────────────────────
|
||||
// Only four are left - the ones whose defining files stay on the window. RenderAllAnnotations,
|
||||
// ClearSelection, UpdateMarquee, IsDescendantOf, the four Canvas_Mouse* handlers,
|
||||
// ClearTextSelection, AccentBrush, RenderPageLinks, AddSecondaryPageLinks and the
|
||||
// PageList_SelectionChanged delegate are real members of this class now.
|
||||
private void PopulateContextMenu(Point pt, int page) => Host!.PopulateContextMenu(this, pt, page);
|
||||
private void RefreshPageList() => Host!.RefreshPageList(this);
|
||||
private void LoadOutlines() => Host!.LoadOutlines(this);
|
||||
private Cursor CursorForTool(EditTool t) => Host!.CursorForTool(t);
|
||||
|
||||
// The render cache is not forwarded either - TryGetCachedRender / CacheRender are real
|
||||
// members of this class. They work per-pane unchanged: the cache is keyed
|
||||
// (page, bucket, rot) and both accessors take the session explicitly, so
|
||||
// two panes at different zooms simply occupy different buckets of their own session's cache.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Editing state and narrow shell-facing adapters owned by each viewer instance. Annotations,
|
||||
/// text editing, crop, forms, links, selection and the current tool remain independent between
|
||||
/// panes; only window chrome is routed through the host.
|
||||
/// </summary>
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ── Selection ────────────────────────────────────────────────────────────────────────
|
||||
private PageAnnotation? _selectedAnnotation;
|
||||
private Border? _selectionBorder;
|
||||
private readonly List<PageAnnotation> _selectedSet = [];
|
||||
private readonly List<Border> _selectionOutlines = [];
|
||||
private Rectangle? _pairedCoverOutline;
|
||||
private Rectangle? _reeditCoverOutline;
|
||||
private string? _selectedText;
|
||||
private readonly List<(PageAnnotation a, Point orig)> _dragGroupOrig = [];
|
||||
|
||||
// ── Draw / highlight tool ────────────────────────────────────────────────────────────
|
||||
private Color _drawColor = Colors.Red;
|
||||
private double _drawWidth = 3;
|
||||
private byte _drawOpacity = 255;
|
||||
private bool _lineLevel = true;
|
||||
private bool _highlightErase;
|
||||
private bool _drawErase;
|
||||
private Color _highlightColor = Color.FromArgb(80, 255, 255, 0);
|
||||
private Color _lineAnnotColor = Color.FromArgb(255, 220, 38, 38);
|
||||
private InkAnnotation? _activeInk;
|
||||
|
||||
// ── Text (typewriter) tool ───────────────────────────────────────────────────────────
|
||||
private TextBox? _activeTextBox;
|
||||
private double _textFontSize = 24;
|
||||
private string _textFontName = "Segoe UI";
|
||||
private bool _textBold;
|
||||
private bool _textItalic;
|
||||
private bool _textStrike;
|
||||
private bool _textUnderline;
|
||||
private Color _textColor = Colors.Black;
|
||||
private byte _textOpacity = 255;
|
||||
private Color _textFillColor = Color.FromArgb(0, 255, 255, 255);
|
||||
private TextAnnotation? _reeditOriginal;
|
||||
private CoverAnnotation? _pendingCover;
|
||||
private bool _pendingEditWasDirty;
|
||||
private Border? _textSettingsBar;
|
||||
private const double EditTextSizeCorrection = 0.8;
|
||||
private const double TextBoxDefaultWidth = 220;
|
||||
|
||||
// ── Resize handles ───────────────────────────────────────────────────────────────────
|
||||
private bool _isResizingSig;
|
||||
private Point _resizeSigStart;
|
||||
private double _resizeSigStartScale;
|
||||
private PlacedAnnotation? _resizeSigAnnot;
|
||||
private TextAnnotation? _resizeTextAnnot;
|
||||
private HighlightAnnotation? _resizeHlAnnot;
|
||||
private InkAnnotation? _resizeInkAnnot;
|
||||
private List<Point>? _resizeInkOrigPoints;
|
||||
private Rect _resizeInkOrigBounds;
|
||||
private readonly List<Rectangle> _resizeHandles = [];
|
||||
private string _resizeCorner = "SE";
|
||||
private Point _resizeAnchor;
|
||||
|
||||
private readonly List<Rectangle> _textEditHandles = [];
|
||||
private bool _draggingTextEditHandle;
|
||||
private string _tehCorner = "SE";
|
||||
private Point _tehAnchor;
|
||||
private TextBox? _tehBox;
|
||||
|
||||
// ── Drag-to-move ─────────────────────────────────────────────────────────────────────
|
||||
private bool _isDraggingAnnot;
|
||||
private Point _dragAnnotStart;
|
||||
private Point _dragAnnotOrigPos;
|
||||
private PageAnnotation? _dragAnnot;
|
||||
|
||||
// ── Crop tool ────────────────────────────────────────────────────────────────────────
|
||||
private Rect _cropCanvasRect;
|
||||
private Rectangle? _cropPreviewRectBorder;
|
||||
private readonly List<System.Windows.Shapes.Path> _cropBrackets = [];
|
||||
private readonly List<Rectangle> _cropHandles = [];
|
||||
private string? _activeCropHandleTag;
|
||||
private Point _cropHandleDragStart;
|
||||
private Rect _cropRectAtHandleDrag;
|
||||
private TextBox? _cropXBox;
|
||||
private TextBox? _cropYBox;
|
||||
private TextBox? _cropWBox;
|
||||
private TextBox? _cropHBox;
|
||||
private TextBox? _cropRangeBox;
|
||||
private string _cropUnit = "pt";
|
||||
private bool _updatingCropInputs;
|
||||
|
||||
// ── Form filling ─────────────────────────────────────────────────────────────────────
|
||||
private Dictionary<int, string> _formTextValues = [];
|
||||
private Dictionary<int, bool> _formCheckValues = [];
|
||||
private Dictionary<string, string> _formRadioValues = [];
|
||||
private Dictionary<int, double> _formFontSizes = [];
|
||||
private Border? _formSizeBar;
|
||||
private TextBox? _activeFormTb;
|
||||
private int _activeFormObj;
|
||||
private double _activeFormScale = 1;
|
||||
private const string FormOverlayTag = "FormFieldOverlay";
|
||||
|
||||
// ── Undo / dirty ─────────────────────────────────────────────────────────────────────
|
||||
private Stack<UndoEntry> _undoStack = new();
|
||||
private Stack<UndoEntry> _redoStack = new();
|
||||
private bool _isDirty;
|
||||
|
||||
// ── State owned by files that did not move ───────────────────────────────────────────
|
||||
private Border? _searchBar => Host!.SearchBar;
|
||||
private Features.SearchController Search => Host!.Search;
|
||||
private bool _ocrRegionMode;
|
||||
private SavedSignature? _pendingSignature;
|
||||
private readonly List<Point> _shapePolyPoints = [];
|
||||
private EditTool? _annotBarTool;
|
||||
private bool _annotBarMinimized;
|
||||
private readonly List<FrameworkElement> _annotBarDragInners = [];
|
||||
private SolidColorBrush _swatchDimBorder => Host!.SwatchDimBorder;
|
||||
|
||||
// ══ What Tabs.cs reaches for, now that it lives here ════════════════════════════════
|
||||
private string? _originalFile;
|
||||
private bool _openedFromProtected;
|
||||
private bool _asyncOpenPending;
|
||||
// This pane's own loader token, NOT the window's - see ThumbCts in PdfViewer.TabsApi.cs.
|
||||
private System.Threading.CancellationTokenSource? _thumbCts { get => ThumbCts; set => ThumbCts = value; }
|
||||
private bool _sidebarShowingOutlines => Host!.SidebarShowingOutlines;
|
||||
private readonly System.Collections.Generic.Stack<int> _navBack = new();
|
||||
private readonly System.Collections.Generic.Stack<int> _navForward = new();
|
||||
|
||||
private TextBlock FileNameLabel => Host!.FileNameLabel;
|
||||
private TreeView OutlineTree => Host!.OutlineTree;
|
||||
private Button SidebarOutlinesTab => Host!.SidebarOutlinesTab;
|
||||
|
||||
private ContextMenu MakeThemedMenu() => Host!.MakeThemedMenu();
|
||||
private void CloseSearchBar() => Host!.CloseSearchBar();
|
||||
private void HideSignaturePopup() => Host!.HideSignaturePopup();
|
||||
private void PopulateRecentFilesList() => Host!.PopulateRecentFilesList(this);
|
||||
private void SwitchSidebarToPagesTab() => Host!.SwitchSidebarToPagesTab();
|
||||
private void SyncSidebarToDocState(bool hasDoc, bool startup) => Host!.SyncSidebarToDocState(hasDoc, startup);
|
||||
private void OpenFile(string path) => Host!.OpenFile(path);
|
||||
private void UpdateFooterFade() => Host!.UpdateFooterFade();
|
||||
private void UpdateTabStripFade() => Host!.UpdateTabStripFade();
|
||||
|
||||
// ── Chrome ───────────────────────────────────────────────────────────────────────────
|
||||
private TextBlock StatusText => Host!.StatusText;
|
||||
private FrameworkElement ShortcutOverlay => Host!.ShortcutOverlay;
|
||||
private CheckBox LinkConfirmCheck => Host!.LinkConfirmCheck;
|
||||
|
||||
// ── Methods still on the window ──────────────────────────────────────────────────────
|
||||
private void MarkDirty(bool dirty = true) => Host!.MarkDirty(dirty);
|
||||
private void SetTool(EditTool t) => Host!.SetTool(t);
|
||||
private void SaveTempAndReload(bool keepAnnotations = false, bool preserveZoom = false)
|
||||
=> Host!.SaveTempAndReload(keepAnnotations, preserveZoom);
|
||||
private void RecordNavJump() => Host!.RecordNavJump();
|
||||
private PageAnnotation? CloneAnnotation(PageAnnotation a) => Host!.CloneAnnotation(a);
|
||||
private PageAnnotation? PairPartner(PageAnnotation a) => Host!.PairPartner(a);
|
||||
private void RenderStamps(int page) => Host!.RenderStamps(page);
|
||||
private void OpenStampTool() => Host!.OpenStampTool();
|
||||
private bool StampHitTest(int page, Point pos) => Host!.StampHitTest(page, pos);
|
||||
private void ApplySearchHighlights(int page, Canvas canvas) => Host!.ApplySearchHighlights(page, canvas);
|
||||
private void HighlightSearchResultsOnCurrentPage() => Host!.HighlightSearchResultsOnCurrentPage();
|
||||
private void ShowTextSettings() => Host!.ShowTextSettings();
|
||||
private void HideTextSettings() => Host!.HideTextSettings();
|
||||
private void StyleEditBox(TextBox tb) => Host!.StyleEditBox(tb);
|
||||
private void ApplyTextStyleToSelection() => Host!.ApplyTextStyleToSelection();
|
||||
private TextDecorationCollection? BuildDecorations(bool underline, bool strike)
|
||||
=> Host!.BuildDecorations(underline, strike);
|
||||
private void ShowDrawSettings(EditTool t) => Host!.ShowDrawSettings(t);
|
||||
private void HideDrawSettings() => Host!.HideDrawSettings();
|
||||
private Border MakeBarGrip(int dotCount = 3) => Host!.MakeBarGrip(dotCount);
|
||||
private FrameworkElement BuildBarHost(FrameworkElement content) => Host!.BuildBarHost(content);
|
||||
private void PlaceAnnotationBar(Border bar, Border grip, bool fadeIn = false)
|
||||
=> Host!.PlaceAnnotationBar(bar, grip, fadeIn);
|
||||
private System.Windows.Media.Effects.DropShadowEffect AnnotBarShadow() => Host!.AnnotBarShadow();
|
||||
private void PlaceImageFromDialog(Point pos, int pageIdx) => Host!.PlaceImageFromDialog(pos, pageIdx);
|
||||
private void PlaceSignature(Point pos, int pageIdx) => Host!.PlaceSignature(pos, pageIdx);
|
||||
private void ShowSignaturePopup() => Host!.ShowSignaturePopup();
|
||||
private void FillSignField(bool initials, int objNum, int pageIndex,
|
||||
double x, double y, double w, double h)
|
||||
=> Host!.FillSignField(initials, objNum, pageIndex, x, y, w, h);
|
||||
private void ShapeToolMouseDown(int pageIdx, Point pos, MouseButtonEventArgs e)
|
||||
=> Host!.ShapeToolMouseDown(pageIdx, pos, e);
|
||||
private void CommitShapeDrag(int pageIdx) => Host!.CommitShapeDrag(pageIdx);
|
||||
private void UpdateShapePolyRubber(MouseEventArgs e) => Host!.UpdateShapePolyRubber(e);
|
||||
private void OcrRegion(int pageIdx, Rect canvasBounds) => Host!.OcrRegion(pageIdx, canvasBounds);
|
||||
private void ShowShortcutsOverlayExclusive() => Host!.ShowShortcutsOverlayExclusive();
|
||||
private void FadeOverlayOut(UIElement el) => Host!.FadeOverlayOut(el);
|
||||
private void FadeOutAndRemoveBar(Border? bar) => Host!.FadeOutAndRemoveBar(bar);
|
||||
private PdfSharpCore.Pdf.PdfItem DerefItem(PdfSharpCore.Pdf.PdfItem item) => Host!.DerefItem(item);
|
||||
private string WordsToText(IEnumerable<UglyToad.PdfPig.Content.Word> src) => Host!.WordsToText(src);
|
||||
private MenuItem MakeMenuItem(string header, RoutedEventHandler click,
|
||||
string? gesture = null, string? glyph = null)
|
||||
=> Host!.MakeMenuItem(header, click, gesture, glyph);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using Docnet.Core;
|
||||
using Docnet.Core.Models;
|
||||
using Microsoft.Win32;
|
||||
using PdfSharpCore.Drawing;
|
||||
using PdfSharpCore.Pdf;
|
||||
using PdfSharpCore.Pdf.IO;
|
||||
using KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Moved from Shell/Crop.cs; the namespace and class line are the only changes. Window members
|
||||
// spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ============================================================
|
||||
// Crop tool
|
||||
// ============================================================
|
||||
|
||||
// Crop coordinate helpers
|
||||
//
|
||||
// The rendered canvas already incorporates the user-applied rotation stored
|
||||
// in _pageRotations. These helpers invert / apply the same transforms that
|
||||
// the link-overlay code uses (lines ~1925-1957), so canvas<->PDF coords are
|
||||
// consistent with how Docnet drew the bitmap.
|
||||
//
|
||||
// rot=0: canvas_x = native_x * cW/pW, canvas_y = (pH - native_y) * cH/pH
|
||||
// rot=90: canvas_x = native_y * cW/pH, canvas_y = native_x * cH/pW
|
||||
// rot=180: canvas_x = (pW - nx) * cW/pW, canvas_y = (pH - ny) * cH/pH
|
||||
// rot=270: canvas_x = (pH - ny) * cW/pH, canvas_y = (pW - nx) * cH/pW
|
||||
|
||||
/// <summary>
|
||||
/// Convert a canvas-space <see cref="Rect"/> to PDF CropBox coordinates
|
||||
/// (bottom-left origin, points) with rotation awareness.
|
||||
/// </summary>
|
||||
private static (double x1, double y1, double x2, double y2) CanvasToPdfRect(
|
||||
Rect cr, double pdfW, double pdfH, double canvasW, double canvasH, int rot)
|
||||
{
|
||||
double cx = cr.X, cy = cr.Y, cw = cr.Width, ch = cr.Height;
|
||||
return rot switch
|
||||
{
|
||||
90 => (cy * pdfW / canvasH,
|
||||
cx * pdfH / canvasW,
|
||||
(cy + ch) * pdfW / canvasH,
|
||||
(cx + cw) * pdfH / canvasW),
|
||||
|
||||
180 => (pdfW - (cx + cw) * pdfW / canvasW,
|
||||
pdfH - (cy + ch) * pdfH / canvasH,
|
||||
pdfW - cx * pdfW / canvasW,
|
||||
pdfH - cy * pdfH / canvasH),
|
||||
|
||||
270 => (pdfW - (cy + ch) * pdfW / canvasH,
|
||||
pdfH - (cx + cw) * pdfH / canvasW,
|
||||
pdfW - cy * pdfW / canvasH,
|
||||
pdfH - cx * pdfH / canvasW),
|
||||
|
||||
_ => (cx * pdfW / canvasW, // 0 deg
|
||||
pdfH - (cy + ch) * pdfH / canvasH,
|
||||
(cx + cw) * pdfW / canvasW,
|
||||
pdfH - cy * pdfH / canvasH),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="CanvasToPdfRect"/> - map PDF CropBox coords back to a canvas-space
|
||||
/// <see cref="Rect"/>.
|
||||
/// </summary>
|
||||
private static Rect PdfToCanvasRect(
|
||||
double x1, double y1, double x2, double y2,
|
||||
double pdfW, double pdfH, double canvasW, double canvasH, int rot)
|
||||
{
|
||||
double cx, cy, cw, ch;
|
||||
switch (rot)
|
||||
{
|
||||
case 90:
|
||||
cx = y1 * canvasW / pdfH;
|
||||
cy = x1 * canvasH / pdfW;
|
||||
cw = (y2 - y1) * canvasW / pdfH;
|
||||
ch = (x2 - x1) * canvasH / pdfW;
|
||||
break;
|
||||
case 180:
|
||||
cx = (pdfW - x2) * canvasW / pdfW;
|
||||
cy = (pdfH - y2) * canvasH / pdfH;
|
||||
cw = (x2 - x1) * canvasW / pdfW;
|
||||
ch = (y2 - y1) * canvasH / pdfH;
|
||||
break;
|
||||
case 270:
|
||||
cx = (pdfH - y2) * canvasW / pdfH;
|
||||
cy = (pdfW - x2) * canvasH / pdfW;
|
||||
cw = (y2 - y1) * canvasW / pdfH;
|
||||
ch = (x2 - x1) * canvasH / pdfW;
|
||||
break;
|
||||
default: // 0 deg
|
||||
cx = x1 * canvasW / pdfW;
|
||||
cy = (pdfH - y2) * canvasH / pdfH;
|
||||
cw = (x2 - x1) * canvasW / pdfW;
|
||||
ch = (y2 - y1) * canvasH / pdfH;
|
||||
break;
|
||||
}
|
||||
return new Rect(Math.Max(0, cx), Math.Max(0, cy),
|
||||
Math.Max(10, cw), Math.Max(10, ch));
|
||||
}
|
||||
|
||||
// The displayed page's point dimensions (width across the canvas, height down it). For a 90/270
|
||||
// rotation the rendered bitmap is turned, so the point dims are swapped relative to the raw page.
|
||||
private (double dispW, double dispH, double sx, double sy) CropDisplayDims(int pi, (double w, double h) dims)
|
||||
{
|
||||
_pageRotations.TryGetValue(pi, out int rot);
|
||||
var page = _doc!.Pages[pi];
|
||||
double pdfW = page.Width.Point, pdfH = page.Height.Point;
|
||||
bool swap = rot == 90 || rot == 270;
|
||||
double dispW = swap ? pdfH : pdfW;
|
||||
double dispH = swap ? pdfW : pdfH;
|
||||
return (dispW, dispH, dispW / dims.w, dispH / dims.h); // sx,sy: page-points per canvas-unit
|
||||
}
|
||||
|
||||
// points -> the active display unit (relative to the page dimension for "%").
|
||||
private double FromPoints(double pts, double pageDim) => _cropUnit switch
|
||||
{
|
||||
"in" => pts / 72.0,
|
||||
"%" => pageDim > 0 ? pts / pageDim * 100.0 : 0,
|
||||
_ => pts,
|
||||
};
|
||||
|
||||
// active display unit -> points.
|
||||
private double ToPoints(double val, double pageDim) => _cropUnit switch
|
||||
{
|
||||
"in" => val * 72.0,
|
||||
"%" => val / 100.0 * pageDim,
|
||||
_ => val,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Push <see cref="_cropCanvasRect"/> into the X/Y/W/H boxes as a top-left origin rectangle
|
||||
/// (GIMP style) in the active unit. No-ops when the bar isn't showing.
|
||||
/// </summary>
|
||||
private void SyncCropBoxInputs()
|
||||
{
|
||||
if (_cropXBox is null || _doc is null) return;
|
||||
int pi = _currentPage;
|
||||
if (pi < 0 || !_renderDims.TryGetValue(pi, out var dims)) return;
|
||||
var (dispW, dispH, sx, sy) = CropDisplayDims(pi, dims);
|
||||
|
||||
var r = _cropCanvasRect;
|
||||
double xPt = Math.Max(0, r.X) * sx;
|
||||
double yPt = Math.Max(0, r.Y) * sy;
|
||||
double wPt = r.Width * sx;
|
||||
double hPt = r.Height * sy;
|
||||
xPt = Math.Min(xPt, dispW); yPt = Math.Min(yPt, dispH);
|
||||
wPt = Math.Min(wPt, dispW - xPt); hPt = Math.Min(hPt, dispH - yPt);
|
||||
|
||||
string fmt = _cropUnit == "in" ? "F2" : "F1";
|
||||
_updatingCropInputs = true;
|
||||
_cropXBox.Text = FromPoints(xPt, dispW).ToString(fmt);
|
||||
_cropYBox!.Text = FromPoints(yPt, dispH).ToString(fmt);
|
||||
_cropWBox!.Text = FromPoints(wPt, dispW).ToString(fmt);
|
||||
_cropHBox!.Text = FromPoints(hPt, dispH).ToString(fmt);
|
||||
_updatingCropInputs = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the X/Y/W/H boxes (top-left origin, active unit) -> update <see cref="_cropCanvasRect"/>.
|
||||
/// Called on Enter or LostFocus inside a box.
|
||||
/// </summary>
|
||||
private void CommitCropBoxInput()
|
||||
{
|
||||
if (_updatingCropInputs || _cropXBox is null || _doc is null) return;
|
||||
int pi = _currentPage;
|
||||
if (pi < 0 || !_renderDims.TryGetValue(pi, out var dims)) return;
|
||||
if (!double.TryParse(_cropXBox.Text, out double x)) return;
|
||||
if (!double.TryParse(_cropYBox!.Text, out double y)) return;
|
||||
if (!double.TryParse(_cropWBox!.Text, out double w)) return;
|
||||
if (!double.TryParse(_cropHBox!.Text, out double h)) return;
|
||||
|
||||
var (dispW, dispH, sx, sy) = CropDisplayDims(pi, dims);
|
||||
double xPt = ToPoints(x, dispW), yPt = ToPoints(y, dispH);
|
||||
double wPt = ToPoints(w, dispW), hPt = ToPoints(h, dispH);
|
||||
|
||||
xPt = Math.Max(0, Math.Min(dispW - 1, xPt));
|
||||
yPt = Math.Max(0, Math.Min(dispH - 1, yPt));
|
||||
wPt = Math.Max(1, Math.Min(dispW - xPt, wPt));
|
||||
hPt = Math.Max(1, Math.Min(dispH - yPt, hPt));
|
||||
|
||||
_cropCanvasRect = new Rect(xPt / sx, yPt / sy, Math.Max(10, wPt / sx), Math.Max(10, hPt / sy));
|
||||
UpdateCropRectVisuals();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a page-range string like "1-3,5,7-9" (1-based) into a zero-based index array.
|
||||
/// Returns <c>null</c> on parse error or if no valid pages are produced.
|
||||
/// </summary>
|
||||
private static int[]? ParsePageRange(string input, int pageCount)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input)) return null;
|
||||
var result = new System.Collections.Generic.HashSet<int>();
|
||||
foreach (var part in input.Split([','], StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var seg = part.Trim();
|
||||
if (seg.Contains('-'))
|
||||
{
|
||||
var halves = seg.Split('-');
|
||||
if (halves.Length == 2 &&
|
||||
int.TryParse(halves[0].Trim(), out int lo) &&
|
||||
int.TryParse(halves[1].Trim(), out int hi))
|
||||
{
|
||||
for (int p = lo; p <= hi; p++)
|
||||
if (p >= 1 && p <= pageCount) result.Add(p - 1);
|
||||
}
|
||||
else return null;
|
||||
}
|
||||
else if (int.TryParse(seg, out int pg))
|
||||
{
|
||||
if (pg >= 1 && pg <= pageCount) result.Add(pg - 1);
|
||||
}
|
||||
else return null;
|
||||
}
|
||||
return result.Count == 0 ? null : [.. result.OrderBy(x => x)];
|
||||
}
|
||||
|
||||
// Entering the Crop tool drops a default crop box (inset from the page edges) and shows the bar
|
||||
// straight away, so the box is visible without the user having to draw one first.
|
||||
private void ShowDefaultCropBox()
|
||||
{
|
||||
if (_doc is null) return;
|
||||
int pi = _currentPage;
|
||||
if (pi < 0) return;
|
||||
var canvas = VisibleCanvasForPage(pi) ?? CanvasForPage(pi);
|
||||
if (canvas is null || canvas.Width <= 0 || canvas.Height <= 0) return;
|
||||
|
||||
_cropPageIndex = pi;
|
||||
_activeCanvas = canvas;
|
||||
_gestureCanvas = canvas;
|
||||
_gesturePage = pi;
|
||||
|
||||
double w = canvas.Width, h = canvas.Height;
|
||||
double mx = w * 0.08, my = h * 0.08;
|
||||
_cropCanvasRect = new Rect(mx, my, Math.Max(10, w - 2 * mx), Math.Max(10, h - 2 * my));
|
||||
|
||||
_cropPreviewRect = new Rectangle
|
||||
{
|
||||
Stroke = Brushes.White, StrokeThickness = 1.5, StrokeDashArray = [5, 3],
|
||||
Fill = AccentBrush(55), Width = _cropCanvasRect.Width, Height = _cropCanvasRect.Height,
|
||||
IsHitTestVisible = false,
|
||||
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.7 }
|
||||
};
|
||||
Canvas.SetLeft(_cropPreviewRect, _cropCanvasRect.X);
|
||||
Canvas.SetTop(_cropPreviewRect, _cropCanvasRect.Y);
|
||||
Panel.SetZIndex(_cropPreviewRect, 1);
|
||||
canvas.Children.Add(_cropPreviewRect);
|
||||
|
||||
ShowCropConfirmBar();
|
||||
}
|
||||
|
||||
// Rebuilds the crop bar (and its box) from the current rect so a language switch picks up the new
|
||||
// locale - the bar is built once with Loc() snapshots and would otherwise stay in the old language.
|
||||
// No-op if the bar isn't showing.
|
||||
private void RebuildCropBarForLocale()
|
||||
{
|
||||
if (_cropConfirmBar is null) return;
|
||||
var rect = _cropCanvasRect;
|
||||
var canvas = _activeCanvas;
|
||||
HideCropConfirmBar();
|
||||
if (canvas is null || canvas.Width <= 0 || rect.Width <= 1 || rect.Height <= 1) return;
|
||||
_cropCanvasRect = rect;
|
||||
_cropPreviewRect = new Rectangle
|
||||
{
|
||||
Stroke = Brushes.White, StrokeThickness = 1.5, StrokeDashArray = [5, 3],
|
||||
Fill = Brushes.Transparent, Width = rect.Width, Height = rect.Height,
|
||||
IsHitTestVisible = false,
|
||||
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.7 }
|
||||
};
|
||||
Canvas.SetLeft(_cropPreviewRect, rect.X);
|
||||
Canvas.SetTop(_cropPreviewRect, rect.Y);
|
||||
Panel.SetZIndex(_cropPreviewRect, 1);
|
||||
canvas.Children.Add(_cropPreviewRect);
|
||||
ShowCropConfirmBar();
|
||||
}
|
||||
|
||||
private void ShowCropConfirmBar()
|
||||
{
|
||||
if (_doc is null) return;
|
||||
if (_cropPreviewRect is not null)
|
||||
{
|
||||
// Committed box: outline only, but darker + a touch thicker so it reads on light scans.
|
||||
_cropPreviewRect.Fill = Brushes.Transparent;
|
||||
_cropPreviewRect.Stroke = new SolidColorBrush(Color.FromRgb(0x20, 0x20, 0x20));
|
||||
_cropPreviewRect.StrokeThickness = 2;
|
||||
}
|
||||
|
||||
// Bar already up: do NOT rebuild it (that flickers it and wipes the Pages/All inputs). Just
|
||||
// refresh the corner handles and the X/Y/W/H fields so the values track the box.
|
||||
if (_cropConfirmBar is not null)
|
||||
{
|
||||
AddCropHandles();
|
||||
SyncCropBoxInputs();
|
||||
return;
|
||||
}
|
||||
|
||||
int currentPage = _cropPageIndex >= 0 ? _cropPageIndex : _currentPage;
|
||||
|
||||
// Build the bar exactly like the other annotate bars: a drag grip first, then the controls,
|
||||
// wrapped by the shared BuildBarHost and placed with PlaceAnnotationBar so it attaches to the top
|
||||
// and slides left/right like Draw/Text/Highlight - no bespoke host, grain, drag, or positioning.
|
||||
_annotBarDragInners.Clear();
|
||||
var outer = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 2, 8, 2), Background = Brushes.Transparent };
|
||||
var grip = MakeBarGrip();
|
||||
outer.Children.Add(grip);
|
||||
static Button CropBtn(Button b) { b.Padding = new Thickness(10, 4, 10, 4); b.Margin = new Thickness(0, 0, 5, 0); return b; }
|
||||
|
||||
TextBlock LocalizedText(string key, FontWeight? weight = null)
|
||||
{
|
||||
var text = new TextBlock
|
||||
{
|
||||
FontFamily = UiKit.UiFont,
|
||||
FontSize = 11,
|
||||
FontWeight = weight ?? FontWeights.Normal,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
text.SetResourceReference(TextBlock.TextProperty, key);
|
||||
text.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
|
||||
return text;
|
||||
}
|
||||
|
||||
// label + themed field. Enter applies the crop; LostFocus just updates the rect from the values.
|
||||
TextBox AddField(string lbl, double width)
|
||||
{
|
||||
var label = new TextBlock
|
||||
{
|
||||
Text = lbl, FontFamily = UiKit.UiFont, FontSize = 11,
|
||||
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 3, 0)
|
||||
};
|
||||
label.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
|
||||
outer.Children.Add(label);
|
||||
var tb = new TextBox
|
||||
{
|
||||
Width = width, Height = 22, FontFamily = UiKit.UiFont, FontSize = 11,
|
||||
BorderThickness = new Thickness(1), Padding = new Thickness(3, 1, 3, 1),
|
||||
VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 8, 0), Style = (Style)FindResource("FormFieldTextBox")
|
||||
};
|
||||
tb.SetResourceReference(TextBox.BackgroundProperty, "PaneBrush");
|
||||
tb.SetResourceReference(TextBox.ForegroundProperty, "TextBrush");
|
||||
tb.SetResourceReference(TextBox.BorderBrushProperty, "CardBorderBrush");
|
||||
tb.KeyDown += (_, e) => { if (e.Key == Key.Enter) { CommitCropBoxInput(); ApplyCrop([currentPage]); e.Handled = true; } };
|
||||
tb.LostFocus += (_, _) => CommitCropBoxInput();
|
||||
outer.Children.Add(tb);
|
||||
return tb;
|
||||
}
|
||||
|
||||
// Group labels (GIMP-style "Position" / "Size") so the single-letter fields read clearly.
|
||||
void GroupLabel(string key, double leftPad)
|
||||
{
|
||||
var label = LocalizedText(key, FontWeights.SemiBold);
|
||||
label.Margin = new Thickness(leftPad, 0, 6, 0);
|
||||
outer.Children.Add(label);
|
||||
}
|
||||
|
||||
GroupLabel("Str_Crop_Position", 0);
|
||||
_cropXBox = AddField("X", 50);
|
||||
_cropYBox = AddField("Y", 50);
|
||||
GroupLabel("Str_Crop_Size", 6); // padding after the Y box, before the size group
|
||||
_cropWBox = AddField("W", 50);
|
||||
_cropHBox = AddField("H", 50);
|
||||
|
||||
// Unit picker (pt / in / %); re-formats the fields on change.
|
||||
var unitCombo = new ComboBox
|
||||
{
|
||||
Width = 54, Height = 22, Margin = new Thickness(0, 0, 8, 0),
|
||||
VerticalContentAlignment = VerticalAlignment.Center,
|
||||
Style = (Style)FindResource("DarkComboBox")
|
||||
};
|
||||
foreach (var u in new[] { "pt", "in", "%" }) unitCombo.Items.Add(u);
|
||||
unitCombo.SelectedItem = _cropUnit;
|
||||
unitCombo.SelectionChanged += (_, _) => { _cropUnit = unitCombo.SelectedItem as string ?? "pt"; SyncCropBoxInputs(); };
|
||||
outer.Children.Add(unitCombo);
|
||||
|
||||
// Divider before the action buttons.
|
||||
var divider = new Border { Width = 1, Margin = new Thickness(2, 0, 8, 0), VerticalAlignment = VerticalAlignment.Stretch };
|
||||
divider.SetResourceReference(Border.BackgroundProperty, "CardBorderBrush");
|
||||
outer.Children.Add(divider);
|
||||
|
||||
// Pages range + "All" checkbox, then a single Crop button on the far right. Crop logic:
|
||||
// All checked -> every page; else a typed range like "1-3,5"; else just the current page.
|
||||
var pagesLabel = LocalizedText("Str_Crop_Pages");
|
||||
pagesLabel.Margin = new Thickness(0, 0, 3, 0);
|
||||
outer.Children.Add(pagesLabel);
|
||||
_cropRangeBox = new TextBox
|
||||
{
|
||||
Width = 64, Height = 22, FontFamily = UiKit.UiFont, FontSize = 11,
|
||||
BorderThickness = new Thickness(1), Padding = new Thickness(3, 1, 3, 1),
|
||||
VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
Style = (Style)FindResource("FormFieldTextBox")
|
||||
};
|
||||
_cropRangeBox.SetResourceReference(FrameworkElement.ToolTipProperty, "Str_Crop_RangeTip");
|
||||
_cropRangeBox.SetResourceReference(TextBox.BackgroundProperty, "PaneBrush");
|
||||
_cropRangeBox.SetResourceReference(TextBox.ForegroundProperty, "TextBrush");
|
||||
_cropRangeBox.SetResourceReference(TextBox.BorderBrushProperty, "CardBorderBrush");
|
||||
outer.Children.Add(_cropRangeBox);
|
||||
|
||||
// "All" checkbox - the same compact look as the annotate-bar toggles (no WPF CheckBox chrome).
|
||||
bool cropAll = false;
|
||||
var allTick = new TextBlock { Text = "✓", Foreground = Brushes.White, FontSize = 10, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Visibility = Visibility.Collapsed };
|
||||
var allBox = new Border { Width = 15, Height = 15, CornerRadius = new CornerRadius(3), BorderThickness = new Thickness(1), BorderBrush = _swatchDimBorder, Background = Brushes.Transparent, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), Child = allTick };
|
||||
var allRow = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Cursor = Cursors.Hand, Margin = new Thickness(0, 0, 10, 0) };
|
||||
allRow.SetResourceReference(FrameworkElement.ToolTipProperty, "Str_Crop_AllTip");
|
||||
allRow.Children.Add(allBox);
|
||||
allRow.Children.Add(LocalizedText("Str_Crop_All"));
|
||||
allRow.MouseLeftButtonDown += (_, _) =>
|
||||
{
|
||||
cropAll = !cropAll;
|
||||
allTick.Visibility = cropAll ? Visibility.Visible : Visibility.Collapsed;
|
||||
if (cropAll) allBox.SetResourceReference(Border.BackgroundProperty, "SelectionAccent");
|
||||
else { allBox.Background = Brushes.Transparent; allBox.BorderBrush = _swatchDimBorder; }
|
||||
};
|
||||
outer.Children.Add(allRow);
|
||||
|
||||
// Single Crop button on the right.
|
||||
var cropBtn = CropBtn(UiKit.Make(Loc("Str_Crop_Apply"), true));
|
||||
cropBtn.SetResourceReference(ContentControl.ContentProperty, "Str_Crop_Apply");
|
||||
cropBtn.SetResourceReference(FrameworkElement.ToolTipProperty, "Str_TT_CropThisPage");
|
||||
cropBtn.Click += (_, _) =>
|
||||
{
|
||||
int pc = _doc?.PageCount ?? 0;
|
||||
int[]? pages;
|
||||
if (cropAll) pages = [.. Enumerable.Range(0, pc)];
|
||||
else if (!string.IsNullOrWhiteSpace(_cropRangeBox?.Text))
|
||||
{
|
||||
pages = ParsePageRange(_cropRangeBox!.Text, pc);
|
||||
if (pages is null) { SetStatus(Loc("Str_InvalidRange")); return; }
|
||||
}
|
||||
else pages = [currentPage];
|
||||
ApplyCrop(pages);
|
||||
};
|
||||
outer.Children.Add(cropBtn);
|
||||
|
||||
// Wrap the controls in the shared bar host + frame and place it like the other annotate bars
|
||||
// (top, right-anchored, slidable via the grip; the X position persists across tools).
|
||||
var bar = new Border
|
||||
{
|
||||
BorderThickness = new Thickness(1, 0, 1, 1),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
CornerRadius = new CornerRadius(0, 0, 4, 4),
|
||||
Padding = new Thickness(4),
|
||||
Effect = AnnotBarShadow(),
|
||||
Child = BuildBarHost(outer)
|
||||
};
|
||||
bar.SetResourceReference(Border.BackgroundProperty, "BgFlyout");
|
||||
bar.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush");
|
||||
_cropConfirmBar = bar;
|
||||
|
||||
var previewArea = PagePreviewPanel.Parent as Grid;
|
||||
if (previewArea is not null)
|
||||
{
|
||||
Panel.SetZIndex(bar, 100);
|
||||
previewArea.Children.Add(bar);
|
||||
PlaceAnnotationBar(bar, grip, fadeIn: false);
|
||||
}
|
||||
_annotBarTool = EditTool.Crop; // so re-clicking the Crop tool minimizes this bar like the others
|
||||
_annotBarMinimized = false;
|
||||
AddCropHandles();
|
||||
SyncCropBoxInputs();
|
||||
}
|
||||
|
||||
private void HideCropConfirmBar()
|
||||
{
|
||||
if (_cropConfirmBar is not null)
|
||||
{
|
||||
// Remove from whichever panel it was added to (outer grid or canvas fallback)
|
||||
(_annotationCanvas.Parent as Panel)?.Children.Remove(_cropConfirmBar);
|
||||
_annotationCanvas.Children.Remove(_cropConfirmBar); // no-op if not there
|
||||
(PagePreviewPanel.Parent as Panel)?.Children.Remove(_cropConfirmBar);
|
||||
_cropConfirmBar = null;
|
||||
}
|
||||
if (_cropPreviewRectBorder is not null)
|
||||
{
|
||||
(_cropPreviewRectBorder.Parent as Panel)?.Children.Remove(_cropPreviewRectBorder);
|
||||
_annotationCanvas.Children.Remove(_cropPreviewRectBorder);
|
||||
_cropPreviewRectBorder = null;
|
||||
}
|
||||
if (_cropPreviewRect is not null)
|
||||
{
|
||||
(_cropPreviewRect.Parent as Panel)?.Children.Remove(_cropPreviewRect);
|
||||
_annotationCanvas.Children.Remove(_cropPreviewRect);
|
||||
_cropPreviewRect = null;
|
||||
}
|
||||
RemoveCropHandles();
|
||||
_cropXBox = _cropYBox = _cropWBox = _cropHBox = null;
|
||||
_cropRangeBox = null;
|
||||
if (_annotBarTool == EditTool.Crop) _annotBarTool = null; // release the shared annotate-bar slot
|
||||
}
|
||||
|
||||
private void AddCropHandles()
|
||||
{
|
||||
RemoveCropHandles();
|
||||
const double hSize = 24;
|
||||
var tags = new[] { "NW", "NE", "SE", "SW" };
|
||||
var cursors = new[] { Cursors.SizeNWSE, Cursors.SizeNESW, Cursors.SizeNWSE, Cursors.SizeNESW };
|
||||
// Handles live in the OUTER unscaled panel (same as the confirm bar) so they render
|
||||
// at a fixed screen size regardless of canvas zoom level.
|
||||
var outerGrid = PagePreviewPanel.Parent as Panel ?? (Panel)_annotationCanvas;
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var tag = tags[i];
|
||||
var h = new Rectangle
|
||||
{
|
||||
Width = hSize, Height = hSize,
|
||||
Fill = Brushes.Transparent,
|
||||
Stroke = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88)),
|
||||
StrokeThickness = 1.5,
|
||||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||||
{ Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.6 },
|
||||
Tag = tag,
|
||||
Cursor = cursors[i],
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
};
|
||||
Panel.SetZIndex(h, 101);
|
||||
// Attach drag directly on the handle so clicks don't need to reach _annotationCanvas.
|
||||
h.MouseLeftButtonDown += (_, e) =>
|
||||
{
|
||||
_activeCropHandleTag = tag;
|
||||
// Measure and capture against the active surface (per-page overlay in
|
||||
// Continuous view) so the drag delta matches the crop rect's coordinate space.
|
||||
_cropHandleDragStart = e.GetPosition(_activeCanvas);
|
||||
_cropRectAtHandleDrag = _cropCanvasRect;
|
||||
_activeCanvas.CaptureMouse();
|
||||
e.Handled = true;
|
||||
};
|
||||
_cropHandles.Add(h);
|
||||
outerGrid.Children.Add(h);
|
||||
}
|
||||
PositionCropHandles();
|
||||
}
|
||||
|
||||
private void RemoveCropHandles()
|
||||
{
|
||||
var outerGrid = PagePreviewPanel.Parent as Panel ?? (Panel)_annotationCanvas;
|
||||
foreach (var h in _cropHandles)
|
||||
{
|
||||
outerGrid.Children.Remove(h);
|
||||
_annotationCanvas.Children.Remove(h); // belt-and-suspenders in case it ended up in canvas
|
||||
}
|
||||
_cropHandles.Clear();
|
||||
_activeCropHandleTag = null;
|
||||
RemoveCropBrackets(); // no-op - list is always empty now, kept for safety
|
||||
}
|
||||
|
||||
private void RemoveCropBrackets()
|
||||
{
|
||||
foreach (var b in _cropBrackets) _annotationCanvas.Children.Remove(b);
|
||||
_cropBrackets.Clear();
|
||||
}
|
||||
|
||||
private void PositionCropHandles()
|
||||
{
|
||||
if (_cropHandles.Count < 4) return;
|
||||
const double hSize = 24;
|
||||
var outerGrid = PagePreviewPanel.Parent as UIElement ?? _annotationCanvas;
|
||||
// Translate canvas-space corners to outer-panel screen space (same as RepositionCropConfirmBar).
|
||||
var canvasCorners = new Point[]
|
||||
{
|
||||
new(_cropCanvasRect.X, _cropCanvasRect.Y),
|
||||
new(_cropCanvasRect.Right, _cropCanvasRect.Y),
|
||||
new(_cropCanvasRect.Right, _cropCanvasRect.Bottom),
|
||||
new(_cropCanvasRect.X, _cropCanvasRect.Bottom),
|
||||
};
|
||||
var offsets = new (double dx, double dy)[]
|
||||
{
|
||||
(0, 0 ), // NW: top-left at top-left corner
|
||||
(-hSize, 0 ), // NE: top-right at top-right corner
|
||||
(-hSize, -hSize ), // SE: bottom-right at bottom-right corner
|
||||
(0, -hSize ), // SW: bottom-left at bottom-left corner
|
||||
};
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
Point screen = _activeCanvas.TranslatePoint(canvasCorners[i], outerGrid);
|
||||
_cropHandles[i].Margin = new Thickness(
|
||||
screen.X + offsets[i].dx,
|
||||
screen.Y + offsets[i].dy,
|
||||
0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCropRectVisuals()
|
||||
{
|
||||
if (_cropPreviewRect is null) return;
|
||||
var r = _cropCanvasRect;
|
||||
Canvas.SetLeft(_cropPreviewRect, r.X); Canvas.SetTop(_cropPreviewRect, r.Y);
|
||||
_cropPreviewRect.Width = r.Width; _cropPreviewRect.Height = r.Height;
|
||||
PositionCropHandles();
|
||||
SyncCropBoxInputs();
|
||||
}
|
||||
|
||||
private void ApplyCrop(int[] pageIndices)
|
||||
{
|
||||
if (_doc is null || _currentFile is null) { SetStatus(Loc("Str_CropNoDoc")); return; }
|
||||
int currentPage = _cropPageIndex >= 0 ? _cropPageIndex : _currentPage;
|
||||
if (currentPage < 0) { SetStatus(Loc("Str_CropNoPage")); return; }
|
||||
if (!_renderDims.TryGetValue(currentPage, out var refDims))
|
||||
{ SetStatus(Loc("Str_CropNoDims")); return; }
|
||||
|
||||
try
|
||||
{
|
||||
PushDocUndo();
|
||||
|
||||
// Convert canvas rect to PDF CropBox coords using the rotation-aware helper.
|
||||
// This is the correct inversion of how Docnet renders the rotated bitmap.
|
||||
_pageRotations.TryGetValue(currentPage, out int rot);
|
||||
var refPage = _doc.Pages[currentPage];
|
||||
double refPdfW = refPage.Width.Point;
|
||||
double refPdfH = refPage.Height.Point;
|
||||
|
||||
var (rx1, ry1, rx2, ry2) = CanvasToPdfRect(
|
||||
_cropCanvasRect, refPdfW, refPdfH, refDims.w, refDims.h, rot);
|
||||
|
||||
foreach (int pi in pageIndices)
|
||||
{
|
||||
if (pi < 0 || pi >= _doc.PageCount) continue;
|
||||
var page = _doc.Pages[pi];
|
||||
double pW = page.Width.Point;
|
||||
double pH = page.Height.Point;
|
||||
|
||||
// Scale proportionally when "All Pages" spans pages of different sizes
|
||||
double x1 = rx1 * pW / refPdfW;
|
||||
double y1 = ry1 * pH / refPdfH;
|
||||
double x2 = rx2 * pW / refPdfW;
|
||||
double y2 = ry2 * pH / refPdfH;
|
||||
|
||||
// Clamp to media box and ensure minimum 1-pt size
|
||||
x1 = Math.Max(0, x1); y1 = Math.Max(0, y1);
|
||||
x2 = Math.Min(pW, x2); y2 = Math.Min(pH, y2);
|
||||
if (x2 - x1 < 1) x2 = x1 + 1;
|
||||
if (y2 - y1 < 1) y2 = y1 + 1;
|
||||
|
||||
// Write CropBox directly into the page dictionary (more reliable across
|
||||
// PdfSharpCore versions than the CropBox property setter).
|
||||
var cropArr = new PdfSharpCore.Pdf.PdfArray();
|
||||
cropArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(x1));
|
||||
cropArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(y1));
|
||||
cropArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(x2));
|
||||
cropArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(y2));
|
||||
page.Elements["/CropBox"] = cropArr;
|
||||
|
||||
// Mirror to TrimBox (PDF spec: TrimBox within CropBox within MediaBox)
|
||||
var trimArr = new PdfSharpCore.Pdf.PdfArray();
|
||||
trimArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(x1));
|
||||
trimArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(y1));
|
||||
trimArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(x2));
|
||||
trimArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(y2));
|
||||
page.Elements["/TrimBox"] = trimArr;
|
||||
}
|
||||
|
||||
HideCropConfirmBar();
|
||||
SetTool(EditTool.Select);
|
||||
SaveTempAndReload(keepAnnotations: true, preserveZoom: true);
|
||||
SetStatus(string.Format(Loc("Str_Cropped"), pageIndices.Length));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(string.Format(Loc("Str_CropFailed"), ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveCropBox(int[] pageIndices)
|
||||
{
|
||||
if (_doc is null || _currentFile is null) return;
|
||||
try
|
||||
{
|
||||
PushDocUndo();
|
||||
foreach (int pi in pageIndices)
|
||||
{
|
||||
if (pi < 0 || pi >= _doc.PageCount) continue;
|
||||
_doc.Pages[pi].Elements.Remove("/CropBox");
|
||||
_doc.Pages[pi].Elements.Remove("/TrimBox");
|
||||
}
|
||||
HideCropConfirmBar();
|
||||
SetTool(EditTool.Select);
|
||||
SaveTempAndReload(keepAnnotations: true);
|
||||
SetStatus(string.Format(Loc("Str_RemovedCrop"), pageIndices.Length));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(string.Format(Loc("Str_RemoveCropFailed"), ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,675 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using Docnet.Core;
|
||||
using Docnet.Core.Models;
|
||||
using Microsoft.Win32;
|
||||
using PdfSharpCore.Drawing;
|
||||
using PdfSharpCore.Pdf;
|
||||
using PdfSharpCore.Pdf.IO;
|
||||
using KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Moved from Shell/Links.cs; the namespace and class line are the only changes. Window members
|
||||
// spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ============================================================
|
||||
// PDF Link Annotation Overlays
|
||||
// ============================================================
|
||||
|
||||
// LinkInfo lives in Models/LinkTypes.cs, not here - ContextMenu.cs is on the window and also
|
||||
// reads the link rects, so the type cannot be nested in whichever class owns them.
|
||||
|
||||
// Per-page link rects for the tiled views (continuous / grid / two-page), keyed by page index.
|
||||
// Clicks and the hover cursor are resolved by bounds-testing these in Canvas_MouseLeftButtonDown
|
||||
// and Canvas_MouseMove: a per-link overlay swallows the click in the tiled layout but its own
|
||||
// handler never fires, so no visual overlay is created - these rects are the source of truth.
|
||||
private readonly Dictionary<int, List<LinkInfo>> _continuousLinks = [];
|
||||
|
||||
/// <summary>The link-rect map, for the window side. ContextMenu.cs bounds-tests it to build
|
||||
/// the right-click menu and FileOperations.cs clears it on document change; both live on the
|
||||
/// window while Links.cs lives here.</summary>
|
||||
internal Dictionary<int, List<LinkInfo>> ContinuousLinks => _continuousLinks;
|
||||
|
||||
/// <summary>Hit-slop around a link rect, shared with ContextMenu.cs so the menu targets the
|
||||
/// same links the click and hover paths do.</summary>
|
||||
internal const double LinkHitPadShared = LinkHitPad;
|
||||
|
||||
// Small hit-slop (render-dim units) added around a link rect for click / hover / right-click
|
||||
// hit-testing so thin one-line link strips are easy to hit without over-reaching neighbors.
|
||||
// Applied identically in single-page (grows the overlay in RenderPageLinks) and tiled views
|
||||
// (bounds-checks) so both feel the same.
|
||||
private const double LinkHitPad = 5;
|
||||
|
||||
// Persisted opt-IN for the click-safety confirmation prompt, surfaced as the
|
||||
// "Confirm before opening links" toggle on the About card footer.
|
||||
//
|
||||
// Positive sense and default OFF: links keep opening immediately unless you ask for the
|
||||
// prompt. ONE key, deliberately - a hardcoded master switch plus an inverted
|
||||
// "SkipLinkConfirm" opt-out can disagree with each other. The dialog's "Don't ask again" is
|
||||
// the same switch as the checkbox.
|
||||
internal const string ConfirmLinksSetting = "ConfirmLinks";
|
||||
|
||||
// Confirms before opening an external link in the browser, unless the user opted out. Returns true
|
||||
// to proceed. Internal go-to-page links never call this.
|
||||
private bool ConfirmOpenLink(string url)
|
||||
{
|
||||
if (App.GetSetting(ConfirmLinksSetting) != "1") return true;
|
||||
var (result, dontAsk) = KillerDialog.ShowWithCheckbox(
|
||||
Host!.Window,
|
||||
$"{Loc("Str_LinkConfirmBody")}\n\n{url}",
|
||||
Loc("Str_LinkDontAsk"),
|
||||
Loc("Str_LinkConfirmTitle"),
|
||||
MessageBoxButton.OKCancel);
|
||||
if (result != MessageBoxResult.OK) return false;
|
||||
// "Don't ask again" IS the toggle, so turn it off rather than setting a second key the
|
||||
// About checkbox knows nothing about - that is how the two could drift apart before.
|
||||
if (dontAsk)
|
||||
{
|
||||
App.SetSetting(ConfirmLinksSetting, "0");
|
||||
if (LinkConfirmCheck != null) LinkConfirmCheck.IsChecked = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Schemes we will hand to the OS shell when a PDF link is clicked. A PDF can embed ANY URI, and
|
||||
// Process.Start(UseShellExecute=true) would happily launch file:// paths, UNC shares, javascript:,
|
||||
// or registered protocol handlers (ms-msdt:/search-ms: - real malware vectors). Anything outside
|
||||
// this allow-list is refused. http/https = web links; mailto = email links.
|
||||
private static readonly HashSet<string> AllowedLinkSchemes =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "http", "https", "mailto" };
|
||||
|
||||
// True only for an absolute URI in an allowed scheme. Rejects scheme-less / relative URIs (a bare
|
||||
// "www.example.com" is a Tier 2 follow-up), plus file:, javascript:, and custom protocol handlers.
|
||||
private static bool IsAllowedLinkUri(string url) =>
|
||||
Uri.TryCreate(url, UriKind.Absolute, out var uri) && AllowedLinkSchemes.Contains(uri.Scheme);
|
||||
|
||||
// A PDF can store a scheme-less link like "www.example.com" or "example.com/page". Treat a domain-
|
||||
// shaped target as https so it still opens; anything with an explicit scheme, a backslash (UNC/path),
|
||||
// or whitespace is left untouched (and thus refused by IsAllowedLinkUri unless it's http/https/mailto).
|
||||
private static string NormalizeLinkUri(string raw)
|
||||
{
|
||||
raw = raw.Trim();
|
||||
if (raw.Length == 0) return raw;
|
||||
if (raw.Contains('\\') || raw.Contains(' ')) return raw; // Windows path / UNC / junk - don't touch
|
||||
if (raw.Contains("://")) return raw; // already scheme://...
|
||||
int colon = raw.IndexOf(':');
|
||||
int slash = raw.IndexOf('/');
|
||||
if (colon >= 0 && (slash < 0 || colon < slash)) return raw; // "scheme:" (mailto:, file:, C:) - don't touch
|
||||
string host = slash >= 0 ? raw[..slash] : raw; // host part before any path
|
||||
return host.Contains('.') ? "https://" + raw : raw; // dotted host => assume https
|
||||
}
|
||||
|
||||
// Maps a PDF rectangle (points, origin bottom-left, already min/max-normalized) to a canvas-space
|
||||
// rectangle (pixels, origin top-left) for a page rendered at bitmapW x bitmapH. Shared by the
|
||||
// PdfSharpCore and PDFium link readers so the two stay pixel-identical.
|
||||
private static (double x, double y, double w, double h) PdfRectToCanvas(
|
||||
double rx1, double ry1, double rx2, double ry2,
|
||||
double pageWidthPt, double pageHeightPt, int bitmapW, int bitmapH)
|
||||
{
|
||||
double x = rx1 / pageWidthPt * bitmapW;
|
||||
double y = (pageHeightPt - ry2) / pageHeightPt * bitmapH;
|
||||
double w = (rx2 - rx1) / pageWidthPt * bitmapW;
|
||||
double h = (ry2 - ry1) / pageHeightPt * bitmapH;
|
||||
return (x, y, w, h);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Follows a resolved link target: an int page index navigates within the document; a string URI
|
||||
/// is scheme-checked, confirmed, then opened via the shell. Single choke point for both the
|
||||
/// single-page (_linkOverlays) and tiled (_continuousLinks) click paths, so the safety checks
|
||||
/// can't be bypassed by one route and a failed open is always reported instead of silent.
|
||||
/// </summary>
|
||||
private void FollowLinkTarget(object? target)
|
||||
{
|
||||
if (target is int pageIndex)
|
||||
{
|
||||
if (_doc != null && pageIndex >= 0 && pageIndex < _doc.PageCount)
|
||||
{
|
||||
RecordNavJump(); // Alt+Left retraces the link hop
|
||||
_currentPage = pageIndex;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (target is not string raw || string.IsNullOrWhiteSpace(raw)) return;
|
||||
|
||||
// Scheme-less but domain-shaped targets (e.g. "www.example.com") become https:// here.
|
||||
string url = NormalizeLinkUri(raw);
|
||||
if (!IsAllowedLinkUri(url))
|
||||
{
|
||||
SetStatus($"{Loc("Str_LinkBlocked")} {raw}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ConfirmOpenLink(url)) return;
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Open link failed: {ex}");
|
||||
SetStatus(Loc("Str_LinkOpenFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the right-click actions for a link onto `menu`: Open Link (via the safe FollowLinkTarget
|
||||
// path), Copy Link Address / Copy Email Address, and - only for PdfSharpCore-sourced links
|
||||
// (annotIndex >= 0) - Remove Link from PDF. Shared by the single-page overlay menu and the tiled-
|
||||
// view canvas menu so both views offer the same actions.
|
||||
private void AddLinkMenuItems(ContextMenu menu, object target, int annotIndex, int pageIndex)
|
||||
{
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_OpenLink"), (_, _) => FollowLinkTarget(target), glyph: ""));
|
||||
if (target is string uri)
|
||||
{
|
||||
if (uri.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CopyEmail"), (_, _) => TrySetClipboard(uri["mailto:".Length..]), "Ctrl+C", ""));
|
||||
else
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CopyLink"), (_, _) => TrySetClipboard(uri), "Ctrl+C", ""));
|
||||
}
|
||||
if (annotIndex >= 0)
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RemoveLink"), (_, _) => RemoveLinkAnnotation(pageIndex, annotIndex), "Delete", ""));
|
||||
}
|
||||
|
||||
// Clipboard COM calls throw when another app is holding the clipboard open; swallow so a copy
|
||||
// never crashes the app (the worst case is the copy silently not happening).
|
||||
private static void TrySetClipboard(string text)
|
||||
{
|
||||
try { Clipboard.SetText(text); } catch { }
|
||||
}
|
||||
|
||||
// Status-bar hover feedback: shows the hovered link's target, restoring the prior status on exit.
|
||||
private string? _preHoverStatus;
|
||||
private void ShowLinkHoverStatus(string? target)
|
||||
{
|
||||
if (target != null)
|
||||
{
|
||||
_preHoverStatus ??= StatusText.Text;
|
||||
StatusText.Text = target;
|
||||
}
|
||||
else if (_preHoverStatus != null)
|
||||
{
|
||||
StatusText.Text = _preHoverStatus;
|
||||
_preHoverStatus = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carries the link target (page index or URI string) plus the annotation's location in
|
||||
/// the PDF so the overlay can be used to remove the native annotation on demand.
|
||||
/// </summary>
|
||||
private sealed class LinkAnnotInfo(object target, int pageIndex, int annotIndex)
|
||||
{
|
||||
public object Target { get; } = target; // int pageIndex or string URI
|
||||
public int PageIndex { get; } = pageIndex; // 0-based page in _doc
|
||||
public int AnnotIndex { get; } = annotIndex; // index inside page /Annots array
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses all link annotations from a PDF page and converts them to canvas-space
|
||||
/// rectangles. Works for both primary and secondary page renders.
|
||||
/// </summary>
|
||||
private List<LinkInfo> GetPageLinks(int pageIndex, int bitmapW, int bitmapH)
|
||||
{
|
||||
var links = new List<LinkInfo>();
|
||||
if (_doc is null) return links;
|
||||
try
|
||||
{
|
||||
var pdfPage = _doc.Pages[pageIndex];
|
||||
var annotsArr = pdfPage.Elements.GetArray("/Annots");
|
||||
if (annotsArr is null || annotsArr.Elements.Count == 0) return links;
|
||||
|
||||
double pageWidthPt = pdfPage.Width.Point;
|
||||
double pageHeightPt = pdfPage.Height.Point;
|
||||
if (pageWidthPt <= 0) pageWidthPt = 595.28;
|
||||
if (pageHeightPt <= 0) pageHeightPt = 841.89;
|
||||
|
||||
for (int i = 0; i < annotsArr.Elements.Count; i++)
|
||||
{
|
||||
PdfItem? elem = annotsArr.Elements[i];
|
||||
PdfDictionary? ann = elem as PdfDictionary ?? DerefItem(elem) as PdfDictionary;
|
||||
if (ann is null) continue;
|
||||
|
||||
var subtype = ann.Elements["/Subtype"]?.ToString() ?? "";
|
||||
if (!subtype.Contains("Link")) continue;
|
||||
|
||||
var rectArr = ann.Elements.GetArray("/Rect");
|
||||
if (rectArr is null || rectArr.Elements.Count < 4) continue;
|
||||
double rx1 = rectArr.Elements.GetReal(0);
|
||||
double ry1 = rectArr.Elements.GetReal(1);
|
||||
double rx2 = rectArr.Elements.GetReal(2);
|
||||
double ry2 = rectArr.Elements.GetReal(3);
|
||||
if (rx1 > rx2) (rx1, rx2) = (rx2, rx1);
|
||||
if (ry1 > ry2) (ry1, ry2) = (ry2, ry1);
|
||||
|
||||
var (cx, cy, cw, ch) = PdfRectToCanvas(rx1, ry1, rx2, ry2, pageWidthPt, pageHeightPt, bitmapW, bitmapH);
|
||||
if (cw < 1 || ch < 1) continue;
|
||||
|
||||
int? targetPage = null;
|
||||
string? uri = null;
|
||||
|
||||
var actionDict = ann.Elements.GetDictionary("/A");
|
||||
if (actionDict != null)
|
||||
{
|
||||
var s = actionDict.Elements["/S"]?.ToString() ?? "";
|
||||
if (s.Contains("GoTo"))
|
||||
targetPage = ResolveDest(actionDict.Elements["/D"]);
|
||||
else if (s.Contains("URI"))
|
||||
uri = actionDict.Elements.GetString("/URI");
|
||||
}
|
||||
else
|
||||
{
|
||||
targetPage = ResolveDest(ann.Elements["/Dest"]);
|
||||
}
|
||||
|
||||
if (targetPage is null && uri is null) continue;
|
||||
|
||||
object tag = targetPage.HasValue ? (object)targetPage.Value : uri!;
|
||||
string tip = targetPage.HasValue ? $"Go to page {targetPage.Value + 1}" : uri!;
|
||||
links.Add(new LinkInfo(cx, cy, cw, ch, tag, tip, i));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"GetPageLinks (PdfSharpCore): {ex}"); }
|
||||
|
||||
// PdfSharpCore cannot dereference link annotations stored in object streams (common in
|
||||
// linearized / PDF 1.5+ files): it sees the /Annots references but resolves them to null,
|
||||
// yielding zero links. PDFium reads object streams natively, so when PdfSharpCore found no
|
||||
// links here, fall back to it. The early "no /Annots" return above means this only runs on
|
||||
// pages that actually declare annotations, so link-free pages never pay the PDFium cost.
|
||||
if (links.Count == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var viaPdfium = GetPageLinksViaPdfium(pageIndex, bitmapW, bitmapH);
|
||||
if (viaPdfium.Count > 0) return viaPdfium;
|
||||
}
|
||||
catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"GetPageLinks (PDFium fallback): {ex}"); }
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PDFium link extraction (fallback for object-stream PDFs)
|
||||
//
|
||||
// PdfSharpCore silently drops link annotations stored in object streams (linearized /
|
||||
// PDF 1.5+). PDFium - already shipped with Docnet and used elsewhere for security
|
||||
// stripping - resolves them natively via Services/PdfiumInterop.cs.
|
||||
// ============================================================
|
||||
|
||||
private const int PDFACTION_GOTO = 1;
|
||||
private const int PDFACTION_URI = 3;
|
||||
|
||||
// ALL direct PDFium P/Invoke (the link + page-size entry points included) lives in
|
||||
// Services/PdfiumInterop.cs - one class, one lock (Docnet's), auditable discipline.
|
||||
|
||||
// Cached PDFium document handle for link extraction. Object-stream PDFs take the PDFium fallback
|
||||
// on every annotated page; without this we'd FPDF_LoadDocument (re-parse the whole file) once per
|
||||
// page during a render sweep. Keyed by path so it self-heals when the working file changes
|
||||
// (SaveTempAndReload swaps in a new temp). NOTE: on a plain open _currentFile IS the user's real
|
||||
// file (it is only a temp copy after a page edit or repair), so holding this open blocks saving
|
||||
// over that file - every save-over path calls CloseLinkPdfiumDoc() first (#129). Only touched from
|
||||
// UI-thread render paths (RenderPageLinks / AddSecondaryPageLinks), so no locking is needed.
|
||||
private IntPtr _linkPdfiumDoc = IntPtr.Zero;
|
||||
private string? _linkPdfiumDocPath;
|
||||
|
||||
/// <summary>Returns the cached PDFium handle for the current file, (re)opening it if the file
|
||||
/// changed or it isn't open yet. Returns IntPtr.Zero if there is no file or the load fails.</summary>
|
||||
private IntPtr EnsureLinkPdfiumDoc()
|
||||
{
|
||||
if (_currentFile is null) { CloseLinkPdfiumDoc(); return IntPtr.Zero; }
|
||||
if (_linkPdfiumDoc != IntPtr.Zero && _linkPdfiumDocPath == _currentFile)
|
||||
return _linkPdfiumDoc;
|
||||
|
||||
CloseLinkPdfiumDoc();
|
||||
try { _ = DocLib.Instance; } catch { } // force Docnet to init PDFium before direct pdfium.dll calls
|
||||
IntPtr doc = PdfiumInterop.FPDF_LoadDocument(_currentFile, null);
|
||||
if (doc != IntPtr.Zero)
|
||||
{
|
||||
_linkPdfiumDoc = doc;
|
||||
_linkPdfiumDocPath = _currentFile;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/// <summary>Closes the cached PDFium link handle if open. Called when the document changes or
|
||||
/// closes; the path check in EnsureLinkPdfiumDoc is the backstop for anything not closed here.</summary>
|
||||
private void CloseLinkPdfiumDoc()
|
||||
{
|
||||
if (_linkPdfiumDoc != IntPtr.Zero)
|
||||
{
|
||||
try { PdfiumInterop.FPDF_CloseDocument(_linkPdfiumDoc); } catch { }
|
||||
_linkPdfiumDoc = IntPtr.Zero;
|
||||
}
|
||||
_linkPdfiumDocPath = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a page's link annotations via PDFium (handles object-stream PDFs that PdfSharpCore
|
||||
/// cannot). Returns the same canvas-space LinkInfo list as GetPageLinks, with AnnotIndex = -1
|
||||
/// because the native annotation isn't addressable through PdfSharpCore's /Annots array - so
|
||||
/// "Remove Link from PDF" is not offered for these.
|
||||
/// </summary>
|
||||
private List<LinkInfo> GetPageLinksViaPdfium(int pageIndex, int bitmapW, int bitmapH)
|
||||
{
|
||||
var links = new List<LinkInfo>();
|
||||
|
||||
// Reuse the PDFium handle cached per document (EnsureLinkPdfiumDoc) instead of reloading the
|
||||
// whole file on every annotated page - object-stream PDFs take this path on every page, so a
|
||||
// per-call FPDF_LoadDocument would re-parse the file once per page during a render sweep. The
|
||||
// page itself is still loaded/closed per call; only the document handle is shared.
|
||||
IntPtr doc = EnsureLinkPdfiumDoc();
|
||||
if (doc == IntPtr.Zero) return links;
|
||||
|
||||
IntPtr page = PdfiumInterop.FPDF_LoadPage(doc, pageIndex);
|
||||
if (page == IntPtr.Zero) return links;
|
||||
try
|
||||
{
|
||||
double pageWidthPt = PdfiumInterop.FPDF_GetPageWidth(page);
|
||||
double pageHeightPt = PdfiumInterop.FPDF_GetPageHeight(page);
|
||||
if (pageWidthPt <= 0) pageWidthPt = 595.28;
|
||||
if (pageHeightPt <= 0) pageHeightPt = 841.89;
|
||||
|
||||
int startPos = 0;
|
||||
while (PdfiumInterop.FPDFLink_Enumerate(page, ref startPos, out IntPtr link))
|
||||
{
|
||||
if (!PdfiumInterop.FPDFLink_GetAnnotRect(link, out PdfiumInterop.FS_RECTF r)) continue;
|
||||
|
||||
// PDFium may report top/bottom in either order; normalize to min/max so the
|
||||
// mapping matches GetPageLinks (PDF origin is bottom-left, y up).
|
||||
double rx1 = Math.Min(r.left, r.right);
|
||||
double rx2 = Math.Max(r.left, r.right);
|
||||
double ry1 = Math.Min(r.top, r.bottom);
|
||||
double ry2 = Math.Max(r.top, r.bottom);
|
||||
|
||||
var (cx, cy, cw, ch) = PdfRectToCanvas(rx1, ry1, rx2, ry2, pageWidthPt, pageHeightPt, bitmapW, bitmapH);
|
||||
if (cw < 1 || ch < 1) continue;
|
||||
|
||||
int? targetPage = null;
|
||||
string? uri = null;
|
||||
|
||||
IntPtr dest = PdfiumInterop.FPDFLink_GetDest(doc, link);
|
||||
if (dest != IntPtr.Zero)
|
||||
{
|
||||
int t = PdfiumInterop.FPDFDest_GetDestPageIndex(doc, dest);
|
||||
if (t >= 0) targetPage = t;
|
||||
}
|
||||
else
|
||||
{
|
||||
IntPtr action = PdfiumInterop.FPDFLink_GetAction(link);
|
||||
if (action != IntPtr.Zero)
|
||||
{
|
||||
uint at = PdfiumInterop.FPDFAction_GetType(action);
|
||||
if (at == PDFACTION_URI)
|
||||
{
|
||||
uint len = PdfiumInterop.FPDFAction_GetURIPath(doc, action, null, 0);
|
||||
if (len > 1)
|
||||
{
|
||||
var buf = new byte[len];
|
||||
PdfiumInterop.FPDFAction_GetURIPath(doc, action, buf, len);
|
||||
uri = System.Text.Encoding.UTF8.GetString(buf, 0, (int)len - 1);
|
||||
}
|
||||
}
|
||||
else if (at == PDFACTION_GOTO)
|
||||
{
|
||||
IntPtr d2 = PdfiumInterop.FPDFAction_GetDest(doc, action);
|
||||
if (d2 != IntPtr.Zero)
|
||||
{
|
||||
int t = PdfiumInterop.FPDFDest_GetDestPageIndex(doc, d2);
|
||||
if (t >= 0) targetPage = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetPage is null && string.IsNullOrEmpty(uri)) continue;
|
||||
|
||||
object tag = targetPage.HasValue ? (object)targetPage.Value : uri!;
|
||||
string tip = targetPage.HasValue ? $"Go to page {targetPage.Value + 1}" : uri!;
|
||||
links.Add(new LinkInfo(cx, cy, cw, ch, tag, tip, -1));
|
||||
}
|
||||
}
|
||||
finally { PdfiumInterop.FPDF_ClosePage(page); }
|
||||
return links;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders link overlays for the primary page onto the annotation canvas.
|
||||
/// Uses a manual bounds-check in Canvas_MouseLeftButtonDown for hit detection
|
||||
/// (transparent Canvas children are unreliable for WPF hit-testing alone).
|
||||
/// </summary>
|
||||
internal void RenderPageLinks(int pageIndex, int bitmapW, int bitmapH)
|
||||
{
|
||||
if (_doc is null || _currentFile is null) return;
|
||||
|
||||
var links = GetPageLinks(pageIndex, bitmapW, bitmapH);
|
||||
foreach (var lnk in links)
|
||||
{
|
||||
var info = new LinkAnnotInfo(lnk.Tag, pageIndex, lnk.AnnotIndex);
|
||||
// Grow the overlay by LinkHitPad on every side so the hand cursor, right-click menu, and the
|
||||
// click bounds-check all share the padded hit area the tiled views use - thin one-line link
|
||||
// strips are easy to hit in single-page view too.
|
||||
var overlay = new Canvas
|
||||
{
|
||||
Width = lnk.Cw + LinkHitPad * 2,
|
||||
Height = lnk.Ch + LinkHitPad * 2,
|
||||
Background = Brushes.Transparent,
|
||||
Cursor = Cursors.Hand,
|
||||
ToolTip = lnk.Tip,
|
||||
Tag = info,
|
||||
IsHitTestVisible = true,
|
||||
};
|
||||
Canvas.SetLeft(overlay, lnk.Cx - LinkHitPad);
|
||||
Canvas.SetTop(overlay, lnk.Cy - LinkHitPad);
|
||||
|
||||
// Right-click menu: same actions as the tiled-view canvas menu, from the shared builder.
|
||||
var cm = new ContextMenu();
|
||||
if (TryFindResource(typeof(ContextMenu)) is Style menuStyle) cm.Style = menuStyle;
|
||||
TextOptions.SetTextFormattingMode(cm, TextFormattingMode.Display);
|
||||
TextOptions.SetTextRenderingMode(cm, TextRenderingMode.Grayscale);
|
||||
AddLinkMenuItems(cm, lnk.Tag, lnk.AnnotIndex, pageIndex);
|
||||
if (cm.Items.Count > 0) overlay.ContextMenu = cm;
|
||||
|
||||
_annotationCanvas.Children.Add(overlay);
|
||||
_linkOverlays.Add(overlay);
|
||||
}
|
||||
|
||||
if (links.Count > 0)
|
||||
SetStatus(string.Format(Loc("Str_PageOfLinks"), pageIndex + 1, _doc.PageCount, links.Count));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a native PDF link annotation from the page /Annots array and persists the change.
|
||||
/// Called from the "Remove Link from PDF" context-menu item on link overlays.
|
||||
/// </summary>
|
||||
private void RemoveLinkAnnotation(int pageIndex, int annotIndex)
|
||||
{
|
||||
if (_doc is null || pageIndex >= _doc.PageCount || annotIndex < 0) return;
|
||||
try
|
||||
{
|
||||
var pdfPage = _doc.Pages[pageIndex];
|
||||
var annotsArr = pdfPage.Elements.GetArray("/Annots");
|
||||
if (annotsArr is null || annotIndex >= annotsArr.Elements.Count) return;
|
||||
|
||||
// Neutralize the annotation object before removing the /Annots reference.
|
||||
// If PdfSharpCore writes the orphaned indirect object to the output file,
|
||||
// aggressive PDF viewers that scan cross-reference tables directly (rather
|
||||
// than following /Annots) would still trigger the link without this step.
|
||||
PdfItem? elem = annotsArr.Elements[annotIndex];
|
||||
PdfDictionary? ann = elem as PdfDictionary ?? DerefItem(elem) as PdfDictionary;
|
||||
if (ann != null)
|
||||
{
|
||||
ann.Elements.Remove("/A");
|
||||
ann.Elements.Remove("/Dest");
|
||||
ann.Elements.Remove("/Subtype");
|
||||
}
|
||||
|
||||
annotsArr.Elements.RemoveAt(annotIndex);
|
||||
MarkDirty();
|
||||
SaveTempAndReload();
|
||||
// Refresh the current page view so the overlay disappears.
|
||||
int sel = _currentPage;
|
||||
_currentPage = -1;
|
||||
_currentPage = sel;
|
||||
SetStatus(Loc("Str_LinkRemoved"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
KillerDialog.Show(Host!.Window, $"{Loc("Str_LinkRemoveFailed")}\n{ex.Message}", "KillerPDF",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
// StripLinkAnnotationBorders lives in Services/PdfScrub.cs (KillerUI refactor), beside
|
||||
// the other pre-save scrubs it always runs with.
|
||||
|
||||
/// <summary>
|
||||
/// Records a page's link rectangles for the tiled views (continuous, grid, two-page). No
|
||||
/// clickable overlay is created: in the tiled layout a per-link overlay swallows the click
|
||||
/// but its own handler never fires, so clicks and the hover cursor are resolved by bounds-
|
||||
/// testing these rects in Canvas_MouseLeftButtonDown and Canvas_MouseMove instead.
|
||||
/// </summary>
|
||||
internal void AddSecondaryPageLinks(int pageIndex, int bitmapW, int bitmapH)
|
||||
{
|
||||
_continuousLinks[pageIndex] = GetPageLinks(pageIndex, bitmapW, bitmapH);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a /Dest value (PdfArray, PdfString, or PdfName) to a 0-based page index.
|
||||
/// Returns null if the destination cannot be resolved.
|
||||
/// Note: PdfReference is internal to PdfSharpCore so we use reflection for ObjectNumber
|
||||
/// and var-inferred types instead of the type name.
|
||||
/// </summary>
|
||||
private int? ResolveDest(PdfItem? destItem)
|
||||
{
|
||||
if (destItem is null || _doc is null) return null;
|
||||
|
||||
// Dereference indirect object if needed (PdfReference is internal, use duck-typing).
|
||||
destItem = DerefItem(destItem);
|
||||
|
||||
PdfArray? arr = null;
|
||||
|
||||
if (destItem is PdfArray a)
|
||||
{
|
||||
arr = a;
|
||||
}
|
||||
else if (destItem is PdfString || destItem is PdfName)
|
||||
{
|
||||
// Named destination - look up in the document catalog
|
||||
arr = ResolveNamedDest(destItem);
|
||||
}
|
||||
|
||||
if (arr is null || arr.Elements.Count == 0) return null;
|
||||
|
||||
// First element of the destination array is an indirect page reference.
|
||||
// PdfReference.ObjectNumber is public but its type is internal; use reflection.
|
||||
var pageRefItem = arr.Elements[0];
|
||||
int elemObjNum = PdfScrub.GetObjectNumber(pageRefItem);
|
||||
if (elemObjNum > 0)
|
||||
{
|
||||
for (int i = 0; i < _doc.PageCount; i++)
|
||||
{
|
||||
// PdfPage.Reference (public) gives us access to ObjectNumber
|
||||
var pgRef = _doc.Pages[i].Reference;
|
||||
if (pgRef != null && pgRef.ObjectNumber == elemObjNum)
|
||||
return i;
|
||||
}
|
||||
}
|
||||
else if (pageRefItem is PdfInteger pageInt)
|
||||
{
|
||||
int pn = pageInt.Value;
|
||||
if (pn >= 0 && pn < _doc.PageCount) return pn;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a named destination (string or name) to a destination array using the
|
||||
/// catalog's /Dests dictionary or /Names /Dests name tree.
|
||||
/// </summary>
|
||||
private PdfArray? ResolveNamedDest(PdfItem nameItem)
|
||||
{
|
||||
if (_doc is null) return null;
|
||||
string name = nameItem switch
|
||||
{
|
||||
PdfString s => s.Value,
|
||||
PdfName n => n.Value.TrimStart('/'),
|
||||
_ => ""
|
||||
};
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
|
||||
var catalog = _doc.Internals.Catalog;
|
||||
|
||||
// Legacy /Dests dictionary (direct mapping)
|
||||
var dests = catalog.Elements.GetDictionary("/Dests");
|
||||
if (dests != null)
|
||||
{
|
||||
PdfItem? val = DerefItem(dests.Elements[name] ?? dests.Elements["/" + name] ?? new PdfInteger(-1));
|
||||
if (val is PdfArray da) return da;
|
||||
if (val is PdfDictionary dd) return dd.Elements.GetArray("/D");
|
||||
}
|
||||
|
||||
// Modern /Names /Dests name tree
|
||||
var names = catalog.Elements.GetDictionary("/Names");
|
||||
var destTree = names?.Elements.GetDictionary("/Dests");
|
||||
if (destTree != null)
|
||||
return ResolveNameTree(destTree, name);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks a PDF name tree to find the destination array for the given name.
|
||||
/// </summary>
|
||||
private static PdfArray? ResolveNameTree(PdfDictionary node, string name)
|
||||
{
|
||||
// Leaf node: flat /Names array [key val key val ...]
|
||||
var namesArr = node.Elements.GetArray("/Names");
|
||||
if (namesArr != null)
|
||||
{
|
||||
for (int i = 0; i + 1 < namesArr.Elements.Count; i += 2)
|
||||
{
|
||||
var key = namesArr.Elements[i];
|
||||
string keyStr = key is PdfString ks ? ks.Value : key?.ToString() ?? "";
|
||||
if (keyStr == name)
|
||||
{
|
||||
PdfItem? val = Services.PdfScrub.DerefItemStatic(namesArr.Elements[i + 1]);
|
||||
if (val is PdfArray va) return va;
|
||||
if (val is PdfDictionary vd) return vd.Elements.GetArray("/D");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Intermediate node: recurse into /Kids
|
||||
var kids = node.Elements.GetArray("/Kids");
|
||||
if (kids != null)
|
||||
{
|
||||
for (int i = 0; i < kids.Elements.Count; i++)
|
||||
{
|
||||
PdfItem? kid = Services.PdfScrub.DerefItemStatic(kids.Elements[i]);
|
||||
if (kid is PdfDictionary kd)
|
||||
{
|
||||
var result = ResolveNameTree(kd, name);
|
||||
if (result != null) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using Docnet.Core;
|
||||
using Docnet.Core.Models;
|
||||
using Microsoft.Win32;
|
||||
using PdfSharpCore.Drawing;
|
||||
using PdfSharpCore.Pdf;
|
||||
using PdfSharpCore.Pdf.IO;
|
||||
using KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Moved from Shell/PageSelection.cs; the namespace and class line are the only changes. Window
|
||||
// members spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ============================================================
|
||||
// Page selection handler
|
||||
// ============================================================
|
||||
|
||||
private void PageList_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
// Same speed knob as the document viewport (WheelScrollFactor in Zoom.cs).
|
||||
Host?.ScrollSidebar(this, -e.Delta * (48.0 / 120.0) * Controls.PdfViewer.WheelScrollFactor);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void PageJumpBox_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Enter || _doc is null) return;
|
||||
e.Handled = true;
|
||||
if (int.TryParse(Host?.PageJumpText, out int pg))
|
||||
{
|
||||
int idx = Math.Max(0, Math.Min(_doc.PageCount - 1, pg - 1));
|
||||
RecordNavJump(); // Alt+Left retraces the typed jump
|
||||
_currentPage = idx;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Restore current page number if input was invalid
|
||||
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||
}
|
||||
Keyboard.ClearFocus();
|
||||
}
|
||||
|
||||
private void PageJumpBox_GotFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Host?.SelectAllPageJumpText();
|
||||
}
|
||||
|
||||
/// <summary>Set by SyncCurrentPageTo (and only it) around its programmatic SelectedIndex
|
||||
/// write, so the scroll-driven sync does not re-enter the render path through the window's
|
||||
/// XAML-bound handler. Replaces the old detach/attach of a cached delegate, which never
|
||||
/// worked: the list's real subscription is the WINDOW stub, so the -= removed nothing and
|
||||
/// the += accumulated direct subscriptions (2026-08-01).</summary>
|
||||
private bool _syncingPageList;
|
||||
|
||||
private void PageList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_syncingPageList) return; // programmatic sync, not a user selection
|
||||
// The sidebar page list is WINDOW chrome - there is one of it, and BOTH panes attach
|
||||
// their own handler to it. It describes the focused pane only, so the other pane has
|
||||
// to sit this out. Scrolling a pane calls SyncCurrentPageTo, which detaches its OWN
|
||||
// handler before setting SelectedIndex to avoid re-entering the render path; the other
|
||||
// pane's handler stayed attached and navigated that pane to the page you had just
|
||||
// scrolled to in this one, which is why scrolling pane A scrolled pane B.
|
||||
if (Host != null && !Host.IsViewerFocused(this)) return;
|
||||
|
||||
// Mirror the sidebar into the view's own current page. Set
|
||||
// BEFORE the >= 0 guard on purpose - clearing the list (tab close, document close)
|
||||
// drops SelectedIndex to -1 and that has to be mirrored too, or a closed document
|
||||
// leaves a stale page number behind. Assigns _view.CurrentPage directly rather than
|
||||
// going through _currentPage, whose setter would write back into PageList and re-enter
|
||||
// this handler.
|
||||
State.CurrentPage = (sender as ListBox)?.SelectedIndex ?? -1;
|
||||
|
||||
if (_currentPage >= 0)
|
||||
{
|
||||
CommitActiveTextBox();
|
||||
ClearSelection();
|
||||
ClearTextSelection();
|
||||
Host?.EnsureSidebarPageVisible(this, _currentPage);
|
||||
if (_viewMode == ViewMode.Continuous)
|
||||
{
|
||||
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||
ScrollContinuousToPage(_currentPage);
|
||||
return;
|
||||
}
|
||||
if (_viewMode == ViewMode.Grid)
|
||||
{
|
||||
// Grid is a stable overview: selecting a page highlights it but must NOT
|
||||
// re-anchor the grid. It still needs an initial render (open / first display)
|
||||
// when no tiles exist yet; later selections only update the highlight.
|
||||
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||
// Keep the statusbar counter honest even when the clicked tile is already in
|
||||
// view (BringIntoView then scrolls nothing, so the scroll-sync never fires).
|
||||
SetStatus(string.Format(Loc("Str_PageOf"), _currentPage + 1, _doc!.PageCount) + $" - {DisplayZoomPct():F0}%");
|
||||
if (_pageContentPanel.Children.Count <= 1)
|
||||
{
|
||||
PagePreviewPanel.ScrollToTop();
|
||||
PagePreviewPanel.ScrollToHorizontalOffset(0);
|
||||
RenderPage(0); // grid primary is always page 0
|
||||
// Default the grid to a clean 3-columns-across fit. Deferred to Loaded so the
|
||||
// viewport width is valid (it can still be 0 mid-open, which would fall back
|
||||
// to a carried-over zoom and show a single large page).
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
|
||||
(Action)(() => SetZoom(GridZoomForN(Math.Min(_doc?.PageCount ?? 1, 3)))));
|
||||
}
|
||||
else if (_currentPage < _pageContentPanel.Children.Count
|
||||
&& _pageContentPanel.Children[_currentPage] is FrameworkElement gridTile)
|
||||
{
|
||||
// Scroll the chosen page's tile into view (BringIntoView accounts for the zoom transform).
|
||||
gridTile.BringIntoView();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Clicking either page of the spread that's already shown (or re-selecting the
|
||||
// current single page) renders the exact same pixels, so skip the re-render and its
|
||||
// flash - just move the page number. SpreadStart, NOT a local % 2: this was the
|
||||
// fourth pairing site and the one #193's book layout missed - its stale (0,1)
|
||||
// pairing matched the rendered cover and swallowed the render of spread (1,2).
|
||||
int targetPrimary = _currentPage;
|
||||
if (_viewMode == ViewMode.TwoPage) targetPrimary = SpreadStart(targetPrimary);
|
||||
if (targetPrimary == _renderedPrimaryPage && Math.Abs(_zoomLevel - _lastRenderZoom) < 0.0001)
|
||||
{
|
||||
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||
return;
|
||||
}
|
||||
PagePreviewPanel.ScrollToTop();
|
||||
PagePreviewPanel.ScrollToHorizontalOffset(0);
|
||||
RenderPage(_currentPage);
|
||||
ApplyZoom();
|
||||
// Update page jump box
|
||||
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||
// Re-highlight search results on this page if a search is active
|
||||
if (_searchBar is not null && _searchBar.Visibility == Visibility.Visible
|
||||
&& Search.HasResults)
|
||||
HighlightSearchResultsOnCurrentPage();
|
||||
}
|
||||
}
|
||||
|
||||
private void ShortcutHelp_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ShortcutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(ShortcutOverlay);
|
||||
else ShowShortcutsOverlayExclusive();
|
||||
}
|
||||
|
||||
private void ShortcutOverlay_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// Click on the dim backdrop closes the overlay.
|
||||
FadeOverlayOut(ShortcutOverlay);
|
||||
}
|
||||
|
||||
private void ShortcutOverlayCard_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// Stop the click from bubbling up to the backdrop handler.
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void ShortcutOverlayClose_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
FadeOverlayOut(ShortcutOverlay);
|
||||
}
|
||||
|
||||
private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using Docnet.Core;
|
||||
using Docnet.Core.Models;
|
||||
using Microsoft.Win32;
|
||||
using PdfSharpCore.Drawing;
|
||||
using PdfSharpCore.Pdf;
|
||||
using PdfSharpCore.Pdf.IO;
|
||||
using KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Moved from Shell/Selection.cs; the namespace and class line are the only changes. Window
|
||||
// members spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ============================================================
|
||||
// Selection
|
||||
// ============================================================
|
||||
|
||||
// Resolve the active theme's "SelectionAccent" color: a per-theme color picked to stay
|
||||
// readable on the white PDF page (Accent is white in several themes, and AccentBorder is a
|
||||
// pale cream that washes out on white). Falls back to brand green.
|
||||
private Color AccentColor()
|
||||
=> TryFindResource("SelectionAccent") is SolidColorBrush b ? b.Color : Color.FromRgb(30, 165, 76);
|
||||
internal SolidColorBrush AccentBrush(byte alpha = 255)
|
||||
{
|
||||
var c = AccentColor();
|
||||
return new SolidColorBrush(Color.FromArgb(alpha, c.R, c.G, c.B));
|
||||
}
|
||||
// A darker shade of the accent, used for a cover's selection chrome and its in-edit outline so a
|
||||
// cover reads as distinct from the lighter accent on the text box stacked over it.
|
||||
private SolidColorBrush DarkerAccentBrush(byte alpha = 255)
|
||||
{
|
||||
var c = AccentColor();
|
||||
return new SolidColorBrush(Color.FromArgb(alpha, (byte)(c.R * 0.6), (byte)(c.G * 0.6), (byte)(c.B * 0.6)));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Flowing text selection (#127)
|
||||
// ============================================================
|
||||
// A drag that STARTS on a text character tracks the actual run of characters in reading
|
||||
// order, browser-style, instead of the rectangle marquee. Geometry comes from
|
||||
// TextRunService (PdfPig words, the same source as search), endpoints are caret positions
|
||||
// (page, 0..N over the page's flattened chars), and the painted quads use the exact
|
||||
// PDF-to-render math AddSearchHighlight uses, so everything lands where search lands.
|
||||
// Drags that start on empty page keep the classic marquee (annotation box-select,
|
||||
// region copy, OCR region) - see Canvas_MouseLeftButtonDown.
|
||||
|
||||
private readonly TextRunService _textRuns = new();
|
||||
private bool _txtSelActive; // drag in progress
|
||||
private bool _txtSelHasRange; // a committed selection is on screen
|
||||
private (int Page, int Caret) _txtSelAnchor;
|
||||
private (int Page, int Caret) _txtSelFocus;
|
||||
private Point _txtSelDownPos; // press point (gesture canvas coords)
|
||||
private bool _txtSelDragStarted; // true once movement exceeds the click threshold
|
||||
private PageAnnotation? _txtSelClickAnnot; // annotation under the press; selected on plain click
|
||||
private Rect _txtSelClickAnnotBounds;
|
||||
private EditTool? _txtSelCommitTool; // #127 Phase 2: non-null while a Highlight/Strike/
|
||||
// Underline tool owns the flowing drag - the release
|
||||
// commits annotations instead of copying text
|
||||
|
||||
/// <summary>Canvas point to PDF space (points, bottom-left origin) - the inverse of the
|
||||
/// mapping AddSearchHighlight paints with, same as ExtractTextFromRegion.</summary>
|
||||
private static (double X, double Y) CanvasToPdf(Point pos, double renderW, double renderH, PageTextRuns runs)
|
||||
=> (pos.X * runs.PdfWidth / renderW, runs.PdfHeight - pos.Y * runs.PdfHeight / renderH);
|
||||
|
||||
/// <summary>Called from the Select tool's mouse-down. Returns true (and arms the drag) only
|
||||
/// when the press lands ON text; empty page falls through to the marquee.</summary>
|
||||
private bool TryBeginTextSelection(int pageIdx, Point pos)
|
||||
{
|
||||
if (_currentFile is null) return false;
|
||||
if (!_renderDims.TryGetValue(pageIdx, out var rd)) return false;
|
||||
var runs = _textRuns.GetPage(_currentFile, pageIdx);
|
||||
if (runs is null || runs.Chars.Count == 0) return false;
|
||||
|
||||
var (px, py) = CanvasToPdf(pos, rd.w, rd.h, runs);
|
||||
if (!TextRunService.IsOverText(runs, px, py)) return false;
|
||||
|
||||
ClearTextSelection();
|
||||
int caret = TextRunService.CaretFromPoint(runs, px, py);
|
||||
_txtSelAnchor = _txtSelFocus = (pageIdx, caret);
|
||||
_txtSelDownPos = pos;
|
||||
_txtSelDragStarted = false;
|
||||
_txtSelActive = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Mouse-move while a flowing selection drag is live. Resolves which page the
|
||||
/// pointer is over (cross-page tracking in Continuous, where every overlay is a live tile),
|
||||
/// moves the focus caret, and repaints.</summary>
|
||||
private void UpdateTextSelectionDrag(MouseEventArgs e)
|
||||
{
|
||||
if (_currentFile is null) return;
|
||||
// Click-vs-drag threshold: below ~4px of movement this is still a click (which selects
|
||||
// the annotation under the press, if any, on mouse-up) - not a text drag.
|
||||
if (!_txtSelDragStarted)
|
||||
{
|
||||
var tcv = _gestureCanvas ?? _activeCanvas;
|
||||
if (tcv is null) return;
|
||||
var tp = e.GetPosition(tcv);
|
||||
if (Math.Abs(tp.X - _txtSelDownPos.X) < 4 && Math.Abs(tp.Y - _txtSelDownPos.Y) < 4) return;
|
||||
_txtSelDragStarted = true;
|
||||
}
|
||||
int page = _gesturePage;
|
||||
Canvas? cv = _gestureCanvas ?? _activeCanvas;
|
||||
|
||||
if (_viewMode == ViewMode.Continuous)
|
||||
{
|
||||
foreach (var kv in _pages)
|
||||
{
|
||||
var c = kv.Value;
|
||||
double cw = double.IsNaN(c.Width) ? c.ActualWidth : c.Width;
|
||||
double ch = double.IsNaN(c.Height) ? c.ActualHeight : c.Height;
|
||||
var p = e.GetPosition(c);
|
||||
if (p.X >= 0 && p.X <= cw && p.Y >= 0 && p.Y <= ch) { page = kv.Key; cv = c; break; }
|
||||
}
|
||||
}
|
||||
if (cv is null) return;
|
||||
|
||||
// Clamp into the canvas so dragging past an edge clamps to the start/end of lines
|
||||
// instead of losing the selection.
|
||||
double cvW = double.IsNaN(cv.Width) ? cv.ActualWidth : cv.Width;
|
||||
double cvH = double.IsNaN(cv.Height) ? cv.ActualHeight : cv.Height;
|
||||
var pos = e.GetPosition(cv);
|
||||
pos = new Point(Math.Max(0, Math.Min(cvW, pos.X)), Math.Max(0, Math.Min(cvH, pos.Y)));
|
||||
|
||||
if (!_renderDims.TryGetValue(page, out var rd)) return;
|
||||
var runs = _textRuns.GetPage(_currentFile, page);
|
||||
if (runs is null) return;
|
||||
|
||||
var (px, py) = CanvasToPdf(pos, rd.w, rd.h, runs);
|
||||
var focus = (page, TextRunService.CaretFromPoint(runs, px, py));
|
||||
if (focus == _txtSelFocus) return;
|
||||
_txtSelFocus = focus;
|
||||
RepaintTextSelection();
|
||||
}
|
||||
|
||||
/// <summary>Mouse-up: commit the range, copy it (matching the app's existing
|
||||
/// select-copies-immediately behavior), and leave the quads on screen.</summary>
|
||||
private void FinishTextSelection()
|
||||
{
|
||||
_txtSelActive = false;
|
||||
var clickAnnot = _txtSelClickAnnot;
|
||||
var clickBounds = _txtSelClickAnnotBounds;
|
||||
var commitTool = _txtSelCommitTool;
|
||||
_txtSelClickAnnot = null;
|
||||
_txtSelCommitTool = null;
|
||||
if (!_txtSelDragStarted || _txtSelAnchor == _txtSelFocus)
|
||||
{
|
||||
// Plain click: the annotation under the press (e.g. a highlight box covering this
|
||||
// paragraph) gets selected, exactly as it did before flowing selection existed.
|
||||
// (Select tool only - a highlight-tool click just drops the empty gesture.)
|
||||
ClearTextSelection();
|
||||
if (commitTool is null && clickAnnot is not null) SelectAnnotation(clickAnnot, clickBounds);
|
||||
return;
|
||||
}
|
||||
if (commitTool is EditTool hlTool)
|
||||
{
|
||||
CommitFlowingHighlight(hlTool);
|
||||
return;
|
||||
}
|
||||
_txtSelHasRange = true;
|
||||
|
||||
int words;
|
||||
_selectedText = BuildSelectedText(out words);
|
||||
if (string.IsNullOrWhiteSpace(_selectedText))
|
||||
{
|
||||
SetStatus(Loc("Str_St_NoTextInSelection"));
|
||||
ClearTextSelection();
|
||||
return;
|
||||
}
|
||||
try { Clipboard.SetText(_selectedText); } catch { /* clipboard momentarily locked by another app */ }
|
||||
SetStatus(string.Format(Loc("Str_St_CopiedWords"), words));
|
||||
}
|
||||
|
||||
private ((int Page, int Caret) Start, (int Page, int Caret) End) OrderedSelection()
|
||||
{
|
||||
var a = _txtSelAnchor;
|
||||
var f = _txtSelFocus;
|
||||
bool aFirst = a.Page < f.Page || (a.Page == f.Page && a.Caret <= f.Caret);
|
||||
return aFirst ? (a, f) : (f, a);
|
||||
}
|
||||
|
||||
/// <summary>The caret slice of the selection that falls on one page, or (0,0) when none.</summary>
|
||||
private (int Start, int End) SelectionSliceForPage(int page, int charCount)
|
||||
{
|
||||
var (s, e) = OrderedSelection();
|
||||
if (page < s.Page || page > e.Page) return (0, 0);
|
||||
int start = page == s.Page ? s.Caret : 0;
|
||||
int end = page == e.Page ? e.Caret : charCount;
|
||||
return (start, end);
|
||||
}
|
||||
|
||||
private string BuildSelectedText(out int wordCount)
|
||||
{
|
||||
wordCount = 0;
|
||||
if (_currentFile is null) return string.Empty;
|
||||
var (s, e) = OrderedSelection();
|
||||
var sb = new System.Text.StringBuilder();
|
||||
for (int p = s.Page; p <= e.Page; p++)
|
||||
{
|
||||
var runs = _textRuns.GetPage(_currentFile, p);
|
||||
if (runs is null || runs.Chars.Count == 0) continue;
|
||||
var (start, end) = SelectionSliceForPage(p, runs.Chars.Count);
|
||||
if (start >= end) continue;
|
||||
string t = TextRunService.TextForRange(runs, start, end, out int w);
|
||||
if (t.Length == 0) continue;
|
||||
if (sb.Length > 0) sb.Append('\n');
|
||||
sb.Append(t);
|
||||
wordCount += w;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Per-line rects (canvas render-dim space) of the current selection on one page -
|
||||
/// one rect per line, first selected char to last, browser-style. Shared by the selection
|
||||
/// quad painter and the flowing-highlight commit (#127 Phase 2) so a committed highlight
|
||||
/// lands exactly where the drag preview showed it.</summary>
|
||||
private List<Rect> SelectionLineRectsForPage(int page)
|
||||
{
|
||||
var result = new List<Rect>();
|
||||
if (_currentFile is null) return result;
|
||||
var (s, e) = OrderedSelection();
|
||||
if (page < s.Page || page > e.Page) return result;
|
||||
if (!_renderDims.TryGetValue(page, out var rd)) return result;
|
||||
var runs = _textRuns.GetPage(_currentFile, page);
|
||||
if (runs is null || runs.Chars.Count == 0) return result;
|
||||
var (start, end) = SelectionSliceForPage(page, runs.Chars.Count);
|
||||
if (start >= end) return result;
|
||||
|
||||
double sx = rd.w / runs.PdfWidth;
|
||||
double sy = rd.h / runs.PdfHeight;
|
||||
|
||||
int i = start;
|
||||
while (i < end)
|
||||
{
|
||||
var line = runs.Lines[runs.Chars[i].Line];
|
||||
int segEnd = Math.Min(end, line.End);
|
||||
|
||||
// A selected caret slice runs left-to-right for LTR and right-to-left for RTL.
|
||||
// Use its physical extremes rather than assuming the first glyph is on the left.
|
||||
double left = runs.Chars.Skip(i).Take(segEnd - i).Min(c => c.Left);
|
||||
double right = runs.Chars.Skip(i).Take(segEnd - i).Max(c => c.Right);
|
||||
double h = (line.Top - line.Bottom) * sy;
|
||||
double pad = h * 0.12; // a touch of breathing room; tighter than search's 0.30
|
||||
|
||||
result.Add(new Rect(left * sx, rd.h - line.Top * sy - pad,
|
||||
Math.Max((right - left) * sx, 2), Math.Max(h + pad * 2, 2)));
|
||||
i = segEnd;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Paints one page's selection quads onto its overlay. Called while dragging and
|
||||
/// from the tail of RenderAllAnnotations so the quads survive re-renders, exactly like
|
||||
/// search highlights do.</summary>
|
||||
private void ApplyTextSelectionQuads(int page, Canvas canvas)
|
||||
{
|
||||
if (!_txtSelActive && !_txtSelHasRange) return;
|
||||
foreach (var r in SelectionLineRectsForPage(page))
|
||||
{
|
||||
var rect = new Rectangle
|
||||
{
|
||||
Opacity = 60.0 / 255.0,
|
||||
Width = r.Width,
|
||||
Height = r.Height,
|
||||
IsHitTestVisible = false,
|
||||
Tag = "TextSelQuad"
|
||||
};
|
||||
// Live theme binding (net48 rule: a plain brush snapshot won't follow a theme
|
||||
// switch) - the quads recolor the moment the theme or accent changes.
|
||||
rect.SetResourceReference(Shape.FillProperty, "SelectionAccent");
|
||||
Canvas.SetLeft(rect, r.X);
|
||||
Canvas.SetTop(rect, r.Y);
|
||||
canvas.Children.Add(rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#127 Phase 2: turns the flowing selection into Highlight / Strikethrough /
|
||||
/// Underline annotations - one per selected line, grouped per page so one gesture behaves
|
||||
/// as one annotation (select, move, delete together), and one page-snapshot undo entry so
|
||||
/// Ctrl+Z reverts the whole gesture in a single step.</summary>
|
||||
private void CommitFlowingHighlight(EditTool tool)
|
||||
{
|
||||
var (s, e) = OrderedSelection();
|
||||
var perPage = new List<(int Page, List<Rect> Rects)>();
|
||||
for (int p = s.Page; p <= e.Page; p++)
|
||||
{
|
||||
var rects = SelectionLineRectsForPage(p);
|
||||
if (rects.Count > 0) perPage.Add((p, rects));
|
||||
}
|
||||
if (perPage.Count == 0) { ClearTextSelection(); return; }
|
||||
|
||||
PushPagesSnapshotUndo(perPage.Select(pp => pp.Page));
|
||||
var style = tool == EditTool.Strikethrough ? HighlightStyle.Strikethrough
|
||||
: tool == EditTool.Underline ? HighlightStyle.Underline
|
||||
: HighlightStyle.Fill;
|
||||
int total = 0;
|
||||
foreach (var (page, rects) in perPage)
|
||||
{
|
||||
// One group per page; a single-line highlight stays ungrouped.
|
||||
string gid = rects.Count > 1 ? Guid.NewGuid().ToString("N") : "";
|
||||
if (!_annotations.ContainsKey(page)) _annotations[page] = [];
|
||||
foreach (var r in rects)
|
||||
{
|
||||
var ha = new HighlightAnnotation { PageIndex = page, Bounds = r, Style = style, GroupId = gid };
|
||||
ha.SetColor(tool == EditTool.Highlight ? _highlightColor : _lineAnnotColor);
|
||||
_annotations[page].Add(ha);
|
||||
total++;
|
||||
}
|
||||
}
|
||||
MarkDirty();
|
||||
ClearTextSelection();
|
||||
foreach (var (page, _) in perPage) RenderAllAnnotations(page);
|
||||
SetStatus(string.Format(Loc(style == HighlightStyle.Fill ? "Str_St_HighlightedLines" : style == HighlightStyle.Strikethrough ? "Str_St_StruckLines" : "Str_St_UnderlinedLines"), total));
|
||||
}
|
||||
|
||||
/// <summary>Drops and repaints the quads on every page the selection touches.</summary>
|
||||
private void RepaintTextSelection()
|
||||
{
|
||||
RemoveTextSelQuads();
|
||||
var (s, e) = OrderedSelection();
|
||||
for (int p = s.Page; p <= e.Page; p++)
|
||||
{
|
||||
var canvas = VisibleCanvasForPage(p);
|
||||
if (canvas is not null) ApplyTextSelectionQuads(p, canvas);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveTextSelQuads()
|
||||
{
|
||||
foreach (var canvas in AllPageCanvases())
|
||||
{
|
||||
var toRemove = canvas.Children.OfType<Rectangle>()
|
||||
.Where(r => r.Tag is string s && s == "TextSelQuad").ToList();
|
||||
foreach (var r in toRemove)
|
||||
canvas.Children.Remove(r);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ctrl+A: flowing select-all on the current page (quads over every line) and copy.</summary>
|
||||
private void SelectAllText()
|
||||
{
|
||||
if (_currentFile is null) return;
|
||||
int pageIdx = _currentPage;
|
||||
if (pageIdx < 0) return;
|
||||
|
||||
try
|
||||
{
|
||||
var runs = _textRuns.GetPage(_currentFile, pageIdx);
|
||||
if (runs is null || runs.Chars.Count == 0)
|
||||
{
|
||||
SetStatus(Loc("Str_St_NoTextOnPage"));
|
||||
return;
|
||||
}
|
||||
ClearTextSelection();
|
||||
_txtSelAnchor = (pageIdx, 0);
|
||||
_txtSelFocus = (pageIdx, runs.Chars.Count);
|
||||
_txtSelHasRange = true;
|
||||
RepaintTextSelection();
|
||||
|
||||
_selectedText = TextRunService.TextForRange(runs, 0, runs.Chars.Count, out _);
|
||||
if (string.IsNullOrWhiteSpace(_selectedText))
|
||||
{
|
||||
SetStatus(Loc("Str_St_NoTextOnPage"));
|
||||
ClearTextSelection();
|
||||
return;
|
||||
}
|
||||
Clipboard.SetText(_selectedText);
|
||||
SetStatus(Loc("Str_St_SelectAllCopied"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(string.Format(Loc("Str_St_SelectAllError"), ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private void CopySelectedText()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_selectedText))
|
||||
{
|
||||
Clipboard.SetText(_selectedText);
|
||||
SetStatus(Loc("Str_St_Copied"));
|
||||
}
|
||||
else
|
||||
{
|
||||
SetStatus(Loc("Str_St_NoTextSelected"));
|
||||
}
|
||||
}
|
||||
|
||||
internal void ClearTextSelection()
|
||||
{
|
||||
if (_selectRect is not null)
|
||||
{
|
||||
// Remove from the rect's ACTUAL parent. Since the cross-page marquee rework the
|
||||
// selection box lives on the window-level MarqueeLayer, not the page canvas, so
|
||||
// removing from _activeCanvas was a silent no-op that orphaned the box on the
|
||||
// layer until app restart (#121).
|
||||
(_selectRect.Parent as Canvas)?.Children.Remove(_selectRect);
|
||||
_selectRect = null;
|
||||
}
|
||||
_selectedText = null;
|
||||
_txtSelActive = false;
|
||||
_txtSelHasRange = false;
|
||||
_txtSelDragStarted = false;
|
||||
_txtSelCommitTool = null;
|
||||
RemoveTextSelQuads();
|
||||
}
|
||||
|
||||
/// <summary>Marquee fallback: rectangle region copy, used when a drag starts on EMPTY page
|
||||
/// (scans, margins). Kept word-box based on purpose - on pages with no text layer there is
|
||||
/// nothing to flow along, and this is also what the annotation box-select falls back to.</summary>
|
||||
private void ExtractTextFromRegion(int pageIdx, Rect canvasBounds)
|
||||
{
|
||||
if (_currentFile is null || pageIdx < 0) return;
|
||||
if (!_renderDims.ContainsKey(pageIdx)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var (renderW, renderH) = _renderDims[pageIdx];
|
||||
|
||||
using var pigDoc = PdfPigDoc.Open(_currentFile);
|
||||
if (pageIdx >= pigDoc.NumberOfPages) return;
|
||||
var page = pigDoc.GetPage(pageIdx + 1); // PdfPig is 1-based
|
||||
|
||||
double pdfW = page.Width;
|
||||
double pdfH = page.Height;
|
||||
double sx = pdfW / renderW;
|
||||
double sy = pdfH / renderH;
|
||||
|
||||
// Convert canvas rect to PDF coordinates (flip Y - PDF origin is bottom-left)
|
||||
double pdfLeft = canvasBounds.Left * sx;
|
||||
double pdfRight = canvasBounds.Right * sx;
|
||||
double pdfTop = pdfH - (canvasBounds.Top * sy);
|
||||
double pdfBottom = pdfH - (canvasBounds.Bottom * sy);
|
||||
// pdfTop > pdfBottom because of Y flip
|
||||
double pdfMinY = Math.Min(pdfTop, pdfBottom);
|
||||
double pdfMaxY = Math.Max(pdfTop, pdfBottom);
|
||||
|
||||
var words = page.GetWords()
|
||||
.Where(w =>
|
||||
{
|
||||
var bb = w.BoundingBox;
|
||||
double cx = (bb.Left + bb.Right) / 2;
|
||||
double cy = (bb.Bottom + bb.Top) / 2;
|
||||
return cx >= pdfLeft && cx <= pdfRight && cy >= pdfMinY && cy <= pdfMaxY;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (words.Count == 0)
|
||||
{
|
||||
SetStatus(Loc("Str_St_NoTextInSelection"));
|
||||
ClearTextSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedText = WordsToText(words);
|
||||
|
||||
Clipboard.SetText(_selectedText);
|
||||
int wordCount = words.Count;
|
||||
SetStatus(string.Format(Loc("Str_St_CopiedWords"), wordCount));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(string.Format(Loc("Str_St_ExtractError"), ex.Message));
|
||||
ClearTextSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// This pane's tab band: which tabs are in the strip, which of them owns an edge, what the card's
|
||||
// top corners do, where the focus ring runs, and the drag that reorders them or hands one to the
|
||||
// other pane.
|
||||
//
|
||||
// Ported from KillerShell (FilePane.xaml + Tabs.cs + DualPane.cs + PaneDrag.cs). The strip is an
|
||||
// ItemsControl bound to _sessions over a UniformGrid, and every visual decision below is a
|
||||
// NOTIFYING FLAG ON THE SESSION that a template trigger reads - not a property written onto a
|
||||
// code-built Border. That is the whole point: there is one place each rule is expressed, so a
|
||||
// fix to one edge case cannot break the next one.
|
||||
//
|
||||
// Two consequences worth knowing before changing anything here:
|
||||
// * UniformGrid divides the band equally, so the last visible tab ALWAYS reaches the strip's
|
||||
// right edge. Edge ownership is decided, never measured. The old strip measured it after
|
||||
// every reflow, which is why the halo came and went with the pane width.
|
||||
// * A collapsed child is not counted when UniformGrid divides the band, so windowing tabs out
|
||||
// into the chevron needs no width arithmetic at all - the survivors fill the band on their own.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
/// <summary>Bind the strip to this pane's sessions. Called once, from InitSplitPanes.</summary>
|
||||
private void InitTabStrip()
|
||||
{
|
||||
if (TabStrip != null) TabStrip.ItemsSource = _sessions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one funnel. Every add, close, switch, drag-reorder and resize ends here, and it is the
|
||||
/// only thing that writes the strip's state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Still called RebuildTabStrip because ~20 call sites and RebuildTabStripExt already say so,
|
||||
/// but it rebuilds nothing: the ItemsControl repaints itself off the collection and the
|
||||
/// notifying flags.
|
||||
/// </remarks>
|
||||
private void RebuildTabStrip()
|
||||
{
|
||||
if (TabStrip == null || TabStripBorder == null) return;
|
||||
|
||||
foreach (var t in _sessions) t.RefreshTabLabel();
|
||||
|
||||
int docTabs = _sessions.Count(t => t.Doc != null || t.DeferredPath != null);
|
||||
// Only show the strip once THIS pane has more than one document - a single open PDF
|
||||
// doesn't need tabs, split or not. (KillerShell forces the band on in both panes whenever
|
||||
// either one has 2+ tabs, so the two card tops always line up; the simpler per-pane
|
||||
// rule is used here instead, so a lone tab never shows a bar even while split.)
|
||||
bool show = docTabs > 1;
|
||||
TabStripBorder.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
// The -1 tucks the card's top border a pixel into the band, so the active tab and the card
|
||||
// read as one surface. ONLY while there IS a band: a collapsed element contributes no
|
||||
// height, so with no strip the -1 lifts this pane a pixel above the other one instead of
|
||||
// tucking under anything. The bandless card gets 3px of top air instead of 0 - at 0 it
|
||||
// opened 3px too high against the chrome (2026-08-01).
|
||||
CardRow.Margin = new Thickness(0, show ? -1 : 3, 0, 0);
|
||||
|
||||
// Win98 tabs sit on a raised client frame. Keep the frame's vertical sides and bottom;
|
||||
// the one-pixel top ledge is drawn by TabBarRing so the active tab can cover its own
|
||||
// segment while the ledge remains visible beneath the inactive tabs.
|
||||
bool retroTheme = Services.ThemeManager.Current == Services.Theme.SE98;
|
||||
bool retroTabs = show && retroTheme;
|
||||
if (retroTheme)
|
||||
{
|
||||
PaneBevelOuterDark.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelTopLeftBrush");
|
||||
PaneBevelOuterLight.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelBottomRightBrush");
|
||||
PaneBevelInnerDark.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelInnerTopLeftBrush");
|
||||
PaneBevelInnerLight.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelInnerBottomRightBrush");
|
||||
}
|
||||
else
|
||||
{
|
||||
PaneBevelOuterDark.SetResourceReference(Border.BorderBrushProperty, "PaneBevelDarkBrush");
|
||||
PaneBevelOuterLight.SetResourceReference(Border.BorderBrushProperty, "PaneBevelLightBrush");
|
||||
PaneBevelInnerDark.SetResourceReference(Border.BorderBrushProperty, "PaneBevelDark2Brush");
|
||||
PaneBevelInnerLight.SetResourceReference(Border.BorderBrushProperty, "PaneBevelLight2Brush");
|
||||
}
|
||||
if (retroTabs)
|
||||
{
|
||||
PaneBorder.BorderThickness = new Thickness(1, 0, 1, 1);
|
||||
PaneBevelOuterDark.BorderThickness = new Thickness(1, 0, 0, 0);
|
||||
PaneBevelOuterLight.BorderThickness = new Thickness(0, 0, 1, 1);
|
||||
PaneBevelInnerDark.BorderThickness = new Thickness(1, 0, 0, 0);
|
||||
// PaneBorder + the outer dark bevel are the complete Win98 right edge.
|
||||
// A third same-color inner rule made the edge fat and produced a one-pixel
|
||||
// step where the selected last tab joined it.
|
||||
PaneBevelInnerLight.BorderThickness = new Thickness(0, 0, 0, 1);
|
||||
}
|
||||
else if (retroTheme)
|
||||
{
|
||||
// With no tab band there is nothing for a raised client frame to join. The old
|
||||
// generic fallback below reapplied all four 98SE bevel resources and drew a heavy
|
||||
// rectangle around the entire document pane. Keep the single-document client
|
||||
// flush; the classic frame is only part of the multi-tab treatment above.
|
||||
PaneBorder.BorderThickness = new Thickness(0);
|
||||
PaneBevelOuterDark.BorderThickness = new Thickness(0);
|
||||
PaneBevelOuterLight.BorderThickness = new Thickness(0);
|
||||
PaneBevelInnerDark.BorderThickness = new Thickness(0);
|
||||
PaneBevelInnerLight.BorderThickness = new Thickness(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
PaneBorder.BorderThickness = new Thickness(1);
|
||||
PaneBevelOuterDark.SetResourceReference(Border.BorderThicknessProperty, "PaneBevelLightThickness");
|
||||
PaneBevelOuterLight.SetResourceReference(Border.BorderThicknessProperty, "PaneBevelDarkThickness");
|
||||
PaneBevelInnerDark.SetResourceReference(Border.BorderThicknessProperty, "PaneBevel2LightThickness");
|
||||
PaneBevelInnerLight.SetResourceReference(Border.BorderThicknessProperty, "PaneBevel2DarkThickness");
|
||||
}
|
||||
|
||||
// Which tabs fit at this width, before anything below asks which is on an edge.
|
||||
ApplyTabWindow();
|
||||
|
||||
// First and last VISIBLE, not first and last in the list. Both are about the strip's own
|
||||
// edges: IsLast drops the divider that would otherwise land on the right edge as a stray
|
||||
// rule, and IsFirst/IsLast keep the tab from drawing the outer ring side that the band
|
||||
// already draws. With tabs windowed out, the tab sitting on an edge is not the one at the
|
||||
// end of the collection.
|
||||
//
|
||||
// And NOT the last visible tab while the chevron is showing: the chevron is what sits on
|
||||
// the band's right edge then, so the tab is a middle tab in every way that matters. Told
|
||||
// otherwise it dropped the divider that separates it from the chevron AND handed its right
|
||||
// side to the band, which drew that side at the band's edge - past the chevron, as an
|
||||
// accent stripe up the far right with nothing under it.
|
||||
bool chevron = TabOverflowBtn.Visibility == Visibility.Visible;
|
||||
|
||||
var strip = _sessions.Where(t => t.IsStripVisible).ToList();
|
||||
foreach (var t in _sessions)
|
||||
{
|
||||
t.IsFirst = false;
|
||||
t.IsLast = false;
|
||||
t.RetroBeforeActive = false;
|
||||
t.RetroAfterActive = false;
|
||||
t.RetroLastInactive = false;
|
||||
t.UseRetroTabChrome = retroTabs;
|
||||
}
|
||||
if (strip.Count > 0)
|
||||
{
|
||||
strip[0].IsFirst = true;
|
||||
strip[strip.Count - 1].IsLast = !chevron;
|
||||
|
||||
int activeIndex = strip.IndexOf(_active!);
|
||||
if (retroTabs && activeIndex >= 0)
|
||||
{
|
||||
if (activeIndex > 0) strip[activeIndex - 1].RetroBeforeActive = true;
|
||||
if (activeIndex + 1 < strip.Count) strip[activeIndex + 1].RetroAfterActive = true;
|
||||
if (!chevron && !strip[^1].IsActive) strip[^1].RetroLastInactive = true;
|
||||
}
|
||||
}
|
||||
|
||||
SyncPaneLeadingCorner();
|
||||
UpdatePaneFocusRing();
|
||||
UpdateTabStripFade();
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)UpdateFooterFade);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// OVERFLOW
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// The strip is a UniformGrid, so every tab takes an equal share of the band whatever the
|
||||
// count - right up to a point, and then off a cliff. Eight tabs in a half-width pane came out
|
||||
// around forty pixels each, which is not a label, it is a shape. A tab you cannot read is a
|
||||
// tab you have to click to identify, and at that point the strip has stopped being navigation.
|
||||
//
|
||||
// So the COUNT is capped rather than the width. As many tabs as fit at TabFloorWidth stay in
|
||||
// the strip and the rest are collapsed. The chevron at the right end lists every tab, so
|
||||
// nothing is unreachable.
|
||||
//
|
||||
// Scrolling was the other option and is what a browser does. It lost because the band is a
|
||||
// bordered surface the pane's focus ring runs along, and a scrolled band cannot be edge to
|
||||
// edge - the ring would have to stop somewhere that is not a corner.
|
||||
|
||||
/// <summary>Narrowest a tab may get before the strip stops taking more.</summary>
|
||||
/// <remarks>
|
||||
/// Picked from what it has to hold rather than off a grid: 120px is about sixteen characters
|
||||
/// at this size once the close x and the padding are paid for - "Quarterly-Repo...", enough to
|
||||
/// tell two documents apart. Much below a hundred and the ellipsis starts eating the part that
|
||||
/// distinguishes them, which is the whole job.
|
||||
/// </remarks>
|
||||
private const double TabFloorWidth = 120;
|
||||
|
||||
/// <summary>What the chevron takes out of the band while it is showing.</summary>
|
||||
private const double TabChevronWidth = 26;
|
||||
|
||||
/// <summary>Index of the leftmost tab currently in the strip. 0 whenever they all fit.</summary>
|
||||
/// <remarks>
|
||||
/// Per pane, like the sessions themselves: the two strips are different widths and hold
|
||||
/// different numbers of tabs, so one shared index would have each pane scrolling the other.
|
||||
/// </remarks>
|
||||
private int _tabWindow;
|
||||
|
||||
/// <summary>
|
||||
/// Decide which of this pane's tabs are in the strip at its current width, and show or hide
|
||||
/// the chevron. Called from RebuildTabStrip, before anything reads which tab is on an edge.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The window is a contiguous RUN, not a set: tabs keep their order and their neighbors, so a
|
||||
/// strip that has moved still reads like the tab bar it was. It shifts the least it can to
|
||||
/// keep the active tab on screen, which is the one invariant that matters - a tab you just
|
||||
/// switched to and cannot see is worse than no strip at all.
|
||||
/// </remarks>
|
||||
private void ApplyTabWindow()
|
||||
{
|
||||
int n = _sessions.Count;
|
||||
if (n == 0) { TabOverflowBtn.Visibility = Visibility.Collapsed; return; }
|
||||
|
||||
// ActualWidth is 0 until the band has been measured once - on the first pass, and on any
|
||||
// pass that runs while the pane is hidden. Falling back to the pane's own width keeps the
|
||||
// answer sane instead of capping the strip at one tab and having to be undone by the
|
||||
// SizeChanged that follows.
|
||||
double avail = TabStripBorder.ActualWidth > 0 ? TabStripBorder.ActualWidth : ActualWidth;
|
||||
|
||||
// Two passes, because the chevron's width changes the answer that decides whether there is
|
||||
// a chevron. Asked without it first: if everything fits there is none, and the whole band
|
||||
// belongs to the strip.
|
||||
int cap = (int)(avail / TabFloorWidth);
|
||||
bool overflow = cap < n;
|
||||
if (overflow)
|
||||
{
|
||||
cap = Math.Max(1, (int)((avail - TabChevronWidth) / TabFloorWidth));
|
||||
if (cap >= n) overflow = false;
|
||||
}
|
||||
|
||||
TabOverflowBtn.Visibility = overflow ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
int start = 0;
|
||||
if (overflow)
|
||||
{
|
||||
// Clamped before the active tab is considered, so a window left pointing past the end
|
||||
// by a close does not survive as a scroll nobody asked for.
|
||||
start = Math.Max(0, Math.Min(_tabWindow, n - cap));
|
||||
|
||||
int active = _active == null ? -1 : _sessions.IndexOf(_active);
|
||||
if (active >= 0 && active < start) start = active;
|
||||
else if (active >= 0 && active > start + cap - 1) start = active - cap + 1;
|
||||
|
||||
_tabWindow = start;
|
||||
}
|
||||
else
|
||||
{
|
||||
_tabWindow = 0;
|
||||
cap = n;
|
||||
}
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
_sessions[i].IsStripVisible = i >= start && i < start + cap;
|
||||
}
|
||||
|
||||
/// <summary>The band was resized, so the strip may hold a different number of tabs.</summary>
|
||||
/// <remarks>
|
||||
/// Goes through RebuildTabStrip rather than calling ApplyTabWindow alone: a different set of
|
||||
/// visible tabs is a different first and last tab, and those are the card's corner rounding
|
||||
/// and the focus ring's outer verticals as much as they are the strip.
|
||||
/// </remarks>
|
||||
private void TabBarResized()
|
||||
{
|
||||
if (_sessions.Count == 0 || _inTabResize) return;
|
||||
|
||||
// Reentrancy guard, not an optimization. RebuildTabStrip writes CardRow.Margin and flips
|
||||
// the band's own Visibility, either of which can raise SizeChanged again from inside this
|
||||
// call - and a layout loop in WPF is not a slow app, it is a hung one. The pass that
|
||||
// follows would compute the same answer anyway.
|
||||
_inTabResize = true;
|
||||
try { RebuildTabStrip(); }
|
||||
finally { _inTabResize = false; }
|
||||
}
|
||||
|
||||
private bool _inTabResize;
|
||||
|
||||
private void TabStripBorder_SizeChanged(object sender, SizeChangedEventArgs e) => TabBarResized();
|
||||
|
||||
/// <summary>The chevron: every tab in this pane, hidden ones included, in strip order.</summary>
|
||||
/// <remarks>
|
||||
/// EVERY tab, not only the overflowed ones. A list that shows just what is off screen makes
|
||||
/// you work out which those are before you can use it, and the visible ones cost nothing to
|
||||
/// include. Built on each open rather than kept: titles change on every save and load.
|
||||
/// </remarks>
|
||||
private void TabOverflow_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var menu = MakeThemedMenu();
|
||||
foreach (var t in _sessions)
|
||||
{
|
||||
var sess = t;
|
||||
// Doubled, because a lone underscore in a MenuItem header is an access-key marker:
|
||||
// "Q3_Report" would draw as "Q3Report" with an R underlined, and file names carry
|
||||
// underscores all the time.
|
||||
var item = MakeMenuItem(sess.TabLabel.Replace("_", "__"), (_, _) => SwitchToTab(sess), glyph: "");
|
||||
// Bold rather than a check mark: the menu has no icon column to put one in.
|
||||
if (sess.IsActive) item.FontWeight = FontWeights.Bold;
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
menu.PlacementTarget = TabOverflowBtn;
|
||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// CARD CORNERS + FOCUS RING
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Square off the card's top corners under a flush active tab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each top corner squares only when the tab sitting on it is the ACTIVE one: the first tab
|
||||
/// owns the top-left, the last owns the top-right. That tab's outer edge is flat and flush
|
||||
/// with the card's, so a curve underneath cuts a notch out from under a square tab. An
|
||||
/// inactive tab is window-colored and so a different surface anyway, and the card keeps its
|
||||
/// rounding under it; no strip at all and the card takes its full radius back.
|
||||
///
|
||||
/// Read off the MODEL, never re-measured from the visual tree. Skipped in full screen, where
|
||||
/// ApplyFullScreen owns the radius and squares all four.
|
||||
/// </remarks>
|
||||
private void SyncPaneLeadingCorner()
|
||||
{
|
||||
// PaneBorder / PaneShadow, not DocPaneBorder / DocPaneShadow: those names are the
|
||||
// window's forwards to the FOCUSED pane, and this runs for whichever pane's strip
|
||||
// changed. Using them here squares pane A's corners when pane B's tabs move.
|
||||
if (PaneBorder == null || _fullScreen) return;
|
||||
double r = TryFindResource("RadCard") is CornerRadius rc ? rc.TopLeft : 6;
|
||||
|
||||
bool strip = TabStripBorder != null && TabStripBorder.Visibility == Visibility.Visible;
|
||||
bool firstActive = strip && _active?.IsFirst == true;
|
||||
bool lastActive = strip && _active?.IsLast == true;
|
||||
|
||||
var cr = new CornerRadius(firstActive ? 0 : r, lastActive ? 0 : r, r, r);
|
||||
PaneBorder.CornerRadius = cr;
|
||||
if (PaneShadow != null) PaneShadow.CornerRadius = cr;
|
||||
// Keep the ring's top radii in step with the card's, so its curved sides land exactly on
|
||||
// the card's own left/right border rather than beside them.
|
||||
if (TabBarRing != null)
|
||||
{
|
||||
TabBarRing.CornerRadius = new CornerRadius(cr.TopLeft, cr.TopRight, 0, 0);
|
||||
if (Services.ThemeManager.Current == Services.Theme.SE98)
|
||||
{
|
||||
// The raised pane's light top rule is the horizontal part of the selected-tab
|
||||
// route. The selected tab covers its own segment; the remaining rule turns up
|
||||
// at the tab sides and therefore reads as one continuous classic outline.
|
||||
TabBarRing.BorderThickness = new Thickness(0, 1, 0, 0);
|
||||
TabBarRing.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Modern themes do not use the Win98 edge overlays, so the ring carries the
|
||||
// side only where the active tab makes that card corner square.
|
||||
TabBarRing.BorderThickness = new Thickness(firstActive ? 1 : 0, 1, lastActive ? 1 : 0, 0);
|
||||
TabBarRing.SetResourceReference(Border.BorderBrushProperty, "PaneEdgeBrush");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark this pane's focus state on its tabs and draw the ring's outer verticals.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The ring has to continue UP and AROUND the active tab, or it stops dead at the band and the
|
||||
/// tab and card read as two surfaces. The tab's own share of that is a template trigger on
|
||||
/// PaneFocused; all this does is set the flag.
|
||||
///
|
||||
/// PaneDimmed is the other half - the active tab of the pane that does NOT have focus drops
|
||||
/// its lip to the card's border color, because two lips at full accent both claim to be the
|
||||
/// live pane. Deliberately NOT !PaneFocused: with one pane open both are false and that pane's
|
||||
/// lip stays bright.
|
||||
///
|
||||
/// The outermost verticals come from the BAND, not from the tab: a first or last tab's own
|
||||
/// outer border sits on the ScrollViewer's clip edge and gets cut, so whether it survived
|
||||
/// depended on how the UniformGrid divided a fractional band width. TabEdgeLeft/Right are
|
||||
/// anchored to the band's own edges, which are the card's edges, so there is no arithmetic to
|
||||
/// land wrong.
|
||||
/// </remarks>
|
||||
private void UpdatePaneFocusRing()
|
||||
{
|
||||
bool split = Host?.IsSplitView == true;
|
||||
bool retro = Services.ThemeManager.Current == Services.Theme.SE98;
|
||||
// Modern themes only need a focus ring when panes are split. 98SE uses the
|
||||
// selected pane's surface color as its focus indicator, including single-pane mode.
|
||||
bool paneActive = PaneHasFocus && (split || retro);
|
||||
bool lit = PaneHasFocus && split && !retro;
|
||||
|
||||
// TabBarRing is the pane border's top segment inside the tab band. Corner syncing
|
||||
// assigns its geometry and an idle brush, so focus must restore the live accent here
|
||||
// every time the tab state is rebuilt. Without this assignment, the active tab and
|
||||
// the vertical card edges lit up while the horizontal segments beside the tab stayed
|
||||
// dark, leaving the focused-pane perimeter visibly broken.
|
||||
if (TabBarRing != null && !retro)
|
||||
TabBarRing.SetResourceReference(Border.BorderBrushProperty,
|
||||
lit ? "TabActiveRingBrush" : "PaneEdgeBrush");
|
||||
|
||||
foreach (var t in _sessions)
|
||||
{
|
||||
// 98SE uses PaneFocused only for the shared darker pane/tab surface. Its focus
|
||||
// thickness resources are zero, so this never revives the modern accent outline.
|
||||
t.PaneFocused = paneActive && t.IsActive;
|
||||
t.PaneDimmed = split && !paneActive && t.IsActive;
|
||||
}
|
||||
|
||||
if (PaneBorder != null)
|
||||
PaneBorder.SetResourceReference(Border.BackgroundProperty,
|
||||
retro ? (paneActive ? "FocusedPaneBrush" : "TabInactiveBrush") : "BgCanvas");
|
||||
if (PaneShadow != null)
|
||||
PaneShadow.SetResourceReference(Border.BackgroundProperty,
|
||||
retro ? (paneActive ? "FocusedPaneBrush" : "TabInactiveBrush") : "BgCanvas");
|
||||
|
||||
// Same ownership rule the card's corner rounding uses, read off the tab rather than
|
||||
// recomputed: with the strip windowed the tab on an edge is not the one at the end of the
|
||||
// list, and two places working that out separately is two places to get it wrong.
|
||||
bool firstActive = _active?.IsFirst == true;
|
||||
bool lastActive = _active?.IsLast == true;
|
||||
bool firstInactiveRetro = retro && _sessions.Any(t => t.IsStripVisible && t.IsFirst && !t.IsActive);
|
||||
bool lastInactiveRetro = retro && _sessions.Any(t => t.RetroLastInactive);
|
||||
// The XAML declares these with NO Background - unlike KillerShell's copy, which paints
|
||||
// them PrimaryBrush directly in markup, KillerPDF's accent key is only known at runtime
|
||||
// (SelectionAccent, resolved the same way SetFocusHalo resolves the card border). Without
|
||||
// this they toggle Visible and still draw nothing: a transparent Border is invisible
|
||||
// whatever its Visibility says.
|
||||
if (TabEdgeLeft != null)
|
||||
{
|
||||
if (retro)
|
||||
{
|
||||
// This is the OUTER gray frame, not a duplicate highlight. The active first
|
||||
// tab is inset one pixel: its own white bevel lands at x+1 and its inset light
|
||||
// gray bevel at x+2, exactly where the pane draws those same two raised layers.
|
||||
// Keeping the three responsibilities separate makes the complete side read
|
||||
// gray / white / light-gray instead of a flat or doubled white line.
|
||||
// Run through the band's final row so it meets the card edge below. Leaving the
|
||||
// inactive case one pixel short exposed a literal gap in the left frame.
|
||||
TabEdgeLeft.Margin = new Thickness(0, firstActive ? 3 : 5, 0, 0);
|
||||
TabEdgeLeft.Visibility = firstActive || firstInactiveRetro
|
||||
? Visibility.Visible : Visibility.Collapsed;
|
||||
TabEdgeLeft.SetResourceReference(Border.BackgroundProperty, "PaneBorderBrush");
|
||||
}
|
||||
else
|
||||
{
|
||||
TabEdgeLeft.Margin = new Thickness(0, 9, 0, 0);
|
||||
TabEdgeLeft.Visibility = lit && firstActive ? Visibility.Visible : Visibility.Collapsed;
|
||||
TabEdgeLeft.SetResourceReference(Border.BackgroundProperty, "SelectionAccent");
|
||||
}
|
||||
}
|
||||
if (TabEdgeRight != null)
|
||||
{
|
||||
// The tab now reserves its final pixel, so this is only the outer frame. Because
|
||||
// the border lives inside TabScroll it shares the tab's vertical origin and no
|
||||
// longer starts above the tab or cuts through the scrollbar-arrow corner.
|
||||
if (retro && lastInactiveRetro)
|
||||
{
|
||||
TabEdgeRight.Margin = new Thickness(0, 5, 0, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
TabEdgeRight.Margin = retro ? new Thickness(0, 3, 0, 0) : new Thickness(0, 9, 0, 0);
|
||||
}
|
||||
TabEdgeRight.Visibility = retro
|
||||
? (lastActive || lastInactiveRetro ? Visibility.Visible : Visibility.Collapsed)
|
||||
: (lit && lastActive ? Visibility.Visible : Visibility.Collapsed);
|
||||
TabEdgeRight.SetResourceReference(Border.BackgroundProperty, retro ? "PaneBorderBrush" : "SelectionAccent");
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// TAB GESTURES
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// Left-click switches on mouse-UP, so a press can begin a drag without switching first.
|
||||
|
||||
private void Tab_MouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not DocumentSession s) return;
|
||||
if (e.ChangedButton == MouseButton.Middle) { e.Handled = true; CloseTab(s); }
|
||||
}
|
||||
|
||||
private void Tab_RightClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not DocumentSession s) return;
|
||||
var menu = MakeThemedMenu();
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CloseTab"), (_, _) => CloseTab(s), "Ctrl+W", ""));
|
||||
var others = MakeMenuItem(Loc("Str_Ctx_CloseOthers"), (_, _) => CloseOtherTabs(s), "Ctrl+Shift+W", "");
|
||||
others.IsEnabled = _sessions.Count(z => z.Doc != null || z.DeferredPath != null) > 1;
|
||||
menu.Items.Add(others);
|
||||
menu.PlacementTarget = fe;
|
||||
menu.IsOpen = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void CloseTab_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button b && b.Tag is DocumentSession s) CloseTab(s);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// DRAG: reorder within this pane, or hand the tab to the other one
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// Arm on press; past the threshold the grabbed tab glues to the cursor and its neighbors
|
||||
// glide aside as it crosses their layout-slot midpoints. A plain click still switches on
|
||||
// release.
|
||||
//
|
||||
// Over the OTHER pane the real tab cannot follow the hand - it is still parked in the strip it
|
||||
// came from - so a ghost takes over and the reorder stands down (Shell/PaneDrag.cs). Coming
|
||||
// back into this pane hands control straight back.
|
||||
|
||||
private DocumentSession? _tabDragSession;
|
||||
private Point _tabDragStart;
|
||||
private double _tabGrabDX;
|
||||
private bool _tabDragging;
|
||||
|
||||
/// <summary>Cursor offset inside the grabbed tab, so the window's ghost can sit exactly where
|
||||
/// the tab did when it was picked up.</summary>
|
||||
internal double TabGrabOffsetX => _tabGrabDX;
|
||||
|
||||
private FrameworkElement? TabContainer(DocumentSession s)
|
||||
=> TabStrip?.ItemContainerGenerator.ContainerFromItem(s) as FrameworkElement;
|
||||
|
||||
/// <summary>Did the press land on a button (the close x) rather than on the tab itself?</summary>
|
||||
private static bool InsideButton(object src)
|
||||
{
|
||||
var d = src as System.Windows.DependencyObject;
|
||||
while (d != null && d is not Button && d is not Window)
|
||||
d = VisualTreeHelper.GetParent(d);
|
||||
return d is Button;
|
||||
}
|
||||
|
||||
/// <summary>Midpoint X of a tab's LAYOUT slot (ignores any in-flight slide transform).</summary>
|
||||
private static double LayoutMidX(FrameworkElement fe)
|
||||
{
|
||||
var slot = System.Windows.Controls.Primitives.LayoutInformation.GetLayoutSlot(fe);
|
||||
return slot.X + slot.Width / 2;
|
||||
}
|
||||
|
||||
/// <summary>Set a tab's horizontal offset immediately - glues the grabbed tab to the cursor.</summary>
|
||||
private static void SetTabOffsetX(FrameworkElement tab, double x)
|
||||
{
|
||||
if (tab.RenderTransform is not TranslateTransform tt)
|
||||
{
|
||||
tt = new TranslateTransform();
|
||||
tab.RenderTransform = tt;
|
||||
}
|
||||
tt.BeginAnimation(TranslateTransform.XProperty, null); // drop any prior animation so the set sticks
|
||||
tt.X = x;
|
||||
}
|
||||
|
||||
/// <summary>Glide a just-reordered neighbor from where it was into its new slot, so a swap
|
||||
/// reads as a movement instead of an instant jump.</summary>
|
||||
private static void AnimateTabSlide(FrameworkElement? tab, double fromX)
|
||||
{
|
||||
if (tab == null) return;
|
||||
if (tab.RenderTransform is not TranslateTransform tt)
|
||||
{
|
||||
tt = new TranslateTransform();
|
||||
tab.RenderTransform = tt;
|
||||
}
|
||||
tt.BeginAnimation(TranslateTransform.XProperty, null);
|
||||
var anim = new System.Windows.Media.Animation.DoubleAnimation(fromX, 0,
|
||||
new Duration(TimeSpan.FromMilliseconds(140)))
|
||||
{
|
||||
EasingFunction = new System.Windows.Media.Animation.CubicEase
|
||||
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut },
|
||||
};
|
||||
tt.BeginAnimation(TranslateTransform.XProperty, anim);
|
||||
}
|
||||
|
||||
internal void CleanupTabTransforms()
|
||||
{
|
||||
foreach (var s in _sessions)
|
||||
if (TabContainer(s) is { } c)
|
||||
{
|
||||
c.RenderTransform = null;
|
||||
Panel.SetZIndex(c, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void Tab_DragDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement bd || bd.DataContext is not DocumentSession s) return;
|
||||
if (InsideButton(e.OriginalSource)) return; // the close x handles its own click
|
||||
_tabDragSession = s;
|
||||
_tabDragStart = e.GetPosition(TabStrip);
|
||||
_tabGrabDX = e.GetPosition(bd).X;
|
||||
_tabDragging = false;
|
||||
bd.CaptureMouse();
|
||||
// Own the press entirely so it cannot bubble to the title bar's window-drag handler, and
|
||||
// so the mouse capture rather than the caption hit-test drives the drag - which is what
|
||||
// makes it Y-independent.
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void Tab_DragMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement bd || !bd.IsMouseCaptured || _tabDragSession is null) return;
|
||||
var cont = TabContainer(_tabDragSession);
|
||||
if (cont == null) return;
|
||||
|
||||
double x = e.GetPosition(TabStrip).X;
|
||||
if (!_tabDragging && Math.Abs(x - _tabDragStart.X) < SystemParameters.MinimumHorizontalDragDistance) return;
|
||||
_tabDragging = true;
|
||||
Panel.SetZIndex(cont, 3); // the grabbed tab rides above its neighbors
|
||||
|
||||
var over = Host?.TabDropTarget(this, e);
|
||||
Host?.UpdateTabDragFeedback(this, _tabDragSession, e, over);
|
||||
if (over != null) return; // the ghost has it; no reorder while the pointer is away
|
||||
|
||||
int cur = _sessions.IndexOf(_tabDragSession);
|
||||
double slide = cont.ActualWidth;
|
||||
double rawLeft = x - _tabGrabDX;
|
||||
double leftEdge = rawLeft;
|
||||
double rightEdge = rawLeft + cont.ActualWidth;
|
||||
double maxLeft = Math.Max(0, TabStrip.ActualWidth - slide);
|
||||
double renderLeft = Math.Min(Math.Max(0, rawLeft), maxLeft);
|
||||
|
||||
// Swap when the ADVANCING edge crosses a neighbor's layout-slot midpoint. Edge against
|
||||
// midpoint gives natural hysteresis, so a tab parked on a boundary does not bounce.
|
||||
bool swapped = false;
|
||||
if (cur + 1 < _sessions.Count && TabContainer(_sessions[cur + 1]) is { } right && rightEdge > LayoutMidX(right))
|
||||
{
|
||||
_sessions.Move(cur + 1, cur);
|
||||
AnimateTabSlide(TabContainer(_sessions[cur]), slide); // it jumped left; glide it in from the right
|
||||
swapped = true;
|
||||
}
|
||||
else if (cur - 1 >= 0 && TabContainer(_sessions[cur - 1]) is { } left && leftEdge < LayoutMidX(left))
|
||||
{
|
||||
_sessions.Move(cur - 1, cur);
|
||||
AnimateTabSlide(TabContainer(_sessions[cur]), -slide); // it jumped right; glide it in from the left
|
||||
swapped = true;
|
||||
}
|
||||
|
||||
// After a swap the grabbed tab's slot has moved by a neighbor's width; refresh layout so
|
||||
// the new slot is current, then offset it back under the cursor.
|
||||
if (swapped) TabStrip.UpdateLayout();
|
||||
var dragged = TabContainer(_tabDragSession);
|
||||
if (dragged == null) return;
|
||||
var slot = System.Windows.Controls.Primitives.LayoutInformation.GetLayoutSlot(dragged);
|
||||
SetTabOffsetX(dragged, renderLeft - slot.X);
|
||||
}
|
||||
|
||||
private void Tab_DragUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement bd || !bd.IsMouseCaptured) return;
|
||||
bd.ReleaseMouseCapture();
|
||||
bool wasDragging = _tabDragging;
|
||||
var s = _tabDragSession;
|
||||
_tabDragSession = null;
|
||||
_tabDragging = false;
|
||||
Host?.HideTabDragFeedback(); // the ghost goes whatever the drop turns out to be
|
||||
|
||||
if (!wasDragging)
|
||||
{
|
||||
if (s != null) SwitchToTab(s);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dropped over the OTHER pane? Then this was a move, not a reorder. Checked on RELEASE
|
||||
// rather than mid-drag on purpose: moving a tab between panes re-creates its container,
|
||||
// which would pull the mouse capture out from under the drag that is still running.
|
||||
if (s != null && Host?.TabDropTarget(this, e) is { } target)
|
||||
{
|
||||
Host.MoveTabToPane(this, target, s, e);
|
||||
return;
|
||||
}
|
||||
|
||||
RebuildTabStrip(); // a reorder may have moved the active tab on or off an edge
|
||||
|
||||
// Settle the grabbed tab from its dragged offset into its final slot.
|
||||
var cont = s != null ? TabContainer(s) : null;
|
||||
if (cont?.RenderTransform is TranslateTransform tt && Math.Abs(tt.X) > 0.5)
|
||||
{
|
||||
var settle = new System.Windows.Media.Animation.DoubleAnimation(0,
|
||||
new Duration(TimeSpan.FromMilliseconds(120)))
|
||||
{
|
||||
EasingFunction = new System.Windows.Media.Animation.CubicEase
|
||||
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut },
|
||||
};
|
||||
settle.Completed += (_, _) => CleanupTabTransforms();
|
||||
tt.BeginAnimation(TranslateTransform.XProperty, settle);
|
||||
}
|
||||
else CleanupTabTransforms();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// CROSS-PANE MOVE (the window drives this - Shell/PaneDrag.cs)
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// A session is already self-contained - it owns its document, annotations, undo stack, render
|
||||
// cache and view state - so a move between panes is a move: out of one collection, into the
|
||||
// other. Nothing is reloaded, which is what lets a large document cross without a re-render.
|
||||
|
||||
/// <summary>This pane's band, for the window's drop hit-test and caret math.</summary>
|
||||
internal Border TabBandCtl => TabStripBorder;
|
||||
|
||||
/// <summary>This pane's strip, for the window's caret math.</summary>
|
||||
internal ItemsControl TabStripCtl => TabStrip;
|
||||
|
||||
/// <summary>How many tabs this pane holds. The caret divides the band by this.</summary>
|
||||
internal int TabCount => _sessions.Count;
|
||||
|
||||
/// <summary>Take <paramref name="s"/> out of this pane and pick whatever should be active in
|
||||
/// its place. Pure bookkeeping - the caller re-renders, because which pane's fields are live
|
||||
/// at that moment is its decision, not this one's.</summary>
|
||||
internal void DetachSessionExt(DocumentSession s)
|
||||
{
|
||||
int idx = _sessions.IndexOf(s);
|
||||
if (idx < 0) return;
|
||||
|
||||
_sessions.Remove(s);
|
||||
// Its bitmaps travel with it: leaving the session in this pane's LRU would have this pane
|
||||
// clearing a cache the other pane is now serving from.
|
||||
_renderLru.Remove(s);
|
||||
|
||||
if (ReferenceEquals(_active, s))
|
||||
SetActiveSession(_sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null);
|
||||
|
||||
RebuildTabStrip();
|
||||
}
|
||||
|
||||
/// <summary>Put <paramref name="s"/> into this pane at <paramref name="index"/> and make it
|
||||
/// the front tab - the tab you just dragged is the one you are looking at.</summary>
|
||||
internal void AdoptSessionExt(DocumentSession s, int index)
|
||||
{
|
||||
_sessions.Insert(Math.Min(Math.Max(0, index), _sessions.Count), s);
|
||||
SetActiveSession(s);
|
||||
RebuildTabStrip();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using PdfSharpCore.Pdf;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Tabbed document support. KillerPDF keeps one window and one live "working set" of
|
||||
// per-document fields (in MainWindow.xaml.cs). Each open PDF is a DocumentSession that
|
||||
// owns its own copy of those fields. Switching tabs captures the live fields into the
|
||||
// outgoing session and applies the incoming session's fields, then re-renders.
|
||||
// Moved from Shell/Tabs.cs. THIS PANE's open documents and its own tab strip - `_sessions` and
|
||||
// `_active` are per-pane, NOT one window-level list serving one window-level strip. That single
|
||||
// ownership is what puts a document opened in the second pane's tab above the first one, and
|
||||
// what makes the two panes fight over which shows a document; both symptoms are the same bug.
|
||||
//
|
||||
// DocumentSession deliberately carries VIEW state (zoom, page, scroll, view mode) alongside
|
||||
// document state. That is correct because a session belongs to exactly one pane, so there is no
|
||||
// second viewer to disagree with it. Opening the same file in both panes gives two independent
|
||||
// copies, which is what makes that true - see the duplicate-file save guard, because two copies
|
||||
// can otherwise save over each other.
|
||||
//
|
||||
// The tab STRIP - the band, the drag physics and the focus ring - lives in
|
||||
// PdfViewer.TabStrip.cs. This file is the session model and its lifecycle.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// One open document. Holds the per-document state that the rest of MainWindow reads
|
||||
// and writes through its instance fields. The collection references here ARE the live
|
||||
// collections while this session is active.
|
||||
// internal, not private: PdfViewer.Bridge.cs types the active session as
|
||||
// MainWindow.DocumentSession so the moved render pipeline can pass it to the render cache
|
||||
// unchanged. Still nested, so it is only reachable as MainWindow.DocumentSession.
|
||||
internal sealed class DocumentSession : System.ComponentModel.INotifyPropertyChanged
|
||||
{
|
||||
public PdfDocument? Doc;
|
||||
public string? CurrentFile;
|
||||
public string? OriginalFile;
|
||||
// Set on a restored tab that hasn't been loaded yet (lazy tabs): Doc stays null until the
|
||||
// user first switches to it, so startup doesn't render every reopened PDF.
|
||||
public string? DeferredPath;
|
||||
|
||||
public double ZoomLevel = 1.0;
|
||||
public double LastRenderZoom = 1.0;
|
||||
public FitMode Fit = FitMode.None;
|
||||
public ViewMode View = ViewMode.Continuous;
|
||||
public int GridColumns = 3; // grid column count; grid zoom is derived from this, so it must be per-tab too
|
||||
public EditTool Tool = EditTool.Select; // active editing tool, remembered per document
|
||||
public int PageIndex;
|
||||
public bool IsDirty;
|
||||
public bool ProtectedSource; // #149: source file had a password/encryption when opened
|
||||
public double ScrollH;
|
||||
public double ScrollV;
|
||||
public int SearchPageCursor = -1;
|
||||
|
||||
public Dictionary<int, List<PageAnnotation>> Annotations = [];
|
||||
public Dictionary<int, (int w, int h)> RenderDims = [];
|
||||
// LRU render cache: rasterized page bitmaps keyed by (page, size-bucket, rotation). Lets a
|
||||
// switch back to a recent tab reuse the bitmaps instead of re-running pdfium. Concurrent because
|
||||
// the continuous/secondary streamers read it from a background thread. Cleared on edits that change
|
||||
// a page's pixels or page order, and dropped entirely when the tab falls out of the LRU window.
|
||||
public readonly System.Collections.Concurrent.ConcurrentDictionary<(int page, int bucket, int rot), System.Windows.Media.Imaging.BitmapSource> RenderCache = new();
|
||||
// #189: each entry's byte size, recorded by the INSERTING thread. The budget must
|
||||
// never be computed by reading PixelWidth/Height off the cached bitmaps - an entry
|
||||
// that could not freeze throws cross-thread from whatever renderer is evicting,
|
||||
// which killed the background render tasks (vanishing thumbnails after invert).
|
||||
public readonly System.Collections.Concurrent.ConcurrentDictionary<(int page, int bucket, int rot), long> RenderCacheSize = new();
|
||||
public Dictionary<int, int> PageRotations = [];
|
||||
public Dictionary<int, string> FormTextValues = [];
|
||||
public Dictionary<int, bool> FormCheckValues = [];
|
||||
public Dictionary<string, string> FormRadioValues = [];
|
||||
public Dictionary<int, double> FormFontSizes = [];
|
||||
public Stack<UndoEntry> UndoStack = new();
|
||||
public Stack<UndoEntry> RedoStack = new();
|
||||
public Dictionary<int, List<(double left, double bottom, double right, double top)>> AllSearchRects = [];
|
||||
public List<int> SearchResultPages = [];
|
||||
|
||||
public string Title =>
|
||||
string.IsNullOrEmpty(OriginalFile)
|
||||
? "Untitled"
|
||||
: System.IO.Path.GetFileNameWithoutExtension(OriginalFile);
|
||||
|
||||
// ── Tab-strip presentation state ─────────────────────────────────────────────────
|
||||
// Everything below is bound by the tab template (PdfViewer.xaml) and nothing else
|
||||
// reads it. It has to NOTIFY: a strip row is only rebuilt when the collection itself
|
||||
// changes, so a property edited in place on a live row would otherwise never repaint.
|
||||
// (The same trap as KillerNotes issue #13.)
|
||||
|
||||
private string _tabLabel = "Untitled";
|
||||
/// <summary>Title with the dirty dot, as the tab shows it.</summary>
|
||||
public string TabLabel { get => _tabLabel; private set { if (_tabLabel != value) { _tabLabel = value; Notify(); } } }
|
||||
|
||||
private string _tabTip = "Untitled";
|
||||
/// <summary>The tab's tooltip: the full path this document came from.</summary>
|
||||
public string TabTip { get => _tabTip; private set { if (_tabTip != value) { _tabTip = value; Notify(); } } }
|
||||
|
||||
/// <summary>Re-read the label and tooltip off the document. Called from RebuildTabStrip,
|
||||
/// which is the one funnel every add, close, save and load already goes through, so
|
||||
/// there is no second place that has to remember to keep the strip current.</summary>
|
||||
internal void RefreshTabLabel()
|
||||
{
|
||||
TabLabel = (IsDirty ? "• " : "") + Title;
|
||||
TabTip = OriginalFile ?? "Untitled";
|
||||
}
|
||||
|
||||
private bool _isActive;
|
||||
/// <summary>The front tab of its pane.</summary>
|
||||
public bool IsActive { get => _isActive; set { if (_isActive != value) { _isActive = value; Notify(); } } }
|
||||
|
||||
// Leftmost tab in the strip. Only the focus ring reads this: the band draws the ring's
|
||||
// outermost verticals itself (TabEdgeLeft / TabEdgeRight), because a tab's own outer
|
||||
// border sits on the ScrollViewer's clip edge and survives or vanishes depending on how
|
||||
// the UniformGrid divided a fractional band width. Without it the first and last tab
|
||||
// drew that side TOO, so the outer edge of the ring came out 2px wherever the clip
|
||||
// spared it and 1px everywhere else.
|
||||
private bool _isFirst;
|
||||
public bool IsFirst { get => _isFirst; set { if (_isFirst != value) { _isFirst = value; Notify(); } } }
|
||||
|
||||
// Sitting on the strip's right EDGE - the last visible tab, but only while the overflow
|
||||
// chevron is hidden. The tab's 1px right border is a divider BETWEEN tabs, so a tab on
|
||||
// the edge drops it, where it would read as a stray rule; a tab with the chevron beside
|
||||
// it still wants it. It also decides who owns the ring's right vertical.
|
||||
private bool _isLast;
|
||||
public bool IsLast { get => _isLast; set { if (_isLast != value) { _isLast = value; Notify(); } } }
|
||||
|
||||
// Win98 tabs overlap their immediate neighbor by one pixel, like the native tab
|
||||
// control. These flags are only enabled by RebuildTabStrip while the retro theme is
|
||||
// active, so the modern themes retain their existing edge-to-edge geometry.
|
||||
private bool _retroBeforeActive;
|
||||
public bool RetroBeforeActive { get => _retroBeforeActive; set { if (_retroBeforeActive != value) { _retroBeforeActive = value; Notify(); } } }
|
||||
|
||||
private bool _retroAfterActive;
|
||||
public bool RetroAfterActive { get => _retroAfterActive; set { if (_retroAfterActive != value) { _retroAfterActive = value; Notify(); } } }
|
||||
|
||||
private bool _retroLastInactive;
|
||||
public bool RetroLastInactive { get => _retroLastInactive; set { if (_retroLastInactive != value) { _retroLastInactive = value; Notify(); } } }
|
||||
|
||||
// Theme gate for chrome that must never leak into the shared tab template. Modern
|
||||
// tabs keep their ShadowBar and normal canvas fills; 98SE replaces those with crisp
|
||||
// pixel bevels and pane-focus shading.
|
||||
private bool _useRetroTabChrome;
|
||||
public bool UseRetroTabChrome { get => _useRetroTabChrome; set { if (_useRetroTabChrome != value) { _useRetroTabChrome = value; Notify(); } } }
|
||||
|
||||
// True only for the ACTIVE tab of the FOCUSED pane, and only while split. The focus ring
|
||||
// has to continue around the active tab - the tab and the card are one surface, so a
|
||||
// ring that stops at the strip reads as broken.
|
||||
private bool _paneFocused;
|
||||
public bool PaneFocused { get => _paneFocused; set { if (_paneFocused != value) { _paneFocused = value; Notify(); } } }
|
||||
|
||||
// Active tab of the pane that does NOT have focus. Not simply !PaneFocused: with one
|
||||
// pane open there is no focused/unfocused distinction to draw, and the single pane's lip
|
||||
// stays bright.
|
||||
private bool _paneDimmed;
|
||||
public bool PaneDimmed { get => _paneDimmed; set { if (_paneDimmed != value) { _paneDimmed = value; Notify(); } } }
|
||||
|
||||
// In the strip right now, as opposed to behind the chevron. The strip caps the NUMBER of
|
||||
// tabs rather than letting them shrink without limit (ApplyTabWindow), and a tab outside
|
||||
// the window collapses - UniformGrid ignores a collapsed child when it divides the band,
|
||||
// so the ones left still fill it edge to edge. True by default: a tab is in the strip
|
||||
// until something works out that it does not fit.
|
||||
private bool _isStripVisible = true;
|
||||
public bool IsStripVisible { get => _isStripVisible; set { if (_isStripVisible != value) { _isStripVisible = value; Notify(); } } }
|
||||
|
||||
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
||||
private void Notify([System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
=> PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
||||
// ObservableCollection, not List: the strip is an ItemsControl bound straight to this, so an
|
||||
// add, a close or a drag-reorder repaints on its own. That binding is the whole point of the
|
||||
// port - the strip used to be code-built Borders kept in step by hand.
|
||||
private readonly System.Collections.ObjectModel.ObservableCollection<DocumentSession> _sessions = [];
|
||||
private DocumentSession? _active;
|
||||
|
||||
// ============================================================
|
||||
// Session state capture / apply
|
||||
// ============================================================
|
||||
|
||||
// Copy the live working set INTO the session (call before switching away from it).
|
||||
private void CaptureSessionState(DocumentSession s)
|
||||
{
|
||||
s.Doc = _doc;
|
||||
s.CurrentFile = _currentFile;
|
||||
s.OriginalFile = _originalFile;
|
||||
s.ZoomLevel = _zoomLevel;
|
||||
s.LastRenderZoom = _lastRenderZoom;
|
||||
s.Fit = _fitMode;
|
||||
s.View = _viewMode;
|
||||
s.GridColumns = _gridColumns;
|
||||
s.Tool = _currentTool;
|
||||
s.IsDirty = _isDirty;
|
||||
s.ProtectedSource = _openedFromProtected;
|
||||
s.SearchPageCursor = Search.PageCursor;
|
||||
// State.CurrentPage, not PageList.SelectedIndex: the sidebar is a window singleton
|
||||
// that follows the FOCUSED pane, so an unfocused pane capturing (the close path, the
|
||||
// save path's dirty check) was parking the OTHER pane's page number into its session.
|
||||
// Identical for the focused pane by the stage-3a sync.
|
||||
s.PageIndex = State.CurrentPage >= 0 ? State.CurrentPage : s.PageIndex;
|
||||
s.ScrollH = PagePreviewPanel?.HorizontalOffset ?? 0;
|
||||
s.ScrollV = PagePreviewPanel?.VerticalOffset ?? 0;
|
||||
|
||||
s.Annotations = _annotations;
|
||||
s.RenderDims = _renderDims;
|
||||
s.PageRotations = _pageRotations;
|
||||
s.FormTextValues = _formTextValues;
|
||||
s.FormCheckValues = _formCheckValues;
|
||||
s.FormRadioValues = _formRadioValues;
|
||||
s.FormFontSizes = _formFontSizes;
|
||||
s.UndoStack = _undoStack;
|
||||
s.RedoStack = _redoStack;
|
||||
s.AllSearchRects = Search.AllSearchRects;
|
||||
s.SearchResultPages = Search.ResultPages;
|
||||
// Persist this document's fit/zoom/view/page so reopening it (even after a restart) restores it.
|
||||
// Two-pane guard: DocStates is keyed by file path, so the SAME file open in BOTH panes
|
||||
// (two independent copies) is two writers on one entry - whichever pane captured last
|
||||
// silently overwrote the state the user actually left the file in, and on quit that was
|
||||
// just the close path's fixed A-then-B capture order. Only the focused pane writes when
|
||||
// the other pane also holds the file. FocusPane captures the outgoing pane BEFORE the
|
||||
// swap, so the pane being LEFT still counts as focused here - the rule this yields is
|
||||
// "the most recently used pane wins". A pane holding the only copy always writes.
|
||||
if (Host == null || Host.IsViewerFocused(this) || !Host.OtherViewerHasFile(this, s.OriginalFile))
|
||||
SaveDocState(s.OriginalFile, s.Fit, s.ZoomLevel, s.View, s.PageIndex);
|
||||
}
|
||||
|
||||
// ── Per-document view state (persisted across restarts, keyed by file path) ──────────────────
|
||||
// So reopening a file restores how you left it (fit mode, zoom, view mode, page) instead of the
|
||||
// per-view-mode default. Stored as one registry value: lines of "path|fit|zoom|view|page", most
|
||||
// recent first, capped. '|' and newline are both illegal in Windows paths, so they're safe delimiters.
|
||||
private const int DocStatesMax = 40;
|
||||
|
||||
private void SaveDocState(string? path, FitMode fit, double zoom, ViewMode view, int page)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return; // skip Untitled/imported
|
||||
string entry = string.Join("|", path,
|
||||
fit.ToString(),
|
||||
zoom.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
view.ToString(),
|
||||
page.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
var lines = new List<string> { entry };
|
||||
var raw = App.GetSetting("DocStates");
|
||||
if (!string.IsNullOrEmpty(raw))
|
||||
foreach (var line in raw!.Split('\n'))
|
||||
{
|
||||
if (line.Length == 0) continue;
|
||||
int bar = line.IndexOf('|');
|
||||
string lpath = bar > 0 ? line[..bar] : line;
|
||||
if (!string.Equals(lpath, path, StringComparison.OrdinalIgnoreCase))
|
||||
lines.Add(line);
|
||||
}
|
||||
if (lines.Count > DocStatesMax) lines = lines.GetRange(0, DocStatesMax);
|
||||
App.SetSetting("DocStates", string.Join("\n", lines));
|
||||
}
|
||||
|
||||
private bool TryGetDocState(string? path, out FitMode fit, out double zoom, out ViewMode view, out int page)
|
||||
{
|
||||
fit = FitMode.None; zoom = 1.0; view = ViewMode.Continuous; page = 0;
|
||||
if (string.IsNullOrEmpty(path)) return false;
|
||||
var raw = App.GetSetting("DocStates");
|
||||
if (string.IsNullOrEmpty(raw)) return false;
|
||||
foreach (var line in raw!.Split('\n'))
|
||||
{
|
||||
var p = line.Split('|');
|
||||
if (p.Length < 5 || !string.Equals(p[0], path, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
Enum.TryParse(p[1], out fit);
|
||||
double.TryParse(p[2], System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out zoom);
|
||||
Enum.TryParse(p[3], out view);
|
||||
int.TryParse(p[4], out page);
|
||||
if (zoom <= 0) zoom = 1.0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Point the live working set AT the session's state. Pure field assignment - no UI.
|
||||
private void ApplySessionState(DocumentSession s)
|
||||
{
|
||||
_doc = s.Doc;
|
||||
_currentFile = s.CurrentFile;
|
||||
_originalFile = s.OriginalFile;
|
||||
// The cached PDFium link handle belongs to the file we're switching AWAY from. This is the one
|
||||
// chokepoint every active-doc swap funnels through (tab switch, close-tab, close-all), so drop
|
||||
// it here and it can never outlive its document; the next link extraction reopens it lazily for
|
||||
// the new file (see EnsureLinkPdfiumDoc). CloseLinkPdfiumDoc is idempotent and cheap.
|
||||
CloseLinkPdfiumDoc();
|
||||
_zoomLevel = s.ZoomLevel;
|
||||
_lastRenderZoom = s.LastRenderZoom;
|
||||
_fitMode = s.Fit;
|
||||
_viewMode = s.View;
|
||||
_gridColumns = s.GridColumns;
|
||||
_currentTool = s.Tool;
|
||||
_isDirty = s.IsDirty;
|
||||
_openedFromProtected = s.ProtectedSource;
|
||||
Search.PageCursor = s.SearchPageCursor;
|
||||
|
||||
_annotations = s.Annotations;
|
||||
_renderDims = s.RenderDims;
|
||||
_pageRotations = s.PageRotations;
|
||||
_formTextValues = s.FormTextValues;
|
||||
_formCheckValues = s.FormCheckValues;
|
||||
_formRadioValues = s.FormRadioValues;
|
||||
_formFontSizes = s.FormFontSizes;
|
||||
_undoStack = s.UndoStack;
|
||||
_redoStack = s.RedoStack;
|
||||
_navBack.Clear(); // jump history is per-view-session: a tab switch starts fresh
|
||||
_navForward.Clear();
|
||||
Search.AllSearchRects = s.AllSearchRects;
|
||||
Search.ResultPages = s.SearchResultPages;
|
||||
TouchRenderLru(s); // this tab is now active: keep its render cache, evict tabs beyond the window
|
||||
}
|
||||
|
||||
// ── LRU render-bitmap cache ───────────────────────────────────────────────────────────────────
|
||||
// Keeps the rasterized page bitmaps of the most-recent few tabs so switching back skips pdfium and
|
||||
// fills instantly. The render paths (single / secondary tiles / continuous) check the active tab's
|
||||
// cache before rasterizing and store the frozen bitmap after building it.
|
||||
private readonly List<DocumentSession> _renderLru = [];
|
||||
private const int RenderCacheTabCap = 3;
|
||||
|
||||
// Background-thread safe: a cached frozen bitmap for this render, or null (the caller must rasterize).
|
||||
internal static System.Windows.Media.Imaging.BitmapSource? TryGetCachedRender(DocumentSession? s, int page, int bucket, int rot)
|
||||
=> (s != null && s.RenderCache.TryGetValue((page, bucket, rot), out var b)) ? b : null;
|
||||
|
||||
// #122: cap the number of cached page bitmaps per tab. The cache used to grow without
|
||||
// bound (one bitmap per page ever rendered, several MB each), so scrolling a large
|
||||
// image-heavy document in Continuous view pinned gigabytes in one tab.
|
||||
private const int RenderCachePageCap = 48;
|
||||
|
||||
// #189: the count cap alone was not enough - an entry's size scales with the page and the
|
||||
// base render budget, so 48 cached Letter pages held ~630 MB in one tab. Budget the cache
|
||||
// in BYTES too, with a floor of nearby pages so the moving window around the viewport
|
||||
// still serves instantly. Frozen BitmapSources are safe to measure from any thread.
|
||||
private const long RenderCacheByteBudget = 160L << 20; // ~160 MB per tab
|
||||
private const int RenderCacheMinPages = 6;
|
||||
|
||||
private static long RenderCacheBytes(DocumentSession s)
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var size in s.RenderCacheSize.Values) total += size;
|
||||
return total;
|
||||
}
|
||||
|
||||
internal static void CacheRender(DocumentSession? s, int page, int bucket, int rot, System.Windows.Media.Imaging.BitmapSource bmp)
|
||||
{
|
||||
if (s == null) return;
|
||||
if (bmp.CanFreeze && !bmp.IsFrozen) bmp.Freeze();
|
||||
// Measured HERE, on the thread that made the bitmap - see RenderCacheSize.
|
||||
s.RenderCacheSize[(page, bucket, rot)] = 4L * bmp.PixelWidth * bmp.PixelHeight;
|
||||
s.RenderCache[(page, bucket, rot)] = bmp;
|
||||
// Evict the entries farthest from the page just cached: renders arrive around the
|
||||
// viewport, so this keeps a moving window of nearby pages hot and stays safe to run
|
||||
// from any thread (no UI state needed).
|
||||
while (true)
|
||||
{
|
||||
int count = s.RenderCache.Count;
|
||||
bool overCount = count > RenderCachePageCap;
|
||||
bool overBytes = count > RenderCacheMinPages && RenderCacheBytes(s) > RenderCacheByteBudget;
|
||||
if (!overCount && !overBytes) break;
|
||||
var farthest = default((int page, int bucket, int rot));
|
||||
int bestDist = -1;
|
||||
foreach (var key in s.RenderCache.Keys)
|
||||
{
|
||||
int d = Math.Abs(key.page - page);
|
||||
if (d > bestDist) { bestDist = d; farthest = key; }
|
||||
}
|
||||
if (bestDist <= 0) break; // only current-page entries left; nothing sane to evict
|
||||
s.RenderCache.TryRemove(farthest, out _);
|
||||
s.RenderCacheSize.TryRemove(farthest, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// #135: the invert state is baked into cached pixels - drop every cached tab's page
|
||||
// bitmaps when it flips so no stale-colored bitmap survives the toggle. The image-rect
|
||||
// cache (the carve-out that keeps pictures uninverted) goes with it; it re-fills lazily.
|
||||
private void FlushAllRenderCaches()
|
||||
{
|
||||
foreach (var s in _renderLru) { s.RenderCache.Clear(); s.RenderCacheSize.Clear(); }
|
||||
// THIS pane's rect cache - the bare call, NOT `Viewer.FlushImageRectCache()`, which
|
||||
// hardcodes pane A and leaves pane B's night-mode carve-out cache serving rects from
|
||||
// the previous state after an invert toggle.
|
||||
FlushImageRectCache();
|
||||
}
|
||||
|
||||
// Mark a tab most-recently-used; drop the bitmap caches of tabs that fall outside the LRU window.
|
||||
private void TouchRenderLru(DocumentSession? s)
|
||||
{
|
||||
if (s == null) return;
|
||||
_renderLru.Remove(s);
|
||||
_renderLru.Add(s);
|
||||
bool dropped = false;
|
||||
while (_renderLru.Count > RenderCacheTabCap)
|
||||
{
|
||||
var old = _renderLru[0];
|
||||
_renderLru.RemoveAt(0);
|
||||
old.RenderCache.Clear();
|
||||
old.RenderCacheSize.Clear();
|
||||
dropped = true;
|
||||
}
|
||||
if (dropped) CompactLohSoon();
|
||||
}
|
||||
|
||||
// #122: .NET Framework never compacts the Large Object Heap on its own, so even after the
|
||||
// page-bitmap caches are dropped the process keeps its peak RAM (the classic "closed the
|
||||
// tab, Task Manager still shows gigabytes"). Request a one-shot LOH compaction at idle,
|
||||
// deferred so it never janks the close/switch animation itself.
|
||||
private void CompactLohSoon()
|
||||
{
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ApplicationIdle, (Action)(() =>
|
||||
{
|
||||
System.Runtime.GCSettings.LargeObjectHeapCompactionMode =
|
||||
System.Runtime.GCLargeObjectHeapCompactionMode.CompactOnce;
|
||||
GC.Collect();
|
||||
}));
|
||||
}
|
||||
|
||||
// Drop a tab's cached bitmaps after an edit that changes page pixels or page order.
|
||||
private void InvalidateRenderCache(DocumentSession? s)
|
||||
{
|
||||
s?.RenderCache.Clear();
|
||||
s?.RenderCacheSize.Clear();
|
||||
}
|
||||
|
||||
// Make sure there is always at least one session, adopting whatever is currently live.
|
||||
private void EnsureInitialSession()
|
||||
{
|
||||
if (_sessions.Count > 0) return;
|
||||
var s = new DocumentSession();
|
||||
_sessions.Add(s);
|
||||
_active = s;
|
||||
// Only adopt the live working set when this pane actually owns it. CaptureSessionState
|
||||
// copies _doc, _annotations, _undoStack and the rest BY REFERENCE, so capturing while
|
||||
// the shared fields still describe the other pane makes this session an alias of that
|
||||
// pane's live document - the same trap ApplyActiveSessionIfAny guards against. An
|
||||
// unfocused pane's first session stays genuinely blank instead.
|
||||
if (Host == null || Host.IsViewerFocused(this)) CaptureSessionState(s);
|
||||
}
|
||||
|
||||
// Commit / cancel any in-progress interaction so it doesn't bleed onto another document.
|
||||
private void CancelTransientForSwitch()
|
||||
{
|
||||
CommitActiveTextBox();
|
||||
RemoveTextEditHandles();
|
||||
ClearSelection();
|
||||
ClearTextSelection();
|
||||
CloseSearchBar();
|
||||
HideDrawSettings();
|
||||
HideTextSettings();
|
||||
HideSignaturePopup();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Rendering the active session
|
||||
// ============================================================
|
||||
|
||||
// Re-render whatever document the active session holds (or show the empty drop zone).
|
||||
// The shared page context menu, attached per pane by MainWindow.BuildContextMenu. The
|
||||
// opening hook rebuilds items at the cursor; per-tile overlays populate programmatically
|
||||
// (which does not raise ContextMenuOpening), same contract as before.
|
||||
internal void AttachContextMenuExt(System.Windows.Controls.ContextMenu menu)
|
||||
{
|
||||
_annotationCanvas.ContextMenu = menu;
|
||||
_annotationCanvas.ContextMenuOpening += (s, e) =>
|
||||
PopulateContextMenu(System.Windows.Input.Mouse.GetPosition(_annotationCanvas),
|
||||
Math.Max(0, _currentPage));
|
||||
}
|
||||
|
||||
// Per-pane cache flush for the per-pane invert toggle: only THIS pane's sessions hold
|
||||
// stale pixels, and flushing every pane's cache made the untouched pane visibly
|
||||
// re-render on the other pane's toggle (2026-08-15).
|
||||
internal void FlushOwnRenderCaches()
|
||||
{
|
||||
foreach (var s in _sessions) { s.RenderCache.Clear(); s.RenderCacheSize.Clear(); }
|
||||
FlushImageRectCache();
|
||||
}
|
||||
|
||||
// Invert repaint for an UNFOCUSED pane: PIXELS ONLY. RenderActiveSession here ran
|
||||
// BootstrapDocumentView / ShowEmptyState, whose Host chrome mutations (sidebar rebuild
|
||||
// with this pane's thumbnails - or ClearSidebarPages and control-disabling when this
|
||||
// pane is EMPTY) trashed the focused pane's shared sidebar (2026-08-15). An empty pane
|
||||
// has no pixels to repaint and must cause no side effects at all.
|
||||
internal void RepaintPixelsExt()
|
||||
{
|
||||
if (_doc is null) return;
|
||||
if (_viewMode == ViewMode.Continuous)
|
||||
{
|
||||
_continuousSharpenCts?.Cancel();
|
||||
_continuousSharpPages.Clear();
|
||||
foreach (var child in _continuousPanel.Children)
|
||||
if (child is Border b && b.Child is Grid g
|
||||
&& g.Children.Count > 0 && g.Children[0] is System.Windows.Controls.Image img)
|
||||
img.Source = null;
|
||||
_ = RenderContinuousPages(Math.Max(0, _currentPage));
|
||||
StartRerenderTimer();
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderPage(_viewMode == ViewMode.Grid ? 0 : Math.Max(0, _currentPage), keepTiles: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderActiveSession()
|
||||
{
|
||||
if (_active == null || _active.Doc == null) { ShowEmptyState(); return; }
|
||||
|
||||
FileNameLabel.Text = System.IO.Path.GetFileName(_active.OriginalFile ?? "");
|
||||
_annotationCanvas.Children.Clear();
|
||||
MarkDirty(_isDirty); // sync the Save button color to this tab's dirty state
|
||||
BootstrapDocumentView(_active.PageIndex, autoFit: false);
|
||||
SetTool(_active.Tool); // restore this document's active editing tool (and its tool bar)
|
||||
|
||||
// Restore the saved scroll position after the Background zoom pass queued inside
|
||||
// BootstrapDocumentView has run (ContextIdle is lower priority than Background).
|
||||
double sh = _active.ScrollH, sv = _active.ScrollV;
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ContextIdle, (Action)(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
PagePreviewPanel.ScrollToHorizontalOffset(sh);
|
||||
PagePreviewPanel.ScrollToVerticalOffset(sv);
|
||||
}
|
||||
catch { }
|
||||
}));
|
||||
}
|
||||
|
||||
// Visual reset to the no-document drop-zone state. Mirrors CloseFile's teardown but
|
||||
// does not close the document or touch session bookkeeping (callers handle that).
|
||||
private void ShowEmptyState()
|
||||
{
|
||||
_activeTextBox = null;
|
||||
RemoveTextEditHandles();
|
||||
_thumbCts?.Cancel();
|
||||
Host?.ClearSidebarPages(this);
|
||||
PageImage.Source = null;
|
||||
_annotationCanvas.Children.Clear();
|
||||
FileNameLabel.Text = "";
|
||||
DropZone.Visibility = Visibility.Visible;
|
||||
PopulateRecentFilesList();
|
||||
PagePreviewPanel.Visibility = Visibility.Collapsed;
|
||||
CloseSearchBar();
|
||||
HideDrawSettings();
|
||||
HideTextSettings();
|
||||
HideSignaturePopup();
|
||||
SetTool(EditTool.Select);
|
||||
if (Host != null)
|
||||
{
|
||||
Host.CloseFileEnabled = false;
|
||||
Host.PageJumpEnabled = false;
|
||||
}
|
||||
_continuousRenderCts?.Cancel();
|
||||
_continuousPanel.Children.Clear();
|
||||
_continuousTops.Clear();
|
||||
if (Host != null)
|
||||
{
|
||||
Host.PageJumpText = "";
|
||||
Host.PageTotalText = "/ -";
|
||||
}
|
||||
OutlineTree.Items.Clear();
|
||||
SidebarOutlinesTab.IsEnabled = false;
|
||||
if (_sidebarShowingOutlines) SwitchSidebarToPagesTab();
|
||||
SyncSidebarToDocState(hasDoc: false, startup: false); // nothing open: collapse the rail, hide page controls
|
||||
MarkDirty(false);
|
||||
SetStatus(Loc("Str_Ready"));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Opening / switching / closing tabs
|
||||
// ============================================================
|
||||
|
||||
// Prepare a tab to receive a document load: capture the current tab, then either reuse
|
||||
// the active tab if it's empty or create a new one, and blank the live working set.
|
||||
private DocumentSession BeginTabLoad(out DocumentSession? prev, out bool createdNew)
|
||||
{
|
||||
EnsureInitialSession();
|
||||
CommitActiveTextBox();
|
||||
CancelTransientForSwitch();
|
||||
prev = _active;
|
||||
if (_active != null) CaptureSessionState(_active);
|
||||
|
||||
DocumentSession target;
|
||||
if (_active != null && _active.Doc == null && _active.DeferredPath == null)
|
||||
{
|
||||
target = _active; // reuse the current empty tab (never a deferred one)
|
||||
createdNew = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
target = new DocumentSession();
|
||||
// Inherit the current view mode so a newly opened PDF doesn't snap back to the
|
||||
// default (Continuous) when the user prefers Single / Two-Page / Grid.
|
||||
if (prev != null) { target.View = prev.View; target.Fit = prev.Fit; }
|
||||
_sessions.Add(target);
|
||||
createdNew = true;
|
||||
}
|
||||
SetActiveSession(target);
|
||||
ApplySessionState(target); // blank live fields (target has no document yet)
|
||||
return target;
|
||||
}
|
||||
|
||||
// Roll back a failed / canceled load started by BeginTabLoad.
|
||||
private void AbortTabLoad(DocumentSession target, DocumentSession? prev, bool createdNew)
|
||||
{
|
||||
if (createdNew) _sessions.Remove(target);
|
||||
SetActiveSession(prev);
|
||||
if (prev != null) { ApplySessionState(prev); RenderActiveSession(); }
|
||||
else { EnsureInitialSession(); RenderActiveSession(); }
|
||||
RebuildTabStrip();
|
||||
}
|
||||
|
||||
// Returns an open session for the given file path (case-insensitive full-path match), or null.
|
||||
private DocumentSession? FindOpenSession(string path)
|
||||
{
|
||||
string full;
|
||||
try { full = System.IO.Path.GetFullPath(path); } catch { full = path; }
|
||||
return _sessions.FirstOrDefault(s =>
|
||||
(s.Doc != null || s.DeferredPath != null) &&
|
||||
!string.IsNullOrEmpty(s.OriginalFile) &&
|
||||
string.Equals(SafeFullPath(s.OriginalFile!), full, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string SafeFullPath(string p)
|
||||
{
|
||||
try { return System.IO.Path.GetFullPath(p); } catch { return p; }
|
||||
}
|
||||
|
||||
// Open a PDF in its own tab (reusing the current tab if it is empty). If the same file is
|
||||
// already open in an unedited tab, switch to that tab instead of opening a duplicate.
|
||||
private void OpenInNewTab(string path)
|
||||
{
|
||||
EnsureInitialSession();
|
||||
CommitActiveTextBox();
|
||||
if (_active != null) CaptureSessionState(_active); // keep dirty / path current for the check
|
||||
|
||||
var existing = FindOpenSession(path);
|
||||
if (existing != null && !existing.IsDirty)
|
||||
{
|
||||
SwitchToTab(existing);
|
||||
SetStatus(string.Format(Loc("Str_St_AlreadyOpen"), System.IO.Path.GetFileName(path)));
|
||||
return;
|
||||
}
|
||||
|
||||
var target = BeginTabLoad(out var prev, out bool createdNew);
|
||||
OpenFile(path);
|
||||
if (_doc == null)
|
||||
{
|
||||
// A background open (encryption strip / repair) finalizes this tab itself, so the
|
||||
// not-yet-loaded _doc isn't a failure - leave the tab in place.
|
||||
if (_asyncOpenPending) return;
|
||||
// Open failed, was canceled, or a password prompt was dismissed.
|
||||
AbortTabLoad(target, prev, createdNew);
|
||||
return;
|
||||
}
|
||||
CaptureSessionState(_active!);
|
||||
SetTool(_currentTool); // sync the tool UI to this (new) tab's tool
|
||||
RebuildTabStrip();
|
||||
}
|
||||
|
||||
// Cycle to the next (dir = +1) or previous (dir = -1) open document tab.
|
||||
private void CycleTab(int dir)
|
||||
{
|
||||
var docTabs = _sessions.Where(t => t.Doc != null || t.DeferredPath != null).ToList();
|
||||
if (docTabs.Count < 2 || _active == null) return;
|
||||
int i = docTabs.IndexOf(_active);
|
||||
if (i < 0) return;
|
||||
int next = (i + dir + docTabs.Count) % docTabs.Count;
|
||||
SwitchToTab(docTabs[next]);
|
||||
}
|
||||
|
||||
/// <summary>Make <paramref name="s"/> this pane's active session and mark its tab.
|
||||
///
|
||||
/// The IsActive flags are what the strip template triggers on, so every write to _active
|
||||
/// goes through here - a raw assignment leaves the old tab drawn as the front one.</summary>
|
||||
private void SetActiveSession(DocumentSession? s)
|
||||
{
|
||||
_active = s;
|
||||
foreach (var t in _sessions) t.IsActive = ReferenceEquals(t, s);
|
||||
}
|
||||
|
||||
// Switch the active tab to an already-loaded session.
|
||||
private void SwitchToTab(DocumentSession target)
|
||||
{
|
||||
if (target == _active) return;
|
||||
// _doc, _annotations and the rest of the "live working set" are window fields shared by
|
||||
// BOTH panes (see PdfViewer.Bridge.cs) - they describe whichever pane is ActiveViewer,
|
||||
// not this pane specifically. Clicking a tab in a pane that is not (yet) focused must
|
||||
// claim that ownership FIRST, or CaptureSessionState/ApplySessionState below read and
|
||||
// write the OTHER pane's fields: this pane's tab strip ends up showing the right tab
|
||||
// while its canvas gets repainted with whatever the actually-focused pane rendered next.
|
||||
// FocusPane no-ops when this pane already owns focus. (#161 - "clicking a tab on one
|
||||
// pane is making it show up in the other one again", 2026-08-01.)
|
||||
Host?.FocusViewer(this);
|
||||
CommitActiveTextBox();
|
||||
CancelTransientForSwitch();
|
||||
if (_active != null) CaptureSessionState(_active);
|
||||
SetActiveSession(target);
|
||||
ApplySessionState(target);
|
||||
// Hide the document content while the new tab renders and restores its scroll position, then fade
|
||||
// it in. This masks the rebuild and the "loads at the top then snaps to my place" jump - the user
|
||||
// only sees the final, correctly-scrolled view fade in. PageContentGrid is the parent of BOTH the
|
||||
// single/grid panel and the continuous panel, so one fade covers every view mode.
|
||||
PageContentGrid.BeginAnimation(UIElement.OpacityProperty, null);
|
||||
PageContentGrid.Opacity = 0;
|
||||
if (target.Doc == null && target.DeferredPath != null)
|
||||
MaterializeDeferred(target);
|
||||
else
|
||||
RenderActiveSession();
|
||||
// The switch can move the overflow window (the incoming tab may have been behind the
|
||||
// chevron), which changes which tab sits on each edge - and that is the card's corner
|
||||
// rounding and the ring's outer verticals, not just the strip.
|
||||
RebuildTabStrip();
|
||||
FadeInDocContent();
|
||||
}
|
||||
|
||||
// Fade the document pane content back in after a switch. Queued at ContextIdle so it runs AFTER the
|
||||
// scroll-position restore (also ContextIdle, queued earlier by RenderActiveSession) - the snap to
|
||||
// position happens while hidden, so it's never seen. Always lands at full opacity.
|
||||
private void FadeInDocContent()
|
||||
{
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ContextIdle, (Action)(() =>
|
||||
{
|
||||
var fade = new System.Windows.Media.Animation.DoubleAnimation(
|
||||
0, 1, new Duration(TimeSpan.FromMilliseconds(140)))
|
||||
{ EasingFunction = new System.Windows.Media.Animation.QuadraticEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut } };
|
||||
fade.Completed += (_, _) =>
|
||||
{
|
||||
PageContentGrid.BeginAnimation(UIElement.OpacityProperty, null);
|
||||
PageContentGrid.Opacity = 1;
|
||||
};
|
||||
PageContentGrid.BeginAnimation(UIElement.OpacityProperty, fade);
|
||||
}));
|
||||
}
|
||||
|
||||
// Load a restored-but-deferred tab's PDF the first time it is viewed (lazy tabs). The session
|
||||
// must already be the live working set (ApplySessionState called) before this runs.
|
||||
private void MaterializeDeferred(DocumentSession target)
|
||||
{
|
||||
var path = target.DeferredPath;
|
||||
target.DeferredPath = null;
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path))
|
||||
{
|
||||
RenderActiveSession(); // file vanished since last session - show the empty state
|
||||
return;
|
||||
}
|
||||
OpenFile(path!); // loads into the live fields and renders the view
|
||||
if (_doc == null)
|
||||
{
|
||||
if (_asyncOpenPending) return; // background strip/repair finalizes the tab itself
|
||||
RenderActiveSession(); return;
|
||||
}
|
||||
CaptureSessionState(target); // persist the now-loaded document back into the session
|
||||
}
|
||||
|
||||
// Close a tab. Prompts to save if that tab has unsaved changes, then switches to a
|
||||
// neighboring tab (or the empty state when the last tab closes).
|
||||
// Closes every open document tab except `keep` (each may prompt to save if dirty, like a manual close).
|
||||
private void CloseOtherTabs(DocumentSession keep)
|
||||
{
|
||||
foreach (var s in _sessions.Where(z => !ReferenceEquals(z, keep) && (z.Doc != null || z.DeferredPath != null)).ToList())
|
||||
CloseTab(s);
|
||||
}
|
||||
|
||||
private void CloseTab(DocumentSession? s)
|
||||
{
|
||||
EnsureInitialSession();
|
||||
if (s == null) return;
|
||||
|
||||
// Same reason as the top of SwitchToTab: claim the shared fields for this pane before
|
||||
// touching them below, in case this pane is not (yet) ActiveViewer - e.g. the tab
|
||||
// context menu's Close Tab / Close Other Tabs, invoked directly on this pane's own
|
||||
// instance. No-ops when already focused.
|
||||
Host?.FocusViewer(this);
|
||||
|
||||
// Make the target the live working set so its dirty flag / document are current.
|
||||
if (s != _active)
|
||||
{
|
||||
CommitActiveTextBox();
|
||||
CancelTransientForSwitch();
|
||||
if (_active != null) CaptureSessionState(_active);
|
||||
SetActiveSession(s);
|
||||
ApplySessionState(s);
|
||||
RenderActiveSession();
|
||||
}
|
||||
else
|
||||
{
|
||||
CommitActiveTextBox();
|
||||
CaptureSessionState(s);
|
||||
}
|
||||
|
||||
if (_isDirty)
|
||||
{
|
||||
var res = KillerDialog.Show(Host!.Window,
|
||||
Loc("Str_Dlg_UnsavedClose"),
|
||||
"KillerPDF", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||
if (res != MessageBoxResult.Yes) { RebuildTabStrip(); return; }
|
||||
}
|
||||
|
||||
try { _doc?.Close(); } catch { }
|
||||
_doc = null;
|
||||
|
||||
int idx = _sessions.IndexOf(s);
|
||||
_sessions.Remove(s);
|
||||
_renderLru.Remove(s); // don't pin a closed tab's render cache in the LRU list
|
||||
s.RenderCache.Clear();
|
||||
s.RenderCacheSize.Clear();
|
||||
CompactLohSoon(); // #122: give the freed bitmap memory back to the OS
|
||||
|
||||
if (_sessions.Count == 0)
|
||||
{
|
||||
App.RemoveSetting("LastFile"); // a manually emptied window won't reopen on launch
|
||||
var blank = new DocumentSession();
|
||||
_sessions.Add(blank);
|
||||
SetActiveSession(blank);
|
||||
ApplySessionState(blank);
|
||||
ShowEmptyState();
|
||||
}
|
||||
else
|
||||
{
|
||||
var next = _sessions[Math.Min(idx, _sessions.Count - 1)];
|
||||
SetActiveSession(next);
|
||||
ApplySessionState(next);
|
||||
if (next.Doc == null && next.DeferredPath != null) MaterializeDeferred(next);
|
||||
else RenderActiveSession();
|
||||
}
|
||||
RebuildTabStrip();
|
||||
}
|
||||
|
||||
// Ctrl+Q: close every open document and reset to a single blank tab, with one combined warning
|
||||
// if anything is unsaved (rather than a prompt per tab).
|
||||
private void CloseAllTabs()
|
||||
{
|
||||
EnsureInitialSession();
|
||||
CommitActiveTextBox();
|
||||
if (_active != null) CaptureSessionState(_active);
|
||||
|
||||
var docTabs = _sessions.Where(t => t.Doc != null || t.DeferredPath != null).ToList();
|
||||
if (docTabs.Count == 0) return;
|
||||
|
||||
if (docTabs.Any(t => t.IsDirty))
|
||||
{
|
||||
var res = KillerDialog.Show(Host!.Window, Loc("Str_Dlg_UnsavedCloseAll"),
|
||||
"KillerPDF", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||
if (res != MessageBoxResult.Yes) { RebuildTabStrip(); return; }
|
||||
}
|
||||
|
||||
foreach (var s in docTabs) { try { s.Doc?.Close(); } catch { } }
|
||||
try { _doc?.Close(); } catch { }
|
||||
_doc = null;
|
||||
|
||||
_sessions.Clear();
|
||||
App.RemoveSetting("LastFile"); // a manually emptied window won't reopen on launch
|
||||
var blank2 = new DocumentSession();
|
||||
_sessions.Add(blank2);
|
||||
SetActiveSession(blank2);
|
||||
ApplySessionState(blank2);
|
||||
ShowEmptyState();
|
||||
RebuildTabStrip();
|
||||
}
|
||||
|
||||
// OpenFromExternal / RestoreAndActivate moved BACK to Shell/ExternalOpen.cs on MainWindow.
|
||||
// They are window chrome, not pane behavior: RestoreAndActivate drives WindowState,
|
||||
// Activate() and Topmost, none of which exist on a UserControl, and App calls both on the
|
||||
// window. Only the OpenInNewTab call inside them belongs to a pane, and that now routes
|
||||
// through ActiveViewer like every other window -> viewer call.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// This pane's tab surface, exposed to the window. Wrappers inside the same partial class, so
|
||||
/// they can reach members PdfViewer.Tabs.cs keeps private; the Ext suffix exists only because a
|
||||
/// wrapper cannot share a name with what it wraps.
|
||||
///
|
||||
/// The window calls these against ActiveViewer, so a shortcut or toolbar button acts on the
|
||||
/// focused pane.
|
||||
/// </summary>
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ── Opening and closing ──────────────────────────────────────────────────────────────
|
||||
internal void OpenInNewTabExt(string path) => OpenInNewTab(path);
|
||||
internal void CloseTabExt(DocumentSession? s) => CloseTab(s);
|
||||
internal void CloseAllTabsExt() => CloseAllTabs();
|
||||
internal void CloseOtherTabsExt(DocumentSession? keep = null)
|
||||
{
|
||||
var target = keep ?? _active;
|
||||
if (target != null) CloseOtherTabs(target);
|
||||
}
|
||||
internal void CycleTabExt(int dir) => CycleTab(dir);
|
||||
internal void EnsureInitialSessionExt() => EnsureInitialSession();
|
||||
internal void MaterializeDeferredExt(DocumentSession target) => MaterializeDeferred(target);
|
||||
internal void SwitchToTabExt(DocumentSession target) => SwitchToTab(target);
|
||||
|
||||
// ── The load handshake (FileOperations / ImportAndZip drive this) ────────────────────
|
||||
internal DocumentSession BeginTabLoadExt(out DocumentSession? prev, out bool createdNew)
|
||||
=> BeginTabLoad(out prev, out createdNew);
|
||||
internal void AbortTabLoadExt(DocumentSession target, DocumentSession? prev, bool createdNew)
|
||||
=> AbortTabLoad(target, prev, createdNew);
|
||||
|
||||
// ── Session state ────────────────────────────────────────────────────────────────────
|
||||
internal void CaptureSessionStateExt(DocumentSession s) => CaptureSessionState(s);
|
||||
|
||||
/// <summary>Fold this pane's live fields back into its own active session, if it has one.
|
||||
/// The close path has to do this for BOTH panes before asking about unsaved work.</summary>
|
||||
internal void CaptureActiveIfAny()
|
||||
{
|
||||
if (_active != null) CaptureSessionState(_active);
|
||||
}
|
||||
internal void ApplySessionStateExt(DocumentSession s) => ApplySessionState(s);
|
||||
|
||||
/// <summary>Swap this pane's active session into the window's shared document fields. The
|
||||
/// counterpart to CaptureActiveIfAny; FocusPane runs both across a pane switch.
|
||||
///
|
||||
/// The empty-pane branch is not an optimization, it prevents cross-pane corruption. The
|
||||
/// shared fields still describe the pane we just left, and EnsureInitialSession ends with
|
||||
/// CaptureSessionState, which copies _doc, _annotations, _undoStack and the rest BY
|
||||
/// REFERENCE. So the first session an empty pane created would alias the other pane's live
|
||||
/// document: opening a file in one pane replaced the other's, and switching tabs in one
|
||||
/// moved the other. Blanking the shared fields here means there is nothing to alias.</summary>
|
||||
internal void ApplyActiveSessionIfAny()
|
||||
{
|
||||
if (_active != null) { ApplySessionState(_active); return; }
|
||||
|
||||
var blank = new DocumentSession(); // every collection field has its own initializer
|
||||
_sessions.Add(blank);
|
||||
SetActiveSession(blank);
|
||||
ApplySessionState(blank);
|
||||
ShowEmptyState();
|
||||
}
|
||||
|
||||
/// <summary>Put this pane's active session back into the shared fields and nothing else -
|
||||
/// pure assignment, no UI, and no session created if there is none. Used by WithOwnSession,
|
||||
/// which runs from layout events and must not cause any further layout.</summary>
|
||||
internal void RestoreActiveFieldsOnly()
|
||||
{
|
||||
if (_active != null) ApplySessionState(_active);
|
||||
}
|
||||
|
||||
/// <summary>Run view math with THIS pane's document in the window's shared fields.
|
||||
///
|
||||
/// _doc, _viewMode, _fitMode, _zoomLevel and _gridColumns are WINDOW fields, but the
|
||||
/// handlers that do view math - the viewport's SizeChanged, the resize-settle timer,
|
||||
/// ReapplyGridOrFit - are per-pane: each pane's own ScrollViewer raises them. So an
|
||||
/// unfocused pane whose viewport ticked was refitting itself against the FOCUSED pane's
|
||||
/// document and fit mode, and writing that pane's zoom back out. That is why switching
|
||||
/// tabs in one pane kept changing the other pane's view.
|
||||
///
|
||||
/// Swap this pane's session in, run, fold the view values back, then restore the focused
|
||||
/// pane. Only the view fields are folded back, not a full CaptureSessionState: that also
|
||||
/// writes DocStates to the registry, which a resize would do dozens of times a second.</summary>
|
||||
private bool _inOwnSessionScope;
|
||||
internal void WithOwnSession(System.Action work)
|
||||
{
|
||||
if (Host == null || _inOwnSessionScope || Host.IsViewerFocused(this))
|
||||
{
|
||||
work();
|
||||
return;
|
||||
}
|
||||
|
||||
_inOwnSessionScope = true;
|
||||
// The element accessors have to follow the fields. Swapping only the document state
|
||||
// left the render path resolving PageHost / PreviewScroller through ActiveViewer, so
|
||||
// this pane's fit measured the OTHER pane's viewport and painted into its tiles.
|
||||
try
|
||||
{
|
||||
Host.RunWithViewerContext(this, () =>
|
||||
{
|
||||
if (_active != null)
|
||||
{
|
||||
ApplySessionState(_active);
|
||||
// ApplySessionState deliberately leaves PageIndex to RenderActiveSession,
|
||||
// which never runs on this path. Seed it from this pane's session.
|
||||
State.CurrentPage = _active.PageIndex;
|
||||
}
|
||||
work();
|
||||
if (_active != null)
|
||||
{
|
||||
_active.ZoomLevel = _zoomLevel;
|
||||
_active.LastRenderZoom = _lastRenderZoom;
|
||||
_active.Fit = _fitMode;
|
||||
_active.View = _viewMode;
|
||||
_active.GridColumns = _gridColumns;
|
||||
_active.ScrollH = PagePreviewPanel?.HorizontalOffset ?? _active.ScrollH;
|
||||
_active.ScrollV = PagePreviewPanel?.VerticalOffset ?? _active.ScrollV;
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inOwnSessionScope = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>This pane's sidebar thumbnails, kept so focusing it again does not re-decode the
|
||||
/// document. Read by RestorePageListForActivePane (PageOperations.cs).</summary>
|
||||
internal PageThumbnailVm[]? ThumbCache { get; set; }
|
||||
internal string? ThumbCacheFile { get; set; }
|
||||
|
||||
/// <summary>This pane's thumbnail loader cancellation. PER PANE, not per window: one shared
|
||||
/// token meant focusing either pane canceled whatever the other was still decoding, and
|
||||
/// since the half-filled cache still matched the page count it counted as usable - so the
|
||||
/// list re-seated with the labels and no pictures, permanently.</summary>
|
||||
internal System.Threading.CancellationTokenSource? ThumbCts { get; set; }
|
||||
|
||||
/// <summary>Highlight this pane's current page after the list is re-seated: assigning
|
||||
/// ItemsSource clears the selection.</summary>
|
||||
internal int CurrentPageIndex => State.CurrentPage;
|
||||
|
||||
internal void SyncPageListSelection(int? preservedPage = null)
|
||||
{
|
||||
if (preservedPage.HasValue) State.CurrentPage = preservedPage.Value;
|
||||
if (State.CurrentPage < 0) return;
|
||||
Host?.ViewerPageChanged(this, State.CurrentPage);
|
||||
if (Host != null) Host.PageJumpText = (State.CurrentPage + 1).ToString();
|
||||
Host?.EnsureSidebarPageVisible(this, State.CurrentPage);
|
||||
}
|
||||
internal void SaveDocStateExt(string? path, FitMode fit, double zoom, ViewMode view, int page)
|
||||
=> SaveDocState(path, fit, zoom, view, page);
|
||||
internal bool TryGetDocStateExt(string? path, out FitMode fit, out double zoom,
|
||||
out ViewMode view, out int page)
|
||||
=> TryGetDocState(path, out fit, out zoom, out view, out page);
|
||||
|
||||
// ── Strip and render ─────────────────────────────────────────────────────────────────
|
||||
internal void InitTabStripExt() => InitTabStrip();
|
||||
internal void RebuildTabStripExt() => RebuildTabStrip();
|
||||
/// <summary>The band changed width. Kept under the old name because the window still wires
|
||||
/// the focused pane's SizeChanged to it; each pane also raises its own now, and the call is
|
||||
/// guarded and idempotent, so the two agreeing costs nothing.</summary>
|
||||
internal void ScheduleTabReflowExt() => TabBarResized();
|
||||
internal void RenderActiveSessionExt() => RenderActiveSession();
|
||||
internal void ShowEmptyStateExt() => ShowEmptyState();
|
||||
internal void FlushAllRenderCachesExt() => FlushAllRenderCaches();
|
||||
internal void InvalidateRenderCacheExt(DocumentSession? s) => InvalidateRenderCache(s);
|
||||
|
||||
/// <summary>Make a brand-new empty session the active one. The startup restore builds the
|
||||
/// session list itself, so it needs to place the result rather than go through
|
||||
/// EnsureInitialSession.</summary>
|
||||
internal void SetSessionsExt(IEnumerable<DocumentSession> sessions, DocumentSession? active)
|
||||
{
|
||||
_sessions.Clear();
|
||||
foreach (var s in sessions) _sessions.Add(s); // ObservableCollection has no AddRange
|
||||
SetActiveSession(active);
|
||||
}
|
||||
|
||||
/// <summary>Build a deferred (lazy) session for the restore path - a tab that shows its
|
||||
/// title but does not load its document until it is first switched to.</summary>
|
||||
internal static DocumentSession MakeDeferredSession(string path)
|
||||
=> new() { OriginalFile = path, CurrentFile = path, DeferredPath = path };
|
||||
|
||||
// ── This pane's strip elements, for the window chrome that still positions them ──────
|
||||
// AppScale scales them, FullScreen hides them, SidebarLayout flips their margins - all of
|
||||
// which now have to act on BOTH panes rather than one window-level band.
|
||||
internal System.Windows.Controls.Border TabStripBorderCtl => TabStripBorder;
|
||||
internal System.Windows.Controls.Border TabStripFadeCtl => TabStripFade;
|
||||
internal System.Windows.Controls.Border TabBarRingCtl => TabBarRing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using Docnet.Core;
|
||||
using Docnet.Core.Models;
|
||||
using Microsoft.Win32;
|
||||
using PdfSharpCore.Drawing;
|
||||
using PdfSharpCore.Pdf;
|
||||
using PdfSharpCore.Pdf.IO;
|
||||
using KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Moved from Shell/TextEditing.cs; the namespace and class line are the only changes. Window
|
||||
// members spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ============================================================
|
||||
// Inline text editing (double-click)
|
||||
// ============================================================
|
||||
|
||||
// While a paired text is being re-edited, trace its cover with a dashed outline so the cover stays
|
||||
// visible (its opaque fill often matches the page, so without this it looks like the cover vanished).
|
||||
private void ShowReeditCoverOutline(string pairId, int pageIdx)
|
||||
{
|
||||
RemoveReeditCoverOutline();
|
||||
if (pairId.Length == 0 || !_annotations.TryGetValue(pageIdx, out var list)) return;
|
||||
var cover = list.OfType<CoverAnnotation>().FirstOrDefault(c => c.PairId == pairId);
|
||||
if (cover is null) return;
|
||||
double inv = 1.0;
|
||||
if (_activeCanvas.LayoutTransform is ScaleTransform st && st.ScaleX > 0.0001) inv = 1.0 / st.ScaleX;
|
||||
var pb = cover.Bounds;
|
||||
_reeditCoverOutline = new Rectangle
|
||||
{
|
||||
Width = pb.Width + 4,
|
||||
Height = pb.Height + 4,
|
||||
Stroke = DarkerAccentBrush(),
|
||||
StrokeThickness = 1.5 * inv,
|
||||
StrokeDashArray = [4, 3],
|
||||
Fill = Brushes.Transparent,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
Canvas.SetLeft(_reeditCoverOutline, pb.X - 2);
|
||||
Canvas.SetTop(_reeditCoverOutline, pb.Y - 2);
|
||||
_activeCanvas.Children.Add(_reeditCoverOutline);
|
||||
}
|
||||
|
||||
private void RemoveReeditCoverOutline()
|
||||
{
|
||||
if (_reeditCoverOutline is not null)
|
||||
{
|
||||
(_reeditCoverOutline.Parent as Canvas)?.Children.Remove(_reeditCoverOutline);
|
||||
_reeditCoverOutline = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristic for a broken glyph->Unicode CMap (common on OCR'd scans): the extracted text comes out
|
||||
// as mojibake - replacement chars, private-use glyphs, or words peppered with currency/math symbols.
|
||||
// We don't pre-fill an in-place edit from text that looks like this. Conservative on purpose so clean
|
||||
// PDFs are never flagged; all-letter garbling (wrong letters that are still valid) can't be caught.
|
||||
private static bool LooksGarbled(string s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return false;
|
||||
const string ok = ".,;:!?'\"()[]{}-/\\&%@#*+=<>|~`^_$";
|
||||
int letters = 0, weird = 0, total = 0;
|
||||
foreach (char c in s)
|
||||
{
|
||||
if (char.IsWhiteSpace(c)) continue;
|
||||
total++;
|
||||
if (char.IsLetterOrDigit(c)) { letters++; continue; }
|
||||
if (ok.IndexOf(c) >= 0) continue; // ordinary punctuation is fine
|
||||
weird++; // replacement / PUA / stray symbol = mapping break
|
||||
}
|
||||
if (total == 0) return false;
|
||||
return (double)weird / total > 0.15 || (double)letters / total < 0.35;
|
||||
}
|
||||
|
||||
private void EditTextAtPosition(Point canvasPos, int pageIdx)
|
||||
{
|
||||
if (_currentFile is null || !_renderDims.ContainsKey(pageIdx)) return;
|
||||
|
||||
// Commit any existing edit first
|
||||
if (_activeTextBox is not null)
|
||||
{
|
||||
CommitActiveTextBox();
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-edit a user-placed text annotation: lift it into an editable box
|
||||
// pre-filled with its content, size (shown in points), and color.
|
||||
if (_annotations.TryGetValue(pageIdx, out var placedPage))
|
||||
{
|
||||
var placed = placedPage.OfType<TextAnnotation>()
|
||||
.LastOrDefault(a => HitTestAnnotation(a, canvasPos, out _));
|
||||
if (placed is not null)
|
||||
{
|
||||
var pcol = placed.GetColor();
|
||||
_textColor = pcol;
|
||||
_textOpacity = pcol.A; // keep the opacity slider in sync with the edited text
|
||||
_textFillColor = placed.GetFill(); // and the fill swatches in sync with the box
|
||||
double syp = 1.0;
|
||||
if (_doc is not null && _renderDims.TryGetValue(pageIdx, out var prd) && prd.h > 0)
|
||||
syp = _doc.Pages[pageIdx].Height.Point / prd.h;
|
||||
_textFontSize = Math.Max(1, Math.Round(placed.FontSize * syp));
|
||||
// Sync the bar's typeface + B/I/S to the box being re-edited.
|
||||
_textFontName = string.IsNullOrEmpty(placed.FontName) ? "Segoe UI" : placed.FontName;
|
||||
_textBold = placed.Bold; _textItalic = placed.Italic; _textStrike = placed.Strike; _textUnderline = placed.Underline;
|
||||
|
||||
_reeditOriginal = placed;
|
||||
placedPage.Remove(placed);
|
||||
RenderAllAnnotations(pageIdx);
|
||||
// Keep the paired cover visible (outlined) for the duration of the edit.
|
||||
ShowReeditCoverOutline(placed.PairId, pageIdx);
|
||||
|
||||
var ptb = new TextBox
|
||||
{
|
||||
Text = placed.Content,
|
||||
Background = TextEditBackground(),
|
||||
Foreground = new SolidColorBrush(pcol),
|
||||
BorderBrush = (SolidColorBrush)FindResource("PrimaryBrush"),
|
||||
SelectionBrush = AccentBrush(),
|
||||
CaretBrush = new SolidColorBrush(pcol),
|
||||
Template = FlatTextBoxTemplate(),
|
||||
BorderThickness = new Thickness(1),
|
||||
FontFamily = UiKit.UiFont,
|
||||
FontSize = placed.FontSize,
|
||||
Width = placed.Width > 0 ? placed.Width : TextBoxDefaultWidth,
|
||||
MinHeight = 24,
|
||||
Padding = new Thickness(2),
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Tag = pageIdx
|
||||
};
|
||||
Canvas.SetLeft(ptb, placed.Position.X);
|
||||
Canvas.SetTop(ptb, placed.Position.Y);
|
||||
_activeCanvas.Children.Add(ptb);
|
||||
_activeTextBox = ptb;
|
||||
StyleEditBox(ptb); // restore the box's typeface + B/I/S
|
||||
ptb.PreviewKeyDown += TextBox_PreviewKeyDown;
|
||||
ptb.Loaded += (s, ev) => { ptb.Focus(); Keyboard.Focus(ptb); ptb.SelectAll(); ptb.LostFocus += TextBox_LostFocus; AttachTextEditResizeHandles(ptb); };
|
||||
ShowTextSettings();
|
||||
SetStatus(Loc("Str_St_EditingText"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The click landed on an existing text cover but not its replacement text (handled above).
|
||||
// Don't start a fresh detection over an edit that already exists - that would stack a second
|
||||
// cover+text. Bail so the user grabs the existing text/cover instead of duplicating it.
|
||||
if (_annotations.TryGetValue(pageIdx, out var coverPage)
|
||||
&& coverPage.OfType<CoverAnnotation>().Any(c => { var b = c.Bounds; b.Inflate(6, 6); return b.Contains(canvasPos); }))
|
||||
{
|
||||
SetStatus(Loc("Str_St_AlreadyEditHere"));
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (renderW, renderH) = _renderDims[pageIdx];
|
||||
|
||||
using var pigDoc = PdfPigDoc.Open(_currentFile);
|
||||
if (pageIdx >= pigDoc.NumberOfPages) return;
|
||||
var page = pigDoc.GetPage(pageIdx + 1);
|
||||
|
||||
double pdfW = page.Width;
|
||||
double pdfH = page.Height;
|
||||
double sxInv = (double)renderW / pdfW; // pdf->canvas
|
||||
double syInv = (double)renderH / pdfH;
|
||||
|
||||
// Convert all words to canvas coordinates upfront
|
||||
var canvasWords = page.GetWords().Select(w =>
|
||||
{
|
||||
double cx = w.BoundingBox.Left * sxInv;
|
||||
double cy = renderH - (w.BoundingBox.Top * syInv);
|
||||
double cw = (w.BoundingBox.Right - w.BoundingBox.Left) * sxInv;
|
||||
double ch = (w.BoundingBox.Top - w.BoundingBox.Bottom) * syInv;
|
||||
return new { Word = w, Rect = new Rect(cx, cy, cw, ch) };
|
||||
}).ToList();
|
||||
|
||||
if (canvasWords.Count == 0)
|
||||
{
|
||||
// Scanned / image-only page: no text layer to detect. Fall back to a manual edit -
|
||||
// drop a cover + empty text box at the click so the user can white out the scanned
|
||||
// text and type over it by hand (resize the cover to fit).
|
||||
double mf = Math.Max(_textFontSize * syInv, 8); // current text size in canvas units
|
||||
StartCoverTextEdit(pageIdx, new Rect(canvasPos.X, canvasPos.Y, 200, mf * 1.35), "", mf, "Segoe UI", syInv);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find words on the same line as the click (Y overlap with tolerance)
|
||||
var clickY = canvasPos.Y;
|
||||
var lineWords = canvasWords
|
||||
.Where(cw => clickY >= cw.Rect.Top - 3 && clickY <= cw.Rect.Bottom + 3)
|
||||
.OrderBy(cw => cw.Rect.Left) // strictly left-to-right
|
||||
.ToList();
|
||||
|
||||
if (lineWords.Count == 0)
|
||||
{
|
||||
// Try nearest line within 20px
|
||||
var nearest = canvasWords
|
||||
.OrderBy(cw => Math.Abs((cw.Rect.Top + cw.Rect.Bottom) / 2 - clickY))
|
||||
.First();
|
||||
double nearMidY = (nearest.Rect.Top + nearest.Rect.Bottom) / 2;
|
||||
lineWords = [..canvasWords
|
||||
.Where(cw => Math.Abs((cw.Rect.Top + cw.Rect.Bottom) / 2 - nearMidY) < 5)
|
||||
.OrderBy(cw => cw.Rect.Left)];
|
||||
}
|
||||
|
||||
if (lineWords.Count == 0)
|
||||
{
|
||||
SetStatus(Loc("Str_St_NoTextLine"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Narrow to the contiguous run of words around the click. Words at the same Y in a
|
||||
// second column are separated by a large horizontal gap, so stop there instead of
|
||||
// merging both columns into one edit (the "weird text" / page-spanning edit).
|
||||
if (lineWords.Count > 1)
|
||||
{
|
||||
int ci = 0; double bestDx = double.MaxValue;
|
||||
for (int i = 0; i < lineWords.Count; i++)
|
||||
{
|
||||
var r = lineWords[i].Rect;
|
||||
double dx = canvasPos.X < r.Left ? r.Left - canvasPos.X
|
||||
: canvasPos.X > r.Right ? canvasPos.X - r.Right : 0;
|
||||
if (dx < bestDx) { bestDx = dx; ci = i; }
|
||||
}
|
||||
double gapMax = Math.Max(lineWords[ci].Rect.Height * 1.5, 24); // word spacing is small; a column gap is large
|
||||
int lo = ci, hi = ci;
|
||||
while (lo > 0 && lineWords[lo].Rect.Left - lineWords[lo - 1].Rect.Right <= gapMax) lo--;
|
||||
while (hi < lineWords.Count - 1 && lineWords[hi + 1].Rect.Left - lineWords[hi].Rect.Right <= gapMax) hi++;
|
||||
lineWords = lineWords.GetRange(lo, hi - lo + 1);
|
||||
}
|
||||
|
||||
// Compute bounding box in canvas space
|
||||
double cLeft = lineWords.Min(w => w.Rect.Left);
|
||||
double cTop = lineWords.Min(w => w.Rect.Top);
|
||||
double cRight = lineWords.Max(w => w.Rect.Right);
|
||||
double cBottom = lineWords.Max(w => w.Rect.Bottom);
|
||||
double cWidth = cRight - cLeft;
|
||||
double cHeight = cBottom - cTop;
|
||||
|
||||
string lineText = string.Join(" ", lineWords.Select(w => w.Word.Text));
|
||||
|
||||
// If this line is already covered by an edit, don't detect it again - that just stacks a
|
||||
// duplicate cover+text on top of the existing one. The original PDF text under a cover is
|
||||
// "consumed": re-edit by clicking the replacement text instead.
|
||||
var lineRect = new Rect(cLeft, cTop, Math.Max(1, cWidth), Math.Max(1, cHeight));
|
||||
if (_annotations.TryGetValue(pageIdx, out var coveredPage)
|
||||
&& coveredPage.OfType<CoverAnnotation>().Any(c => c.Bounds.IntersectsWith(lineRect)))
|
||||
{
|
||||
SetStatus(Loc("Str_St_LineAlreadyEdited"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get actual font info from PdfPig letter data
|
||||
double canvasFontSize = cHeight * 0.75; // fallback
|
||||
string fontName = "Segoe UI"; // fallback
|
||||
bool fontBold = false;
|
||||
bool fontItalic = false;
|
||||
var firstWord = lineWords.First().Word;
|
||||
try
|
||||
{
|
||||
if (firstWord.Letters.Count > 0)
|
||||
{
|
||||
var letter = firstWord.Letters[0];
|
||||
// PointSize is the glyph size in points. FontSize is the size as written in the
|
||||
// content stream, which only matches the point size when the text matrix doesn't
|
||||
// scale: a generator that emits "/F1 1 Tf" and scales through Tm reports FontSize
|
||||
// 1, which collapsed the replacement onto the floor below and read back as 3pt
|
||||
// (#163). Fall back to FontSize, then to the line-height estimate above, since
|
||||
// PointSize can be 0 on fonts with no usable metrics (e.g. some Type3).
|
||||
double pdfFontPts = letter.PointSize > 0 ? letter.PointSize : letter.FontSize;
|
||||
if (pdfFontPts > 0)
|
||||
canvasFontSize = pdfFontPts * syInv;
|
||||
|
||||
// Font family from the LETTER, never the word (#166, thanks Ryokoxx):
|
||||
// Word.FontName joins its letters' names ("Helvetica Helvetica Helvetica
|
||||
// ..."), which FontFamily cannot resolve - so that fallback landed on the
|
||||
// default font, exactly where no fallback at all would have. The outer
|
||||
// catch already covers a read that throws, so this needs no inner one.
|
||||
string? rawFont = letter.FontName;
|
||||
if (!string.IsNullOrEmpty(rawFont))
|
||||
{
|
||||
var detected = PdfFontStyle.FromPdfName(rawFont!);
|
||||
fontName = detected.Family;
|
||||
fontBold = detected.Bold;
|
||||
fontItalic = detected.Italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* use fallbacks */ }
|
||||
|
||||
// Drop the cover + editable text box for the detected line. Detected size carries the
|
||||
// EditTextSizeCorrection (WPF renders the source point size ~25% large); manual edits don't.
|
||||
// Scanned PDFs with a broken glyph->Unicode map extract as mojibake; in that case start the
|
||||
// box empty (like a manual edit) instead of pre-filling garbage - the user types over the
|
||||
// whited-out original.
|
||||
string prefill = LooksGarbled(lineText) ? "" : lineText;
|
||||
StartCoverTextEdit(pageIdx, new Rect(cLeft, cTop, cWidth, cHeight), prefill,
|
||||
Math.Max(canvasFontSize * EditTextSizeCorrection, 8), fontName, syInv,
|
||||
fontBold, fontItalic);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(string.Format(Loc("Str_St_TextEditError"), ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
// Drops an opaque cover at the given line and opens an editable text box on top of it - the two
|
||||
// halves of an in-place edit. Used for a detected PDF-text line and, on a scanned page with no
|
||||
// text layer, for a manual edit at the click point. boxFontCanvas is the on-canvas font size;
|
||||
// the cover fill and text ink are sampled from the page so the edit blends in.
|
||||
private void StartCoverTextEdit(int pageIdx, Rect lineRect, string text, double boxFontCanvas,
|
||||
string fontName, double syInv, bool bold = false, bool italic = false)
|
||||
{
|
||||
double cLeft = lineRect.X, cTop = lineRect.Y, cWidth = lineRect.Width, cHeight = lineRect.Height;
|
||||
// Pair id shared with the replacement text - the cover renders dashed while paired.
|
||||
var cover = new CoverAnnotation
|
||||
{
|
||||
PageIndex = pageIdx,
|
||||
PairId = Guid.NewGuid().ToString("N"),
|
||||
Bounds = new Rect(cLeft - 3, cTop - 3, cWidth + 6, cHeight + 6)
|
||||
};
|
||||
var sampleRect = new Rect(cLeft, cTop, cWidth, cHeight);
|
||||
Color coverBg = SampleCoverColor(pageIdx, sampleRect);
|
||||
Color inkColor = SampleTextColor(pageIdx, sampleRect, coverBg);
|
||||
cover.SetColor(coverBg);
|
||||
_textColor = inkColor; _textOpacity = inkColor.A;
|
||||
_textFontSize = Math.Max(1, Math.Round(boxFontCanvas / syInv)); // canvas units -> points
|
||||
// Replacing raw PDF text starts from the detected font and its face styling. PDF fonts
|
||||
// encode bold and italic in the font name, so resetting these flags made every detected
|
||||
// line plain as soon as it was double-clicked (#182).
|
||||
_textFontName = string.IsNullOrEmpty(fontName) ? "Segoe UI" : fontName;
|
||||
_textBold = bold;
|
||||
_textItalic = italic;
|
||||
_textStrike = _textUnderline = false;
|
||||
_pendingEditWasDirty = _isDirty; // capture before the cover dirties the doc
|
||||
if (!_annotations.ContainsKey(pageIdx)) _annotations[pageIdx] = [];
|
||||
_annotations[pageIdx].Add(cover);
|
||||
_pendingCover = cover;
|
||||
MarkDirty();
|
||||
RenderAllAnnotations(pageIdx);
|
||||
|
||||
var tb = new TextBox
|
||||
{
|
||||
Text = text,
|
||||
Background = Brushes.Transparent, // the opaque cover behind supplies the backdrop
|
||||
Foreground = new SolidColorBrush(inkColor),
|
||||
BorderBrush = (SolidColorBrush)FindResource("PrimaryBrush"),
|
||||
SelectionBrush = AccentBrush(),
|
||||
CaretBrush = new SolidColorBrush(inkColor),
|
||||
Template = FlatTextBoxTemplate(),
|
||||
BorderThickness = new Thickness(1),
|
||||
FontFamily = new FontFamily(fontName),
|
||||
FontSize = boxFontCanvas,
|
||||
Width = Math.Max(cWidth + 20, 80),
|
||||
MinHeight = 24,
|
||||
Padding = new Thickness(2, 0, 2, 0),
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Tag = pageIdx
|
||||
};
|
||||
Canvas.SetLeft(tb, cLeft);
|
||||
Canvas.SetTop(tb, cTop);
|
||||
_activeCanvas.Children.Add(tb);
|
||||
_activeTextBox = tb;
|
||||
StyleEditBox(tb);
|
||||
tb.PreviewKeyDown += TextBox_PreviewKeyDown;
|
||||
tb.Loaded += (s, ev) => { tb.Focus(); Keyboard.Focus(tb); tb.SelectAll(); tb.LostFocus += TextBox_LostFocus; AttachTextEditResizeHandles(tb); };
|
||||
ShowTextSettings();
|
||||
SetStatus(string.IsNullOrEmpty(text)
|
||||
? "Type your text, then drag the cover over the original - Enter to save, Escape to cancel"
|
||||
: "Editing text - change size/color above, Enter to save, Escape to cancel");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Text box handling
|
||||
// ============================================================
|
||||
|
||||
// A flat TextBox template (just a themed border hosting the text) so the OS default focus border
|
||||
// and selection chrome - the stray WPF "blue" - never show on the in-canvas text editor.
|
||||
private static ControlTemplate FlatTextBoxTemplate()
|
||||
{
|
||||
var b = new FrameworkElementFactory(typeof(Border));
|
||||
b.SetBinding(Border.BackgroundProperty, new System.Windows.Data.Binding("Background")
|
||||
{ RelativeSource = new System.Windows.Data.RelativeSource(System.Windows.Data.RelativeSourceMode.TemplatedParent) });
|
||||
b.SetBinding(Border.BorderBrushProperty, new System.Windows.Data.Binding("BorderBrush")
|
||||
{ RelativeSource = new System.Windows.Data.RelativeSource(System.Windows.Data.RelativeSourceMode.TemplatedParent) });
|
||||
b.SetBinding(Border.BorderThicknessProperty, new System.Windows.Data.Binding("BorderThickness")
|
||||
{ RelativeSource = new System.Windows.Data.RelativeSource(System.Windows.Data.RelativeSourceMode.TemplatedParent) });
|
||||
b.SetValue(Border.CornerRadiusProperty, new CornerRadius(2));
|
||||
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
|
||||
b.AppendChild(sv);
|
||||
return new ControlTemplate(typeof(TextBox)) { VisualTree = b };
|
||||
}
|
||||
|
||||
// Background shown WHILE editing a text box: the chosen fill if one is set, otherwise a faint
|
||||
// translucent neutral gray. Gray (not white) so the empty editable box stays visible on both
|
||||
// light/white pages and dark pages; it's only shown during editing and never committed.
|
||||
private Brush TextEditBackground()
|
||||
=> _textFillColor.A > 0 ? new SolidColorBrush(_textFillColor)
|
||||
: new SolidColorBrush(Color.FromArgb(64, 128, 128, 128));
|
||||
|
||||
// True when 'pos' (in _activeCanvas coordinates) falls inside the text box currently being
|
||||
// edited AND that box lives on _activeCanvas. Used so a click inside the box doesn't get
|
||||
// treated as a request to place a new one (the Grid-view "box jumps to cursor" bug).
|
||||
private bool ClickInsideActiveTextBox(Point pos)
|
||||
{
|
||||
if (_activeTextBox is null || !ReferenceEquals(_activeTextBox.Parent, _activeCanvas)) return false;
|
||||
double x = Canvas.GetLeft(_activeTextBox), y = Canvas.GetTop(_activeTextBox);
|
||||
if (double.IsNaN(x) || double.IsNaN(y)) return false;
|
||||
double w = _activeTextBox.ActualWidth > 0 ? _activeTextBox.ActualWidth : _activeTextBox.Width;
|
||||
double h = _activeTextBox.ActualHeight > 0 ? _activeTextBox.ActualHeight : Math.Max(_activeTextBox.MinHeight, 24);
|
||||
return pos.X >= x && pos.X <= x + w && pos.Y >= y && pos.Y <= y + h;
|
||||
}
|
||||
|
||||
private void PlaceTextBox(Point pos, int pageIdx)
|
||||
{
|
||||
// _textFontSize is a point size; convert to the page's canvas (render-dim) units so
|
||||
// it renders and exports as real points. DrawAnnotationsOnDocument multiplies by
|
||||
// sy = page.Height.Point / renderH, so dividing by sy here makes "14" export as 14pt.
|
||||
double fontCanvas = _textFontSize;
|
||||
if (_doc is not null && _renderDims.TryGetValue(pageIdx, out var rdims) && rdims.h > 0)
|
||||
{
|
||||
double sy = _doc.Pages[pageIdx].Height.Point / rdims.h;
|
||||
if (sy > 0) fontCanvas = _textFontSize / sy;
|
||||
}
|
||||
// A default-size box dropped at the click point. Width is fixed (text wraps to it) and the
|
||||
// box auto-grows downward as you type; resize the width later via the corner handles.
|
||||
var tb = new TextBox
|
||||
{
|
||||
Background = TextEditBackground(),
|
||||
Foreground = new SolidColorBrush(_textColor),
|
||||
BorderBrush = (SolidColorBrush)FindResource("PrimaryBrush"),
|
||||
SelectionBrush = AccentBrush(),
|
||||
CaretBrush = new SolidColorBrush(_textColor),
|
||||
Template = FlatTextBoxTemplate(),
|
||||
BorderThickness = new Thickness(1),
|
||||
FontFamily = UiKit.UiFont,
|
||||
FontSize = fontCanvas,
|
||||
Width = TextBoxDefaultWidth,
|
||||
MinHeight = 24,
|
||||
Padding = new Thickness(2),
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Tag = pageIdx
|
||||
};
|
||||
Canvas.SetLeft(tb, pos.X);
|
||||
Canvas.SetTop(tb, pos.Y);
|
||||
_activeCanvas.Children.Add(tb);
|
||||
_activeTextBox = tb;
|
||||
StyleEditBox(tb); // current typeface + B/I/S
|
||||
tb.PreviewKeyDown += TextBox_PreviewKeyDown;
|
||||
tb.LostFocus += TextBox_LostFocus;
|
||||
// Focus the box and attach its live resize handles once laid out. Loaded fires on first
|
||||
// placement; a dispatcher fallback covers re-entry (Text tool -> Select -> Text again),
|
||||
// where Loaded may have already run - without it the new box silently took no typing and
|
||||
// showed no handles. Activate is idempotent (guards against double focus/handle attach).
|
||||
void Activate()
|
||||
{
|
||||
if (!ReferenceEquals(_activeTextBox, tb)) return;
|
||||
tb.Focus();
|
||||
Keyboard.Focus(tb);
|
||||
if (!ReferenceEquals(_tehBox, tb)) AttachTextEditResizeHandles(tb);
|
||||
}
|
||||
tb.Loaded += (s, e) => Activate();
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, new Action(Activate));
|
||||
}
|
||||
|
||||
// ── Live resize handles around the editing TextBox ──────────────────────────────
|
||||
// Corner squares the user can drag to resize the box mid-edit, then keep typing. The
|
||||
// box auto-grows in height until a handle is dragged, after which the height is free-form.
|
||||
private void AttachTextEditResizeHandles(TextBox tb)
|
||||
{
|
||||
RemoveTextEditHandles();
|
||||
_tehBox = tb;
|
||||
double inv = 1.0;
|
||||
if (_activeCanvas.LayoutTransform is ScaleTransform sc && sc.ScaleX > 0.0001) inv = 1.0 / sc.ScaleX;
|
||||
double hs = 12 * inv;
|
||||
foreach (string tag in new[] { "NW", "NE", "SE", "SW" })
|
||||
{
|
||||
var hd = new Rectangle
|
||||
{
|
||||
Width = hs,
|
||||
Height = hs,
|
||||
Fill = AccentBrush(),
|
||||
Stroke = Brushes.White,
|
||||
StrokeThickness = 1 * inv,
|
||||
Cursor = (tag is "NW" or "SE") ? Cursors.SizeNWSE : Cursors.SizeNESW,
|
||||
Focusable = false, // so grabbing a handle does not blur (and commit) the TextBox
|
||||
Tag = tag
|
||||
};
|
||||
Panel.SetZIndex(hd, 200);
|
||||
// Hit detection + drag are handled in the canvas gesture handlers (which run as
|
||||
// PreviewMouseLeftButtonDown and would otherwise intercept the click), mirroring the
|
||||
// committed-annotation resize handles.
|
||||
_textEditHandles.Add(hd);
|
||||
_activeCanvas.Children.Add(hd);
|
||||
}
|
||||
tb.SizeChanged += TextEditBox_SizeChanged;
|
||||
LayoutTextEditHandles();
|
||||
}
|
||||
|
||||
private void TextEditBox_SizeChanged(object sender, SizeChangedEventArgs e) => LayoutTextEditHandles();
|
||||
|
||||
private void LayoutTextEditHandles()
|
||||
{
|
||||
if (_tehBox is null || _textEditHandles.Count == 0) return;
|
||||
double x = Canvas.GetLeft(_tehBox), y = Canvas.GetTop(_tehBox);
|
||||
double w = _tehBox.ActualWidth > 0 ? _tehBox.ActualWidth : _tehBox.Width;
|
||||
double h = _tehBox.ActualHeight;
|
||||
foreach (var hd in _textEditHandles)
|
||||
{
|
||||
double hsz = hd.Width;
|
||||
(double cx, double cy) = (hd.Tag as string) switch
|
||||
{
|
||||
"NW" => (x, y),
|
||||
"NE" => (x + w, y),
|
||||
"SW" => (x, y + h),
|
||||
_ => (x + w, y + h) // SE
|
||||
};
|
||||
Canvas.SetLeft(hd, cx - hsz / 2);
|
||||
Canvas.SetTop(hd, cy - hsz / 2);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveTextEditHandles()
|
||||
{
|
||||
if (_tehBox is not null) _tehBox.SizeChanged -= TextEditBox_SizeChanged;
|
||||
foreach (var hd in _textEditHandles) RemoveFromParent(hd);
|
||||
_textEditHandles.Clear();
|
||||
_tehBox = null;
|
||||
_draggingTextEditHandle = false;
|
||||
}
|
||||
|
||||
// Remove a canvas child from whatever Panel actually parents it, instead of assuming it lives
|
||||
// on _activeCanvas. In continuous/grid view _activeCanvas follows the mouse to whichever page
|
||||
// was last clicked, so a text-edit box, its whiteout, or its handles - placed earlier on a
|
||||
// different page's canvas - would otherwise survive removal and become orphaned: still painted,
|
||||
// but unreachable by Delete, Clear All, or resize. Its live Parent is always the correct host.
|
||||
private static void RemoveFromParent(UIElement? el)
|
||||
{
|
||||
if (el is FrameworkElement fe && fe.Parent is Panel p)
|
||||
p.Children.Remove(el);
|
||||
}
|
||||
|
||||
// Hit-test a live text-edit handle at the given canvas point; returns its corner tag or null.
|
||||
private string? TextEditHandleAt(Point pos)
|
||||
{
|
||||
foreach (var hd in _textEditHandles)
|
||||
{
|
||||
double hx = Canvas.GetLeft(hd), hy = Canvas.GetTop(hd);
|
||||
if (pos.X >= hx && pos.X <= hx + hd.Width &&
|
||||
pos.Y >= hy && pos.Y <= hy + hd.Height)
|
||||
return hd.Tag as string ?? "SE";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Attached as PreviewKeyDown (tunneling) so Enter is caught before the TextBox inserts a line
|
||||
// break: Enter commits, Shift+Enter falls through to make a newline (the box is AcceptsReturn).
|
||||
private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
CancelActiveTextEdit();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Z && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control
|
||||
&& sender is TextBox ztb && !ztb.CanUndo)
|
||||
{
|
||||
// The box has no typed text left to undo, so Ctrl+Z backs out of the whole in-place edit
|
||||
// (same as Escape) instead of being a no-op - otherwise a fresh edit could only be undone
|
||||
// after committing it. While the box still has edits to undo, WPF's TextBox handles Ctrl+Z.
|
||||
CancelActiveTextEdit();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Enter && Keyboard.Modifiers != ModifierKeys.Shift)
|
||||
{
|
||||
CommitActiveTextBox();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (Keyboard.Modifiers == ModifierKeys.Control
|
||||
&& (e.Key == Key.B || e.Key == Key.I || e.Key == Key.U))
|
||||
{
|
||||
// 1.6.6: the standard formatting chords while typing in a box - mirror the text
|
||||
// bar's B/I/U toggles (whole-annotation style, like the buttons). Ctrl+I became
|
||||
// available when Invert moved to the bare N key.
|
||||
if (e.Key == Key.B) _textBold = !_textBold;
|
||||
else if (e.Key == Key.I) _textItalic = !_textItalic;
|
||||
else _textUnderline = !_textUnderline;
|
||||
if (sender is TextBox stb) StyleEditBox(stb);
|
||||
ApplyTextStyleToSelection();
|
||||
ShowTextSettings();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Abandons the in-progress text edit: removes the editing box and its handles, drops a pending
|
||||
// cover (placed un-undone), and restores a re-edited annotation. Shared by Escape and the
|
||||
// "Ctrl+Z with nothing left in the box" path.
|
||||
private void CancelActiveTextEdit()
|
||||
{
|
||||
RemoveTextEditHandles();
|
||||
RemoveReeditCoverOutline(); // edit canceled; drop the cover hint (repaint follows)
|
||||
if (_activeTextBox is not null)
|
||||
{
|
||||
RemoveFromParent(_activeTextBox);
|
||||
_activeTextBox = null;
|
||||
}
|
||||
// Canceling an existing-text edit drops the cover too (it was placed un-undone).
|
||||
if (_pendingCover is not null) DiscardPendingCover();
|
||||
if (_reeditOriginal is not null)
|
||||
{
|
||||
int rp = _reeditOriginal.PageIndex;
|
||||
if (!_annotations.TryGetValue(rp, out var rlist)) { rlist = []; _annotations[rp] = rlist; }
|
||||
rlist.Add(_reeditOriginal);
|
||||
_reeditOriginal = null;
|
||||
RenderAllAnnotations(rp);
|
||||
}
|
||||
if (_currentTool != EditTool.Text) HideTextSettings();
|
||||
}
|
||||
|
||||
private void TextBox_LostFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Don't commit while a resize handle is being dragged (the box temporarily loses focus).
|
||||
if (_draggingTextEditHandle) return;
|
||||
// Commit if the box has content, or (for an existing-text edit) even when emptied, so the
|
||||
// pending cover is resolved instead of lingering when the user clicks away from a blank edit.
|
||||
if (_activeTextBox is not null && (!string.IsNullOrWhiteSpace(_activeTextBox.Text) || _pendingCover is not null))
|
||||
{
|
||||
Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
// Keep the edit box open if focus moved into the size/color bar so the
|
||||
// user can restyle (the Size ComboBox takes focus; color swatches do not).
|
||||
if (_textSettingsBar is not null && Keyboard.FocusedElement is DependencyObject fe
|
||||
&& IsDescendantOf(fe, _textSettingsBar))
|
||||
return;
|
||||
CommitActiveTextBox();
|
||||
}),
|
||||
System.Windows.Threading.DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
private void CommitActiveTextBox()
|
||||
{
|
||||
if (_activeTextBox is null) return;
|
||||
var tb = _activeTextBox;
|
||||
_activeTextBox = null;
|
||||
RemoveTextEditHandles();
|
||||
RemoveReeditCoverOutline(); // the re-edit is ending; drop its cover hint (repaint follows)
|
||||
string reeditPair = _reeditOriginal?.PairId ?? ""; // preserve a re-edited text's cover pairing
|
||||
_reeditOriginal = null; // committing replaces any annotation being re-edited
|
||||
|
||||
string content = tb.Text.Trim();
|
||||
int pageIdx = tb.Tag is int idx ? idx : _currentPage;
|
||||
double x = Canvas.GetLeft(tb);
|
||||
double y = Canvas.GetTop(tb);
|
||||
|
||||
// Remove the editing box from whatever canvas actually parents it. _activeCanvas may have
|
||||
// moved to another page when the user clicked away to commit (continuous/grid), and a
|
||||
// re-looked-up page canvas can be a different instance than the one the box was placed on,
|
||||
// either of which leaves the box orphaned (visible, but immune to Delete/Clear All). Its
|
||||
// live Parent is the correct host.
|
||||
RemoveFromParent(tb);
|
||||
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
double boxW = (!double.IsNaN(tb.Width) && tb.Width > 0) ? tb.Width
|
||||
: (tb.ActualWidth > 0 ? tb.ActualWidth : TextBoxDefaultWidth);
|
||||
var ta = new TextAnnotation
|
||||
{
|
||||
PageIndex = pageIdx,
|
||||
Position = new Point(x, y),
|
||||
Content = content,
|
||||
FontSize = tb.FontSize,
|
||||
FontName = _textFontName,
|
||||
Bold = _textBold,
|
||||
Italic = _textItalic,
|
||||
Strike = _textStrike,
|
||||
Underline = _textUnderline,
|
||||
Width = boxW
|
||||
};
|
||||
ta.SetColor(tb.Foreground is SolidColorBrush scb ? scb.Color : Colors.Black);
|
||||
// A cover-paired edit gets no fill of its own - the opaque cover behind it is the backdrop.
|
||||
ta.SetFill(_pendingCover is not null ? Colors.Transparent : _textFillColor);
|
||||
// Free-form height if the box was manually resized; otherwise fit to the wrapped text.
|
||||
ta.Height = (!double.IsNaN(tb.Height) && tb.Height > 0)
|
||||
? tb.Height
|
||||
: MeasureTextBoxHeight(content, boxW, tb.FontSize);
|
||||
// Keep the placed box fully on-page so its corners (and resize handles) stay reachable.
|
||||
ta.Position = ClampRectToPage(pageIdx, new Rect(ta.Position, new Size(ta.Width, ta.Height))).Location;
|
||||
// Carry the pairing so the cover knows its partner text exists (renders dashed).
|
||||
ta.PairId = _pendingCover is not null ? _pendingCover.PairId : reeditPair;
|
||||
if (_pendingCover is not null)
|
||||
{
|
||||
// Existing-text edit: the cover is already in _annotations. Add the text beside it and
|
||||
// push ONE grouped undo so a single Ctrl+Z right after cancels the whole edit. After
|
||||
// this, cover and text are independent annotations (move/resize/recolor separately).
|
||||
_annotations[pageIdx].Add(ta);
|
||||
PushUndo(new UndoEntry(UndoKind.AnnotationGroup, pageIdx,
|
||||
WasDirty: _pendingEditWasDirty, AnnotGroup: [_pendingCover, ta]));
|
||||
_pendingCover = null;
|
||||
MarkDirty();
|
||||
}
|
||||
else
|
||||
{
|
||||
AddAnnotation(ta);
|
||||
}
|
||||
RenderAllAnnotations(pageIdx); // redraw on the correct page's canvas
|
||||
WarnIfGlyphsWillBeLost(ta); // #168: say so NOW, not after saving and reopening
|
||||
}
|
||||
else if (_pendingCover is not null)
|
||||
{
|
||||
// Edit left empty - abandon it and drop the cover (added without its own undo entry).
|
||||
DiscardPendingCover();
|
||||
}
|
||||
if (_currentTool != EditTool.Text) HideTextSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #168: the editor borrows glyphs from any installed font, so text ALWAYS looks right while
|
||||
/// being typed - but a save can only embed fonts, and a character no installed font carries
|
||||
/// becomes a box in the file. That used to be invisible until the user saved, closed and
|
||||
/// reopened. Now it is said at the moment the text is placed, while it can still be fixed.
|
||||
///
|
||||
/// Only fires when the whole fallback chain comes up short (a box mixing two non-Latin
|
||||
/// scripts, or a script with no font installed at all), so it does not nag: ordinary
|
||||
/// Japanese, Chinese, Korean or Bengali text resolves silently.
|
||||
/// </summary>
|
||||
private void WarnIfGlyphsWillBeLost(TextAnnotation ta)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(ta.Content)) return;
|
||||
string want = string.IsNullOrEmpty(ta.FontName) ? "Segoe UI" : ta.FontName;
|
||||
string family = Services.FontCoverage.PickFamily(want, ta.Content);
|
||||
string missing = Services.FontCoverage.UncoveredChars(family, ta.Content);
|
||||
if (missing.Length == 0) return;
|
||||
KillerDialog.Show(Host!.Window, string.Format(Loc("Str_Font_NoGlyphs"), missing), "KillerPDF",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
catch { /* the warning must never be the thing that breaks placing text */ }
|
||||
}
|
||||
|
||||
// Remove the not-yet-committed cover when an existing-text edit is canceled or left empty. The
|
||||
// cover was added straight to _annotations without an undo entry, so just drop it and repaint.
|
||||
private void DiscardPendingCover()
|
||||
{
|
||||
if (_pendingCover is null) return;
|
||||
int pg = _pendingCover.PageIndex;
|
||||
if (_annotations.TryGetValue(pg, out var list)) list.Remove(_pendingCover);
|
||||
_pendingCover = null;
|
||||
MarkDirty(_pendingEditWasDirty);
|
||||
RenderAllAnnotations(pg);
|
||||
}
|
||||
|
||||
// ── Cover background sampling ───────────────────────────────────────────────
|
||||
// Reads the page background color around an existing-text line so a cover blends into colored
|
||||
// headers/panels instead of showing a white box. Best-effort: returns white on any failure.
|
||||
|
||||
// The page's rendered bitmap: the Image sibling of its overlay canvas (continuous/grid/two-page),
|
||||
// or the single-view PageImage. View-mode independent so sampling works everywhere.
|
||||
private System.Windows.Media.Imaging.BitmapSource? PageBitmapFor(int pageIdx)
|
||||
{
|
||||
if (_continuousCanvases.TryGetValue(pageIdx, out var overlay) && overlay.Parent is Panel mp)
|
||||
foreach (var ch in mp.Children)
|
||||
if (ch is Image im && im.Source is System.Windows.Media.Imaging.BitmapSource bs) return bs;
|
||||
if (pageIdx == _currentPage && PageImage.Source is System.Windows.Media.Imaging.BitmapSource pbs)
|
||||
return pbs;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Color ReadBgraPixel(System.Windows.Media.Imaging.BitmapSource bmp, int x, int y)
|
||||
{
|
||||
x = Math.Max(0, Math.Min(x, bmp.PixelWidth - 1));
|
||||
y = Math.Max(0, Math.Min(y, bmp.PixelHeight - 1));
|
||||
var px = new byte[4];
|
||||
bmp.CopyPixels(new Int32Rect(x, y, 1, 1), px, 4, 0); // Bgra32: B,G,R,A
|
||||
// Composite over white (the PDF page background) so transparent pixels - common on repaired
|
||||
// or scanned renders - read as white, not black. Returns an opaque color for sampling.
|
||||
double a = px[3] / 255.0;
|
||||
byte r = (byte)(px[2] * a + 255 * (1 - a));
|
||||
byte g = (byte)(px[1] * a + 255 * (1 - a));
|
||||
byte b = (byte)(px[0] * a + 255 * (1 - a));
|
||||
return Color.FromRgb(r, g, b);
|
||||
}
|
||||
|
||||
/// <summary>Background color around a text line, in canvas (render-dim) coordinates. White on failure.</summary>
|
||||
private Color SampleCoverColor(int pageIdx, Rect textBounds)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bmp = PageBitmapFor(pageIdx);
|
||||
if (bmp is null || !_renderDims.TryGetValue(pageIdx, out var rd) || rd.w <= 0 || rd.h <= 0)
|
||||
return Colors.White;
|
||||
double sx = bmp.PixelWidth / (double)rd.w; // render-dim -> bitmap pixels
|
||||
double sy = bmp.PixelHeight / (double)rd.h;
|
||||
// Sample the whitespace just above and below the line (usually pure background) at a few
|
||||
// x offsets; take the median by luminance to shrug off a stray glyph or anti-aliased edge.
|
||||
double gap = Math.Max(3.0, textBounds.Height * 0.4);
|
||||
var cols = new List<Color>();
|
||||
foreach (double f in new[] { 0.2, 0.5, 0.8 })
|
||||
{
|
||||
double x = textBounds.Left + textBounds.Width * f;
|
||||
cols.Add(ReadBgraPixel(bmp, (int)Math.Round(x * sx), (int)Math.Round((textBounds.Top - gap) * sy)));
|
||||
cols.Add(ReadBgraPixel(bmp, (int)Math.Round(x * sx), (int)Math.Round((textBounds.Bottom + gap) * sy)));
|
||||
}
|
||||
if (cols.Count == 0) return Colors.White;
|
||||
cols.Sort((a, b) => (0.299 * a.R + 0.587 * a.G + 0.114 * a.B)
|
||||
.CompareTo(0.299 * b.R + 0.587 * b.G + 0.114 * b.B));
|
||||
return cols[cols.Count / 2];
|
||||
}
|
||||
catch { return Colors.White; }
|
||||
}
|
||||
|
||||
private static double ColorDist(Color a, Color b)
|
||||
{
|
||||
double dr = a.R - b.R, dg = a.G - b.G, db = a.B - b.B;
|
||||
return Math.Sqrt(dr * dr + dg * dg + db * db);
|
||||
}
|
||||
|
||||
/// <summary>The text "ink" color of a line: the color inside the glyph box farthest from the
|
||||
/// page background. Averages the purest-ink samples so anti-aliased edges don't desaturate it.
|
||||
/// Black on failure or when no real contrast is found.</summary>
|
||||
private Color SampleTextColor(int pageIdx, Rect textBounds, Color bg)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bmp = PageBitmapFor(pageIdx);
|
||||
if (bmp is null || !_renderDims.TryGetValue(pageIdx, out var rd) || rd.w <= 0 || rd.h <= 0)
|
||||
return Colors.Black;
|
||||
double sx = bmp.PixelWidth / (double)rd.w;
|
||||
double sy = bmp.PixelHeight / (double)rd.h;
|
||||
int cols = 16, rows = Math.Max(3, (int)Math.Min(8, textBounds.Height / 3));
|
||||
var scored = new List<(double dist, Color c)>();
|
||||
for (int ix = 0; ix < cols; ix++)
|
||||
for (int iy = 0; iy < rows; iy++)
|
||||
{
|
||||
double x = textBounds.Left + textBounds.Width * (ix + 0.5) / cols;
|
||||
double y = textBounds.Top + textBounds.Height * (iy + 0.5) / rows;
|
||||
var c = ReadBgraPixel(bmp, (int)Math.Round(x * sx), (int)Math.Round(y * sy));
|
||||
scored.Add((ColorDist(c, bg), c));
|
||||
}
|
||||
if (scored.Count == 0) return Colors.Black;
|
||||
scored.Sort((a, b) => b.dist.CompareTo(a.dist)); // most ink-like first
|
||||
double maxDist = scored[0].dist;
|
||||
if (maxDist < 24) return Colors.Black; // no real contrast -> default
|
||||
double thresh = maxDist * 0.7; // purest-ink cluster only
|
||||
double r = 0, g = 0, bl = 0; int n = 0;
|
||||
foreach (var (dist, c) in scored) { if (dist < thresh) break; r += c.R; g += c.G; bl += c.B; n++; }
|
||||
return n == 0 ? Colors.Black : Color.FromRgb((byte)(r / n), (byte)(g / n), (byte)(bl / n));
|
||||
}
|
||||
catch { return Colors.Black; }
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using Docnet.Core;
|
||||
using Docnet.Core.Models;
|
||||
using Microsoft.Win32;
|
||||
using PdfSharpCore.Drawing;
|
||||
using PdfSharpCore.Pdf;
|
||||
using PdfSharpCore.Pdf.IO;
|
||||
using KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Wheel zoom, wheel scroll, and the pointer gestures that start on the page surface.
|
||||
//
|
||||
// Moved from Shell/Zoom.cs; this namespace and class line are the only changes. It lives with
|
||||
// the render pipeline because the two share the zoom state and the gesture routing
|
||||
// (_activeCanvas / _gestureCanvas) that decides which page a press landed on.
|
||||
//
|
||||
// Window members referenced bare here resolve through PdfViewer.Bridge.cs.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
private readonly WheelPageFlipGate _wheelPageFlipGate = new();
|
||||
|
||||
// ============================================================
|
||||
// Zoom
|
||||
// ============================================================
|
||||
|
||||
// internal: PdfViewer's XAML binds this and forwards to it.
|
||||
internal void PagePreview_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
// #209: match the standard Windows/browser gesture and reuse the same path as a
|
||||
// physical tilt wheel. Wheel-down moves right; wheel-up moves left.
|
||||
if (Keyboard.Modifiers == ModifierKeys.Shift)
|
||||
{
|
||||
e.Handled = true;
|
||||
ScrollHorizontalExt(-e.Delta);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Keyboard.Modifiers == ModifierKeys.Control)
|
||||
{
|
||||
e.Handled = true;
|
||||
if (_viewMode == ViewMode.Grid) { GridZoomStep(e.Delta < 0); return; }
|
||||
|
||||
// Capture cursor position and scroll offsets BEFORE zoom changes so we can
|
||||
// compute the new offsets that keep the point under the cursor stationary.
|
||||
Point cursorInViewport = e.GetPosition(PagePreviewPanel);
|
||||
double oldZoom = _zoomLevel;
|
||||
double oldHOff = PagePreviewPanel.HorizontalOffset;
|
||||
double oldVOff = PagePreviewPanel.VerticalOffset;
|
||||
|
||||
// Smooth wheel zoom. Two parts:
|
||||
// 1) A multiplicative step - every notch changes the zoom by the same RATIO. The
|
||||
// old additive ZoomStep was a ~50% jump when zoomed out and barely visible when
|
||||
// zoomed in. The exponent scales with e.Delta, so a precision touchpad's small
|
||||
// frequent deltas produce proportionally small ratios (a continuous glide).
|
||||
// 2) A lite apply - only the ScaleTransform moves during the gesture (instant,
|
||||
// flicker-free, same path as live window-resize); the expensive tile/link
|
||||
// refresh and hi-res re-sharpen run ONCE when the wheel rests (settle timer)
|
||||
// instead of on every notch, which is what made zooming feel steppy.
|
||||
_fitMode = FitMode.None;
|
||||
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax,
|
||||
_zoomLevel * Math.Pow(WheelZoomFactor, e.Delta / 120.0)));
|
||||
ApplyZoom(lite: true);
|
||||
StartZoomSettleTimer();
|
||||
|
||||
// After layout settles, reposition the scroll so the cursor point stays fixed.
|
||||
// Formula: newOffset = (oldOffset + cursorPos) * (newZoom / oldZoom) - cursorPos
|
||||
double ratio = _zoomLevel / oldZoom;
|
||||
double newHOff = (oldHOff + cursorInViewport.X) * ratio - cursorInViewport.X;
|
||||
double newVOff = (oldVOff + cursorInViewport.Y) * ratio - cursorInViewport.Y;
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() =>
|
||||
{
|
||||
PagePreviewPanel.ScrollToHorizontalOffset(Math.Max(0, newHOff));
|
||||
PagePreviewPanel.ScrollToVerticalOffset(Math.Max(0, newVOff));
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular scroll. Grid and Continuous are a single scroll over the WHOLE document, so the
|
||||
// wheel must never be hijacked for page navigation there - it always scrolls. (Page-nav
|
||||
// hijacking here was the old grid-refuses-to-scroll bug: right after a zoom/column change
|
||||
// the extent can momentarily measure as zero and the nav fallback fired instead.)
|
||||
if (_viewMode == ViewMode.Grid || _viewMode == ViewMode.Continuous)
|
||||
{
|
||||
ScrollWheel(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single / Two-Page: a page often fits the viewport, so at the scroll boundary fall
|
||||
// through to page navigation so the user can reach adjacent pages without the sidebar.
|
||||
if (PagePreviewPanel.ScrollableHeight <= 0)
|
||||
{
|
||||
e.Handled = true;
|
||||
if (_wheelPageFlipGate.TryConfirm(e.Delta, DateTime.UtcNow))
|
||||
NavigatePageByWheel(e.Delta);
|
||||
return;
|
||||
}
|
||||
|
||||
bool atTop = PagePreviewPanel.VerticalOffset <= 0;
|
||||
bool atBottom = PagePreviewPanel.VerticalOffset >= PagePreviewPanel.ScrollableHeight - 1;
|
||||
if ((atTop && e.Delta > 0) || (atBottom && e.Delta < 0))
|
||||
{
|
||||
e.Handled = true;
|
||||
if (_wheelPageFlipGate.TryConfirm(e.Delta, DateTime.UtcNow))
|
||||
NavigatePageByWheel(e.Delta);
|
||||
return;
|
||||
}
|
||||
_wheelPageFlipGate.NoteContentScroll(DateTime.UtcNow);
|
||||
ScrollWheel(e);
|
||||
}
|
||||
|
||||
// Zoom ratio per full wheel notch (e.Delta = 120) for Ctrl+scroll. 1.1 lands close to the
|
||||
// old additive step at 100% zoom but stays a constant 10% everywhere on the range.
|
||||
private const double WheelZoomFactor = 1.1;
|
||||
|
||||
// Wheel over the toolbar zoom dropdown: same multiplicative step as Ctrl+scroll, without
|
||||
// the cursor anchoring (the cursor is on the toolbar, not the page). Handled is set so the
|
||||
// ComboBox does not cycle its preset items under the wheel.
|
||||
internal void ZoomBoxWheel(MouseWheelEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
if (_doc is null) return;
|
||||
if (_viewMode == ViewMode.Grid) { GridZoomStep(e.Delta < 0); return; }
|
||||
_fitMode = FitMode.None;
|
||||
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax,
|
||||
_zoomLevel * Math.Pow(WheelZoomFactor, e.Delta / 120.0)));
|
||||
ApplyZoom(lite: true); // SyncZoomBox inside keeps the shown % live per notch
|
||||
StartZoomSettleTimer();
|
||||
}
|
||||
|
||||
// Debounced full zoom apply, shared by every Ctrl+scroll notch: while the wheel is moving
|
||||
// only the lite ScaleTransform runs; once it rests for a beat, do the one full ApplyZoom
|
||||
// (tile/link refresh, and the hi-res re-sharpen it queues) plus the status-bar update that
|
||||
// SetZoom would have shown per notch.
|
||||
private System.Windows.Threading.DispatcherTimer? _zoomSettleTimer;
|
||||
|
||||
private void StartZoomSettleTimer()
|
||||
{
|
||||
if (_zoomSettleTimer is null)
|
||||
{
|
||||
_zoomSettleTimer = new System.Windows.Threading.DispatcherTimer
|
||||
{ Interval = TimeSpan.FromMilliseconds(200) };
|
||||
_zoomSettleTimer.Tick += (_, _) =>
|
||||
{
|
||||
_zoomSettleTimer!.Stop();
|
||||
if (_doc is null) return;
|
||||
ApplyZoom();
|
||||
if (_currentPage >= 0)
|
||||
SetStatus(string.Format(Loc("Str_PageOf"), _currentPage + 1, _doc.PageCount) + $" - {DisplayZoomPct():F0}%");
|
||||
};
|
||||
}
|
||||
_zoomSettleTimer.Stop();
|
||||
_zoomSettleTimer.Start();
|
||||
}
|
||||
|
||||
// The ScrollViewer default (3 lines = 48 DIP per wheel notch) feels slow on tall documents,
|
||||
// so scroll WheelScrollFactor times that instead. e.Delta is +-120 per notch on a standard
|
||||
// wheel (precision touchpads send smaller, more frequent deltas, which scale the same way).
|
||||
// ScrollToVerticalOffset clamps to the valid range itself.
|
||||
// internal: PageSelection.cs reuses it so the sidebar scrolls at the document's speed.
|
||||
internal const double WheelScrollFactor = 3.0;
|
||||
|
||||
private void ScrollWheel(MouseWheelEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
PagePreviewPanel.ScrollToVerticalOffset(
|
||||
PagePreviewPanel.VerticalOffset - e.Delta * (48.0 / 120.0) * WheelScrollFactor);
|
||||
}
|
||||
|
||||
// #196: horizontal scroll fed from the window's WM_MOUSEHWHEEL hook (WPF surfaces no
|
||||
// event for it). Same per-delta distance as the vertical wheel; positive = right.
|
||||
internal void ScrollHorizontalExt(int delta)
|
||||
{
|
||||
if (_doc is null || PagePreviewPanel.Visibility != Visibility.Visible) return;
|
||||
PagePreviewPanel.ScrollToHorizontalOffset(
|
||||
PagePreviewPanel.HorizontalOffset + delta * (48.0 / 120.0) * WheelScrollFactor);
|
||||
}
|
||||
|
||||
// Walks up the visual tree from the press's hit element to see if it landed on the scrollbar
|
||||
// (thumb, track, or repeat buttons). Used to exempt scrollbar presses from pane pan/marquee/crop.
|
||||
private static bool PressIsOnScrollBar(MouseButtonEventArgs e)
|
||||
{
|
||||
DependencyObject? d = e.OriginalSource as DependencyObject;
|
||||
while (d is not null)
|
||||
{
|
||||
if (d is System.Windows.Controls.Primitives.ScrollBar) return true;
|
||||
d = d is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? System.Windows.Media.VisualTreeHelper.GetParent(d)
|
||||
: LogicalTreeHelper.GetParent(d);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
internal void PagePreviewPanel_PreviewMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// A press that lands on the document scrollbar must reach the scrollbar itself (thumb drag,
|
||||
// track paging). The pan/crop/marquee handling below otherwise claims the press first and sets
|
||||
// e.Handled, so the thumb could never be grabbed. Let scrollbar presses fall through untouched.
|
||||
if (PressIsOnScrollBar(e)) return;
|
||||
|
||||
bool spaceDown = Keyboard.IsKeyDown(Key.Space);
|
||||
if (e.ChangedButton == MouseButton.Middle ||
|
||||
(e.ChangedButton == MouseButton.Left && spaceDown))
|
||||
{
|
||||
_isPanning = true;
|
||||
_panStart = e.GetPosition(PagePreviewPanel);
|
||||
_panScrollH = PagePreviewPanel.HorizontalOffset;
|
||||
_panScrollV = PagePreviewPanel.VerticalOffset;
|
||||
PagePreviewPanel.CaptureMouse();
|
||||
PagePreviewPanel.Cursor = Cursors.SizeAll;
|
||||
e.Handled = true;
|
||||
}
|
||||
// Crop: allow starting the selection OUTSIDE the page - catch margin clicks, route them to the
|
||||
// nearest page overlay, and clamp the start to the page edge so the crop rect stays on the page.
|
||||
else if (e.ChangedButton == MouseButton.Left && !spaceDown
|
||||
&& _currentTool == EditTool.Crop && _doc is not null)
|
||||
{
|
||||
Canvas? target = ResolveMarginOverlay(e);
|
||||
if (target is not null && target.Width > 0 && target.Height > 0)
|
||||
{
|
||||
_activeCanvas = target;
|
||||
// Pin the gesture surface/page so mouse-move/up resolve against this overlay
|
||||
// (a margin crop start doesn't go through Canvas_MouseLeftButtonDown).
|
||||
_gestureCanvas = target;
|
||||
_gesturePage = target.Tag is int gt ? gt : _currentPage;
|
||||
var p = e.GetPosition(target);
|
||||
p.X = Math.Max(0, Math.Min(target.Width, p.X));
|
||||
p.Y = Math.Max(0, Math.Min(target.Height, p.Y));
|
||||
StartCropDraw(p);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
// Marquee select: start a selection rectangle in the margin so it can span onto the pages. Same
|
||||
// routing as crop, but the start point is NOT clamped, so the box can begin off-page.
|
||||
else if (e.ChangedButton == MouseButton.Left && !spaceDown
|
||||
&& _currentTool == EditTool.Select && _doc is not null)
|
||||
{
|
||||
Canvas? target = ResolveMarginOverlay(e);
|
||||
if (target is not null && target.Width > 0 && target.Height > 0)
|
||||
{
|
||||
StartMarqueeDraw(target, e.GetPosition(target));
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves which page overlay a margin (off-page) click attaches to, or null when the click is
|
||||
// actually on a page (left to that page's own surface). Shared by off-page crop and marquee starts.
|
||||
private Canvas? ResolveMarginOverlay(MouseButtonEventArgs e)
|
||||
{
|
||||
if (_viewMode == ViewMode.Continuous)
|
||||
{
|
||||
if (e.OriginalSource is DependencyObject osc && IsWithinPageOverlay(osc)) return null;
|
||||
int pg = _currentPage;
|
||||
if (pg < 0 || !_continuousCanvases.ContainsKey(pg))
|
||||
pg = NearestContinuousPage(e.GetPosition(_continuousPanel).Y);
|
||||
return pg >= 0 && _continuousCanvases.TryGetValue(pg, out var c) ? c : null;
|
||||
}
|
||||
bool onPrimary = e.OriginalSource is DependencyObject oss && IsDescendantOf(oss, _annotationCanvas);
|
||||
bool onTile = e.OriginalSource is DependencyObject ost && IsWithinPageOverlay(ost);
|
||||
return (!onPrimary && !onTile) ? _annotationCanvas : null;
|
||||
}
|
||||
|
||||
// Begins a marquee anchored to refCanvas at posInRef (that page's coords, possibly off-page and
|
||||
// un-clamped). The box draws on the cross-page MarqueeLayer; the existing move/up handlers finish it.
|
||||
private void StartMarqueeDraw(Canvas refCanvas, Point posInRef)
|
||||
{
|
||||
_activeCanvas = refCanvas;
|
||||
_gestureCanvas = refCanvas;
|
||||
_gesturePage = refCanvas.Tag is int gt ? gt : _currentPage;
|
||||
ClearSelection();
|
||||
ClearTextSelection();
|
||||
_isSelecting = true;
|
||||
_selectStart = posInRef;
|
||||
_selectRect = new Rectangle
|
||||
{
|
||||
Fill = AccentBrush(40),
|
||||
Stroke = AccentBrush(150),
|
||||
StrokeThickness = 1,
|
||||
Width = 0, Height = 0,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
MarqueeLayer.Children.Add(_selectRect);
|
||||
UpdateMarquee(posInRef, posInRef);
|
||||
refCanvas.CaptureMouse();
|
||||
}
|
||||
|
||||
// Begin a crop selection on the active overlay at pos (render-dim coords).
|
||||
private void StartCropDraw(Point pos)
|
||||
{
|
||||
_cropPageIndex = _activeCanvas.Tag is int cpi ? cpi : (_viewMode == ViewMode.Grid ? 0 : _currentPage);
|
||||
ClearSelection();
|
||||
_isDrawing = true;
|
||||
_drawStart = pos;
|
||||
// Draw the NEW box as a separate rect; the existing box, handles, and bar stay put until this
|
||||
// draw is committed on mouse-up (so a mouse-down never wipes the current box or bar).
|
||||
var cropDrawRect = new Rectangle
|
||||
{
|
||||
Stroke = Brushes.White,
|
||||
StrokeThickness = 1.5,
|
||||
StrokeDashArray = [5, 3],
|
||||
Fill = AccentBrush(55),
|
||||
Width = 0,
|
||||
Height = 0,
|
||||
IsHitTestVisible = false,
|
||||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||||
{ Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.7 },
|
||||
};
|
||||
Canvas.SetLeft(cropDrawRect, pos.X);
|
||||
Canvas.SetTop(cropDrawRect, pos.Y);
|
||||
Panel.SetZIndex(cropDrawRect, 2);
|
||||
_activeCanvas.Children.Add(cropDrawRect);
|
||||
_activePreview = cropDrawRect;
|
||||
_activeCanvas.CaptureMouse();
|
||||
}
|
||||
|
||||
private bool IsWithinPageOverlay(DependencyObject node)
|
||||
{
|
||||
var cur = node;
|
||||
while (cur != null)
|
||||
{
|
||||
if (cur is Canvas c && _continuousCanvases.ContainsValue(c)) return true;
|
||||
cur = VisualTreeHelper.GetParent(cur);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
internal void PagePreviewPanel_PreviewMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (!_isPanning) return;
|
||||
var pos = e.GetPosition(PagePreviewPanel);
|
||||
PagePreviewPanel.ScrollToHorizontalOffset(_panScrollH - (pos.X - _panStart.X));
|
||||
PagePreviewPanel.ScrollToVerticalOffset(_panScrollV - (pos.Y - _panStart.Y));
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
internal void PagePreviewPanel_PreviewMouseUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (!_isPanning) return;
|
||||
if (e.ChangedButton != MouseButton.Middle && e.ChangedButton != MouseButton.Left) return;
|
||||
_isPanning = false;
|
||||
PagePreviewPanel.ReleaseMouseCapture();
|
||||
PagePreviewPanel.Cursor = _spaceHeld ? Cursors.Hand : Cursors.Arrow;
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
<UserControl x:Class="KillerPDF.Controls.PdfViewer"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- One document view: this pane's tab strip and its card.
|
||||
|
||||
The card's MARGIN lives on this control, not on the borders below - ApplySidebarSide sets
|
||||
it to flip the 8px gutter to whichever side the document is on. -->
|
||||
<Grid>
|
||||
<!-- Row 0 is the strip, row 1 the card. Each pane carrying its own strip means the strip
|
||||
tracks its pane's width and position for free, including through a boundary drag.
|
||||
Row 0 is Auto and the band collapses below two tabs. -->
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- ZIndex above the card: the card's -1 top margin tucks its top border into this band's
|
||||
row, and the active tab - which carries the pane's own BgCanvas - covers that pixel so
|
||||
the two read as one surface. -->
|
||||
<Border Grid.Row="0" x:Name="TabStripBorder" Panel.ZIndex="10"
|
||||
Height="{DynamicResource TabBandHeight}"
|
||||
Background="Transparent" Visibility="Collapsed"
|
||||
SizeChanged="TabStripBorder_SizeChanged">
|
||||
<Grid>
|
||||
<!-- Match KillerShell: the strip is transparent and does not paint another grain
|
||||
tile over the window's existing background. Only the opaque active tab
|
||||
replaces that shared grain below. -->
|
||||
<Border x:Name="TabStripFade" IsHitTestVisible="False"
|
||||
Opacity="{DynamicResource BarShadowOpacity}">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#00000000" Offset="0"/>
|
||||
<GradientStop Color="#00000000" Offset="0.45"/>
|
||||
<GradientStop Color="#59000000" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
<!-- TabBarRing, ported from KillerShell. The card's top border drawn again, radii
|
||||
and all - 7px tall with a -6 bottom margin, so its 1px top edge lands in the
|
||||
band's last row and its curved sides drop out of the band onto the card's own
|
||||
left/right border. A flat 1px strip would run past the card's rounded corners.
|
||||
SyncPaneLeadingCorner keeps the radii in step with the card's; SetFocusHalo
|
||||
drives its brush alongside the card's.
|
||||
Declared after the fade (whose dark bottom would wash it out) and before the
|
||||
tabs, so the active tab covers its own segment and breaks the line at the tab. -->
|
||||
<Border x:Name="TabBarRing" Height="7" VerticalAlignment="Bottom" Margin="0,0,0,-6"
|
||||
BorderThickness="1,1,1,0" CornerRadius="{DynamicResource TabCornerRadius}" IsHitTestVisible="False"
|
||||
BorderBrush="{DynamicResource PaneEdgeBrush}"/>
|
||||
<DockPanel LastChildFill="True">
|
||||
<!-- Overflow chevron: every tab in this pane, hidden ones included
|
||||
(TabOverflowMenu). Docked BEFORE the strip so the strip measures against
|
||||
the band's REMAINING width - docked after, the strip would take the whole
|
||||
band and the chevron would sit on top of the last tab.
|
||||
Collapsed while everything fits, which is the normal case: a chevron that
|
||||
is always there is a control that does nothing most of the time, and its
|
||||
26px would come out of the tabs to say so. -->
|
||||
<!-- DynamicResource, NOT StaticResource, on the style - same reason as
|
||||
GrainBrushShared above. TabNewButton lives in MainWindow.Resources, and
|
||||
StaticResource resolves at parse time against this control's own scope,
|
||||
before the control is in the window's tree, so it throws from
|
||||
InitializeComponent. -->
|
||||
<Button x:Name="TabOverflowBtn" DockPanel.Dock="Right" Visibility="Collapsed"
|
||||
Style="{DynamicResource TabNewButton}"
|
||||
Content="" FontFamily="{DynamicResource IconFont}" FontSize="10"
|
||||
Width="22" Height="20" Margin="2,4,2,3" VerticalAlignment="Center"
|
||||
FocusVisualStyle="{x:Null}"
|
||||
ToolTip="{DynamicResource Str_TT_TabOverflow}" Click="TabOverflow_Click"/>
|
||||
<!-- No side insets. The strip has to be flush with the card beneath it, or the
|
||||
first tab's left edge sits 6px inside the card's left border and the ring
|
||||
steps sideways where it should run straight up. -->
|
||||
<!-- Horizontal scrolling is DISABLED, not hidden: hidden still measures the
|
||||
content at infinite width, which would let the tabs size to their own
|
||||
content and defeat the UniformGrid below. Disabled constrains them to the
|
||||
band's actual width, which is the point.
|
||||
FocusVisualStyle nulled: a ScrollViewer is focusable, and once focus lands
|
||||
here WPF draws its stock dotted rectangle around the whole strip. -->
|
||||
<ScrollViewer x:Name="TabScroll" VerticalScrollBarVisibility="Disabled" HorizontalScrollBarVisibility="Disabled"
|
||||
Background="Transparent" FocusVisualStyle="{x:Null}"
|
||||
UseLayoutRounding="True" SnapsToDevicePixels="True"
|
||||
MouseLeftButtonDown="TabScroll_MouseLeftButtonDown">
|
||||
<!-- Keep the edge overlays in the SAME viewport as the tab containers.
|
||||
When they were siblings of the ScrollViewer they started from the
|
||||
band's origin while the tabs started from the viewport's origin,
|
||||
producing the three-pixel vertical mismatch at the scrollbar corner. -->
|
||||
<Grid UseLayoutRounding="True" SnapsToDevicePixels="True">
|
||||
<ItemsControl x:Name="TabStrip" UseLayoutRounding="True" SnapsToDevicePixels="True">
|
||||
<!-- The ACTIVE tab draws above its neighbors. Items paint in index
|
||||
order, so the tab to the RIGHT of the active one painted over the
|
||||
active tab's 1px right border and the ring stopped dead at that
|
||||
side - which is why a middle tab lost its right edge while the last
|
||||
tab, having no neighbor after it, kept both. -->
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsActive}" Value="True">
|
||||
<Setter Property="Panel.ZIndex" Value="1"/>
|
||||
</DataTrigger>
|
||||
<!-- Windowed out of the strip and living in the chevron
|
||||
instead (ApplyTabWindow). Collapsed rather than removed
|
||||
from the collection, which is what makes this cheap:
|
||||
UniformGrid does not count a collapsed child when it
|
||||
divides the band, so the survivors fill it edge to edge
|
||||
on their own, and nothing about the tabs' order or
|
||||
their drag indices moves. -->
|
||||
<DataTrigger Binding="{Binding IsStripVisible}" Value="False">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemsPanel>
|
||||
<!-- Tabs share the band equally and fill it, browser-style, instead
|
||||
of hugging their titles and leaving dead space on the right.
|
||||
This is also what makes the last tab always REACH the strip's
|
||||
right edge, so edge ownership is a fact rather than something
|
||||
that has to be measured after every reflow. -->
|
||||
<ItemsPanelTemplate><UniformGrid Rows="1"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<!-- No DataType: the item is PdfViewer.DocumentSession, an internal
|
||||
nested type, which x:Type cannot name. -->
|
||||
<DataTemplate>
|
||||
<!-- No MaxWidth: it would cap each cell and put the dead space
|
||||
back. The MinWidth floor is low so a lot of open tabs still
|
||||
divide the band rather than overflowing it.
|
||||
CornerRadius matches the card's 6: the rightmost tab sits
|
||||
directly above the card's top-right corner, and a 4px curve
|
||||
stacked on a 6px one reads as a mistake rather than as two
|
||||
rounded things.
|
||||
No right MARGIN: the 1px right BorderThickness below
|
||||
already separates one tab from the next, and a margin only
|
||||
stopped the last tab reaching the strip's right edge -
|
||||
which is the card's edge, so it left a 1px step under the
|
||||
corner.
|
||||
1px bottom margin keeps an inactive tab clear of
|
||||
TabBarRing; the active tab drops it and covers its own
|
||||
segment, which is what breaks that line exactly at the tab
|
||||
and makes the browser-tab join. -->
|
||||
<Border x:Name="tabBd" CornerRadius="{DynamicResource TabCornerRadius}" Margin="{DynamicResource TabMargin}" Padding="{DynamicResource TabPadding}"
|
||||
MinWidth="60"
|
||||
UseLayoutRounding="True" SnapsToDevicePixels="True"
|
||||
Cursor="Hand" Background="Transparent"
|
||||
BorderThickness="0,0,1,0" BorderBrush="{DynamicResource PaneEdgeBrush}"
|
||||
ToolTip="{Binding TabTip}"
|
||||
MouseDown="Tab_MouseDown"
|
||||
MouseRightButtonUp="Tab_RightClick"
|
||||
PreviewMouseLeftButtonDown="Tab_DragDown"
|
||||
PreviewMouseMove="Tab_DragMove"
|
||||
PreviewMouseLeftButtonUp="Tab_DragUp">
|
||||
<Grid>
|
||||
<!-- Grain follows the tab's radius, or the texture
|
||||
squares off the corner the border just rounded. -->
|
||||
<Border x:Name="tabGrain" IsHitTestVisible="False"
|
||||
CornerRadius="{DynamicResource TabCornerRadius}" Margin="-12,-4,-5,-5"
|
||||
Background="{DynamicResource GrainTileBrush}" Opacity="0"/>
|
||||
<DockPanel LastChildFill="True">
|
||||
<Button DockPanel.Dock="Right" Content=""
|
||||
Style="{DynamicResource TabCloseButton}"
|
||||
FontFamily="{DynamicResource IconFont}" FontSize="9"
|
||||
Width="16" Height="16" Margin="6,0,0,0" Padding="0"
|
||||
VerticalAlignment="Center" FocusVisualStyle="{x:Null}"
|
||||
ToolTip="{DynamicResource Str_TT_CloseTab}"
|
||||
Tag="{Binding}" Click="CloseTab_Click"/>
|
||||
<TextBlock x:Name="tabLbl" Text="{Binding TabLabel}"
|
||||
FontFamily="{DynamicResource UiFont}" FontSize="11"
|
||||
Foreground="{DynamicResource MutedTextBrush}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
<Border x:Name="tabBevelLight" IsHitTestVisible="False" Panel.ZIndex="4"
|
||||
Margin="{DynamicResource TabBevelMargin}" SnapsToDevicePixels="True"
|
||||
BorderBrush="{DynamicResource BevelLightBrush}" BorderThickness="{DynamicResource BevelLightThickness}"/>
|
||||
<Border x:Name="tabBevelDark" IsHitTestVisible="False" Panel.ZIndex="4"
|
||||
Margin="{DynamicResource TabBevelMargin}" SnapsToDevicePixels="True"
|
||||
BorderBrush="{DynamicResource BevelDarkBrush}" BorderThickness="{DynamicResource TabInactiveBevelDarkThickness}"/>
|
||||
<!-- Second, inset highlight of the selected 98SE tab. Together
|
||||
with tabBevelLight this makes the tab a raised two-tone edge,
|
||||
matching the pane's outer and inner highlights instead of
|
||||
degenerating into a flat rule at the tab/pane join. Other
|
||||
themes resolve these resources to a transparent zero edge. -->
|
||||
<Border x:Name="tabActiveRetroInnerBevel" IsHitTestVisible="False" Panel.ZIndex="6"
|
||||
Margin="{DynamicResource TabActiveInnerBevelMargin}"
|
||||
BorderBrush="{DynamicResource TabActiveInnerBevelBrush}"
|
||||
BorderThickness="{DynamicResource TabActiveInnerBevelThickness}"
|
||||
SnapsToDevicePixels="True" Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<DataTemplate.Triggers>
|
||||
<!-- Last tab drops its right border: that 1px is a divider
|
||||
BETWEEN tabs, and on the strip's right edge it reads as
|
||||
a stray rule instead. Declared BEFORE the IsActive
|
||||
trigger so the active tab's accent-stripe thickness
|
||||
still wins when the last tab is also the active one. -->
|
||||
<DataTrigger Binding="{Binding IsLast}" Value="True">
|
||||
<Setter TargetName="tabBd" Property="BorderThickness" Value="0"/>
|
||||
</DataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||
<Condition Binding="{Binding IsActive}" Value="False"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabInactiveFirstMargin}"/>
|
||||
</MultiDataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||
<Condition Binding="{Binding IsActive}" Value="False"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabInactiveLastMargin}"/>
|
||||
</MultiDataTrigger>
|
||||
<DataTrigger Binding="{Binding IsActive}" Value="True">
|
||||
<!-- Front tab takes the card's own canvas color, so the
|
||||
tab and the card read as one surface. -->
|
||||
<Setter TargetName="tabBd" Property="Background" Value="{DynamicResource BgCanvas}"/>
|
||||
<Setter TargetName="tabGrain" Property="Opacity" Value="{DynamicResource GrainOpacity}"/>
|
||||
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabStripeThickness}"/>
|
||||
<!-- Reaches the foot of the band so its fill breaks the
|
||||
ring line where the tab is. -->
|
||||
<Setter TargetName="tabBd" Property="Margin" Value="0,3,0,0"/>
|
||||
<!-- Top padding gives back the 3px the accent stripe
|
||||
takes, so the title does not drop on activation. -->
|
||||
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabActivePadding}"/>
|
||||
<Setter TargetName="tabBd" Property="BorderBrush" Value="{DynamicResource TabActiveRingBrush}"/>
|
||||
<!-- Modern themes use the original raised-tab shadow. 98SE
|
||||
disables it in the theme-gated trigger below because an Effect
|
||||
rasterizes the label and destroys the crisp classic text. -->
|
||||
<Setter TargetName="tabBd" Property="Effect" Value="{DynamicResource BarShadowEffect}"/>
|
||||
<Setter TargetName="tabLbl" Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||
<Setter TargetName="tabLbl" Property="FontWeight" Value="Bold"/>
|
||||
<Setter TargetName="tabLbl" Property="FontSize" Value="11.5"/>
|
||||
<Setter TargetName="tabBevelDark" Property="BorderThickness" Value="{DynamicResource TabActiveBevelDarkThickness}"/>
|
||||
<Setter TargetName="tabBevelDark" Property="Margin" Value="{DynamicResource TabActiveBevelDarkMargin}"/>
|
||||
<Setter TargetName="tabActiveRetroInnerBevel" Property="Visibility" Value="{DynamicResource RetroActiveTabOutlineVisibility}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding UseRetroTabChrome}" Value="True">
|
||||
<Setter TargetName="tabBd" Property="Effect" Value="{x:Null}"/>
|
||||
</DataTrigger>
|
||||
<!-- A first 98SE tab follows the pane's inner light bevel,
|
||||
one pixel inside its dark outer frame. Modern themes
|
||||
resolve this token to the normal flush active margin. -->
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsActive}" Value="True"/>
|
||||
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabActiveFirstMargin}"/>
|
||||
</MultiDataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsActive}" Value="True"/>
|
||||
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabActiveLastMargin}"/>
|
||||
</MultiDataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsActive}" Value="True"/>
|
||||
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabActiveOnlyMargin}"/>
|
||||
</MultiDataTrigger>
|
||||
<!-- Unfocused pane: the lip drops off the accent entirely
|
||||
and takes the card's own border color, so the whole
|
||||
unlit ring - lip, band line and card border - is ONE
|
||||
value. At full accent both panes claimed to be the live
|
||||
one. The active tab is still obvious on an unfocused
|
||||
pane from its canvas fill and bold title, which is how
|
||||
a browser marks it too.
|
||||
Mutually exclusive with PaneFocused below, and NOT
|
||||
simply !PaneFocused: with one pane open both are false
|
||||
and the lone pane's lip stays bright. -->
|
||||
<DataTrigger Binding="{Binding PaneDimmed}" Value="True">
|
||||
<Setter TargetName="tabBd" Property="BorderBrush" Value="{DynamicResource PaneBorderBrush}"/>
|
||||
</DataTrigger>
|
||||
<!-- Focused pane: the ring continues UP both sides of the
|
||||
active tab, so the tab and the card read as one
|
||||
outlined surface instead of the ring stopping dead at
|
||||
the strip. Declared LAST on purpose - PaneFocused
|
||||
implies IsActive, both triggers match, and with
|
||||
multiple matches the last one wins. Put earlier,
|
||||
IsActive would overwrite the thickness straight back to
|
||||
a top-only stripe.
|
||||
One brush for the whole lip, matching the card's ring:
|
||||
a top stripe in one color meeting sides in another
|
||||
reads as two edges rather than one ring.
|
||||
Padding drops 1px each side to pay for the new borders,
|
||||
so the title does not shift when focus arrives. -->
|
||||
<DataTrigger Binding="{Binding PaneFocused}" Value="True">
|
||||
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabFocusThickness}"/>
|
||||
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabFocusPadding}"/>
|
||||
<Setter TargetName="tabBd" Property="BorderBrush" Value="{DynamicResource TabActiveRingBrush}"/>
|
||||
</DataTrigger>
|
||||
<!-- Pane shading is the 98SE focus cue only. Keeping it behind an
|
||||
explicit theme flag preserves every modern palette's original
|
||||
active-tab BgCanvas fill. -->
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding UseRetroTabChrome}" Value="True"/>
|
||||
<Condition Binding="{Binding PaneDimmed}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Background" Value="{DynamicResource TabInactiveBrush}"/>
|
||||
</MultiDataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding UseRetroTabChrome}" Value="True"/>
|
||||
<Condition Binding="{Binding PaneFocused}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Background" Value="{DynamicResource FocusedPaneBrush}"/>
|
||||
</MultiDataTrigger>
|
||||
<!-- The OUTERMOST side is the band's to draw (TabEdgeLeft /
|
||||
TabEdgeRight), so the first and last tab must not draw
|
||||
it as well. Two 1px borders at the same x is a 2px edge
|
||||
on a ring that is 1px everywhere else - and only
|
||||
SOMETIMES, because whether the tab's own outer border
|
||||
clears the ScrollViewer's clip depends on how the
|
||||
UniformGrid divided a fractional band width. That is
|
||||
where the halo came out uneven, and why it changed when
|
||||
the split moved.
|
||||
The padding gives the pixel back on that side so the
|
||||
title does not shift, exactly as PaneFocused does.
|
||||
Declared after PaneFocused - all three match on a first
|
||||
or last tab and the last match wins. -->
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding PaneFocused}" Value="True"/>
|
||||
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabFocusFirstThickness}"/>
|
||||
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabFocusFirstPadding}"/>
|
||||
</MultiDataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding PaneFocused}" Value="True"/>
|
||||
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabFocusLastThickness}"/>
|
||||
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabFocusLastPadding}"/>
|
||||
</MultiDataTrigger>
|
||||
<!-- One tab in a focused pane owns BOTH edges, so it draws
|
||||
neither. Last of the three for the same reason. -->
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding PaneFocused}" Value="True"/>
|
||||
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabFocusOnlyThickness}"/>
|
||||
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabFocusOnlyPadding}"/>
|
||||
</MultiDataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<!-- The outer gray frame pixels. The tabs reserve their edge pixel and
|
||||
draw the inner white/gray bevel themselves; these borders complete
|
||||
only the outermost frame and therefore cannot thicken the bevel. -->
|
||||
<Border x:Name="TabEdgeLeft" Width="1" HorizontalAlignment="Left"
|
||||
IsHitTestVisible="False" Visibility="Collapsed"/>
|
||||
<Border x:Name="TabEdgeRight" Width="1" HorizontalAlignment="Right"
|
||||
IsHitTestVisible="False" Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── This pane's card ───────────────────────────────────────────────────────────
|
||||
Row 1. The -1 top margin tucks the card's top border up into the strip band so the
|
||||
active tab and the card read as one surface (see TabBarRing).
|
||||
|
||||
Margin is set in CODE, not here: it must be -1 only while this pane HAS a strip.
|
||||
A pane with one tab collapses its strip, and the -1 then lifts that pane a pixel above
|
||||
the other one instead of tucking under anything. RebuildTabStrip owns both. -->
|
||||
<Grid x:Name="CardRow" Grid.Row="1">
|
||||
<!-- Separate shadow caster keeps the pane content crisp while allowing each modern
|
||||
theme to supply its intended elevation. 98SE sets PaneShadowOpacity to zero. -->
|
||||
<!-- DynamicResource PaneShadowEffect, built per theme in ThemeManager (null on 98SE) -
|
||||
the old app-level StaticResource effect froze with its startup opacity and 98SE's
|
||||
zero never applied. -->
|
||||
<Border x:Name="PaneShadow" IsHitTestVisible="False" Margin="0,1,0,0"
|
||||
Background="{DynamicResource BgCanvas}"
|
||||
CornerRadius="{DynamicResource RadCard}"
|
||||
Effect="{DynamicResource PaneShadowEffect}"/>
|
||||
<Border x:Name="PaneBorder" Panel.ZIndex="6"
|
||||
Background="{DynamicResource BgCanvas}"
|
||||
BorderBrush="{DynamicResource PaneBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="{DynamicResource RadCard}">
|
||||
<!-- Clipped to the card's own corners (DocPane_SizeChanged). A Border with a
|
||||
CornerRadius does NOT clip its child, so the canvas and the page square the
|
||||
corners straight back off. Same treatment as KillerShell's PaneContent. -->
|
||||
<Grid x:Name="DocPaneContent" SizeChanged="DocPane_SizeChanged">
|
||||
<Border x:Name="PaneBevelOuterDark" Panel.ZIndex="100" IsHitTestVisible="False" BorderBrush="{DynamicResource PaneBevelDarkBrush}" BorderThickness="{DynamicResource PaneBevelLightThickness}"/>
|
||||
<Border x:Name="PaneBevelOuterLight" Panel.ZIndex="100" IsHitTestVisible="False" BorderBrush="{DynamicResource PaneBevelLightBrush}" BorderThickness="{DynamicResource PaneBevelDarkThickness}"/>
|
||||
<Border x:Name="PaneBevelInnerDark" Panel.ZIndex="100" IsHitTestVisible="False" Margin="{DynamicResource PaneBevelInnerMargin}" BorderBrush="{DynamicResource PaneBevelDark2Brush}" BorderThickness="{DynamicResource PaneBevel2LightThickness}"/>
|
||||
<Border x:Name="PaneBevelInnerLight" Panel.ZIndex="100" IsHitTestVisible="False" Margin="{DynamicResource PaneBevelInnerMargin}" BorderBrush="{DynamicResource PaneBevelLight2Brush}" BorderThickness="{DynamicResource PaneBevel2DarkThickness}"/>
|
||||
<!-- Film grain - sits on the canvas background, behind the document.
|
||||
Needs no CornerRadius of its own: the clip on DocPaneContent rounds
|
||||
everything inside the card, grain included. -->
|
||||
<Border IsHitTestVisible="False" Opacity="{DynamicResource GrainOpacity}">
|
||||
<Border.Background>
|
||||
<ImageBrush x:Name="GrainBrush"
|
||||
TileMode="Tile"
|
||||
ViewportUnits="Absolute"
|
||||
Viewport="0,0,256,256"
|
||||
Stretch="None"/>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<Border x:Name="DropZone" Background="Transparent"
|
||||
AllowDrop="True" Drop="DropZone_Drop" DragOver="DropZone_DragOver"
|
||||
MouseLeftButtonDown="DropZone_Click">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Drop target, centered in the remaining space -->
|
||||
<Border Grid.Column="0" BorderBrush="{DynamicResource DropBorder}" BorderThickness="2"
|
||||
CornerRadius="{DynamicResource PanelCornerRadius}" Padding="40"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Background="Transparent" Style="{x:Null}">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Text="{DynamicResource Str_Drop_Title}" FontFamily="Segoe UI, Microsoft JhengHei UI, Nirmala UI" FontSize="20"
|
||||
Foreground="{DynamicResource MutedTextBrush}" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="{DynamicResource Str_Drop_Sub}" FontFamily="Segoe UI, Microsoft JhengHei UI, Nirmala UI" FontSize="13"
|
||||
Foreground="{DynamicResource MutedTextBrush}" HorizontalAlignment="Center" Margin="0,8,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Recent files sidebar (populated in code-behind; hidden when empty).
|
||||
Width and visibility are driven by SyncRecentBoxWidth: at 340 fixed it
|
||||
swamped the drop target in a half-width pane, so it now scales with the
|
||||
pane and drops out entirely below a threshold. -->
|
||||
<Border x:Name="RecentFilesBox" Grid.Column="1" Width="340" Visibility="Collapsed"
|
||||
Background="{DynamicResource BgRecentPanel}"
|
||||
BorderBrush="{DynamicResource PaneBorderBrush}" BorderThickness="1,0,0,0">
|
||||
<Grid>
|
||||
<Border Background="{DynamicResource GrainBrushShared}" IsHitTestVisible="False"
|
||||
Opacity="{DynamicResource GrainOpacity}"/>
|
||||
<DockPanel Margin="18,22,12,16">
|
||||
<DockPanel DockPanel.Dock="Top" Margin="2,0,0,10">
|
||||
<!-- Clear all (#146): one click empties the whole list, matching the
|
||||
dropdown's Clear list item. Foreground lives in the style so the
|
||||
hover trigger can override it (a local value would win over it). -->
|
||||
<TextBlock DockPanel.Dock="Right" Text="{DynamicResource Str_Menu_ClearList}"
|
||||
FontFamily="Segoe UI, Microsoft JhengHei UI, Nirmala UI"
|
||||
FontSize="11" Cursor="Hand" Margin="8,0,6,0"
|
||||
MouseLeftButtonDown="RecentClearAll_Click">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource MutedTextBrush}"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||
<Setter Property="TextDecorations" Value="Underline"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{DynamicResource Str_RecentHeading}" FontFamily="Segoe UI, Microsoft JhengHei UI, Nirmala UI"
|
||||
FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryBrush}"/>
|
||||
</DockPanel>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl x:Name="RecentFilesList"/>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- PDF page with annotation overlay -->
|
||||
<ScrollViewer x:Name="PagePreviewPanel" Visibility="Collapsed"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Background="Transparent"
|
||||
FocusVisualStyle="{x:Null}"
|
||||
PreviewMouseWheel="PagePreview_PreviewMouseWheel"
|
||||
SizeChanged="PagePreviewPanel_SizeChanged"
|
||||
PreviewMouseDown="PagePreviewPanel_PreviewMouseDown"
|
||||
PreviewMouseMove="PagePreviewPanel_PreviewMouseMove"
|
||||
PreviewMouseUp="PagePreviewPanel_PreviewMouseUp"
|
||||
MouseRightButtonUp="DocPaneBackground_RightClick"
|
||||
AllowDrop="True" Drop="DropZone_Drop" DragOver="DropZone_DragOver">
|
||||
<!-- Custom template: the VERTICAL scrollbar spans the full height (RowSpan=2),
|
||||
covering the bottom-right corner cell, and the horizontal bar butts up
|
||||
against it. The default template puts a white system-colored corner
|
||||
rectangle there, which read as a stray white square against the dark
|
||||
canvas whenever both scrollbars were visible.
|
||||
The PART_ names below are TEMPLATE-scoped and so are unaffected by this
|
||||
control being its own namescope - do not "fix" them. -->
|
||||
<ScrollViewer.Template>
|
||||
<ControlTemplate TargetType="ScrollViewer">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<ScrollContentPresenter Grid.Row="0" Grid.Column="0"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
CanContentScroll="{TemplateBinding CanContentScroll}"/>
|
||||
<ScrollBar x:Name="PART_VerticalScrollBar" Grid.Row="0" Grid.Column="1" Grid.RowSpan="2"
|
||||
Panel.ZIndex="101"
|
||||
Value="{TemplateBinding VerticalOffset}"
|
||||
Maximum="{TemplateBinding ScrollableHeight}"
|
||||
ViewportSize="{TemplateBinding ViewportHeight}"
|
||||
Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"/>
|
||||
<ScrollBar x:Name="PART_HorizontalScrollBar" Grid.Row="1" Grid.Column="0"
|
||||
Panel.ZIndex="101"
|
||||
Orientation="Horizontal"
|
||||
Value="{TemplateBinding HorizontalOffset}"
|
||||
Maximum="{TemplateBinding ScrollableWidth}"
|
||||
ViewportSize="{TemplateBinding ViewportWidth}"
|
||||
Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}"/>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</ScrollViewer.Template>
|
||||
<Border x:Name="DocSurfacePad" VerticalAlignment="Top" HorizontalAlignment="Center" Padding="12">
|
||||
<!-- Override the window's Display/ClearType text mode for the zoomed
|
||||
document surface. Display + ClearType pixel-snaps and color-fringes
|
||||
when scaled, giving hard-edged text on annotations and form fields;
|
||||
Ideal + Grayscale stays smoothly anti-aliased at any zoom, matching
|
||||
how other PDF editors render. -->
|
||||
<Grid x:Name="PageContentGrid"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
TextOptions.TextRenderingMode="Grayscale">
|
||||
<Grid.LayoutTransform>
|
||||
<ScaleTransform ScaleX="1" ScaleY="1"/>
|
||||
</Grid.LayoutTransform>
|
||||
<WrapPanel x:Name="PageContentPanel" Orientation="Horizontal">
|
||||
<!-- Tile 0 (the primary page) is built in code by BuildPrimaryTile and
|
||||
inserted at index 0; additional pages are rendered dynamically too. -->
|
||||
</WrapPanel>
|
||||
<!-- Continuous scroll panel - shown in Continuous view mode -->
|
||||
<StackPanel x:Name="ContinuousPanel"
|
||||
HorizontalAlignment="Center"
|
||||
Orientation="Vertical"
|
||||
Visibility="Collapsed"/>
|
||||
<!-- Top-most layer for the selection marquee, so a drag can span pages -->
|
||||
<Canvas x:Name="MarqueeLayer" IsHitTestVisible="False"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- #197: current-page badge. Replaces the cursor-following page tooltips: slides
|
||||
up from the bottom corner on scroll and page changes, slides back down when
|
||||
the view settles (ShowPageBadge, Viewport.cs). Never hit-testable, so it
|
||||
cannot eat a page click. 26px right margin clears the vertical scrollbar. -->
|
||||
<Grid x:Name="PageBadge" HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||
Margin="0,0,26,14" Panel.ZIndex="40" IsHitTestVisible="False" Opacity="0">
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform x:Name="PageBadgeSlide" Y="46"/>
|
||||
</Grid.RenderTransform>
|
||||
|
||||
<!-- Cast from a separate empty rectangle so the effect never rasterizes the
|
||||
badge text. BarShadowOpacity keeps the weight consistent with the chrome
|
||||
and lets 98SE suppress it without a special-case code path. -->
|
||||
<Border Background="{DynamicResource MenuBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect Color="Black" BlurRadius="7" ShadowDepth="2"
|
||||
Direction="270" Opacity="{DynamicResource BarShadowOpacity}"
|
||||
RenderingBias="Quality"/>
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource MenuBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource MenuBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}" Padding="9,3">
|
||||
<Grid>
|
||||
<TextBlock x:Name="PageBadgeText" FontFamily="Consolas" FontSize="12"
|
||||
Foreground="{DynamicResource TextBrush}"/>
|
||||
<!-- Grain OVER the content, family rule -->
|
||||
<Border IsHitTestVisible="False" Margin="-9,-3"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid><!-- /card row -->
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using KillerPDF.Features;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// One document view: its tab strip, its card and everything inside. Two instances make the
|
||||
/// split.
|
||||
///
|
||||
/// The handlers here are one-line forwards to the host, following KillerShell's FilePane idiom -
|
||||
/// chrome that belongs to the window stays on the window.
|
||||
/// </summary>
|
||||
public partial class PdfViewer : UserControl
|
||||
{
|
||||
/// <summary>The explicit shell boundary used by the viewer.</summary>
|
||||
internal IViewerHost? Host { get; private set; }
|
||||
|
||||
/// <summary>Document dark-mode invert, PER PANE (was a global that flipped both panes of
|
||||
/// a split at once). Display only; every render path in this pane reads this flag. The
|
||||
/// moon toggles the focused pane and its lit state follows pane focus.</summary>
|
||||
internal bool DocInvert;
|
||||
|
||||
internal void AttachHost(IViewerHost host) => Host = host;
|
||||
|
||||
/// <summary>This viewer's per-view state - page maps, view mode, zoom, render cancellation,
|
||||
/// continuous bookkeeping. MainWindow's `_view` reads it back from here, so a second pane
|
||||
/// gets its own simply by existing.</summary>
|
||||
internal ViewerState State { get; } = new();
|
||||
|
||||
public PdfViewer() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Build this pane's tile tree. Every pane must do this for itself: routed through
|
||||
/// ActiveViewer it would run twice on pane A and leave pane B's State.AnnotationCanvas
|
||||
/// null, which AllPageCanvases() yields first and the first text-selection repaint then
|
||||
/// dereferences. The panel references must be assigned before BuildPrimaryTile, which
|
||||
/// inserts into PageContentPanel.
|
||||
/// </summary>
|
||||
internal void InitTiles()
|
||||
{
|
||||
State.PageContentGrid = PageContentGrid;
|
||||
State.PageContentPanel = PageContentPanel;
|
||||
State.ContinuousPanel = ContinuousPanel;
|
||||
BuildPrimaryTile();
|
||||
State.ActiveCanvas = State.AnnotationCanvas;
|
||||
}
|
||||
|
||||
/// <summary>Accent ring marking this pane as the focused one in a split.
|
||||
///
|
||||
/// SetResourceReference, not a brush snapshot, on both states: an assigned brush would not
|
||||
/// follow a live theme switch.
|
||||
///
|
||||
/// "SelectionAccent", not "AccentBrush". KillerPDF uses the older family resource set
|
||||
/// (BgCanvas / AccentLogo / TextPrimary) and has no AccentBrush key in any theme.
|
||||
/// SetResourceReference to a missing key does not throw, it silently leaves the property
|
||||
/// unset, which blanks the border instead of accenting it.
|
||||
///
|
||||
/// Both borders, matching KillerShell's UpdatePaneFocusRing: TabBarRing is the card's top
|
||||
/// border drawn again inside the tab band, so lighting only the card leaves the ring open
|
||||
/// along its whole top edge.
|
||||
///
|
||||
/// The brush moves, never the thickness - a thickness change would reflow the pane on every
|
||||
/// click between panes.
|
||||
///
|
||||
/// PaneHasFocus below records the same state for the tab halo, which continues the ring up
|
||||
/// around the active tab in the focused pane only.</summary>
|
||||
internal bool PaneHasFocus { get; private set; }
|
||||
|
||||
internal void SetFocusHalo(bool focused)
|
||||
{
|
||||
PaneHasFocus = focused;
|
||||
bool retro = Services.ThemeManager.Current == Services.Theme.SE98;
|
||||
string key = focused && !retro ? "SelectionAccent" : "PaneBorderBrush";
|
||||
PaneBorder.SetResourceReference(Border.BorderBrushProperty, key);
|
||||
// The 98SE band is the raised client's white top ledge. Replacing it with the gray
|
||||
// outer-frame brush on focus made the tab/pane join visibly change after a click.
|
||||
TabBarRing.SetResourceReference(Border.BorderBrushProperty,
|
||||
retro ? "BevelLightBrush" : key);
|
||||
// The ring runs on around the active tab, so it moves with the pane border. The tab's own
|
||||
// share of that is a template trigger on PaneFocused / PaneDimmed, which this sets, plus
|
||||
// the band-drawn outer verticals.
|
||||
UpdatePaneFocusRing();
|
||||
}
|
||||
|
||||
// ---- Element access for the window --------------------------------------------------
|
||||
// A UserControl is its OWN NAMESCOPE, so the window's FindName cannot reach any of these -
|
||||
// and it fails SILENTLY, returning null rather than throwing. The window ctor assigns
|
||||
// _view.X from these instead, which works because those fields are forwarding properties
|
||||
// onto ViewerState.
|
||||
internal Border PaneShadowBorder => PaneShadow;
|
||||
internal Border PaneCardBorder => PaneBorder;
|
||||
internal Grid ContentHost => DocPaneContent;
|
||||
internal StackPanel ContinuousHost => ContinuousPanel;
|
||||
internal WrapPanel PageHost => PageContentPanel;
|
||||
internal Grid PageGrid => PageContentGrid;
|
||||
internal ScrollViewer PreviewScroller => PagePreviewPanel;
|
||||
internal Border DropSurface => DropZone;
|
||||
internal Border RecentBox => RecentFilesBox;
|
||||
internal ItemsControl RecentList => RecentFilesList;
|
||||
internal Canvas Marquee => MarqueeLayer;
|
||||
internal Border SurfacePad => DocSurfacePad;
|
||||
internal System.Windows.Media.ImageBrush Grain => GrainBrush;
|
||||
|
||||
// ---- Forwards to the owning window ----------------------------------------------------
|
||||
// MainWindow's copies were private; they are internal now purely so these can reach them.
|
||||
|
||||
private void DocPane_SizeChanged(object s, SizeChangedEventArgs e)
|
||||
{
|
||||
Host?.ViewerSizeChanged(this, s, e);
|
||||
|
||||
// Window resizing changes a pane's usable width without going through the split-pane
|
||||
// callbacks. Keep the empty-state recents panel on the same width gate in that path too.
|
||||
// SyncRecentBoxWidth only writes materially changed values and guards re-entry, so the
|
||||
// follow-up layout pass caused by crossing the threshold settles immediately.
|
||||
if (e.WidthChanged) SyncRecentBoxWidth();
|
||||
}
|
||||
|
||||
/// <summary>Size the start screen's Recent panel to this pane, and drop it entirely once the
|
||||
/// pane is too narrow to carry both it and the drop target. At its old fixed 340 it took
|
||||
/// most of a half-width pane and left the "Drop PDF here" zone as a sliver. Owns the
|
||||
/// panel's visibility outright, so PopulateRecentFilesList defers to it rather than the two
|
||||
/// of them setting it from different rules.</summary>
|
||||
private bool _syncingRecentBox;
|
||||
internal void SyncRecentBoxWidth()
|
||||
{
|
||||
if (RecentFilesBox is null || RecentFilesList is null || _syncingRecentBox) return;
|
||||
_syncingRecentBox = true;
|
||||
try
|
||||
{
|
||||
double w = ActualWidth;
|
||||
double want = Math.Min(340, Math.Max(220, w * 0.4));
|
||||
var vis = RecentFilesList.Items.Count > 0 && w >= 560
|
||||
? Visibility.Visible : Visibility.Collapsed;
|
||||
// Only write when the value actually changes. Both of these re-trigger layout, and
|
||||
// this runs FROM a size handler - an unconditional assignment gives the layout pass
|
||||
// something new to react to every time round and it never settles.
|
||||
if (Math.Abs(RecentFilesBox.Width - want) > 0.5) RecentFilesBox.Width = want;
|
||||
if (RecentFilesBox.Visibility != vis) RecentFilesBox.Visibility = vis;
|
||||
}
|
||||
finally { _syncingRecentBox = false; }
|
||||
}
|
||||
|
||||
// Focus THIS pane before forwarding: the open path routes through ActiveViewer, and a
|
||||
// drag-drop raises no PreviewMouseDown (the focus trigger), so a drop on the unfocused
|
||||
// pane opened the file in the OTHER pane. FocusPane is cheap and idempotent.
|
||||
private void DropZone_Drop(object s, DragEventArgs e) => Host?.ViewerDrop(this, s, e);
|
||||
private void DropZone_DragOver(object s, DragEventArgs e) => Host?.ViewerDragOver(s, e);
|
||||
private void DropZone_Click(object s, MouseButtonEventArgs e) => Host?.ViewerDropZoneClick(s, e);
|
||||
|
||||
private void RecentClearAll_Click(object s, MouseButtonEventArgs e) => Host?.ClearRecentFiles(s, e);
|
||||
|
||||
// The five preview/scroll handlers do NOT forward from here: their bodies are in this class
|
||||
// (PdfViewer.Zoom.cs, PdfViewer.Viewport.cs), so a forward would call straight back into
|
||||
// itself. The XAML binds them directly.
|
||||
private void DocPaneBackground_RightClick(object s, MouseButtonEventArgs e) => Host?.ViewerBackgroundRightClick(s, e);
|
||||
|
||||
/// <summary>Empty space on this pane's tab strip drags the window, the way a strip in the
|
||||
/// title-bar row would. Named apart from MainWindow's TitleBar_MouseLeftButtonDown, which
|
||||
/// keeps the body - it is window chrome, not pane behavior.</summary>
|
||||
private void TabScroll_MouseLeftButtonDown(object s, MouseButtonEventArgs e) => Host?.ViewerTabStripMouseDown(s, e);
|
||||
|
||||
/// <summary>This pane's active document, for the window's chrome. The session list lives
|
||||
/// here, so the window asks the focused pane rather than owning one itself.</summary>
|
||||
internal DocumentSession? ActiveSessionRef => _active;
|
||||
|
||||
/// <summary>Every open document in THIS pane. The quit prompt has to union both panes to
|
||||
/// decide whether anything is unsaved, and the settings writer needs each pane's list.</summary>
|
||||
internal System.Collections.ObjectModel.ObservableCollection<DocumentSession> SessionsRef => _sessions;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user