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

This commit is contained in:
2026-08-27 06:58:22 +02:00
commit 532485a830
577 changed files with 149058 additions and 0 deletions
+233
View File
@@ -0,0 +1,233 @@
using System.Diagnostics;
using System.Windows;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using KillerPDF.Features;
namespace KillerPDF
{
/// <summary>
/// The About overlay's window half: the IAboutHost implementation that maps the controller's
/// values onto the named XAML elements, the inline construction the card needs, and the click
/// handlers. All the logic - signature, hashing, update check, self-update - lives in
/// <see cref="AboutController"/>.
///
/// NOTE: this stays "namespace KillerPDF" rather than KillerPDF.Shell, because it is a partial
/// of MainWindow and every partial of a class must share one namespace. It moves to
/// KillerPDF.Shell when MainWindow itself does.
/// </summary>
public partial class MainWindow : IAboutHost
{
private AboutController? _aboutController;
private AboutController About => _aboutController ??= new AboutController(this);
private void VersionLabel_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
ShowAboutOverlay();
}
private void ShowAboutOverlay() => About.Show();
private void CloseAboutOverlay()
{
FadeOverlayOut(AboutOverlay);
}
// ---- IShellServices ------------------------------------------------------------------
Window IShellServices.Window => this;
// MainWindow's own Loc and SetStatus are private, and a private member cannot implement an
// interface. Forwarding explicitly satisfies IShellServices without widening either of them
// to public, which would be a change nobody asked for.
string IShellServices.Loc(string key) => Loc(key);
void IShellServices.SetStatus(string text) => SetStatus(text);
// ---- IAboutHost ----------------------------------------------------------------------
string IAboutHost.Publisher { set => AboutPublisherBlock.Text = value; }
string IAboutHost.Thumbprint { set => AboutThumbprintBlock.Text = value; }
string IAboutHost.Sha256 { set => AboutSha256Block.Text = value; }
string IAboutHost.ReleaseDate { set => AboutReleaseDateBlock.Text = value; }
string IAboutHost.UpdateText { set => AboutUpdateText.Text = value; }
bool IAboutHost.UpdateVisible
{
set => AboutUpdateButton.Visibility = value ? Visibility.Visible : Visibility.Collapsed;
}
bool IAboutHost.UpdateEnabled { set => AboutUpdateButton.IsEnabled = value; }
bool IAboutHost.IsDirty => _isDirty;
string? IAboutHost.FileToReopen => _originalFile ?? _currentFile;
/// <summary>Version line, as a hyperlink through to that release tag.</summary>
void IAboutHost.SetVersion(string version)
{
AboutVersionBlock.Inlines.Clear();
AboutVersionBlock.Inlines.Add(AccentLink($"v{version}", () => About.OpenReleaseNotes()));
}
/// <summary>The AKA line. Null hides it entirely.</summary>
void IAboutHost.SetAlias(string? alias)
{
AboutAkaBlock.Visibility = alias is null ? Visibility.Collapsed : Visibility.Visible;
if (alias is null) return;
AboutAkaBlock.Inlines.Clear();
AboutAkaBlock.Inlines.Add(new Run("AKA ") { Foreground = Res("MutedTextBrush") });
var hl = AccentLink(alias, () => AboutController.OpenUrl("https://thekiller.net"));
hl.ToolTip = "thekiller.net";
AboutAkaBlock.Inlines.Add(hl);
}
/// <summary>Dismisses any other full-window overlay, then fades the card in. The overlays
/// are mutually exclusive rather than stacking on top of one another.</summary>
void IAboutHost.ShowCard()
{
if (ShortcutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(ShortcutOverlay);
BuildAboutStaticContent();
FadeOverlayIn(AboutOverlay);
}
// ---- Card content that never varies with the signature or the update state ------------
private void BuildAboutStaticContent()
{
// Reuse the main window's film-grain texture on the About card.
if (GrainBrush?.ImageSource != null) AboutGrainBrush.ImageSource = GrainBrush.ImageSource;
BuildAboutWordmark();
BuildAboutTagline();
}
/// <summary>"Killer" in the text color, "PDF" in the brand green, the pair clickable.</summary>
private void BuildAboutWordmark()
{
AboutLogoBlock.Inlines.Clear();
var hl = new Hyperlink { TextDecorations = null };
hl.Inlines.Add(new Run("Killer")
{
FontFamily = UiKit.WordmarkFont,
FontSize = 21,
FontWeight = FontWeights.Normal,
Foreground = Res("TextBrush")
});
hl.Inlines.Add(new Run("PDF")
{
FontFamily = UiKit.WordmarkFontPdf,
FontSize = 27.3,
Foreground = Res("AccentLogo")
});
hl.Click += (_, _) => AboutController.OpenUrl("https://killerpdf.net");
AboutLogoBlock.Inlines.Add(hl);
// The shadow copy mirrors the real runs exactly (sizes, weights, fonts), so the blur
// sits directly behind each letter instead of smearing above the word - a flat
// single-size copy had different line metrics than the two-run wordmark.
AboutLogoShadowBlock.Inlines.Clear();
var shadowBrush = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromArgb(0xB0, 0, 0, 0));
shadowBrush.Freeze();
AboutLogoShadowBlock.Inlines.Add(new Run("Killer")
{
FontFamily = UiKit.WordmarkFont,
FontSize = 21,
FontWeight = FontWeights.Normal,
Foreground = shadowBrush
});
AboutLogoShadowBlock.Inlines.Add(new Run("PDF")
{
FontFamily = UiKit.WordmarkFontPdf,
FontSize = 27.3,
Foreground = shadowBrush
});
}
/// <summary>
/// Localized tagline. {0} is the (untranslated) brand, so splitting on the placeholder keeps
/// "Killer Tools" a styled, clickable link while the rest translates and the brand can sit
/// anywhere in the sentence the language needs it. A "\n" in the localized string marks the
/// line break; the second line carries the license and the brand.
/// </summary>
private void BuildAboutTagline()
{
AboutTaglineBlock.Inlines.Clear();
var dim = Res("MutedTextBrush");
var text = Loc("Str_Tagline");
int brand = text.IndexOf("{0}", System.StringComparison.Ordinal);
string pre = brand >= 0 ? text[..brand] : text;
string suf = brand >= 0 ? text[(brand + 3)..] : "";
void AddText(string s)
{
var lines = s.Split('\n');
for (int i = 0; i < lines.Length; i++)
{
if (i > 0) AboutTaglineBlock.Inlines.Add(new LineBreak());
AboutTaglineBlock.Inlines.Add(new Run(lines[i]) { Foreground = dim });
}
}
AddText(pre);
AboutTaglineBlock.Inlines.Add(
AccentLink("Killer Tools", () => AboutController.OpenUrl("https://killertools.net")));
AddText(suf);
}
// ---- Small helpers -------------------------------------------------------------------
private Brush Res(string key) => (Brush)FindResource(key);
/// <summary>An accent-colored hyperlink with no underline - the family's one treatment for
/// "this is clickable" on a card.</summary>
private Hyperlink AccentLink(string text, System.Action onClick)
{
var hl = new Hyperlink(new Run(text))
{
Foreground = Res("PrimaryBrush"),
TextDecorations = null
};
hl.Click += (_, _) => onClick();
return hl;
}
// ---- Handlers ------------------------------------------------------------------------
// Click the dim backdrop to dismiss; a click on the card itself is swallowed.
private void AboutOverlay_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
=> CloseAboutOverlay();
private void AboutOverlayCard_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
=> e.Handled = true;
private void AboutOverlayClose_Click(object sender, RoutedEventArgs e)
=> CloseAboutOverlay();
private void AboutUpdateButton_Click(object sender, RoutedEventArgs e) => About.Update();
/// <summary>
/// "Clear all Data" footer link: wipes settings, downloaded OCR language packs, and temp
/// files after an explicit confirmation. Destructive, so it always warns first; the user's
/// PDFs are untouched.
/// </summary>
private void AboutClearData_Click(object sender, RoutedEventArgs e)
{
var res = KillerDialog.Show(this,
Loc("Str_ClearDataConfirm"),
Loc("Str_ClearAllData"), MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (res != MessageBoxResult.Yes) return;
App.ClearAllData();
SetStatus(Loc("Str_St_DataCleared"));
KillerDialog.Show(this,
Loc("Str_ClearDataDone"),
Loc("Str_ClearAllData"), MessageBoxButton.OK, MessageBoxImage.Information);
}
}
}
+941
View File
@@ -0,0 +1,941 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
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
{
public partial class MainWindow
{
// ============================================================
// Draw/Highlight settings bar
// ============================================================
// The quick-colors shown in the annotate bars. User-configurable via the color picker's swatch
// row (shared "UserSwatches" setting); seeded with these 8 defaults, restorable via the picker's
// Reset. SwatchColors reads the live set each time a bar is built, so edits show up immediately.
private static readonly Color[] DefaultSwatchColors = UiKit.DefaultSwatches;
private static Color[] SwatchColors => LoadUserSwatches();
private static Color[] LoadUserSwatches()
{
var raw = App.GetSetting("UserSwatches");
if (string.IsNullOrWhiteSpace(raw)) return [.. DefaultSwatchColors];
List<Color> list = [];
foreach (var part in raw!.Split(','))
{
var t = part.Trim().TrimStart('#');
if (t.Length == 6 && int.TryParse(t, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out int v))
list.Add(Color.FromRgb((byte)((v >> 16) & 0xFF), (byte)((v >> 8) & 0xFF), (byte)(v & 0xFF)));
}
return list.Count > 0 ? [.. list] : [.. DefaultSwatchColors];
}
// Frozen cached brushes for hot-path UI construction
private static readonly SolidColorBrush _swatchDimBorder = Freeze(new SolidColorBrush(Color.FromRgb(0x44, 0x44, 0x44)));
private static readonly SolidColorBrush _drawBarBackground = Freeze(new SolidColorBrush(Color.FromRgb(0x1a, 0x1a, 0x1a)));
private static readonly SolidColorBrush _thumbBorderBrush = Freeze(new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)));
private static T Freeze<T>(T freezable) where T : System.Windows.Freezable
{
freezable.Freeze();
return freezable;
}
// Wraps a floating annotation bar's content with the app's film-grain layer so these bars
// carry the same texture as the Settings / signature / dialog surfaces. The grain extends
// under the host border's 4px padding (negative margin) and matches its bottom corners.
private Grid GrainWrap(UIElement content)
{
var g = new Grid();
// SetResourceReference, not a FindResource snapshot: these hosts are built once and
// outlive theme switches, so a snapshot kept the grain painting on 98SE (whose
// GrainOpacity is 0) and its -4 margin overhang made the bevel read as misaligned.
var grain = new Border { Margin = new Thickness(-4), IsHitTestVisible = false };
grain.SetResourceReference(Border.CornerRadiusProperty, "AnnotationBarCornerRadius");
grain.SetResourceReference(UIElement.OpacityProperty, "GrainOpacity");
grain.SetResourceReference(Border.BackgroundProperty, "GrainBrushShared");
g.Children.Add(grain);
g.Children.Add(content);
return g;
}
// A grab handle (vertical dots) placed at the left of an annotation bar so it can be slid
// left/right along the top of the document. Returns the handle for EnableBarSlide.
private Border MakeBarGrip(int dotCount = 3)
{
// Real ellipse dots (not a braille glyph, which didn't render on some fonts/themes - the
// grip looked empty on the Light bars). Matches the sidebar splitter / minimized-bar dots.
// dotCount scales with bar height: 3 for single-row bars, 4 for the double-height text bar.
var dots = new StackPanel { Orientation = Orientation.Vertical, VerticalAlignment = VerticalAlignment.Center };
var fill = (Brush)FindResource("MutedTextBrush");
for (int i = 0; i < dotCount; i++)
dots.Children.Add(new System.Windows.Shapes.Ellipse
{ Width = 3, Height = 3, Margin = new Thickness(0, 1.5, 0, 1.5), Fill = fill });
return new Border
{
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
Padding = new Thickness(1, 0, 10, 0), // hard to the left edge, more gap before the labels
VerticalAlignment = VerticalAlignment.Stretch,
Child = dots
};
}
// Wraps an annotate bar's content with a film-grain layer (always visible, even minimized) and a
// hidden grip-dots strip (the same dots the sidebar splitter uses) revealed when minimized.
private FrameworkElement BuildBarHost(FrameworkElement content)
{
var host = new Grid();
// KillerNotes ribbon contract: 98SE adds the dark right edge to the host's light
// top/left edge. Other themes define this as transparent/zero, so their floating
// annotation card remains unchanged.
var darkEdge = new Border { IsHitTestVisible = false };
darkEdge.SetResourceReference(Border.BorderBrushProperty, "BarEdgeDarkBrush");
darkEdge.SetResourceReference(Border.BorderThicknessProperty, "BarEdgeDarkThickness");
Panel.SetZIndex(darkEdge, 20);
host.Children.Add(darkEdge);
// Grain stays put when the controls collapse, so the minimized strip keeps the texture.
// SetResourceReference so a theme switch retargets it - 98SE's GrainOpacity of 0 must
// actually clear the texture on an already-built bar (a FindResource snapshot did not).
var hostGrain = new Border { Margin = new Thickness(-4), IsHitTestVisible = false };
hostGrain.SetResourceReference(Border.CornerRadiusProperty, "AnnotationBarCornerRadius");
hostGrain.SetResourceReference(UIElement.OpacityProperty, "GrainOpacity");
hostGrain.SetResourceReference(Border.BackgroundProperty, "GrainBrushShared");
host.Children.Add(hostGrain);
host.Children.Add(content); // the collapsible controls
var dots = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
IsHitTestVisible = false
};
var fill = TryFindResource("MutedTextBrush") as Brush ?? Brushes.Gray; // match the sidebar handle dots
for (int i = 0; i < 6; i++)
dots.Children.Add(new System.Windows.Shapes.Ellipse
{ Width = 3, Height = 3, Margin = new Thickness(2, 0, 2, 0), Fill = fill });
// The dots live in a transparent, hit-testable strip that fills the bar. While the bar is
// minimized this strip is shown, so the whole peek strip can be dragged left/right (the slide
// is wired to it in PlaceAnnotationBar) - same as dragging the grip on the expanded bar.
var dotsStrip = new Border
{
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
Visibility = Visibility.Collapsed,
Child = dots
};
host.Children.Add(dotsStrip);
_annotBarContent = content;
_annotBarDots = dotsStrip;
return host;
}
// Drop shadow for the annotate bars: offset straight down with depth >= blur, so it falls on the
// sides and bottom but never above the bar (no halo between it and the toolbar). Removed entirely
// while minimized.
private static System.Windows.Media.Effects.DropShadowEffect AnnotBarShadow()
=> new() { Color = Colors.Black, BlurRadius = 6, ShadowDepth = 3, Direction = 270,
Opacity = Application.Current.TryFindResource("BarShadowOpacity") is double opacity ? opacity : 0.38 };
// Lets the annotation bars slide horizontally along the top via their grip, clamped inside
// the document area, with the X position remembered (shared across the draw/text bars).
private void EnableBarSlide(FrameworkElement grip, Border bar, FrameworkElement bounds, bool backgroundOnly = false)
{
grip.MouseLeftButtonDown += (s, e) =>
{
// When wired on a content panel, only act on clicks that hit the panel's OWN background
// (empty gaps). A click on a child control (slider, swatch, combo) reports that child as
// the source, so we bail and let the control handle it - even controls that don't mark the
// event handled never start a drag. The grip / peek strip pass backgroundOnly=false.
if (backgroundOnly && !ReferenceEquals(e.OriginalSource, grip)) return;
// Double-click any draggable surface (grip, peek strip, or an empty area of the bar)
// toggles minimize - same gesture everywhere.
if (e.ClickCount == 2) { e.Handled = true; ToggleAnnotBarMinimized(); return; }
double w = bar.ActualWidth;
// Drag uniformly in left-edge coordinates whatever the current anchor; the edge it
// anchors to is decided on release from where it ends up.
double curLeft = bar.HorizontalAlignment == HorizontalAlignment.Right
? bounds.ActualWidth - bar.Margin.Right - w
: bar.Margin.Left;
bar.HorizontalAlignment = HorizontalAlignment.Left;
bar.Margin = new Thickness(curLeft, bar.Margin.Top, 0, 0);
bar.Tag = (e.GetPosition(bounds).X, curLeft); // (startX, origLeft)
grip.CaptureMouse();
e.Handled = true;
};
grip.MouseMove += (s, e) =>
{
if (bar.Tag is not (double startX, double origLeft) || !grip.IsMouseCaptured) return;
double w = bar.ActualWidth;
// Stop the drag at the scrollbar's left edge (not the pane edge) so the bar never
// overshoots the scrollbar and then snaps back on release - the "bounce" the user saw.
double sb = VerticalScrollBarInset();
double maxLeft = Math.Max(0, bounds.ActualWidth - w - sb);
double nl = Math.Max(0, Math.Min(maxLeft, origLeft + (e.GetPosition(bounds).X - startX)));
bar.Margin = new Thickness(nl, bar.Margin.Top, 0, 0);
// Merge the docked-side border with the pane border live while dragging (no footprint
// change), so it doesn't pop in on release. The right side only docks flush when no
// scrollbar sits between the bar and the pane edge.
SetBarDockedBorder(bar, dockedLeft: nl <= 0.5, dockedRight: sb <= 0 && nl >= maxLeft - 0.5);
};
grip.MouseLeftButtonUp += (s, e) =>
{
if (!grip.IsMouseCaptured) return;
grip.ReleaseMouseCapture();
double w = bar.ActualWidth;
double left = bar.Margin.Left;
// Measure the right gap from the scrollbar's left edge (the usable content edge), so a
// bar parked against the scrollbar records gap ~0 and PositionAnnotationBar re-adds the
// scrollbar width once - no double inset, no jump.
double sb = VerticalScrollBarInset();
double rightGap = Math.Max(0, (bounds.ActualWidth - sb) - (left + w));
const double snap = 24; // within this many px of an edge, cling to that edge exactly
if (left <= snap)
{
_annotBarCenterFrac = null; _annotBarAnchorRight = false; _annotBarGap = Math.Max(0, left);
}
else if (rightGap <= snap)
{
_annotBarCenterFrac = null; _annotBarAnchorRight = true; _annotBarGap = rightGap;
}
else
{
// Away from both edges: remember it as a fraction of the width so resizing scales it
// smoothly rather than snapping it to an edge.
_annotBarCenterFrac = (left + w / 2) / bounds.ActualWidth;
}
App.SetSetting("AnnotBarFrac",
_annotBarCenterFrac?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "");
App.SetSetting("AnnotBarGap", ((int)(_annotBarGap ?? 8)).ToString());
App.SetSetting("AnnotBarRightSide", _annotBarAnchorRight ? "1" : "0");
if (PagePreviewPanel?.Parent is Grid area) PositionAnnotationBar(bar, area);
e.Handled = true;
};
}
// Saved horizontal placement for the floating draw/text settings bars (shared by both). The bar
// anchors to whichever edge it sits nearer and remembers its gap from that edge, so it clings to
// that edge on resize. RepositionAnnotationBars re-applies it - clamped fully inside the document
// area - from the same window events that keep the Settings panel in-window, so it can never end
// up off-screen regardless of which edge it was parked against.
private double? _annotBarGap;
private bool _annotBarAnchorRight = true;
private double? _annotBarCenterFrac; // set when parked away from both edges: hold this fraction of the width
private bool _vScrollVisible; // last-known document vertical scrollbar state, to reposition bars on change
private EditTool? _annotBarTool { get => ActiveViewer.AnnotBarToolRef; set => ActiveViewer.AnnotBarToolRef = value; }
private bool _annotBarMinimized { get => ActiveViewer.AnnotBarMinimizedRef; set => ActiveViewer.AnnotBarMinimizedRef = value; }
private double _annotBarFullHeight; // remembered full height to expand back to
private FrameworkElement? _annotBarContent; // the bar's normal content (hidden while minimized)
private FrameworkElement? _annotBarDots; // grip-dots strip shown while minimized
private List<FrameworkElement> _annotBarDragInners => ActiveViewer.AnnotBarDragInnersRef;
// Positions an annotation bar and wires up sliding. If we already know the X (this session or
// saved), set it synchronously so the bar appears in place; only the very first time do we
// defer to compute the default top-right from the laid-out width.
private void PlaceAnnotationBar(Border bar, Border grip, bool fadeIn = false)
{
if (PagePreviewPanel.Parent is not Grid area) return;
if (_annotBarGap is null && _annotBarCenterFrac is null)
{
if (double.TryParse(App.GetSetting("AnnotBarFrac"), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double f))
{
_annotBarCenterFrac = f; // parked away from both edges last time
}
else
{
_annotBarGap = int.TryParse(App.GetSetting("AnnotBarGap"), out int sg) ? sg : 8;
_annotBarAnchorRight = App.GetSetting("AnnotBarRightSide") != "0"; // default: right edge
}
}
// Track the bar's own size (first measure, wrap to a second row) so the 98SE scroller
// inset and the floating themes' edge clamp stay in step with the real height/width.
bar.SizeChanged -= AnnotBarSizeChanged;
bar.SizeChanged += AnnotBarSizeChanged;
EnableBarSlide(grip, bar, area);
// The minimized peek strip drags the bar too, so a collapsed bar can be repositioned.
if (_annotBarDots is not null) EnableBarSlide(_annotBarDots, bar, area);
// Empty areas of the bar content drag it too (and double-click them to minimize). The content
// panel(s) have a Transparent background, so only the gaps between controls trigger this -
// clicks that land on a slider/swatch/combo still go to that control.
if (_annotBarContent is not null) EnableBarSlide(_annotBarContent, bar, area, backgroundOnly: true);
foreach (var inner in _annotBarDragInners)
if (inner is not null) EnableBarSlide(inner, bar, area, backgroundOnly: true);
// A freshly built bar has no measured width yet, so PositionAnnotationBar can't place it until
// layout runs. Hide it for that one frame (Opacity 0 still lays out, so width measures), then
// anchor and reveal it - otherwise it renders at its default right edge first and visibly
// jumps to its saved spot on every tool switch.
bar.Opacity = 0;
PositionAnnotationBar(bar, area); // sync position (edge-anchored modes are correct without a width)
if (fadeIn)
{
// Start the fade-in immediately so it overlaps the outgoing bar's fade-out (a true
// crossfade). Waiting for the deferred layout pass left a frame where neither bar was
// visible, which read as a blink. Final clamp/center still happens once laid out.
bar.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, new Duration(TimeSpan.FromMilliseconds(110)))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
(Action)(() => PositionAnnotationBar(bar, area)));
}
else if (_annotBarCenterFrac is not null)
{
// Center-parked needs a measured width to place, so stay hidden one layout frame so it
// can't render at the default edge first and then jump.
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
(Action)(() => { PositionAnnotationBar(bar, area); bar.Opacity = 1; }));
}
else
{
// Edge-anchored: already placed correctly above (no width needed), so reveal it right away
// instead of hiding for a frame - that one hidden frame was the "blink" on a same-tool
// refresh / same-family tool switch.
bar.Opacity = 1;
}
}
// Subtracts the annotate-bar chrome (padding + border + a gap from the scrollbar) from the document
// area's width, giving the wrap panel the width it's actually allowed to occupy before wrapping.
private static readonly InsetWidthConverter _barWidthInset = new();
private sealed class InsetWidthConverter : System.Windows.Data.IValueConverter
{
public object Convert(object value, Type t, object p, System.Globalization.CultureInfo c)
=> value is double d ? Math.Max(0.0, d - 28.0) : value;
public object ConvertBack(object value, Type t, object p, System.Globalization.CultureInfo c) => value;
}
// Adapts a wrapping annotate bar to very narrow widths: once its content blocks can no longer sit on
// a single row, the drag grip is hidden (non-essential at that size) and the blocks gain a little
// vertical breathing room as they stack; the inline separators are hidden so they don't float as
// stray ticks between stacked rows. The single-row fit test deliberately uses only fixed quantities
// (block widths + a constant gap allowance, never the grip or separator visibility we toggle), so a
// toggle can never change the test result and oscillate.
private void WireBarWrapAdaptation(WrapPanel host, FrameworkElement grip, FrameworkElement primary,
FrameworkElement sizeSource)
{
// The grip only shrinks to a thin draggable nub once the grip + the first block can no longer
// share a row (the window's skinniest) - not merely when the bar wraps. The threshold is the
// grip + first-block widths, measured ONCE at full size and frozen, so shrinking the grip can
// never feed back into the decision and oscillate. Spacing between groups is handled by the
// groups' own margins (not separators), so it survives wrapping with no stray ticks.
double gripThreshold = 0;
Thickness gripFull = (grip as Border)?.Padding ?? new Thickness();
bool? lastGripMin = null;
void SetGripMinimized(bool min)
{
if (grip is not Border gb) return;
if (gb.Child is UIElement dots) dots.Visibility = min ? Visibility.Collapsed : Visibility.Visible;
gb.Padding = min ? new Thickness(1, 0, 2, 0) : gripFull; // keep a few draggable px
}
void Apply()
{
double avail = host.MaxWidth;
if (double.IsNaN(avail) || double.IsInfinity(avail) || avail <= 0) return;
if (gripThreshold <= 0)
{
double g = grip.DesiredSize.Width, p = primary.DesiredSize.Width;
if (g <= 0 || p <= 0) return; // not measured yet; a later pass will retry
gripThreshold = g + p + 8.0;
}
bool gripMin = avail + 0.5 < gripThreshold;
if (lastGripMin == gripMin) return;
lastGripMin = gripMin;
SetGripMinimized(gripMin);
}
// Drive off the document area's width (the monotonic signal) rather than the panel's own size:
// once the grip shrinks and the bar fits a row again, the panel stops resizing, so only the
// source's width change tells us there is room to restore the grip. Unhook on teardown - the bar
// is rebuilt often (every swatch click), so a lingering handler would leak.
void onSource(object? sender, SizeChangedEventArgs e) => Apply();
sizeSource.SizeChanged += onSource;
host.Unloaded += (_, _) => sizeSource.SizeChanged -= onSource;
host.SizeChanged += (_, _) => Apply(); // catches the first valid measurement
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)Apply);
}
// Shared overflow chevron + popup for the annotate bars: an E712 "More" button whose popup
// (anchored to the button - flyouts anchor to their own button, always) stacks whatever
// groups WireBarOverflow sheds.
private Border MakeBarOverflow(out Popup popup, out StackPanel stack)
{
var s = new StackPanel { Margin = new Thickness(10, 6, 10, 6) };
// Family flyout rule: the film grain is the LAST child, OVER the items, non-hit-testable.
var inner = new Grid();
inner.Children.Add(s);
// SetResourceReference so a theme switch retargets it (98SE clears grain via opacity 0).
var popGrain = new Border { CornerRadius = new CornerRadius(4), IsHitTestVisible = false };
popGrain.SetResourceReference(UIElement.OpacityProperty, "GrainOpacity");
popGrain.SetResourceReference(Border.BackgroundProperty, "GrainBrushShared");
inner.Children.Add(popGrain);
var border = new Border
{
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(4),
Child = inner
};
border.SetResourceReference(Border.BackgroundProperty, "MenuBackgroundBrush");
border.SetResourceReference(Border.BorderBrushProperty, "MenuBorderBrush");
var glyph = new TextBlock
{
Text = ((char)0xE712).ToString(), // MDL2 More - the same chevron as the toolbar overflow
FontFamily = UiKit.IconFont,
FontSize = 13,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
glyph.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
var btn = new Border
{
Width = 22,
Height = 20,
CornerRadius = new CornerRadius(3),
Margin = new Thickness(0, 3, 0, 3),
VerticalAlignment = VerticalAlignment.Center,
Cursor = Cursors.Hand,
Background = Brushes.Transparent,
ToolTip = Loc("Str_Bar_More"),
Child = glyph,
Visibility = Visibility.Collapsed
};
var p = new Popup
{
PlacementTarget = btn,
Placement = PlacementMode.Bottom,
VerticalOffset = 4,
StaysOpen = false,
AllowsTransparency = true,
Child = border
};
// Open on mouse UP, not down: a StaysOpen=false popup opened mid-mouse-down inherits
// the press's capture and closes again the moment the button is released, so it only
// stayed open while the click was held. Down is still handled so the press can't start
// a bar drag. The 200ms guard covers the toggle-close case (the closing click's up
// would otherwise instantly reopen it).
var closedAt = DateTime.MinValue;
p.Closed += (_, _) => closedAt = DateTime.UtcNow;
btn.MouseLeftButtonDown += (_, e) => e.Handled = true;
btn.MouseLeftButtonUp += (_, e) =>
{
e.Handled = true;
if ((DateTime.UtcNow - closedAt).TotalMilliseconds < 200) return;
p.IsOpen = true;
};
popup = p;
stack = s;
return btn;
}
// Caps an annotate bar at two wrapped rows. Group widths are measured ONCE and frozen (the
// WireBarWrapAdaptation anti-oscillation rule): the fit test simulates the WrapPanel's own
// first-fit line breaking against the available width and, while the visible groups would
// need a third row, sheds the next group in shedOrder into the overflow popup - restoring
// them in reverse when there is room again. Groups not named in shedOrder never collapse.
private void WireBarOverflow(WrapPanel host, Border overflowBtn, Popup popup,
StackPanel popupStack, StackPanel[] groups, int[] shedOrder,
FrameworkElement sizeSource)
{
double[] widths = new double[groups.Length];
double btnWidth = 26; // chevron + margin; always reserved in the test (constant input)
int shedCount = 0;
int LinesNeeded(int shed)
{
var inPopup = new bool[groups.Length];
for (int i = 0; i < shed; i++) inPopup[shedOrder[i]] = true;
double avail = host.MaxWidth;
double line = btnWidth; int lines = 1;
for (int i = 0; i < groups.Length; i++)
{
if (inPopup[i]) continue;
double w = widths[i];
if (line + w > avail && line > btnWidth) { lines++; line = btnWidth; }
line += w;
}
return lines;
}
void MoveToPopup(int gi)
{
host.Children.Remove(groups[gi]);
int at = 0; // keep display order inside the popup too
foreach (UIElement child in popupStack.Children)
if (child is StackPanel sp && Array.IndexOf(groups, sp) is int ci && ci >= 0 && ci < gi) at++;
popupStack.Children.Insert(at, groups[gi]);
}
void MoveToBar(int gi)
{
popupStack.Children.Remove(groups[gi]);
int at = 1; // index 0 is the grip
for (int i = 0; i < gi; i++)
if (host.Children.Contains(groups[i])) at++;
host.Children.Insert(at, groups[gi]);
}
void Apply()
{
double avail = host.MaxWidth;
if (double.IsNaN(avail) || double.IsInfinity(avail) || avail <= 0) return;
if (widths[0] <= 0)
{
for (int i = 0; i < groups.Length; i++)
{
double w = groups[i].DesiredSize.Width;
if (w <= 0) return; // not measured yet; a later pass retries
widths[i] = w;
}
}
int want = shedCount;
while (want < shedOrder.Length && LinesNeeded(want) > 2) want++;
while (want > 0 && LinesNeeded(want - 1) <= 2) want--;
if (want == shedCount) return;
if (want > shedCount)
for (int i = shedCount; i < want; i++) MoveToPopup(shedOrder[i]);
else
for (int i = shedCount - 1; i >= want; i--) MoveToBar(shedOrder[i]);
shedCount = want;
overflowBtn.Visibility = shedCount > 0 ? Visibility.Visible : Visibility.Collapsed;
if (shedCount == 0 && popup.IsOpen) popup.IsOpen = false;
}
void onSource(object? sender, SizeChangedEventArgs e) => Apply();
sizeSource.SizeChanged += onSource;
host.Unloaded += (_, _) => sizeSource.SizeChanged -= onSource;
host.SizeChanged += (_, _) => Apply();
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)Apply);
}
private void ShowDrawSettings(EditTool tool)
{
// Fade the bar in only when it's genuinely appearing (no bar yet, or coming from the text
// bar). Switching between tools that share this same draw bar (Highlight / Underline /
// Strikethrough / Draw) swaps it in place instantly - otherwise the two near-identical bars
// crossfade through ~50% opacity and read as a blink even though nothing visually changed.
bool prevWasDrawBar = _annotBarTool is EditTool.Draw or EditTool.Highlight
or EditTool.Underline or EditTool.Strikethrough or EditTool.Line
or EditTool.Shape;
bool appearing = _annotBarTool != tool && !prevWasDrawBar;
if (_drawSettingsBar is not null)
{
// On a switch, fade the old bar out (crossfades with the new one); on a refresh, swap it
// out instantly so clicking a swatch doesn't flicker the whole bar.
if (appearing) FadeOutAndRemoveBar(_drawSettingsBar);
else (PagePreviewPanel.Parent as Grid)?.Children.Remove(_drawSettingsBar);
_drawSettingsBar = null;
}
// WrapPanel so that on a too-narrow window the control groups drop to a second/third row
// instead of overflowing. Its MaxWidth is pinned to the document area below so it knows when
// to wrap; controls are added in self-contained groups so a wrap never splits a label from
// its slider.
var panel = new WrapPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 2, 8, 2), Background = Brushes.Transparent };
// Drag grip so the bar can be slid left/right along the top.
var drawGrip = MakeBarGrip();
panel.Children.Add(drawGrip);
// A small checkbox + label for the bar (Level on the Line tool, Eraser on Highlight / Draw).
// Toggling rebuilds the bar to reflect the new state.
StackPanel BarCheck(string label, bool active, string tip, Action onClick)
{
var p = new StackPanel
{
Orientation = Orientation.Horizontal,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(2, 0, 18, 0),
Cursor = Cursors.Hand,
ToolTip = tip
};
var box = new Border
{
Width = 15,
Height = 15,
CornerRadius = new CornerRadius(3),
BorderThickness = new Thickness(active ? 0 : 1),
Background = Brushes.Transparent,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 5, 0)
};
if (active)
{
// Live theme reference (not a snapshot) so the checked fill tracks the current theme's
// accent - the old AccentBrush() snapshot kept whatever accent was active when the bar
// was first built, so it showed the wrong (often green) color after a theme switch.
box.SetResourceReference(Border.BackgroundProperty, "SelectionAccent");
box.Child = new TextBlock
{
Text = "✓",
Foreground = Brushes.White,
FontSize = 10,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
}
else box.BorderBrush = _swatchDimBorder;
var lbl = new TextBlock
{
Text = label,
FontFamily = UiKit.UiFont,
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center
};
lbl.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
p.Children.Add(box);
p.Children.Add(lbl);
p.MouseLeftButtonDown += (_, _) => onClick();
return p;
}
// Built here, added at the END of the bar (after Opacity) where there's more room than
// squeezed in before the color swatches.
StackPanel? barCheck = tool switch
{
EditTool.Line => BarCheck(Loc("Str_Bar_Level"), _lineLevel,
Loc("Str_Bar_TT_Level"),
() => { _lineLevel = !_lineLevel; ShowDrawSettings(tool); }),
EditTool.Highlight => BarCheck(Loc("Str_Bar_Eraser"), _highlightErase,
Loc("Str_Bar_TT_EraserBox"),
() => { _highlightErase = !_highlightErase; ShowDrawSettings(tool); }),
EditTool.Draw => BarCheck(Loc("Str_Bar_Eraser"), _drawErase,
Loc("Str_Bar_TT_EraserBrush"),
() => { _drawErase = !_drawErase; ShowDrawSettings(tool); }),
EditTool.Shape => ShapeKindPicker(),
_ => null
};
// Shapes tool (#127 Phase 3): radio-style buttons whose glyphs are literal mini shapes
// (drawn WPF elements, not font glyphs, so they render on every locale/font setup),
// plus the Fill toggle. Clicking a shape switches the sub-mode and rebuilds the bar.
StackPanel ShapeKindPicker()
{
Border KindBtn(ShapeKind kind, Shape glyph, string tip)
{
bool active = _shapeKind == kind;
glyph.StrokeThickness = 1.5;
glyph.Fill = Brushes.Transparent;
glyph.HorizontalAlignment = HorizontalAlignment.Center;
glyph.VerticalAlignment = VerticalAlignment.Center;
glyph.SetResourceReference(Shape.StrokeProperty, active ? "SelectionAccent" : "MutedTextBrush");
var b = new Border
{
Width = 26,
Height = 20,
CornerRadius = new CornerRadius(3),
BorderThickness = new Thickness(active ? 1.5 : 1),
Background = Brushes.Transparent,
Margin = new Thickness(1, 0, 1, 0),
Cursor = Cursors.Hand,
ToolTip = tip,
Child = glyph
};
if (active) b.SetResourceReference(Border.BorderBrushProperty, "SelectionAccent");
else b.BorderBrush = _swatchDimBorder;
b.MouseLeftButtonDown += (_, _) =>
{
if (_shapeKind == kind) return;
_shapeKind = kind;
if (kind != ShapeKind.Polygon) CancelShapePolygon();
ShowDrawSettings(tool);
};
return b;
}
var row = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(2, 0, 0, 0) };
row.Children.Add(KindBtn(ShapeKind.Rectangle, new Rectangle { Width = 13, Height = 9 },
Loc("Str_Bar_TT_ShapeBox")));
row.Children.Add(KindBtn(ShapeKind.Ellipse, new Ellipse { Width = 13, Height = 9 },
Loc("Str_Bar_TT_ShapeEllipse")));
var pent = new Polygon
{
Width = 13,
Height = 11,
Points = [new Point(6.5, 0), new Point(13, 4.5), new Point(10.5, 11), new Point(2.5, 11), new Point(0, 4.5)]
};
row.Children.Add(KindBtn(ShapeKind.Polygon, pent,
Loc("Str_Bar_TT_ShapeFreeform")));
var fillCheck = BarCheck(Loc("Str_Bar_ShapeFill"), _shapeFill,
Loc("Str_Bar_TT_ShapeFill"),
() => { _shapeFill = !_shapeFill; ShowDrawSettings(tool); });
fillCheck.Margin = new Thickness(10, 0, 18, 0);
row.Children.Add(fillCheck);
return row;
}
// Color group (label + swatches + more) - one wrap unit.
var colorGroup = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 2, 16, 2) };
var colorLbl = new TextBlock
{
Text = Loc("Str_Bar_Color"),
FontFamily = UiKit.UiFont,
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0)
};
colorLbl.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
colorGroup.Children.Add(colorLbl);
// Color swatches
bool isLineTool = tool == EditTool.Strikethrough || tool == EditTool.Underline;
var activeColor = (tool is EditTool.Draw or EditTool.Line or EditTool.Shape) ? _drawColor
: isLineTool ? Color.FromRgb(_lineAnnotColor.R, _lineAnnotColor.G, _lineAnnotColor.B)
: Color.FromRgb(_highlightColor.R, _highlightColor.G, _highlightColor.B);
foreach (var color in SwatchColors)
{
bool isActive = color == activeColor;
var swatch = new Border
{
Width = 18,
Height = 18,
Background = Freeze(new SolidColorBrush(color)),
BorderThickness = new Thickness(isActive ? 2 : 1),
CornerRadius = new CornerRadius(3),
Margin = new Thickness(1),
Cursor = Cursors.Hand,
Tag = color
};
if (isActive)
swatch.SetResourceReference(Border.BorderBrushProperty, "PrimaryBrush");
else
swatch.BorderBrush = _swatchDimBorder;
swatch.MouseLeftButtonDown += (s, e) =>
{
var c = (Color)((Border)s!).Tag;
if ((tool is EditTool.Draw or EditTool.Line or EditTool.Shape))
_drawColor = Color.FromArgb(_drawOpacity, c.R, c.G, c.B);
else if (isLineTool)
_lineAnnotColor = Color.FromArgb(_lineAnnotColor.A, c.R, c.G, c.B);
else
_highlightColor = Color.FromArgb(_highlightColor.A, c.R, c.G, c.B);
ApplyDrawStyleToSelection(); // edit the selected annotation, if any
ShowDrawSettings(tool); // refresh selection
};
colorGroup.Children.Add(swatch);
}
// "More colors..." -> full RGB picker, applied to whichever draw color this bar drives.
var moreDraw = new Border
{
Width = 18,
Height = 18,
CornerRadius = new CornerRadius(3),
Margin = new Thickness(1),
Cursor = Cursors.Hand,
BorderThickness = new Thickness(1),
ToolTip = Loc("Str_Bar_MoreColors"),
BorderBrush = _swatchDimBorder,
Background = new LinearGradientBrush
{
StartPoint = new Point(0, 0),
EndPoint = new Point(1, 1),
GradientStops =
{
new GradientStop(Colors.Red, 0), new GradientStop(Colors.Yellow, 0.25),
new GradientStop(Colors.Lime, 0.5), new GradientStop(Colors.Cyan, 0.7),
new GradientStop(Colors.Blue, 1)
}
}
};
moreDraw.MouseLeftButtonDown += (_, _) => OpenColorPicker(activeColor, c =>
{
if ((tool is EditTool.Draw or EditTool.Line or EditTool.Shape)) _drawColor = Color.FromArgb(_drawOpacity, c.R, c.G, c.B);
else if (isLineTool) _lineAnnotColor = Color.FromArgb(_lineAnnotColor.A, c.R, c.G, c.B);
else _highlightColor = Color.FromArgb(_highlightColor.A, c.R, c.G, c.B);
ApplyDrawStyleToSelection();
ShowDrawSettings(tool);
}, () => ShowDrawSettings(tool));
colorGroup.Children.Add(moreDraw);
panel.Children.Add(colorGroup);
// Single-row groups (Color, Size, Opacity+toggle) packed directly by the outer WrapPanel;
// Opacity and the Level/Eraser toggle share one unit so the toggle never lands on a row by
// itself. When even one group per row would need a third row, the least important groups
// collapse into the overflow chevron - see WireBarOverflow below.
var dOpacityUnit = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
StackPanel? sizeGroupRef = null;
_annotBarDragInners.Clear();
// Size slider (draw only)
if ((tool is EditTool.Draw or EditTool.Line or EditTool.Shape))
{
var sizeGroup = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 2, 16, 2) };
var sizeLbl = new TextBlock
{
Text = Loc("Str_Bar_Size"),
FontFamily = UiKit.UiFont,
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0)
};
sizeLbl.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
sizeGroup.Children.Add(sizeLbl);
var sizeSlider = new Slider
{
Minimum = 1,
Maximum = 60,
Value = _drawWidth,
Width = 90,
VerticalAlignment = VerticalAlignment.Center,
TickFrequency = 1,
IsSnapToTickEnabled = true,
Style = (Style)FindResource("DarkSlider")
};
sizeSlider.ValueChanged += (s, e) => { _drawWidth = e.NewValue; ApplyDrawStyleToSelection(); };
sizeGroup.Children.Add(sizeSlider);
var sizeLabel = new TextBlock
{
Text = $"{_drawWidth:F0}px",
FontFamily = UiKit.UiFont,
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 0, 0),
Width = 34,
TextAlignment = TextAlignment.Right
};
sizeLabel.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
sizeSlider.ValueChanged += (s, e) => sizeLabel.Text = $"{e.NewValue:F0}px";
sizeGroup.Children.Add(sizeLabel);
panel.Children.Add(sizeGroup);
sizeGroupRef = sizeGroup;
}
// Opacity group (label + slider + value) - one wrap unit.
var opacityGroup = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 2, 16, 2) };
var opacityLbl = new TextBlock
{
Text = Loc("Str_Bar_Opacity"),
FontFamily = UiKit.UiFont,
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0)
};
opacityLbl.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
opacityGroup.Children.Add(opacityLbl);
byte currentOpacity = (tool is EditTool.Draw or EditTool.Line or EditTool.Shape) ? _drawOpacity : isLineTool ? _lineAnnotColor.A : _highlightColor.A;
var opacitySlider = new Slider
{
Minimum = 10,
Maximum = 255,
Value = currentOpacity,
Width = 90,
VerticalAlignment = VerticalAlignment.Center,
Style = (Style)FindResource("DarkSlider")
};
var opacityLabel = new TextBlock
{
Text = $"{(int)(currentOpacity / 255.0 * 100)}%",
FontFamily = UiKit.UiFont,
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 6, 0),
Width = 40,
TextAlignment = TextAlignment.Right
};
opacityLabel.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
opacitySlider.ValueChanged += (s, e) =>
{
byte a = (byte)e.NewValue;
opacityLabel.Text = $"{(int)(a / 255.0 * 100)}%";
if ((tool is EditTool.Draw or EditTool.Line or EditTool.Shape))
{
_drawOpacity = a;
_drawColor = Color.FromArgb(a, _drawColor.R, _drawColor.G, _drawColor.B);
}
else if (isLineTool)
{
_lineAnnotColor = Color.FromArgb(a, _lineAnnotColor.R, _lineAnnotColor.G, _lineAnnotColor.B);
}
else
{
_highlightColor = Color.FromArgb(a, _highlightColor.R, _highlightColor.G, _highlightColor.B);
}
ApplyDrawStyleToSelection(); // edit the selected annotation, if any
};
opacityGroup.Children.Add(opacitySlider);
opacityGroup.Children.Add(opacityLabel);
dOpacityUnit.Children.Add(opacityGroup);
// Level (Line) / Eraser (Highlight, Draw) toggle rides at the end of the Opacity unit, so it
// stays beside Opacity as the bar collapses instead of dropping onto a row by itself.
if (barCheck is not null)
{
barCheck.Margin = new Thickness(0, 2, 2, 2);
dOpacityUnit.Children.Add(barCheck);
}
panel.Children.Add(dOpacityUnit);
var drawOverflowBtn = MakeBarOverflow(out var drawOverflowPopup, out var drawOverflowStack);
panel.Children.Add(drawOverflowBtn);
panel.Children.Add(drawOverflowPopup); // renders nothing; keeps the popup in the tree for DynamicResource
_drawSettingsBar = new Border
{
HorizontalAlignment = HorizontalAlignment.Right, // right-anchored; slid via the grip
VerticalAlignment = VerticalAlignment.Top,
CornerRadius = new CornerRadius(0),
Effect = AnnotBarShadow(),
Child = BuildBarHost(panel),
Margin = new Thickness(0, 0, 0, 0)
};
_drawSettingsBar.SetResourceReference(Border.BackgroundProperty, "BgFlyout");
_drawSettingsBar.SetResourceReference(Border.CornerRadiusProperty, "AnnotationBarCornerRadius");
// Match the Text bar's pane-integrated edge. BarEdgeBrush is white in 98SE and made
// this one bar look like a separate raised slab even though both use the same host.
_drawSettingsBar.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush");
_drawSettingsBar.SetResourceReference(Border.BorderThicknessProperty, "BarEdgeThickness");
_drawSettingsBar.SetResourceReference(Border.PaddingProperty, "BarPadding");
var previewArea = PagePreviewPanel.Parent as Grid;
if (previewArea is not null)
{
Panel.SetZIndex(_drawSettingsBar, 100);
previewArea.Children.Add(_drawSettingsBar);
// Cap the wrap panel to the document area's width (less the bar chrome) so it wraps the
// control groups to new rows once the window is too narrow to hold them on one line.
panel.SetBinding(FrameworkElement.MaxWidthProperty, new System.Windows.Data.Binding("ActualWidth")
{ Source = previewArea, Converter = _barWidthInset });
WireBarWrapAdaptation(panel, drawGrip, colorGroup, previewArea);
var drawGroups = sizeGroupRef is null
? new StackPanel[] { colorGroup, dOpacityUnit }
: new StackPanel[] { colorGroup, sizeGroupRef, dOpacityUnit };
int[] drawShed = sizeGroupRef is null ? [1] : [2, 1]; // opacity unit first, then size; color anchored
WireBarOverflow(panel, drawOverflowBtn, drawOverflowPopup, drawOverflowStack, drawGroups, drawShed, previewArea);
PlaceAnnotationBar(_drawSettingsBar, drawGrip, fadeIn: appearing);
}
_annotBarTool = tool;
_annotBarMinimized = false; // a freshly built bar is full-size
}
private void HideDrawSettings()
{
FadeOutAndRemoveBar(_drawSettingsBar);
_drawSettingsBar = null;
if (_annotBarTool is EditTool.Draw or EditTool.Highlight
or EditTool.Strikethrough or EditTool.Underline or EditTool.Shape)
_annotBarTool = null;
}
}
}
+141
View File
@@ -0,0 +1,141 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
namespace KillerPDF
{
// App-wide accessibility size, ported from KillerNotes: a LayoutTransform scale on the
// chrome (toolbar row, sidebar, tab strip) grows or shrinks the UI crisply -
// LayoutTransform reflows and re-rasterizes text rather than bitmap-stretching it. The
// title bar and footer stay fixed, so the logo you scroll to drive this (MainWindow.xaml,
// LogoBar) never moves. The document pane is deliberately NOT scaled: app size and page
// zoom are two separate controls. Persisted app-wide ("AppScale").
public partial class MainWindow
{
internal double _appScale = 1.0;
private const double AppScaleMin = 0.7, AppScaleMax = 2.5, AppScaleStep = 0.02;
// The sidebar column lives in the UNSCALED grid (screen px) while its content lays
// out at screen/scale logical px. Every site that pushes a logical sidebar width
// (SidebarMinOpen, SidebarMaxPages, the 24px collapse strip...) into the column
// converts through this, so the sidebar's LOGICAL width holds steady across scales
// and the thumbnails grow with the rest of the chrome instead of being squeezed.
internal double SbPx(double logical) => logical * _appScale;
private void InitAppScale()
{
if (double.TryParse(App.GetSetting("AppScale"), NumberStyles.Float,
CultureInfo.InvariantCulture, out double s))
ApplyAppScale(s);
}
// Roll the wheel over the logo: one small step per notch (fine-grained, no big jumps).
private void LogoBar_MouseWheel(object sender, MouseWheelEventArgs e)
{
ApplyAppScale(_appScale + (e.Delta > 0 ? AppScaleStep : -AppScaleStep), persist: true);
e.Handled = true;
}
// The logo is marked IsHitTestVisibleInChrome (MainWindow.xaml) so the scroll wheel
// reaches it for the zoom above - but that also takes it out of WindowChrome's native
// caption, so window drag and double-click-maximize are restored here by hand.
private void LogoBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ClickCount == 2)
{
MaximizeBtn_Click(this, new RoutedEventArgs()); // WindowChrome.cs
e.Handled = true;
return;
}
// #206: hand the drag to Windows the way TitleBar_MouseLeftButtonDown does, rather than
// calling DragMove. DragMove throws while maximized, so the logo had to bail out there
// and drag-down-to-restore did nothing over it - worst in themes whose wordmark makes
// the logo wide, leaving only the file-name gap in the middle of the bar draggable.
// WM_NCLBUTTONDOWN(HTCAPTION) restores under the cursor and keeps following it.
if (e.ButtonState != MouseButtonState.Pressed) return;
e.Handled = true;
var hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle;
if (hwnd != IntPtr.Zero) SendMessage(hwnd, WM_NCLBUTTONDOWN, new IntPtr(HTCAPTION), IntPtr.Zero);
}
private void ApplyAppScale(double scale, bool persist = false)
{
double prev = _appScale;
scale = Math.Round(Math.Max(AppScaleMin, Math.Min(AppScaleMax, scale)), 3);
_appScale = scale;
// CHROME ONLY: toolbar, sidebar, and tab strip scale; the document pane is
// deliberately untouched, so the app size and the page zoom stay two separate
// controls.
var t = scale == 1.0 ? Transform.Identity : new ScaleTransform(scale, scale);
ToolbarRowBorder.LayoutTransform = t;
SidebarOuterGrid.LayoutTransform = t;
// BOTH panes: each carries its own strip, and scaling only the focused one would leave
// the other pane's tabs at the previous size.
Viewer.TabStripBorderCtl.LayoutTransform = t;
ViewerB.TabStripBorderCtl.LayoutTransform = t;
// Keep the sidebar's LOGICAL width constant across the change: the column and
// the saved widths are screen px, so grow them with the scale (see SbPx above).
if (scale != prev && prev > 0)
{
double f = scale / prev;
_savedPagesWidth *= f;
_savedOutlinesWidth *= f;
if (_sidebarCol is { } col)
{
if (col.Width.GridUnitType == GridUnitType.Pixel)
col.Width = new GridLength(col.Width.Value * f);
if (col.MinWidth > 0) col.MinWidth *= f;
if (!double.IsPositiveInfinity(col.MaxWidth)) col.MaxWidth *= f;
}
}
if (persist)
{
App.SetSetting("AppScale", scale.ToString("0.###", CultureInfo.InvariantCulture));
ShowScaleReadout(scale);
}
}
// The readout is transient. Every wheel notch rewrites it and restarts the hold timer,
// so the footer carries it while you are zooming and gives the line back a beat after
// you stop. It still goes out through SetStatusHeld, because the chrome resize re-runs
// the fit pipeline and its page/zoom status would otherwise stomp this the same frame
// (MainWindow.xaml.cs SetStatus) - that hold is short and only covers the stomp.
//
// Whatever was showing before the first notch of a burst is snapshotted and put back,
// but only if the readout is still the text on screen, so a status written after the
// hold expired is never overwritten by a stale one. The restore assigns directly
// rather than going through SetStatus: this is putting a line back, not reporting
// something new, so it should not land in the crash breadcrumb a second time.
//
// Normal priority rather than the DispatcherTimer default of Background, so a busy
// render cannot leave the readout parked on the footer.
private System.Windows.Threading.DispatcherTimer? _appScaleHide;
private string _appScaleStatusWas = string.Empty;
private string _appScaleReadout = string.Empty;
private void ShowScaleReadout(double scale)
{
if (_appScaleHide is null)
{
_appScaleHide = new System.Windows.Threading.DispatcherTimer(
System.Windows.Threading.DispatcherPriority.Normal)
{ Interval = TimeSpan.FromSeconds(5) };
_appScaleHide.Tick += (_, _) =>
{
_appScaleHide!.Stop();
if (StatusText.Text == _appScaleReadout) StatusText.Text = _appScaleStatusWas;
};
}
// Only the first notch of a burst snapshots; the rest are our own readout.
if (!_appScaleHide.IsEnabled) _appScaleStatusWas = StatusText.Text;
_appScaleHide.Stop();
_appScaleReadout = string.Format(Loc("Str_St_AppSize"), (int)Math.Round(scale * 100));
SetStatusHeld(_appScaleReadout);
_appScaleHide.Start();
}
}
}
+714
View File
@@ -0,0 +1,714 @@
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
{
public partial class MainWindow
{
// ============================================================
// Context menu
// ============================================================
private void ApplyGrainTexture()
{
// Film grain with a mix of bright AND dark specks so the texture reads
// on any background - bright specks show on dark themes, dark specks
// show on light themes. Denser and a touch stronger than the first pass.
const int size = 256;
var bmp = new WriteableBitmap(size, size, 96, 96, PixelFormats.Bgra32, null);
var pixels = new byte[size * size * 4]; // start fully transparent
var rng = new Random(1337);
for (int i = 0; i < pixels.Length; i += 4)
{
if (rng.Next(3) != 0) continue; // ~33% pixel density
bool bright = rng.Next(2) == 0; // half bright, half dark
byte v = bright ? (byte)rng.Next(190, 255) : (byte)rng.Next(0, 50);
byte a = (byte)rng.Next(35, 95); // alpha for subtlety
pixels[i] = v;
pixels[i + 1] = v;
pixels[i + 2] = v;
pixels[i + 3] = a;
}
bmp.WritePixels(new System.Windows.Int32Rect(0, 0, size, size), pixels, size * 4, 0);
// BOTH panes by name, not through GrainBrush - that member resolves to ActiveViewer, so
// only the focused pane ever got the texture and pane B rendered flat.
Viewer.Grain.ImageSource = bmp;
ViewerB.Grain.ImageSource = bmp;
// TryFindResource, NOT the Resources indexer: the indexer looks ONLY in this window's
// own dictionary and returns null without complaint if the key lives higher up.
// A key defined above this window (GrainBrushShared can be) is invisible to the
// indexer, and the failure is silent - the grain simply never gets its texture.
// TryFindResource walks window -> app, so it works wherever the key is defined.
if (TryFindResource("GrainBrushShared") is ImageBrush sharedGrain) sharedGrain.ImageSource = bmp;
StatusGrainBrush.ImageSource = bmp;
// The family flyout standard's grain tile (FlyoutGrain style) shares the same texture.
// GrainTileBrush lives at APPLICATION scope so standalone windows (the file picker)
// resolve it too - and app-level Freezables are frozen, so it cannot be mutated in
// place like the window-scoped brushes above. Build a finished frozen brush and
// REPLACE the dictionary entry; the swap retriggers every DynamicResource reference.
var tileSource = System.Windows.Media.Imaging.BitmapFrame.Create(bmp);
tileSource.Freeze(); // a WriteableBitmap itself cannot freeze; its frame can
var tile = new ImageBrush(tileSource)
{
TileMode = TileMode.Tile,
ViewportUnits = BrushMappingMode.Absolute,
Viewport = new Rect(0, 0, size, size),
Stretch = Stretch.None,
};
tile.Freeze();
Application.Current.Resources["GrainTileBrush"] = tile;
}
/// <summary>Generated film-grain tile, exposed so secondary windows (e.g. the
/// print preview) can paint the same texture over their document area.</summary>
public ImageSource? GrainTexture => GrainBrush?.ImageSource;
// One shared ContextMenu instance; its items are rebuilt on every open to match whatever is
// under the cursor (a specific annotation -> per-type actions; empty page -> page-level actions).
private ContextMenu _ctxMenu = null!;
private void BuildContextMenu()
{
_ctxMenu = MakeThemedMenu();
// BOTH panes' primary canvases (2026-08-15): `_annotationCanvas` here bridged to the
// ACTIVE viewer, which is pane A at startup - pane B's canvas never got the menu and
// right-click did nothing there. One shared menu instance is fine: WPF opens it
// against whichever element raised it.
Viewer.AttachContextMenuExt(_ctxMenu);
ViewerB.AttachContextMenuExt(_ctxMenu);
}
// Rebuild the shared context menu for a right-click at canvas point pt on the given page. If an
// annotation sits under the cursor it is selected and gets a menu tailored to its type; otherwise
// the page-level menu (tools, rotate, stamp, undo, clear) is shown.
internal void PopulateContextMenu(Point pt, int pageIdx)
{
_ctxMenu.Items.Clear();
var hit = AnnotationAt(pt, pageIdx);
if (hit is not null)
{
// Target this annotation: select it unless it's already part of the current selection
// (so right-clicking one of several selected items doesn't collapse the multi-selection).
// A grouped annotation selects its whole group so the menu acts on the group.
if (!ReferenceEquals(hit, _selectedAnnotation) && !_selectedSet.Contains(hit))
{
if (hit.GroupId.Length > 0)
SelectGroup(hit);
else
{
ClearSelection();
RenderAllAnnotations(pageIdx);
SelectAnnotation(hit, AnnotBounds(hit));
}
}
AddAnnotationMenuItems(hit);
}
// Tiled views (continuous/grid/two-page) have no clickable link overlay, so resolve a link
// under the cursor by bounds-check and offer the same menu the single-page overlay carries.
else if (LinkAt(pt, pageIdx) is { } lk)
AddLinkMenuItems(_ctxMenu, lk.target, lk.annotIndex, pageIdx);
else AddPageMenuItems(pt, pageIdx);
}
// Topmost draggable annotation under pt on the given page, or null. Mirrors the click-select loop
// (reverse order = topmost first) so the menu targets exactly what a left-click would select.
private PageAnnotation? AnnotationAt(Point pt, int pageIdx)
{
if (pageIdx < 0 || !_annotations.TryGetValue(pageIdx, out var list)) return null;
for (int i = list.Count - 1; i >= 0; i--)
if (IsDraggable(list[i]) && HitTestAnnotation(list[i], pt, out _)) return list[i];
return null;
}
// Link under pt on the given page, resolved by the same padded bounds-check the click/hover use.
// Tiled views (continuous/grid/two-page) have no clickable link overlay, so _continuousLinks is the
// source of truth there; single-page links carry their own overlay ContextMenu and never reach here.
private (object target, int annotIndex)? LinkAt(Point pt, int pageIdx)
{
if (!_continuousLinks.TryGetValue(pageIdx, out var links)) return null;
const double pad = LinkHitPad; // matches the click/hover pad so the menu targets the same links
foreach (var l in links)
if (pt.X >= l.Cx - pad && pt.X <= l.Cx + l.Cw + pad &&
pt.Y >= l.Cy - pad && pt.Y <= l.Cy + l.Ch + pad)
return (l.Tag, l.AnnotIndex);
return null;
}
// A short human label for the kind of annotation right-clicked, shown as a disabled menu header.
private static string AnnotationKindLabel(PageAnnotation a) => a switch
{
CoverAnnotation => "Cover",
TextAnnotation => "Text box",
InkAnnotation => "Drawing",
SignatureAnnotation => "Signature",
ImageAnnotation => "Image",
HighlightAnnotation h => h.Style switch
{
HighlightStyle.Strikethrough => "Strikethrough",
HighlightStyle.Underline => "Underline",
_ => "Highlight"
},
_ => "Annotation"
};
// Per-type menu for a clicked annotation. Page-level items (tools, rotate, stamp) are omitted.
private void AddAnnotationMenuItems(PageAnnotation hit)
{
bool multi = SelectionCount() > 1;
// Edit sits at the top of every single-annotation menu (completes the menu). For a text box it
// lifts the text into an editable box; for other types it just ensures the editing bar is open.
if (!multi)
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Edit"), (s, e) => EditAnnotation(hit), glyph: ""));
// Raise / Lower one visual layer. Enabled only when something actually overlaps in that
// direction (so they're grayed out when nothing is stacked above / below at this spot).
int idx = -1;
_annotations.TryGetValue(hit.PageIndex, out var pageList);
if (pageList is not null) idx = pageList.IndexOf(hit);
var raise = MakeMenuItem(Loc("Str_Ctx_Raise"), (s, e) => MoveAnnotationLayer(hit, +1), glyph: "");
raise.IsEnabled = pageList is not null && idx >= 0 && OverlapNeighbor(pageList, idx, hit, +1) >= 0;
_ctxMenu.Items.Add(raise);
var lower = MakeMenuItem(Loc("Str_Ctx_Lower"), (s, e) => MoveAnnotationLayer(hit, -1), glyph: "");
lower.IsEnabled = pageList is not null && idx >= 0 && OverlapNeighbor(pageList, idx, hit, -1) >= 0;
_ctxMenu.Items.Add(lower);
// Pairing: on a single paired item, offer to jump to its other half. Unpair shows whenever the
// selection contains a pair - including when both halves are selected together (or as a group).
if (hit.PairId.Length > 0 && !multi)
{
var partner = PairPartner(hit);
if (partner is not null)
_ctxMenu.Items.Add(MakeMenuItem(
Loc(partner is CoverAnnotation ? "Str_Ctx_SelectCover" : "Str_Ctx_SelectText"),
(s, e) => SelectPartner(hit), glyph: ""));
}
if (SelectedPaired() is not null)
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Unpair"), (s, e) => UnpairSelected(), glyph: ""));
// Grouping: group an ad-hoc multi-selection, or - on a grouped item - drop just this one
// (Remove from group) or dissolve the whole group (Ungroup).
if (hit.GroupId.Length > 0)
{
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RemoveFromGroup"), (s, e) => RemoveFromGroup(hit), glyph: ""));
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Ungroup"), (s, e) => UngroupAnnotation(hit), glyph: ""));
}
else if (multi)
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Group"), (s, e) => GroupSelected(), glyph: ""));
_ctxMenu.Items.Add(new Separator());
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Copy"), (s, e) => CopySelectedAnnotations(), "Ctrl+C", ""));
if (_annotationClipboard.Count > 0)
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Paste"), (s, e) => PasteAnnotations(hit.PageIndex), "Ctrl+V", ""));
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_DeleteSel"), (s, e) => DeleteSelected(), "Delete", ""));
}
// Page-level menu shown when the right-click lands on empty page space (no annotation under it).
// Tailored to the click: Copy Text only over a text selection, the placement actions drop their
// item where the user right-clicked, and delete is "Delete Selected" when something is selected
// (otherwise "Delete Page").
private void AddPageMenuItems(Point pt, int pageIdx)
{
bool hasSelection = SelectionCount() > 0;
bool hasTextSel = !string.IsNullOrEmpty(_selectedText);
// Copy Text only when there is actually selected text to copy.
if (hasTextSel)
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CopyText"), (s, e) => CopySelectedText(), "Ctrl+C", ""));
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Print"), (s, e) => Print_Click(s!, e), "Ctrl+P", ""));
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_OcrPage"), (s, e) => OcrPageToClipboard(pageIdx), "Ctrl+Shift+O", ""));
_ctxMenu.Items.Add(new Separator());
// Placement actions - drop the item exactly where the user right-clicked (Select / Highlight /
// Draw were removed: they only make sense as toolbar modes, not as one-shot right-click actions).
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Lbl_Text"), (s, e) =>
{
SetTool(EditTool.Text);
_activeCanvas = CanvasForPage(pageIdx);
PlaceTextBox(pt, pageIdx);
}, "T", ""));
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Lbl_Image"), (s, e) =>
{
_activeCanvas = CanvasForPage(pageIdx);
PlaceImageFromDialog(pt, pageIdx);
}, "I", ""));
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Lbl_Signature"), (s, e) =>
{
_activeCanvas = CanvasForPage(pageIdx);
if (_pendingSignature is not null) PlaceSignature(pt, pageIdx);
else { SetTool(EditTool.Signature); ShowSignaturePopup(); }
}, "G", ""));
_ctxMenu.Items.Add(new Separator());
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Lbl_Rotate"), (s, e) => OpenTransformWindow(), "R", ""));
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RotateCW"), (s, e) => RotatePages_Click(90), glyph: ""));
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RotateCCW"), (s, e) => RotatePages_Click(-90), glyph: ""));
_ctxMenu.Items.Add(new Separator());
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_DuplicatePage"), (s, e) => DuplicatePage(pageIdx), glyph: ""));
// Delete Selected when something is selected; otherwise Delete Page (the page under the cursor,
// which the right-click already made the selected page).
if (hasSelection)
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_DeleteSel"), (s, e) => DeleteSelected(), "Delete", ""));
else
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_DeletePage"), (s, e) => Delete_Click(s!, e), glyph: ""));
_ctxMenu.Items.Add(new Separator());
if (_annotationClipboard.Count > 0)
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Paste"), (s, e) => PasteAnnotations(pageIdx), "Ctrl+V", ""));
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_StampPages"), (s, e) => OpenStampTool(), "S", ""));
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_UndoLast"), (s, e) => Undo_Click(s!, e), "Ctrl+Z", ""));
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Redo"), (s, e) => Redo_Click(s!, e), "Ctrl+Y", ""));
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_ClearPage"), (s, e) => ClearAnnotations_Click(s!, e), glyph: ""));
AppendBookLayoutItem(_ctxMenu);
}
// Book layout changes the whole two-page viewport, so it belongs on both the page menu and
// the surrounding-canvas menu. Keeping the item construction here prevents the two menus
// from drifting apart (including the live check state and keyboard hint).
private void AppendBookLayoutItem(ContextMenu menu)
{
if (_viewMode != ViewMode.TwoPage) return;
menu.Items.Add(new Separator());
var book = MakeMenuItem(Loc("Str_View_BookMode"), (s, e) => ToggleBookMode(), "B", "");
book.IsCheckable = true;
book.IsChecked = Controls.PdfViewer.BookMode;
menu.Items.Add(book);
}
// Deep-copies page pageIdx and inserts the copy right after it. AddPage on a same-document page
// would share the reference rather than duplicate, so the page is re-imported from an in-memory
// copy of the document (the same round-trip the undo snapshot uses).
private void DuplicatePage(int pageIdx)
{
if (_doc is null || pageIdx < 0 || pageIdx >= _doc.PageCount) return;
var doc = _doc;
try
{
using var ms = new MemoryStream();
doc.Save(ms, false);
ms.Position = 0;
using var src = PdfReader.Open(ms, PdfDocumentOpenMode.Import);
var copy = doc.AddPage(src.Pages[pageIdx]); // imported copy, appended at the end
doc.Pages.RemoveAt(doc.PageCount - 1);
doc.Pages.Insert(pageIdx + 1, copy);
SaveTempAndReload();
PageList.SelectedIndex = pageIdx + 1;
SetStatus(string.Format(Loc("Str_St_DuplicatedPage"), pageIdx + 1));
}
catch (Exception ex)
{
KillerDialog.Show(this, Loc("Str_Err_DuplicateFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
// Nearest annotation that visually overlaps `a`, searching up (dir>0) or down (dir<0) the page's
// z-order list from a's index. Returns its list index, or -1 if nothing overlaps in that direction.
// "Layer" order is judged by what actually sits on top of / under a at its location, so Raise/Lower
// step past only the things stacked with it - not unrelated annotations elsewhere on the page.
private int OverlapNeighbor(List<PageAnnotation> list, int i, PageAnnotation a, int dir)
{
var ab = AnnotBounds(a);
if (dir > 0)
{
for (int k = i + 1; k < list.Count; k++)
if (AnnotBounds(list[k]).IntersectsWith(ab)) return k;
}
else
{
for (int k = i - 1; k >= 0; k--)
if (AnnotBounds(list[k]).IntersectsWith(ab)) return k;
}
return -1;
}
// Raise (+1) or Lower (-1) an annotation one visual layer: move it just past the nearest annotation
// that overlaps it in that direction. No-op when nothing overlaps above / below. Later in the list
// = drawn on top.
private void MoveAnnotationLayer(PageAnnotation a, int dir)
{
if (!_annotations.TryGetValue(a.PageIndex, out var list)) return;
int i = list.IndexOf(a);
if (i < 0) return;
int target = OverlapNeighbor(list, i, a, dir);
if (target < 0) return;
PushPageSnapshotUndo(a.PageIndex);
list.RemoveAt(i);
list.Insert(target, a); // after removal, inserting at the neighbor's old index lands a past it
RenderAllAnnotations(a.PageIndex);
ReattachSelectionVisuals();
MarkDirty();
}
// Annotations copied via the context menu, held as page-independent deep clones until pasted.
private readonly List<PageAnnotation> _annotationClipboard = [];
// Deep-copy an annotation so the clipboard (and paste) is fully independent of the live object.
// CoverAnnotation must be matched before HighlightAnnotation since it derives from it.
private static PageAnnotation? CloneAnnotation(PageAnnotation a) => a switch
{
CoverAnnotation c => new CoverAnnotation
{
PageIndex = c.PageIndex,
PairId = c.PairId,
GroupId = c.GroupId,
Bounds = c.Bounds,
Style = c.Style,
ColorR = c.ColorR,
ColorG = c.ColorG,
ColorB = c.ColorB,
ColorA = c.ColorA
},
HighlightAnnotation h => new HighlightAnnotation
{
PageIndex = h.PageIndex,
PairId = h.PairId,
GroupId = h.GroupId,
Bounds = h.Bounds,
Style = h.Style,
ColorR = h.ColorR,
ColorG = h.ColorG,
ColorB = h.ColorB,
ColorA = h.ColorA,
Erases = h.Erases?.Select(e => new HighlightErase { Points = [.. e.Points], Radius = e.Radius }).ToList()
},
TextAnnotation t => new TextAnnotation
{
PageIndex = t.PageIndex,
PairId = t.PairId,
GroupId = t.GroupId,
Position = t.Position,
Content = t.Content,
FontSize = t.FontSize,
FontName = t.FontName,
Bold = t.Bold,
Italic = t.Italic,
Strike = t.Strike,
Underline = t.Underline,
Width = t.Width,
Height = t.Height,
ColorR = t.ColorR,
ColorG = t.ColorG,
ColorB = t.ColorB,
ColorA = t.ColorA,
BgR = t.BgR,
BgG = t.BgG,
BgB = t.BgB,
BgA = t.BgA
},
InkAnnotation ink => new InkAnnotation
{
PageIndex = ink.PageIndex,
PairId = ink.PairId,
GroupId = ink.GroupId,
Points = [.. ink.Points],
StrokeWidth = ink.StrokeWidth,
ColorR = ink.ColorR,
ColorG = ink.ColorG,
ColorB = ink.ColorB,
ColorA = ink.ColorA
},
SignatureAnnotation s => new SignatureAnnotation
{
PageIndex = s.PageIndex,
PairId = s.PairId,
GroupId = s.GroupId,
Position = s.Position,
Scale = s.Scale,
SourceWidth = s.SourceWidth,
SourceHeight = s.SourceHeight,
Strokes = [.. s.Strokes.Select(st => new List<Point>(st))],
StrokeWidth = s.StrokeWidth,
ImageData = s.ImageData
},
ImageAnnotation img => new ImageAnnotation
{
PageIndex = img.PageIndex,
PairId = img.PairId,
GroupId = img.GroupId,
Position = img.Position,
Scale = img.Scale,
SourceWidth = img.SourceWidth,
SourceHeight = img.SourceHeight,
ImageData = img.ImageData
},
_ => null
};
// Copy the current selection (primary or multi-select) into the annotation clipboard.
private void CopySelectedAnnotations()
{
var sel = new List<PageAnnotation>();
if (_selectedAnnotation is not null) sel.Add(_selectedAnnotation);
foreach (var a in _selectedSet) if (!sel.Contains(a)) sel.Add(a);
if (sel.Count == 0) return;
_annotationClipboard.Clear();
foreach (var a in sel)
if (CloneAnnotation(a) is { } c) _annotationClipboard.Add(c);
SetStatus(string.Format(Loc(_annotationClipboard.Count == 1
? "Str_St_CopiedAnnotationOne" : "Str_St_CopiedAnnotationMany"), _annotationClipboard.Count));
}
// Paste the clipboard onto pageIdx: fresh clones nudged down-right so they don't sit exactly on
// the originals, clamped on-page, added (each its own undo step) and left selected. A pasted
// text/cover pair keeps its pairing internally but is regenerated so it's independent of the source.
private void PasteAnnotations(int pageIdx)
{
if (_annotationClipboard.Count == 0 || pageIdx < 0) return;
const double off = 14;
var pasted = new List<PageAnnotation>();
var pairMap = new Dictionary<string, string>();
var groupMap = new Dictionary<string, string>();
foreach (var src in _annotationClipboard)
{
if (CloneAnnotation(src) is not { } c) continue;
c.PageIndex = pageIdx;
// Remap pairing and grouping so the pasted set stays internally linked but independent
// of the originals (a copied group/pair pastes as its own new group/pair).
if (c.PairId.Length > 0)
{
if (!pairMap.TryGetValue(c.PairId, out var np))
{
np = Guid.NewGuid().ToString("N");
pairMap[c.PairId] = np;
}
c.PairId = np;
}
if (c.GroupId.Length > 0)
{
if (!groupMap.TryGetValue(c.GroupId, out var ng))
{
ng = Guid.NewGuid().ToString("N");
groupMap[c.GroupId] = ng;
}
c.GroupId = ng;
}
AnnotSetPos(c, new Point(AnnotGetPos(c).X + off, AnnotGetPos(c).Y + off));
AnnotSetPos(c, ClampAnnotPos(c));
pasted.Add(c);
}
if (pasted.Count == 0) return;
ClearSelection();
foreach (var c in pasted) AddAnnotation(c);
RenderAllAnnotations(pageIdx);
var canvas = CanvasForPage(pageIdx);
_activeCanvas = canvas;
if (pasted.Count == 1)
SelectAnnotation(pasted[0], AnnotBounds(pasted[0]));
else
foreach (var c in pasted) ToggleMultiSelect(c, AnnotBounds(c), canvas);
SetStatus(string.Format(Loc(pasted.Count == 1
? "Str_St_PastedAnnotationOne" : "Str_St_PastedAnnotationMany"), pasted.Count));
}
// --- Edit / pairing / grouping menu actions ------------------------------------------------
// Other members of a group are moved alongside the primary during a drag; this holds each one
// with the position it had when the drag began so the whole group translates rigidly.
private List<(PageAnnotation a, Point orig)> _dragGroupOrig => ActiveViewer.DragGroupOrigRef;
// "Edit" menu action: inline-edit a text box. For any other annotation, selecting it (which the
// right-click already did) opened its color/size bar, so there's nothing more to do here.
private void EditAnnotation(PageAnnotation hit)
{
if (hit is TextAnnotation ta)
{
var p = new Point(ta.Position.X + Math.Min(Math.Max(ta.Width, 8) / 2, 10),
ta.Position.Y + Math.Min(Math.Max(ta.Height, 8) / 2, 8));
EditTextAtPosition(p, ta.PageIndex);
}
}
// The other half of a text/cover pair, if any.
private PageAnnotation? PairPartner(PageAnnotation a)
{
if (a.PairId.Length == 0 || !_annotations.TryGetValue(a.PageIndex, out var list)) return null;
return list.FirstOrDefault(x => !ReferenceEquals(x, a) && x.PairId == a.PairId);
}
// Switch the selection from one half of a pair to the other.
private void SelectPartner(PageAnnotation a)
{
var p = PairPartner(a);
if (p is null) return;
ClearSelection();
RenderAllAnnotations(p.PageIndex);
_activeCanvas = CanvasForPage(p.PageIndex);
SelectAnnotation(p, AnnotBounds(p));
}
private void PageList_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
// Did the click land on a thumbnail, or the empty area below the list? Page-specific actions
// (rotate, move, delete) only make sense on a thumbnail; the empty area gets the page-agnostic
// menu (same one the gray area around the page uses). With no document open the menu still
// opens, carrying only the sidebar-side section appended at the bottom.
bool onThumb = false;
if (_doc is not null)
for (var d = e.OriginalSource as DependencyObject; d != null; d = VisualTreeHelper.GetParent(d))
if (d is ListBoxItem) { onThumb = true; break; }
var menu = MakeThemedMenu();
if (onThumb)
{
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_InsertBlank"), (s, ev) => InsertBlankPage_Click(s!, ev), glyph: ""));
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_DuplicatePage"), (s, ev) => DuplicatePage(PageList.SelectedIndex), glyph: ""));
menu.Items.Add(new Separator());
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RotateCWShort"), (s, ev) => RotatePages_Click(90), glyph: ""));
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RotateCCWShort"), (s, ev) => RotatePages_Click(-90), glyph: ""));
menu.Items.Add(new Separator());
menu.Items.Add(MakeMenuItem(Loc("Str_Lbl_MoveUp"), (s, ev) => MoveUp_Click(s!, ev), glyph: ""));
menu.Items.Add(MakeMenuItem(Loc("Str_Lbl_MoveDown"), (s, ev) => MoveDown_Click(s!, ev), glyph: ""));
menu.Items.Add(new Separator());
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_ExtractPages"), (s, ev) => Split_Click(s!, ev), glyph: ""));
// #207: the shared export dialog, scoped to the clicked selection (still editable).
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_ExportPageImage"), (s, ev) =>
{
var nums = PageList.SelectedItems.Cast<PageThumbnailVm>()
.Select(vm => vm.PageIndex + 1).OrderBy(n => n);
_ = ExportImagesFlow(string.Join(",", nums));
}, glyph: null));
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_DeletePages"), (s, ev) => Delete_Click(s!, ev), glyph: ""));
menu.Items.Add(new Separator());
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_StampPages"), (s, ev) => OpenStampTool(), glyph: ""));
}
else if (_doc is not null)
{
FillPageAgnosticMenu(menu);
}
AppendSidebarSideSection(menu);
menu.PlacementTarget = PageList;
menu.IsOpen = true;
e.Handled = true;
}
// Right-click on any other part of the sidebar - the PAGES/OUTLINES header, the page
// controls row, the outline panel's empty space, the toggle strip - reaches this handler
// on the two container Borders (SidebarBorder, SidebarToggleStrip). The page list's own
// handler above marks its clicks handled, so they never double up; the outline TREE is
// ceded entirely, because its bookmark menu opens on Preview mouse DOWN and does not mark
// the Up handled - without the guard this would open a second menu over it.
private void SidebarArea_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
if (e.OriginalSource is DependencyObject d)
for (var n = d; n != null; n = VisualTreeHelper.GetParent(n))
if (ReferenceEquals(n, OutlineTree)) return;
var menu = MakeThemedMenu();
if (_doc is not null) FillPageAgnosticMenu(menu);
AppendSidebarSideSection(menu);
menu.PlacementTarget = (UIElement)sender;
menu.IsOpen = true;
e.Handled = true;
}
// The sidebar-side picker rides the bottom of the sidebar's own right-click menu (it moved
// out of the Settings panel, like the toolbar picker moved onto the toolbar). The keyboard
// gesture is shown on the side Ctrl+Shift+B would switch TO - it is a toggle, so putting it
// on the checked row would advertise a press that does nothing.
private void AppendSidebarSideSection(ContextMenu menu)
{
if (menu.Items.Count > 0) menu.Items.Add(new Separator());
menu.Items.Add(new MenuItem { Header = Loc("Str_Sidebar"), IsEnabled = false });
var left = new MenuItem { Header = Loc("Str_Sidebar_Left"), IsCheckable = true, IsChecked = !_sidebarRight };
var right = new MenuItem { Header = Loc("Str_Sidebar_Right"), IsCheckable = true, IsChecked = _sidebarRight };
(_sidebarRight ? left : right).InputGestureText = "Shift+F9";
left.Click += (_, _2) => SelectSidebarSide(false);
right.Click += (_, _2) => SelectSidebarSide(true);
menu.Items.Add(left);
menu.Items.Add(right);
}
private ContextMenu MakeThemedMenu()
{
var menu = new ContextMenu();
// Code-created ContextMenus are popup roots and can miss the owning Window's implicit
// style during construction. Attach it explicitly so every generated menu receives
// the square, two-stage 98SE frame instead of WPF's rounded fallback chrome.
if (TryFindResource(typeof(ContextMenu)) is Style style) menu.Style = style;
TextOptions.SetTextFormattingMode(menu, TextFormattingMode.Display);
TextOptions.SetTextRenderingMode(menu, TextRenderingMode.Grayscale);
return menu;
}
// Document-wide actions for a right-click with no specific page under the cursor: the empty sidebar
// area below the thumbnails, or the gray area around the page in the document pane.
private void FillPageAgnosticMenu(ContextMenu menu)
{
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_AddBlankPage"), (s, e) => AddBlankPageAtEnd(), glyph: ""));
menu.Items.Add(new Separator());
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Print"), (s, e) => Print_Click(s!, e), "Ctrl+P", ""));
menu.Items.Add(MakeMenuItem(Loc("Str_Lbl_ZoomIn"), (s, e) => ZoomIn_Click(s!, e), "Ctrl+=", ""));
menu.Items.Add(MakeMenuItem(Loc("Str_Lbl_ZoomOut"), (s, e) => ZoomOut_Click(s!, e), "Ctrl+-", ""));
menu.Items.Add(new Separator());
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_UndoLast"), (s, e) => Undo_Click(s!, e), "Ctrl+Z", ""));
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Redo"), (s, e) => Redo_Click(s!, e), "Ctrl+Y", ""));
menu.Items.Add(MakeMenuItem(Loc("Str_Lbl_Clear"), (s, e) => ClearAllAnnotations_Click(s!, e), glyph: ""));
AppendBookLayoutItem(menu);
}
// Opens the page-agnostic menu for a right-click on the gray area around the page (the document
// pane background). Wired in code so right-clicking outside the page is no longer a dead spot.
// internal: PdfViewer's XAML binds this and forwards to it.
internal void DocPaneBackground_RightClick(object sender, MouseButtonEventArgs e)
{
if (_doc is null) return;
// Only when the click really hit the background, not a page tile (those have their own menu).
if (e.OriginalSource is DependencyObject d)
for (var n = d; n != null; n = VisualTreeHelper.GetParent(n))
if (n is Canvas) return;
var menu = MakeThemedMenu();
FillPageAgnosticMenu(menu);
menu.PlacementTarget = (UIElement)sender;
menu.IsOpen = true;
e.Handled = true;
}
// glyph: optional Segoe MDL2 codepoint rendered in the menu's left gutter (the 16px
// check column doubles as an icon slot - see the MenuItem template in MainWindow.xaml).
private static MenuItem MakeMenuItem(string header, RoutedEventHandler click, string? gesture = null, string? glyph = null)
{
var item = new MenuItem { Header = header };
item.Click += click;
if (gesture != null)
item.InputGestureText = gesture;
if (glyph != null)
{
var icon = new TextBlock
{
Text = glyph,
FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
VerticalAlignment = VerticalAlignment.Center,
};
item.Icon = icon;
}
return item;
}
}
}
+146
View File
@@ -0,0 +1,146 @@
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
{
public partial class MainWindow
{
// ============================================================
// Dirty / unsaved-change tracking
// ============================================================
private void MarkDirty(bool dirty = true)
{
_isDirty = dirty;
if (_saveAsBtnRef != null)
{
if (dirty)
{
// Deeper orange = unsaved. The old #FFA500 washed out on the light theme's white
// toolbar; this reads on light and dark. A soft dark halo (ShadowDepth 0) outlines
// the glyph so it pops on light backgrounds and stays invisible on dark ones.
_saveAsBtnRef.Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0x73, 0x00));
_saveAsBtnRef.Effect = new System.Windows.Media.Effects.DropShadowEffect
{
Color = Colors.Black,
BlurRadius = 5,
ShadowDepth = 0,
Opacity = 0.55
};
}
else
{
// Saved / clean: just a normal toolbar icon (no color). The orange above is the only
// signal, reserved for "you have unsaved changes".
_saveAsBtnRef.SetResourceReference(Control.ForegroundProperty, "TextBrush");
_saveAsBtnRef.Effect = null;
}
}
}
// Cryptographic certificate signing (the real digital signature, not the drawn stamp tool).
private void OpenSignDialog()
{
if (_doc is null || string.IsNullOrEmpty(_currentFile))
{
KillerDialog.Show(this, Loc("Str_Msg_OpenFirst"));
return;
}
// Sign the user's real document, not the temp working copy. Operations like print/crop/repair
// repoint _currentFile at a temp (e.g. "...printfixed...") while _originalFile keeps the real
// path - which is the name the user expects to see and the file Save targets.
new SignDocumentDialog(this, _originalFile ?? _currentFile!).ShowDialog();
}
// ---- generic busy overlay (indeterminate spinner) for blocking background work ----
/// <summary>
/// Dims the window and shows a spinning ring plus a message while a background task runs.
/// Returned Border is passed to HideBusyOverlay when the work completes.
/// </summary>
private Border ShowBusyOverlay(string message)
{
var spinner = new System.Windows.Shapes.Ellipse
{
Width = 34,
Height = 34,
Stroke = AccentBrush(),
StrokeThickness = 3,
StrokeDashArray = [5.5, 3.5], // dashed ring reads as "spinning"
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 14),
RenderTransformOrigin = new Point(0.5, 0.5)
};
var rot = new RotateTransform();
spinner.RenderTransform = rot;
rot.BeginAnimation(RotateTransform.AngleProperty,
new DoubleAnimation(0, 360, new Duration(TimeSpan.FromSeconds(0.9)))
{ RepeatBehavior = RepeatBehavior.Forever });
var text = new TextBlock
{
Text = message,
Foreground = Brushes.White,
FontFamily = UiKit.UiFont,
FontSize = 14,
HorizontalAlignment = HorizontalAlignment.Center
};
var panel = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
panel.Children.Add(spinner);
panel.Children.Add(text);
var overlay = new Border
{
Background = new SolidColorBrush(Color.FromArgb(190, 0x12, 0x12, 0x12)),
Child = panel
};
Panel.SetZIndex(overlay, 10050); // above the Settings/Shortcuts/About overlays
// Cover the whole window for a uniform dim, but let the user drag the window by pressing
// anywhere on the overlay - so a long operation (e.g. repair) doesn't trap the window in place.
overlay.Cursor = Cursors.SizeAll;
overlay.MouseLeftButtonDown += (_, e) =>
{
if (e.ButtonState == MouseButtonState.Pressed) { try { DragMove(); } catch { } }
};
if (ShortcutOverlay?.Parent is Grid host)
{
if (host.RowDefinitions.Count > 0) Grid.SetRowSpan(overlay, host.RowDefinitions.Count);
host.Children.Add(overlay);
}
else
{
RootClipGrid?.Children.Add(overlay);
}
return overlay;
}
private static void HideBusyOverlay(Border overlay)
=> (overlay.Parent as Panel)?.Children.Remove(overlay);
// RenderToPng lives in Services/BitmapHelpers.cs (KillerUI refactor), with the other
// raw-bitmap helpers.
}
}
+212
View File
@@ -0,0 +1,212 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
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
{
public partial class MainWindow
{
// ============================================================
// Drag/drop: file open
// ============================================================
internal void DropZone_DragOver(object sender, DragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
e.Handled = true;
}
// internal: PdfViewer's XAML binds these three and forwards to them.
internal void DropZone_Drop(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
OnPathsDropped((string[])e.Data.GetData(DataFormats.FileDrop)!);
e.Handled = true; // don't let the same drop bubble to the window-level handler
}
}
internal void DropZone_Click(object sender, MouseButtonEventArgs e) => Open_Click(sender, e);
// ============================================================
// Drag/drop: page reorder
// ============================================================
private bool _pageDragArmed;
private void PageList_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
_dragStartPoint = e.GetPosition(null);
// Only arm a page-reorder drag when the press lands on a page thumbnail, not the
// scrollbar - otherwise grabbing the scrollbar starts a page-move drag (the "insert"
// cursor) instead of scrolling.
_pageDragArmed = false;
for (var d = e.OriginalSource as DependencyObject; d != null; d = VisualTreeHelper.GetParent(d))
{
if (d is System.Windows.Controls.Primitives.ScrollBar) break;
if (d is ListBoxItem) { _pageDragArmed = true; break; }
}
}
private void PageList_PreviewMouseMove(object sender, MouseEventArgs e)
{
if (!_pageDragArmed || e.LeftButton != MouseButtonState.Pressed) return;
var diff = _dragStartPoint - e.GetPosition(null);
if (Math.Abs(diff.X) > SystemParameters.MinimumHorizontalDragDistance ||
Math.Abs(diff.Y) > SystemParameters.MinimumVerticalDragDistance)
{
if (PageList.SelectedIndex >= 0)
DragDrop.DoDragDrop(PageList, PageList.SelectedIndex, DragDropEffects.Move);
}
}
private void PageList_DragOver(object sender, DragEventArgs e)
{
// #172: files dropped onto the Pages sidebar append to the open document,
// so the list accepts FileDrop as well as its own page-reorder payload.
if (e.Data.GetDataPresent(typeof(int)))
e.Effects = DragDropEffects.Move;
else if (_doc != null && DroppedOpenablePaths(e).Length > 0)
e.Effects = DragDropEffects.Copy;
else
e.Effects = DragDropEffects.None;
e.Handled = true;
}
private static string[] DroppedOpenablePaths(DragEventArgs e)
=> e.Data.GetDataPresent(DataFormats.FileDrop)
? ((string[])e.Data.GetData(DataFormats.FileDrop)!).Where(IsOpenablePath).ToArray()
: [];
// #172: append the dropped files' pages to the open document. Appending (not inserting at
// the drop point) keeps existing page indices stable, so annotations and rotations need no
// remapping.
private async void AppendFilesToCurrentDoc(string[] files)
{
if (_doc is null) return;
CommitActiveTextBox();
int before = _doc.PageCount;
foreach (var f in files)
{
if (PdfImport.IsPdfPath(f))
{
var target = _doc;
if (target != null && TryAppendPdfPages(target, f)) continue;
// #203: a damaged PDF used to be swallowed here, so nothing was added and
// nothing was said. Offer the same repair the open path offers.
string? repaired = await RepairDroppedPdfAsync(f);
if (repaired != null && _doc != null) TryAppendPdfPages(_doc, repaired);
}
else
{
var target = _doc;
if (target != null)
try { PdfImport.AddImagePagesFromFile(target, f); } catch { /* skip an unreadable image */ }
}
}
if (_doc is null) return;
if (_doc.PageCount == before) { SetStatus(Loc("Str_Drop_NothingOpenable")); return; }
MarkDirty(true);
SaveTempAndReload(keepAnnotations: true, preserveZoom: true);
SetStatus(string.Format(Loc("Str_Status_Merged"), files.Length));
}
/// <summary>
/// Import-mode page copy. False when the file cannot be read at all, which is the signal
/// to offer a repair rather than silently dropping it.
/// </summary>
private static bool TryAppendPdfPages(PdfDocument target, string path)
{
try
{
using var src = PdfReader.Open(path, PdfDocumentOpenMode.Import);
if (src.PageCount == 0) return false;
for (int i = 0; i < src.PageCount; i++) target.AddPage(src.Pages[i]);
return true;
}
catch { return false; }
}
/// <summary>
/// Runs the open path's three repair strategies against a dropped file and returns the
/// repaired temp copy, or null if the user declined or nothing recovered it. The original
/// file is never written to.
/// </summary>
private async System.Threading.Tasks.Task<string?> RepairDroppedPdfAsync(string path)
{
var ask = KillerDialog.Show(this,
string.Format(Loc("Str_Dlg_RepairAsk"), System.IO.Path.GetFileName(path)),
"KillerPDF", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (ask != MessageBoxResult.Yes) return null;
var busy = ShowBusyOverlay(Loc("Str_Busy_Repairing"));
try
{
// Same order as TryRepairAndOpen: lossless PDFium re-save first (keeps forms and
// bookmarks), then a PdfSharpCore page-copy, then the rasterize that always
// produces something openable.
string? repaired = await System.Threading.Tasks.Task.Run(() =>
{
var p = App.MakeTempFile("repaired");
return PdfiumInterop.TryPdfiumStripEncryption(path, p) ? p : null;
});
repaired ??= await System.Threading.Tasks.Task.Run(() => PdfImport.RepairViaImportToFile(path));
repaired ??= await System.Threading.Tasks.Task.Run(() => PdfImport.RepairViaDocnetRasterizeToFile(path));
if (repaired is null)
KillerDialog.Show(this,
$"\"{System.IO.Path.GetFileName(path)}\" could not be repaired.",
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
return repaired;
}
finally { HideBusyOverlay(busy); }
}
private void PageList_Drop(object sender, DragEventArgs e)
{
if (_doc != null && !e.Data.GetDataPresent(typeof(int)))
{
var files = DroppedOpenablePaths(e);
if (files.Length > 0) { AppendFilesToCurrentDoc(files); e.Handled = true; return; }
}
if (_doc is null || !e.Data.GetDataPresent(typeof(int))) return;
var doc = _doc;
int fromIdx = (int)e.Data.GetData(typeof(int))!;
var pos = e.GetPosition(PageList);
int toIdx = PageList.Items.Count - 1;
for (int i = 0; i < PageList.Items.Count; i++)
{
if (PageList.ItemContainerGenerator.ContainerFromIndex(i) is ListBoxItem item)
{
var itemPos = item.TranslatePoint(new Point(0, item.ActualHeight / 2), PageList);
if (pos.Y < itemPos.Y) { toIdx = i; break; }
}
}
if (fromIdx == toIdx) return;
var page = doc.Pages[fromIdx];
doc.Pages.RemoveAt(fromIdx);
if (toIdx > fromIdx) toIdx--;
doc.Pages.Insert(toIdx, page);
SaveTempAndReload();
PageList.SelectedIndex = toIdx;
}
}
}
+106
View File
@@ -0,0 +1,106 @@
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows;
using KillerPDF.Services;
namespace KillerPDF
{
/// <summary>
/// Single-instance entry points: App forwards a second launch's file path here rather than
/// starting another process.
///
/// These stay on the window: RestoreAndActivate drives WindowState, Activate() and Topmost,
/// which a UserControl does not have. The OpenInNewTab call routes through ActiveViewer so the
/// forwarded file lands in whichever pane has focus.
/// </summary>
public partial class MainWindow
{
/// <summary>A forwarded path that arrived before the panes existed, replayed from Loaded.</summary>
private string? _pendingExternalPath;
public async void OpenFromExternal(string? path)
{
// A second launch is forwarded here off the pipe thread, and Application.MainWindow is
// already set while this window's constructor is still running - so ActiveViewer can
// still be null (it is assigned by InitSplitPanes). Hold the path and let Loaded
// replay it rather than dereferencing null and losing the file the user double-clicked.
if (ActiveViewer == null)
{
_pendingExternalPath = path;
return;
}
if (!string.IsNullOrEmpty(path) && File.Exists(path))
{
ActiveViewer.OpenInNewTabExt(path!);
return;
}
if (ProtocolRegistrar.TryGetTargetUrl(path, out var target) && target != null)
await OpenProtocolUrlAsync(target);
}
/// <summary>Replay whatever arrived during startup. No-op in the normal case.</summary>
internal void FlushPendingExternalOpen()
{
if (_pendingExternalPath == null) return;
var path = _pendingExternalPath;
_pendingExternalPath = null;
OpenFromExternal(path);
}
private async Task OpenProtocolUrlAsync(System.Uri target)
{
string temp = App.MakeTempFile("browser");
try
{
using var http = new HttpClient { Timeout = System.TimeSpan.FromSeconds(90) };
using var response = await http.GetAsync(target, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
const long MaxBytes = 256L * 1024 * 1024;
if (response.Content.Headers.ContentLength is long length && length > MaxBytes)
throw new InvalidDataException("The PDF is larger than the 256 MB browser handoff limit.");
using (var input = await response.Content.ReadAsStreamAsync())
using (var output = File.Create(temp))
{
var buffer = new byte[81920];
long total = 0;
int read;
while ((read = await input.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
total += read;
if (total > MaxBytes) throw new InvalidDataException("The PDF is larger than the 256 MB browser handoff limit.");
await output.WriteAsync(buffer, 0, read);
}
}
using (var check = File.OpenRead(temp))
{
var magic = new byte[5];
if (check.Read(magic, 0, magic.Length) != magic.Length ||
System.Text.Encoding.ASCII.GetString(magic) != "%PDF-")
throw new InvalidDataException("The downloaded file is not a PDF.");
}
ActiveViewer.OpenInNewTabExt(temp);
}
catch (System.Exception ex)
{
try { File.Delete(temp); } catch { }
KillerDialog.Show(this,
$"KillerPDF could not open the browser PDF.\n\n{ex.Message}",
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
public void RestoreAndActivate()
{
if (WindowState == WindowState.Minimized) WindowState = WindowState.Normal;
Activate();
// Briefly toggle Topmost to pull the window in front without keeping it pinned.
Topmost = true;
Topmost = false;
Focus();
}
}
}
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
namespace KillerPDF
{
public partial class MainWindow
{
private bool _fullScreen;
private GridLength _fsTitleRow, _fsFooterRow, _fsSidebarCol, _fsSplitterCol;
private double _fsSidebarMin;
private WindowState _fsPrevState;
private bool _fsPrevTopmost;
private ResizeMode _fsPrevResize;
private double _fsPrevLeft, _fsPrevTop, _fsPrevW, _fsPrevH;
private bool _fsAnimating;
// F11 distraction-free mode: hides all chrome (title bar, toolbar, tab strip, sidebar, footer) and
// grows the window over the whole monitor so just the document pane fills the screen on a dark-gray
// backdrop. F11 or Esc exits. The switch happens under a black cross-fade so the resize never jumps.
private void ToggleFullScreen()
{
if (_fsAnimating) return; // ignore re-presses mid-transition
_fsAnimating = true;
bool entering = !_fullScreen;
var cover = new Border { Background = Brushes.Black, Opacity = 0, IsHitTestVisible = false };
Grid.SetRow(cover, 0);
Grid.SetRowSpan(cover, RootClipGrid.RowDefinitions.Count);
Panel.SetZIndex(cover, 99998);
RootClipGrid.Children.Add(cover);
var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(150))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn } };
fadeIn.Completed += (_, _2) =>
{
ApplyFullScreen(entering);
// Reveal only after the resize/relayout has settled, so no edge of old layout flashes through.
Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() =>
{
var fadeOut = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } };
fadeOut.Completed += (_, _3) =>
{
RootClipGrid.Children.Remove(cover);
_fsAnimating = false;
if (entering) ShowFullScreenHint();
};
cover.BeginAnimation(UIElement.OpacityProperty, fadeOut);
}));
};
cover.BeginAnimation(UIElement.OpacityProperty, fadeIn);
}
private void ApplyFullScreen(bool entering)
{
// The split panes keep their RATIO across the size jump - OnSplitHostResized handles
// every non-interactive resize (full screen included), so nothing extra is needed here.
_fullScreen = entering;
var v = entering ? Visibility.Collapsed : Visibility.Visible;
TitleBarBorder.Visibility = v;
// Exit must respect the user's hide-toolbar setting (#215), not blanket-restore it.
ToolbarRowBorder.Visibility = entering || _toolbarHidden ? Visibility.Collapsed : Visibility.Visible;
// BOTH panes - full screen hides every strip, not just the focused pane's.
Viewer.TabStripBorderCtl.Visibility = v;
ViewerB.TabStripBorderCtl.Visibility = v;
FooterBorder.Visibility = v;
SidebarOuterGrid.Visibility = v;
SidebarToggleStrip.Visibility = v;
SidebarSplitter.Visibility = v;
// The document pane is a lifted card in normal use (RadCard corners, an 8px inset on
// its outer side, PaneShadow). Full screen wants the canvas edge to edge, so the card
// treatment comes off entirely and goes back on exit - otherwise a rounded corner and
// an 8px strip of window background sit inside a "full screen" view.
// The MARGIN is on the PdfViewer control, not on the card border inside it - the
// control is what the layout positions.
// SplitHost, not Viewer - the inset to the window edge belongs to the host that holds
// both panes. See the note in ApplySidebarSide.
SplitHost.Margin = entering ? new Thickness(0) : DocPaneInsetMargin();
DocPaneBorder.CornerRadius = entering ? new CornerRadius(0)
: (CornerRadius)FindResource("RadCard");
DocPaneBorder.BorderThickness = new Thickness(entering ? 0 : 1);
// The shadow caster is inside the control now, so it needs no margin of its own -
// hiding it is still right, so nothing casts onto a full-screen canvas.
DocPaneShadow.Visibility = v;
if (entering)
{
_fsTitleRow = RootClipGrid.RowDefinitions[0].Height;
_fsFooterRow = RootClipGrid.RowDefinitions[4].Height;
_fsSidebarCol = _sidebarCol.Width;
_fsSidebarMin = _sidebarCol.MinWidth;
_fsSplitterCol = MainContentGrid.ColumnDefinitions[1].Width;
RootClipGrid.RowDefinitions[0].Height = new GridLength(0);
RootClipGrid.RowDefinitions[4].Height = new GridLength(0);
// Collapse the ACTUAL sidebar column (_sidebarCol), which ApplySidebarSide repoints to DocCol
// when the sidebar is on the right. MinWidth must drop to 0 too (it floors the width to 24
// otherwise) and the splitter column (col 1) collapses, so the document fills the whole screen
// with no leftover strip on either side, regardless of side or sidebar width.
_sidebarCol.MinWidth = 0;
_sidebarCol.Width = new GridLength(0);
MainContentGrid.ColumnDefinitions[1].Width = new GridLength(0);
DocPaneBorder.Background = new SolidColorBrush(Color.FromRgb(0x26, 0x26, 0x26)); // dark-gray backdrop
// Cover the whole monitor with explicit bounds. A maximized window is clamped to the work
// area (taskbar stays visible), so instead we go Normal, size to the full monitor rect, and
// set Topmost so the window sits above the always-on-top taskbar - true full screen.
_fsPrevState = WindowState;
_fsPrevTopmost = Topmost;
_fsPrevResize = ResizeMode;
_fsPrevLeft = Left; _fsPrevTop = Top; _fsPrevW = Width; _fsPrevH = Height;
// Read the target monitor while still on it (before any WindowState change). Set the target
// bounds FIRST, then drop to Normal: WPF restores to the just-set bounds, so the window lands
// straight on this monitor instead of momentarily restoring to its old normal rect on another
// screen (the "flash to another monitor"). Re-apply bounds after Normal to be certain.
// Paint the window background black for the grow. When the window jumps from its small
// rect to the full monitor, the newly-exposed area is filled by the window background
// until the black cover re-lays-out over it. Black (instead of the gray BgDark) makes
// that exposed edge match the cover, so entering no longer flashes a gray border the way
// it did - shrinking on exit exposes nothing, which is why exit already looked clean.
// Restored to BgDark on exit.
Background = Brushes.Black;
var b = CurrentMonitorBoundsDip();
Topmost = true;
// #215: Topmost exists only to cover the always-on-top taskbar while KillerPDF is
// the ACTIVE window. Held unconditionally, it sat over every other program the user
// switched to. Yield it on deactivate, take it back on return - browser behavior.
Deactivated += FsYieldTopmost;
Activated += FsReassertTopmost;
ResizeMode = ResizeMode.NoResize;
Left = b.Left; Top = b.Top; Width = b.Width; Height = b.Height;
if (WindowState == WindowState.Maximized) WindowState = WindowState.Normal;
Left = b.Left; Top = b.Top; Width = b.Width; Height = b.Height;
}
else
{
RootClipGrid.RowDefinitions[0].Height = _fsTitleRow;
RootClipGrid.RowDefinitions[4].Height = _fsFooterRow;
_sidebarCol.MinWidth = _fsSidebarMin;
_sidebarCol.Width = _fsSidebarCol;
MainContentGrid.ColumnDefinitions[1].Width = _fsSplitterCol;
DocPaneBorder.SetResourceReference(Border.BackgroundProperty, "BgCanvas");
SetResourceReference(BackgroundProperty, "SurfaceBrush"); // undo the black grow-backdrop
// Drop topmost and restore the pre-full-screen window placement. Restore the normal bounds
// first (so WPF's remembered restore rect is correct) then re-maximize if it was maximized.
Deactivated -= FsYieldTopmost;
Activated -= FsReassertTopmost;
Topmost = _fsPrevTopmost;
ResizeMode = _fsPrevResize;
WindowState = WindowState.Normal;
Left = _fsPrevLeft; Top = _fsPrevTop; Width = _fsPrevW; Height = _fsPrevH;
if (_fsPrevState == WindowState.Maximized) WindowState = WindowState.Maximized;
}
// Re-apply the frame treatment: squared (no border, square corners, full clip) in full screen,
// back to the floating border on exit. _fullScreen is already set, so UpdateWindowChrome reads it.
UpdateWindowChrome();
}
// #215: full-screen topmost is active-window-only; see the enter branch above.
private void FsYieldTopmost(object? sender, EventArgs e) => Topmost = false;
private void FsReassertTopmost(object? sender, EventArgs e) => Topmost = true;
// Full bounds (taskbar included) of the monitor the window is currently on, in WPF device-independent
// units. MonitorFromWindow/GetMonitorInfo/MONITORINFO/RECT are declared in WindowChrome.cs (same class).
private Rect CurrentMonitorBoundsDip()
{
var hwnd = new WindowInteropHelper(this).Handle;
IntPtr mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
var info = new MONITORINFO { cbSize = Marshal.SizeOf(typeof(MONITORINFO)) };
GetMonitorInfo(mon, ref info);
var r = info.rcMonitor;
var dpi = VisualTreeHelper.GetDpi(this);
return new Rect(r.left / dpi.DpiScaleX, r.top / dpi.DpiScaleY,
(r.right - r.left) / dpi.DpiScaleX, (r.bottom - r.top) / dpi.DpiScaleY);
}
// Chrome-style toast: fades in near the top, holds, then fades out and removes itself.
private void ShowFullScreenHint()
{
var toast = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0xE0, 0x1c, 0x1c, 0x1c)),
CornerRadius = new CornerRadius(7),
Padding = new Thickness(18, 9, 18, 9),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 44, 0, 0),
Opacity = 0,
IsHitTestVisible = false,
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 12, ShadowDepth = 2, Opacity = 0.5 },
Child = new TextBlock
{
Text = Loc("Str_FullScreen_Hint"), Foreground = Brushes.White, FontSize = 13,
FontFamily = UiKit.UiFont
}
};
Grid.SetRow(toast, 0);
Grid.SetRowSpan(toast, RootClipGrid.RowDefinitions.Count);
Panel.SetZIndex(toast, 99999);
RootClipGrid.Children.Add(toast);
toast.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)));
var t = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2.2) };
t.Tick += (_, _2) =>
{
t.Stop();
var fade = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(400));
fade.Completed += (_, _3) => RootClipGrid.Children.Remove(toast);
toast.BeginAnimation(UIElement.OpacityProperty, fade);
};
t.Start();
}
}
}
+351
View File
@@ -0,0 +1,351 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using Microsoft.Win32;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
using KillerPDF.Services;
namespace KillerPDF
{
// Import images as a PDF, and compress the current PDF to a .zip. Kept in its own partial-class
// file rather than the MainWindow monolith. User-facing strings go through Loc() (keys live in
// Strings/*.xaml) so the feature is fully localized.
public partial class MainWindow
{
// ----- Import images as a single PDF -------------------------------------------------
private void ImportImages_Click(object sender, RoutedEventArgs e)
{
var dlg = new Controls.FileDialog(Controls.FileDialogMode.Open)
{
Title = Loc("Str_Menu_Import"),
Filter = $"{Loc("Str_Filter_Images")}|*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.tif;*.tiff|{Loc("Str_Filter_AllFiles")}|*.*",
Multiselect = true,
ShowImagePreview = true
};
if (dlg.ShowDialog(this) != true) return;
// Open in its own tab using the same flow as New, so the import is treated as UNSAVED work:
// dirty (orange Save icon), Save routes to Save As (no real file yet), and Close warns.
var target = BeginTabLoad(out var prev, out bool createdNew);
try
{
string tempPath = BuildPdfFromImages(dlg.FileNames);
_doc = PdfReader.Open(tempPath, PdfDocumentOpenMode.Modify);
FinishOpenFile("Imported.pdf", tempPath);
_originalFile = null; // no saved location yet -> Save becomes Save As
MarkDirty(true); // unsaved -> orange icon + close warns
SetStatus(string.Format(Loc("Str_Status_Imported"), dlg.FileNames.Length));
CaptureSessionState(_active!);
SetTool(_currentTool);
RebuildTabStrip();
}
catch (Exception ex)
{
AbortTabLoad(target, prev, createdNew);
KillerDialog.Show(this, Loc("Str_Err_ImportFailed") + "\n" + ex.Message,
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
// Builds a PDF where each page is exactly one source image. Multi-frame TIFF/GIF expand to one
// page per frame. Page size matches the image's physical size (pixels at its own DPI, 96 if the
// image declares none). Returns a temp PDF path; the caller opens it and the user can Save As.
private static string BuildPdfFromImages(string[] imagePaths)
{
using var pdf = new PdfDocument();
foreach (var path in imagePaths) PdfImport.AddImagePagesFromFile(pdf, path);
if (pdf.PageCount == 0)
throw new InvalidOperationException(
Application.Current.TryFindResource("Str_Err_NoImages") as string ?? "No images could be read.");
string outPath = Path.Combine(Path.GetTempPath(),
$"Imported-{DateTime.Now:yyyyMMdd-HHmmss-fff}.pdf");
pdf.Save(outPath);
return outPath;
}
// AddImagePagesFromFile and IsPdfPath live in Services/PdfImport.cs (KillerUI refactor).
// ----- Drag/drop of folders, archives, and multiple files ----------------------------
private static readonly string[] DropImageExt = [".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tif", ".tiff"];
private static bool IsImagePath(string p) => DropImageExt.Any(e => p.EndsWith(e, StringComparison.OrdinalIgnoreCase));
private static bool IsOpenablePath(string p) => PdfImport.IsPdfPath(p) || IsImagePath(p);
// Entry point for any file/folder/archive drop. Expands dropped folders (recursively) and .zip
// archives, then opens the collected PDFs/images - asking merge-vs-separate when there's >1.
private void OnPathsDropped(string[] paths)
{
if (paths == null || paths.Length == 0) return;
var found = new List<string>();
var tempDirs = new List<string>(); // extracted-zip temp dirs we may need to clean up
bool expanded = false; // a folder or archive was opened
try
{
foreach (var p in paths)
{
if (Directory.Exists(p)) { expanded = true; CollectOpenable(p, found); }
else if (p.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
var dir = ExtractZipToTemp(p);
if (dir != null) { expanded = true; tempDirs.Add(dir); CollectOpenable(dir, found); }
}
else if (IsOpenablePath(p)) found.Add(p);
}
}
catch (Exception ex)
{
CleanupDirs(tempDirs);
KillerDialog.Show(this, Loc("Str_Err_ImportFailed") + "\n" + ex.Message,
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
if (found.Count == 0)
{
CleanupDirs(tempDirs);
SetStatus(Loc("Str_Drop_NothingOpenable"));
return;
}
// Guard against a folder/archive holding a huge number of files: opening or merging them all
// would exhaust memory. Cap to a sane maximum, opening only the first N (name-sorted) after asking.
const int MaxDropFiles = 50;
if (found.Count > MaxDropFiles)
{
var proceed = KillerDialog.Show(this,
string.Format(Loc("Str_Drop_TooMany"), found.Count, MaxDropFiles),
"KillerPDF", MessageBoxButton.OKCancel);
if (proceed != MessageBoxResult.OK) { CleanupDirs(tempDirs); return; }
found = found.GetRange(0, MaxDropFiles);
}
// A single dropped file (no folder/zip): open it directly, as before. Temp from a single-file
// zip is kept for the session since the opened doc references it.
if (!expanded && found.Count == 1) { OpenDropped(found[0]); return; }
int choice = KillerDialog.ShowChoices(this,
string.Format(Loc("Str_Drop_Prompt"), found.Count),
[Loc("Str_Drop_Merge"), Loc("Str_Drop_Separate"), Loc("Str_Stamp_Cancel")],
accentIndex: 0);
if (choice == 0)
{
OpenMerged(found, tempDirs); // async: builds on a background thread, then owns temp cleanup
}
else if (choice == 1)
{
OpenSeparately(found); // opened docs may reference extracted files - keep temp for the session
}
else
{
CleanupDirs(tempDirs); // canceled
}
}
private void OpenDropped(string path)
{
if (PdfImport.IsPdfPath(path)) OpenInNewTab(path);
else OpenImagesAsImportedTab([path], Path.GetFileName(path));
}
private void OpenSeparately(List<string> found)
{
if (found.Count > 30)
{
var ok = KillerDialog.Show(this, string.Format(Loc("Str_Drop_ManyTabs"), found.Count),
"KillerPDF", MessageBoxButton.OKCancel);
if (ok != MessageBoxResult.OK) return;
}
foreach (var f in found)
{
if (PdfImport.IsPdfPath(f)) OpenInNewTab(f);
else OpenImagesAsImportedTab([f], Path.GetFileName(f));
}
}
private async void OpenMerged(List<string> found, List<string> tempDirs)
{
var target = BeginTabLoad(out var prev, out bool createdNew);
var busy = ShowBusyOverlay(Loc("Str_Drop_Merging"));
var ct = BeginCancellableOp(Loc("Str_Op_Merge")); // Esc cancels; the busy overlay keeps the window draggable
try
{
// Build off the UI thread so the window stays responsive (and movable) while it works.
string? tempPath = await Task.Run(() => BuildCombinedPdf(found, ct));
if (ct.IsCancellationRequested || tempPath == null)
{
HideBusyOverlay(busy);
AbortTabLoad(target, prev, createdNew);
SetStatus(Loc("Str_Drop_MergeCanceled"));
return;
}
_doc = PdfReader.Open(tempPath, PdfDocumentOpenMode.Modify);
FinishOpenFile("Combined.pdf", tempPath);
_originalFile = null; // unsaved -> Save routes to Save As
MarkDirty(true);
SetStatus(string.Format(Loc("Str_Status_Merged"), found.Count));
CaptureSessionState(_active!);
SetTool(_currentTool);
RebuildTabStrip();
HideBusyOverlay(busy);
}
catch (Exception ex)
{
HideBusyOverlay(busy);
AbortTabLoad(target, prev, createdNew);
KillerDialog.Show(this, Loc("Str_Err_ImportFailed") + "\n" + ex.Message,
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
EndCancellableOp();
CleanupDirs(tempDirs);
}
}
// Opens the given image(s) as a single unsaved imported-PDF tab (same flow as Import Images).
private void OpenImagesAsImportedTab(string[] images, string displayName)
{
var target = BeginTabLoad(out var prev, out bool createdNew);
try
{
string tempPath = BuildPdfFromImages(images);
_doc = PdfReader.Open(tempPath, PdfDocumentOpenMode.Modify);
FinishOpenFile(displayName, tempPath);
_originalFile = null;
MarkDirty(true);
CaptureSessionState(_active!);
SetTool(_currentTool);
RebuildTabStrip();
}
catch (Exception ex)
{
AbortTabLoad(target, prev, createdNew);
KillerDialog.Show(this, Loc("Str_Err_ImportFailed") + "\n" + ex.Message,
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
// Builds one PDF from a mix of PDFs (pages imported in order) and images (one page each).
// Unreadable / encrypted entries are skipped rather than aborting the whole merge.
private static string? BuildCombinedPdf(List<string> files, CancellationToken ct)
{
using var outPdf = new PdfDocument();
foreach (var f in files)
{
if (ct.IsCancellationRequested) return null;
if (PdfImport.IsPdfPath(f))
{
try
{
using var src = PdfReader.Open(f, PdfDocumentOpenMode.Import);
for (int i = 0; i < src.PageCount; i++) outPdf.AddPage(src.Pages[i]);
}
catch { /* skip an unreadable/encrypted PDF */ }
}
else
{
try { PdfImport.AddImagePagesFromFile(outPdf, f); } catch { /* skip an unreadable image */ }
}
}
if (ct.IsCancellationRequested) return null;
if (outPdf.PageCount == 0)
throw new InvalidOperationException(
Application.Current.TryFindResource("Str_Err_NoImages") as string ?? "Nothing could be read.");
string outPath = Path.Combine(Path.GetTempPath(),
$"Combined-{DateTime.Now:yyyyMMdd-HHmmss-fff}.pdf");
outPdf.Save(outPath);
return outPath;
}
// Recursively gathers the PDFs and images under a folder, in a stable name order.
private static void CollectOpenable(string dir, List<string> found)
{
IEnumerable<string> files;
try { files = Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories); }
catch { return; }
foreach (var f in files.Where(IsOpenablePath).OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
found.Add(f);
}
private static string? ExtractZipToTemp(string zipPath)
{
string dir = Path.Combine(Path.GetTempPath(), "KillerPDF-zip-" + Guid.NewGuid().ToString("N")[..8]);
try { Directory.CreateDirectory(dir); ZipFile.ExtractToDirectory(zipPath, dir); return dir; }
catch { try { Directory.Delete(dir, true); } catch { } return null; }
}
private static void CleanupDirs(List<string> dirs)
{
foreach (var d in dirs) try { Directory.Delete(d, true); } catch { }
}
// ----- Compress the current PDF to a .zip --------------------------------------------
private void CompressToZip_Click(object sender, RoutedEventArgs e)
{
if (_doc is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
// The zip wraps the PDF on disk, so make sure what's on screen is saved first.
if (_isDirty || string.IsNullOrEmpty(_originalFile) || !File.Exists(_originalFile))
{
var ask = KillerDialog.Show(this, Loc("Str_Dlg_SaveBeforeZip"),
"KillerPDF", MessageBoxButton.OKCancel);
if (ask != MessageBoxResult.OK) return;
SaveInPlace();
if (_isDirty || string.IsNullOrEmpty(_originalFile) || !File.Exists(_originalFile))
return; // save was canceled or failed
}
string sourcePdf = _originalFile!;
var dlg = new Controls.FileDialog(Controls.FileDialogMode.Save)
{
Filter = $"{Loc("Str_Filter_Zip")}|*.zip",
Title = Loc("Str_Menu_CompressZip"),
FileName = Path.GetFileNameWithoutExtension(sourcePdf) + ".zip"
};
var srcDir = Path.GetDirectoryName(sourcePdf);
if (!string.IsNullOrEmpty(srcDir) && Directory.Exists(srcDir)) dlg.InitialDirectory = srcDir;
if (dlg.ShowDialog(this) != true) return;
try
{
if (File.Exists(dlg.FileName)) File.Delete(dlg.FileName);
using (var zip = ZipFile.Open(dlg.FileName, ZipArchiveMode.Create))
zip.CreateEntryFromFile(sourcePdf, Path.GetFileName(sourcePdf), CompressionLevel.Optimal);
long before = new FileInfo(sourcePdf).Length;
long after = new FileInfo(dlg.FileName).Length;
SetStatus(string.Format(Loc("Str_Status_Zipped"),
Path.GetFileName(dlg.FileName), FormatSize(after), FormatSize(before)));
}
catch (Exception ex)
{
KillerDialog.Show(this, Loc("Str_Err_ZipFailed") + "\n" + ex.Message,
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private static string FormatSize(long bytes)
{
if (bytes >= 1024 * 1024) return $"{bytes / 1024.0 / 1024.0:F1} MB";
if (bytes >= 1024) return $"{bytes / 1024.0:F0} KB";
return $"{bytes} B";
}
}
}
+361
View File
@@ -0,0 +1,361 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;
namespace KillerPDF
{
// ============================================================
// Visual keyboard for the shortcuts overlay.
//
// Same philosophy as ShortcutsOverlay.cs: the board is generated from the tables below (one
// source of truth), and every brush and label is wired with SetResourceReference so theme and
// language switches repaint live. Category colors are theme brushes (KsCat* in Themes/*.xaml),
// keycap faces ride BgPanel / BorderDim / TextPrimary - the board always looks native to the
// active theme. Holding a real Ctrl / Shift / Alt previews that layer, like the key will work.
// ============================================================
public partial class MainWindow
{
// KbLayer, the binding table and its types live in Services/ShortcutTable.cs, which is free
// of WPF so KillerPDF.Tests can link it.
private KbLayer _kbLayer = KbLayer.Base;
private bool _kbBuilt;
private TextBlock? _kbDetail;
private TextBlock? _kbHoverAct; // caption of the key under the mouse (marquee restart on layer switch)
private string? _kbHoverId;
private readonly Dictionary<string, (Border Cap, TextBlock Act, Rectangle Bar)> _kbKeys = new();
private readonly Dictionary<KbLayer, Button> _kbLayerBtns = new();
private const string KsViewSetting = "ShortcutView"; // "list" (default) | "keyboard"
// ── Binding table ──────────────────────────────────────────────────────────────────────
// DERIVED from ShortcutTable.KsAll, the same array the list is built from, so the two views
// cannot describe a binding differently. Adding a shortcut is a one-line edit there.
// A double claim on one cap would silently let the last writer win, which is how Ctrl+B
// meant two things for so long; ShortcutTableTests asserts that never happens.
private static readonly Dictionary<KbLayer, Dictionary<string, (string Cat, string Label)>> KbMap =
ShortcutTable.BuildMap();
// ── Physical layout ────────────────────────────────────────────────────────────────────
// (id, cap text, width units). id "" = spacer. Numpad omitted (digits mirror the number row).
private static readonly (string Id, string Cap, double W)[][] KbRows =
[
[("Esc","Esc",1), ("","",0.8), ("F1","F1",1),("F2","F2",1),("F3","F3",1),("F4","F4",1), ("","",0.6),
("F5","F5",1),("F6","F6",1),("F7","F7",1),("F8","F8",1), ("","",0.6),
("F9","F9",1),("F10","F10",1),("F11","F11",1),("F12","F12",1)],
[("Grave","`",1),("D1","1",1),("D2","2",1),("D3","3",1),("D4","4",1),("D5","5",1),("D6","6",1),
("D7","7",1),("D8","8",1),("D9","9",1),("D0","0",1),("Minus","-",1),("Equals","=",1),("Back","\u232B",2),
("","",0.6), ("Ins","Ins",1),("Home","Home",1),("PgUp","PgUp",1)],
[("Tab","Tab",1.5),("Q","Q",1),("W","W",1),("E","E",1),("R","R",1),("T","T",1),("Y","Y",1),("U","U",1),
("I","I",1),("O","O",1),("P","P",1),("LBr","[",1),("RBr","]",1),("Bslash","\\",1.5),
("","",0.6), ("Del","Del",1),("End","End",1),("PgDn","PgDn",1)],
[("Caps","Caps",1.8),("A","A",1),("S","S",1),("D","D",1),("F","F",1),("G","G",1),("H","H",1),("J","J",1),
("K","K",1),("L","L",1),("Semi",";",1),("Quote","'",1),("Enter","Enter",2.2)],
[("Shift","Shift",2.3),("Z","Z",1),("X","X",1),("C","C",1),("V","V",1),("B","B",1),("N","N",1),("M","M",1),
("Comma",",",1),("Period",".",1),("Slash","/",1),("RShift","Shift",2.7),
("","",1.6), ("Up","\u2191",1)],
[("Ctrl","Ctrl",1.5),("Win","Win",1.2),("Alt","Alt",1.5),("Space","",6.8),("RAlt","Alt",1.5),("Menu","\u2630",1),("RCtrl","Ctrl",1.5),
("","",0.6), ("Left","\u2190",1),("Down","\u2193",1),("Right","\u2192",1)],
];
private static readonly (KbLayer Layer, string Caption)[] KbLayerButtons =
[
(KbLayer.Base, "BASE"), (KbLayer.Ctrl, "CTRL"), (KbLayer.CtrlShift, "CTRL+SHIFT"),
(KbLayer.Shift, "SHIFT"), (KbLayer.Alt, "ALT"),
];
// Modifier keycaps that light up per layer (they define it rather than carry a binding).
private static readonly Dictionary<KbLayer, string[]> KbLayerMods = new()
{
[KbLayer.Base] = [], [KbLayer.Ctrl] = ["Ctrl", "RCtrl"],
[KbLayer.CtrlShift] = ["Ctrl", "RCtrl", "Shift", "RShift"],
[KbLayer.Shift] = ["Shift", "RShift"], [KbLayer.Alt] = ["Alt", "RAlt"],
};
private static string KbSectionKeyFor(string cat) => cat switch
{
"File" => "Str_KS_File", "Tools" => "Str_KS_Tools", "Edit" => "Str_KS_Editing",
"Nav" => "Str_KS_Navigation", "View" => "Str_KS_View", "Search" => "Str_KS_SearchSelect",
"Help" => "Str_KS_Help", _ => "Str_KS_Ocr",
};
// ── View toggle (LIST / KEYBOARD) ──────────────────────────────────────────────────────
private void KsViewList_Click(object sender, RoutedEventArgs e) => ApplyShortcutView(keyboard: false, persist: true);
private void KsViewKeyboard_Click(object sender, RoutedEventArgs e) => ApplyShortcutView(keyboard: true, persist: true);
/// <summary>Shows the list or the keyboard inside the shortcuts overlay card. Called on
/// every overlay open with the persisted choice, and by the two toggle captions.</summary>
private void ApplyShortcutView(bool keyboard, bool persist = false)
{
if (keyboard && !_kbBuilt) BuildKeyboardView();
ShortcutListHost.Visibility = keyboard ? Visibility.Collapsed : Visibility.Visible;
ShortcutKeyboardHost.Visibility = keyboard ? Visibility.Visible : Visibility.Collapsed;
ShortcutCardGrid.MaxWidth = keyboard ? 1080 : 640;
KsViewListBtn.SetResourceReference(ForegroundProperty, keyboard ? "MutedTextBrush" : "PrimaryBrush");
KsViewKeyboardBtn.SetResourceReference(ForegroundProperty, keyboard ? "PrimaryBrush" : "MutedTextBrush");
if (keyboard) SetKbLayer(KbLayer.Base);
if (persist) App.SetSetting(KsViewSetting, keyboard ? "keyboard" : "list");
}
private void ApplyPersistedShortcutView() =>
ApplyShortcutView(App.GetSetting(KsViewSetting) == "keyboard");
// ── Board construction (once, lazily) ──────────────────────────────────────────────────
private void BuildKeyboardView()
{
_kbBuilt = true;
var host = ShortcutKeyboardHost;
host.Children.Clear();
_kbKeys.Clear();
_kbLayerBtns.Clear();
// Layer captions row.
var layerRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 10) };
foreach (var (layer, caption) in KbLayerButtons)
{
// Use the shared command-button factory. In 98SE this supplies the raised two-tone
// face and inverted pressed bevel; the old hand-built flat template bypassed it.
var b = UiKit.Make(caption, accent: false);
b.FontFamily = UiKit.MonoFont;
b.FontSize = 11;
b.Padding = new Thickness(10, 4, 10, 4);
b.Margin = new Thickness(0, 0, 8, 0);
b.BorderThickness = new Thickness(1);
b.FocusVisualStyle = null;
b.SetResourceReference(ForegroundProperty, "MutedTextBrush");
b.SetResourceReference(BorderBrushProperty, "CardBorderBrush");
var l = layer;
b.Click += (_, _2) => SetKbLayer(l);
_kbLayerBtns[layer] = b;
layerRow.Children.Add(b);
}
var hint = new TextBlock
{
FontSize = 11, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(6, 0, 0, 0),
};
hint.SetResourceReference(TextBlock.TextProperty, "Str_KS_HoldHint");
hint.SetResourceReference(TextBlock.ForegroundProperty, "DimTextBrush");
layerRow.Children.Add(hint);
host.Children.Add(layerRow);
// The board. A DownOnly Viewbox keeps it fitting smaller windows without scrollbars.
const double U = 46; // one key unit incl. its 4px gap
var board = new StackPanel();
foreach (var row in KbRows)
{
var r = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 4) };
foreach (var (id, cap, w) in row)
{
if (id.Length == 0) { r.Children.Add(new Border { Width = U * w }); continue; }
var capText = new TextBlock
{
Text = cap, FontFamily = UiKit.MonoFont, // symbols render via font fallback
FontSize = 11, HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 5, 0, 0),
};
capText.SetResourceReference(TextBlock.ForegroundProperty, "TextBrush");
var act = new TextBlock
{
FontSize = 8.5, HorizontalAlignment = HorizontalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis, Visibility = Visibility.Collapsed,
RenderTransform = new TranslateTransform(),
};
var actHost = new Border // clips the caption so it can marquee on hover
{
ClipToBounds = true, VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(2, 0, 2, 5), Child = act,
};
var bar = new Rectangle
{
Height = 3, VerticalAlignment = VerticalAlignment.Bottom, RadiusX = 1.5, RadiusY = 1.5,
Margin = new Thickness(3, 0, 3, 0), Visibility = Visibility.Collapsed,
};
var inner = new Grid();
inner.Children.Add(capText);
inner.Children.Add(actHost);
inner.Children.Add(bar);
var key = new Border
{
Width = U * w - 4, Height = 44, CornerRadius = new CornerRadius(0),
BorderThickness = new Thickness(1), Margin = new Thickness(0, 0, 4, 0),
Child = inner,
};
key.SetResourceReference(Border.CornerRadiusProperty, "ControlCornerRadius");
key.SetResourceReference(Border.BackgroundProperty, "KeyboardKeyBrush");
key.SetResourceReference(Border.BorderBrushProperty, "CardBorderBrush");
// Hover: the keycap lifts a few pixels, like the cards on the killertools.net front page.
var lift = new TranslateTransform();
key.RenderTransform = lift;
string keyId = id;
key.MouseEnter += (_, _2) =>
{
_kbHoverAct = act; _kbHoverId = keyId;
KbShowDetail(keyId);
if (KbMap[_kbLayer].ContainsKey(keyId)) // only keys with a binding lift; dummies stay put
{
lift.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(-3, TimeSpan.FromMilliseconds(90)));
KbMarqueeStart(act); // a cut-off caption scrolls, marquee-style
}
};
key.MouseLeave += (_, _2) =>
{
_kbHoverAct = null; _kbHoverId = null;
if (_kbDetail is not null) _kbDetail.Text = " ";
lift.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(0, TimeSpan.FromMilliseconds(130)));
KbMarqueeStop(act);
};
_kbKeys[id] = (key, act, bar);
r.Children.Add(key);
}
board.Children.Add(r);
}
host.Children.Add(new Viewbox
{
Child = board, Stretch = Stretch.Uniform, StretchDirection = StretchDirection.DownOnly,
HorizontalAlignment = HorizontalAlignment.Center,
});
_kbDetail = new TextBlock
{
Text = " ", FontFamily = UiKit.MonoFont, FontSize = 12.5,
Margin = new Thickness(2, 10, 0, 0), Height = 18,
};
_kbDetail.SetResourceReference(TextBlock.ForegroundProperty, "PrimaryBrush");
host.Children.Add(_kbDetail);
}
private void KbShowDetail(string id)
{
if (_kbDetail is null) return;
if (KbMap[_kbLayer].TryGetValue(id, out var b))
{
string section = TryFindResource(KbSectionKeyFor(b.Cat)) as string ?? b.Cat;
string label = TryFindResource(b.Label) as string ?? b.Label;
_kbDetail.Text = $"{section} :: {label}";
}
else _kbDetail.Text = " ";
}
// ── Caption marquee (hover a lit key whose caption is cut off) ─────────────────────────
/// <summary>Scrolls a truncated caption back and forth inside its clipped host while the
/// key is hovered. No-op when the full text already fits.</summary>
private void KbMarqueeStart(TextBlock act)
{
if (act.Visibility != Visibility.Visible || act.Parent is not Border host) return;
// Measure with a probe TextBlock, NOT FormattedText: the probe inherits the same
// text formatting mode as the live control, so its width matches what actually
// renders. FormattedText measures Ideal-mode metrics and under-reports by a couple
// of pixels, which made barely-trimmed captions ("Signature") never scroll.
var probe = new TextBlock
{
Text = act.Text, FontFamily = act.FontFamily, FontSize = act.FontSize,
FontStyle = act.FontStyle, FontWeight = act.FontWeight, FontStretch = act.FontStretch,
};
TextOptions.SetTextFormattingMode(probe, TextOptions.GetTextFormattingMode(act));
probe.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
double over = probe.DesiredSize.Width - host.ActualWidth;
if (over <= 0.5) return;
// Reparent the caption into a Canvas for the ride. A Canvas measures children with
// INFINITE space, so the TextBlock escapes WPF's layout clip and renders the whole
// caption; the host border clips the viewport. (Arranged directly in the too-small
// host, the TextBlock is clipped to its slot BEFORE the transform runs, so the
// animation just slides a pre-cut snapshot - the "tamp Pa" bug.)
double h = act.ActualHeight;
act.TextTrimming = TextTrimming.None;
host.Child = null;
var cv = new Canvas { Height = h };
cv.Children.Add(act);
Canvas.SetLeft(act, 0);
Canvas.SetTop(act, 0);
host.Child = cv;
var tt = (TranslateTransform)act.RenderTransform;
tt.BeginAnimation(TranslateTransform.XProperty,
new DoubleAnimation(0, -over, TimeSpan.FromMilliseconds(System.Math.Max(600, over * 40)))
{ AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever, BeginTime = TimeSpan.FromMilliseconds(350) });
}
private void KbMarqueeStop(TextBlock act)
{
var tt = (TranslateTransform)act.RenderTransform;
tt.BeginAnimation(TranslateTransform.XProperty, null);
tt.X = 0;
act.TextTrimming = TextTrimming.CharacterEllipsis;
if (act.Parent is Canvas cv && cv.Parent is Border host)
{
cv.Children.Clear();
host.Child = act; // back to the plain centered, ellipsized layout
}
}
// ── Layer painting ─────────────────────────────────────────────────────────────────────
private void SetKbLayer(KbLayer layer)
{
_kbLayer = layer;
if (!_kbBuilt) return;
var map = KbMap[layer];
foreach (var kv in _kbKeys) // no KeyValuePair deconstruction on net48
{
var vis = kv.Value;
if (map.TryGetValue(kv.Key, out var b))
{
vis.Cap.SetResourceReference(Border.BorderBrushProperty, "KsCat" + b.Cat);
vis.Bar.SetResourceReference(Shape.FillProperty, "KsCat" + b.Cat);
vis.Bar.Visibility = Visibility.Visible;
vis.Act.SetResourceReference(TextBlock.TextProperty, b.Label);
vis.Act.SetResourceReference(TextBlock.ForegroundProperty, "KsCat" + b.Cat);
vis.Act.Visibility = Visibility.Visible;
}
else
{
vis.Cap.SetResourceReference(Border.BorderBrushProperty, "CardBorderBrush");
vis.Bar.Visibility = Visibility.Collapsed;
vis.Act.Visibility = Visibility.Collapsed;
}
}
// Modifier caps that define the layer glow accent; the layer captions follow suit.
string[] allMods = ["Ctrl", "RCtrl", "Shift", "RShift", "Alt", "RAlt"];
foreach (var m in allMods)
if (_kbKeys.TryGetValue(m, out var vis))
vis.Cap.SetResourceReference(Border.BorderBrushProperty,
System.Array.IndexOf(KbLayerMods[layer], m) >= 0 ? "PrimaryBrush" : "CardBorderBrush");
foreach (var kv in _kbLayerBtns) // no KeyValuePair deconstruction on net48
{
kv.Value.SetResourceReference(ForegroundProperty, kv.Key == layer ? "PrimaryBrush" : "MutedTextBrush");
kv.Value.SetResourceReference(BorderBrushProperty, kv.Key == layer ? "PrimaryBrush" : "CardBorderBrush");
}
// Layer changed while a key is hovered (holding Ctrl / Shift / Alt): restart that key's
// marquee for its NEW caption - MouseEnter alone never re-fires. Deferred one layout
// pass so the caption text and size reflect the new layer before measuring.
if (_kbHoverAct is not null && _kbHoverId is not null)
{
KbMarqueeStop(_kbHoverAct);
KbShowDetail(_kbHoverId);
var act = _kbHoverAct;
Dispatcher.BeginInvoke(new Action(() =>
{
if (ReferenceEquals(act, _kbHoverAct)) KbMarqueeStart(act);
}), System.Windows.Threading.DispatcherPriority.Loaded);
}
}
/// <summary>Maps the live modifier state to a layer while the keyboard view is showing -
/// called from OnPreviewKeyDown/Up so holding Ctrl / Shift / Alt previews that layer.</summary>
private void KbSyncLayerFromModifiers()
{
if (!_kbBuilt || ShortcutKeyboardHost.Visibility != Visibility.Visible) return;
var m = Keyboard.Modifiers;
var layer = m.HasFlag(ModifierKeys.Control) && m.HasFlag(ModifierKeys.Shift) ? KbLayer.CtrlShift
: m.HasFlag(ModifierKeys.Control) ? KbLayer.Ctrl
: m.HasFlag(ModifierKeys.Alt) ? KbLayer.Alt
: m.HasFlag(ModifierKeys.Shift) ? KbLayer.Shift
: KbLayer.Base;
if (layer != _kbLayer) SetKbLayer(layer);
}
}
}
+705
View File
@@ -0,0 +1,705 @@
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
{
public partial class MainWindow
{
// ============================================================
// Keyboard shortcuts
// ============================================================
protected override void OnPreviewKeyDown(KeyEventArgs e)
{
base.OnPreviewKeyDown(e);
// Keyboard view of the shortcuts overlay: holding Ctrl / Shift / Alt previews that layer.
KbSyncLayerFromModifiers();
// Bold / italic / underline while a text annotation is being edited. This has to come
// BEFORE the early return below, which hands every other key to the edit box: that
// return is exactly why Ctrl+B never reached the text tool and collapsed the sidebar
// instead. The two meanings of Ctrl+B cannot collide, because the sidebar binding is
// only reachable when no edit box has focus and this one only when it does.
if (_activeTextBox is not null && _activeTextBox.IsFocused &&
Keyboard.Modifiers == ModifierKeys.Control &&
e.Key is Key.B or Key.I or Key.U)
{
switch (e.Key)
{
case Key.B: _textBold = !_textBold; break;
case Key.I: _textItalic = !_textItalic; break;
case Key.U: _textUnderline = !_textUnderline; break;
}
ApplyTextStyleToSelection(); // TextSettingsBar.cs, same pair the toggle buttons call
ShowTextSettings();
e.Handled = true;
return;
}
// Don't intercept keys when typing in an editable TextBox (typewriter tool or form field).
// The zoom ComboBox is editable-but-read-only; after using it, focus parks on its inner
// TextBox and would otherwise swallow every shortcut (e.g. Ctrl+F) until the user clicked away.
if (e.OriginalSource is TextBox tbSrc && !tbSrc.IsReadOnly) return;
if (_activeTextBox is not null && _activeTextBox.IsFocused) return;
if (e.Key == Key.C && Keyboard.Modifiers == ModifierKeys.Control)
{
// An annotation selection copies the annotation(s); otherwise copy page text.
if (_selectedAnnotation is not null || _selectedSet.Count > 0) CopySelectedAnnotations();
else CopySelectedText();
e.Handled = true;
}
else if (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control)
{
// Internal annotation clipboard takes priority over an OS-clipboard image paste.
if (_annotationClipboard.Count > 0) PasteAnnotations(PageList.SelectedIndex);
else PasteFromClipboard();
e.Handled = true;
}
else if (e.Key == Key.A && Keyboard.Modifiers == ModifierKeys.Control)
{
// Prefer selecting all annotations (shows where everything is, makes stacked annotations
// editable); fall back to selecting page text when there are none on screen.
if (!SelectAllAnnotations()) SelectAllText();
e.Handled = true;
}
else if (e.Key == Key.F && Keyboard.Modifiers == ModifierKeys.Control)
{
ToggleSearchBar();
e.Handled = true;
}
else if (e.Key == Key.F3 && (Keyboard.Modifiers == ModifierKeys.None || Keyboard.Modifiers == ModifierKeys.Shift))
{
// F3 / Shift+F3 - next / previous search match, the Find Next convention (Acrobat and
// Sumatra do the same). With the search bar closed, F3 opens it like Ctrl+F.
if (_searchBar is null || _searchBar.Visibility != Visibility.Visible)
ToggleSearchBar();
else if (Search.HasResults)
{
if (Keyboard.Modifiers == ModifierKeys.Shift) SearchPrevResult();
else SearchNextResult();
}
e.Handled = true;
}
else if (e.Key == Key.Escape && _shapePolyPoints.Count > 0)
{
// Shapes tool (#127 Phase 3): abandon the in-progress polygon.
CancelShapePolygon();
e.Handled = true;
}
else if (e.Key == Key.Back && _shapePolyPoints.Count > 0)
{
// Remove the last placed polygon vertex.
ShapePolyBackspace();
e.Handled = true;
}
else if (e.Key == Key.Enter && _shapePolyPoints.Count >= 3)
{
// Close the polygon from the keyboard.
CommitShapePolygon();
e.Handled = true;
}
else if (e.Key == Key.Enter && _currentTool == EditTool.Crop && _cropConfirmBar is not null)
{
ApplyCrop([PageList.SelectedIndex]);
e.Handled = true;
}
else if (e.Key == Key.Escape && _currentTool == EditTool.Crop && _cropConfirmBar is not null)
{
HideCropConfirmBar();
e.Handled = true;
}
else if (e.Key == Key.Escape && ShortcutOverlay.Visibility == Visibility.Visible)
{
FadeOverlayOut(ShortcutOverlay);
e.Handled = true;
}
else if (e.Key == Key.Escape && AboutOverlay.Visibility == Visibility.Visible)
{
CloseAboutOverlay();
e.Handled = true;
}
else if (e.Key == Key.Escape && _searchBar is not null && _searchBar.Visibility == Visibility.Visible)
{
CloseSearchBar();
e.Handled = true;
}
else if (e.Key == Key.Escape && _busyCts is not null)
{
// A cancellable long operation (OCR, repair) is running behind the busy overlay - offer to
// cancel it instead of letting Escape fall through to the app-exit handler below.
if (KillerDialog.Show(this, string.Format(Loc("Str_Dlg_CancelBusy"), _busyOpLabel), "KillerPDF",
MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes)
_busyCts?.Cancel();
e.Handled = true;
}
// #153: matched by the character the key TYPES, not its position. On a German layout
// "?" is Shift+ss, so the US-positional OemQuestion check never fired (and the exact
// modifier equality failed too, since typing "?" holds Shift). The VK test stays as a
// fast path for layouts where it already worked.
else if (Services.KeyLayout.IsCtrlChar(e.Key, '?')
|| (e.Key == Key.OemQuestion && Keyboard.Modifiers == ModifierKeys.Control))
{
if (ShortcutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(ShortcutOverlay);
else ShowShortcutsOverlayExclusive();
e.Handled = true;
}
else if (e.Key == Key.F1)
{
// Toggle the shortcuts overlay (conventional Help key, alongside Ctrl+?).
if (ShortcutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(ShortcutOverlay);
else ShowShortcutsOverlayExclusive();
e.Handled = true;
}
else if (e.Key == Key.F12)
{
// Toggle the About dialog. (Moved off F2, which now belongs to rename-bookmark in the
// outline panel, #133 - Windows convention. Document Info moved to F4 / Ctrl+D below.)
if (AboutOverlay.Visibility == Visibility.Visible) CloseAboutOverlay();
else ShowAboutOverlay();
e.Handled = true;
}
else if (e.Key == Key.F4 && Keyboard.Modifiers == ModifierKeys.None)
{
// Document Info - the advertised single-key shortcut (F1 help, F4 info, F5-F8 views,
// F11 full screen, F12 about). Looked it up on the shortcuts overlay and pressed it?
// The cheat sheet gets out of the way first.
if (ShortcutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(ShortcutOverlay);
OpenDocumentInfo();
e.Handled = true;
}
else if (e.Key == Key.D && Keyboard.Modifiers == ModifierKeys.Control)
{
// Compatibility alias: Ctrl+D is Document Properties in Acrobat, Foxit, and SumatraPDF.
if (ShortcutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(ShortcutOverlay);
OpenDocumentInfo();
e.Handled = true;
}
else if (e.Key == Key.F5) { SetViewMode(ViewMode.Continuous); e.Handled = true; }
else if (e.Key == Key.F6) { SetViewMode(ViewMode.Single); e.Handled = true; }
else if (e.Key == Key.F7) { SetViewMode(ViewMode.TwoPage); e.Handled = true; }
else if (e.Key == Key.F8) { SetViewMode(ViewMode.Grid); e.Handled = true; }
// F10 = split pane. Matched as Key.System + SystemKey == Key.F10, NOT e.Key == Key.F10:
// F10 is a system key in WPF (it activates the menu bar), so it never arrives as
// Key.F10 and a plain check silently never fires. The Shift+F10 branch further down has
// the same shape; F11 below does not need it, because F11 is not a system key.
// Shift is excluded here because Shift+F10 is the context menu - without that guard the
// two would both fire off one press.
else if (e.Key == Key.System && e.SystemKey == Key.F10
&& !Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
{
ToggleSplit();
e.Handled = true;
}
// #215: reading view - toolbar off. Alt+M arrives as a system key (Alt+letter), same
// shape as the F10 branches above.
else if (e.Key == Key.System && e.SystemKey == Key.M && Keyboard.Modifiers == ModifierKeys.Alt)
{ ToggleToolbarHidden(); e.Handled = true; }
else if (e.Key == Key.F11) { ToggleFullScreen(); e.Handled = true; }
else if (e.Key == Key.Escape && _fullScreen) { ToggleFullScreen(); e.Handled = true; }
else if (e.Key == Key.F4 && Keyboard.Modifiers == ModifierKeys.Shift && _doc is not null)
{
ShowCurrentFileSize();
e.Handled = true;
}
// PgDn / PgUp navigate to the next / previous page - they never reorder pages (that's the
// toolbar Move Up/Down buttons). Handled at the window level with e.Handled so it behaves the
// same whether the page canvas or a sidebar thumbnail has focus; without this, a focused
// PageList (ListBox) would page its own selection instead. The TextBox guard at the top of this
// handler already exempts typing in a form field / typewriter box.
else if (e.Key == Key.PageDown && Keyboard.Modifiers == ModifierKeys.None)
{
NavigatePageStep(1); // one page; one SPREAD in Two-Page mode (#120)
e.Handled = true;
}
else if (e.Key == Key.PageUp && Keyboard.Modifiers == ModifierKeys.None)
{
NavigatePageStep(-1);
e.Handled = true;
}
else if (e.Key == Key.P && Keyboard.Modifiers == ModifierKeys.Control)
{
Print_Click(this, e);
e.Handled = true;
}
else if (e.Key == Key.Delete && (_selectedAnnotation is not null || _selectedSet.Count > 0))
{
DeleteSelected();
e.Handled = true;
}
else if (e.Key == Key.Z && Keyboard.Modifiers == ModifierKeys.Control)
{
if (!e.IsRepeat) Undo_Click(this, e); // ignore key auto-repeat so one press = one undo
e.Handled = true;
}
else if (e.Key == Key.S && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
SaveAs_Click(this, e);
e.Handled = true;
}
else if (e.Key == Key.S && Keyboard.Modifiers == ModifierKeys.Control)
{
SaveInPlace();
e.Handled = true;
}
else if (e.Key == Key.W && Keyboard.Modifiers == ModifierKeys.Control)
{
CloseTab(_active);
e.Handled = true;
}
else if (e.Key == Key.W && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
CloseOtherTabs(_active);
e.Handled = true;
}
else if (e.Key == Key.Q && Keyboard.Modifiers == ModifierKeys.Control)
{
CloseAllTabs();
e.Handled = true;
}
else if (e.Key == Key.Tab && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
CycleTab(-1);
e.Handled = true;
}
else if (e.Key == Key.Tab && Keyboard.Modifiers == ModifierKeys.Control)
{
CycleTab(1);
e.Handled = true;
}
else if (e.Key == Key.O && Keyboard.Modifiers == ModifierKeys.Control)
{
Open_Click(this, e);
e.Handled = true;
}
else if (e.Key == Key.O && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
OcrPageToClipboard(PageList.SelectedIndex);
e.Handled = true;
}
else if (e.Key == Key.I && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
BeginOcrRegion();
e.Handled = true;
}
else if (e.Key == Key.B && Keyboard.Modifiers == ModifierKeys.None && _doc is not null
&& _viewMode == ViewMode.TwoPage
&& ShortcutOverlay.Visibility != Visibility.Visible
&& AboutOverlay.Visibility != Visibility.Visible)
{
// #193: bare B = book layout toggle, only while Two-Page is active (single-key
// house style; the sidebar is F9). Same guards as the bare-key switches.
ToggleBookMode();
e.Handled = true;
}
else if (e.Key == Key.N && Keyboard.Modifiers == ModifierKeys.None && _doc is not null
&& ShortcutOverlay.Visibility != Visibility.Visible
&& AboutOverlay.Visibility != Visibility.Visible)
{
// Bare N = invert document colors (night mode), #135. Moved off Ctrl+I in 1.6.6 so
// the conventional italic chord is free while editing text; single-key house style.
// Same guards as the bare-key tool switches below (doc open, no overlay, not typing).
ToggleDocInvert(!ActiveViewer.DocInvert); // per pane: flips the focused pane only
e.Handled = true;
}
else if (e.Key == Key.N && Keyboard.Modifiers == ModifierKeys.Shift && _doc is not null
&& ShortcutOverlay.Visibility != Visibility.Visible
&& AboutOverlay.Visibility != Visibility.Visible)
{
// Shift+N pairs with bare N: toggles whether night mode inverts pictures too
// (the moon's right-click option). Same guards as N.
ToggleInvertImages(!BitmapHelpers.DocInvertImages);
e.Handled = true;
}
// App-wide accessibility size (AppScale.cs), distinct from the Ctrl+wheel page zoom:
// Ctrl+Shift +/- steps the whole-app size, Ctrl+Shift+0 resets it. Works with no
// mouse; the wheel-over-logo gesture drives the same scale.
else if ((e.Key == Key.OemPlus || e.Key == Key.Add) && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
ApplyAppScale(_appScale + AppScaleStep, persist: true);
e.Handled = true;
}
else if ((e.Key == Key.OemMinus || e.Key == Key.Subtract) && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
ApplyAppScale(_appScale - AppScaleStep, persist: true);
e.Handled = true;
}
else if ((e.Key == Key.D0 || e.Key == Key.NumPad0) && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
ApplyAppScale(1.0, persist: true);
e.Handled = true;
}
// Toolbar appearance, mirroring the bar's right-click menu top to bottom: Ctrl+Shift+1/2
// pick the icon size, Ctrl+Shift+3..6 pick where the text goes. Number row only - the
// numpad digits stay clear of the tool shortcuts' numpad mappings.
else if (e.Key is >= Key.D1 and <= Key.D6 && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
switch (e.Key)
{
case Key.D1: SetToolbarIconSize(ToolbarIconSize.Small); break;
case Key.D2: SetToolbarIconSize(ToolbarIconSize.Large); break;
case Key.D3: SetToolbarLabelMode(ToolbarLabelMode.None); break;
case Key.D4: SetToolbarLabelMode(ToolbarLabelMode.Beside);break;
case Key.D5: SetToolbarLabelMode(ToolbarLabelMode.Under); break;
case Key.D6: SetToolbarLabelMode(ToolbarLabelMode.Only); break;
}
e.Handled = true;
}
else if (e.Key == Key.F9 && Keyboard.Modifiers == ModifierKeys.Shift)
{
ToggleSidebarSide(); // left/right, pairing with F9's collapse toggle
e.Handled = true;
}
else if (e.Key == Key.N && Keyboard.Modifiers == ModifierKeys.Control)
{
NewDocument();
e.Handled = true;
}
else if (e.Key == Key.F9 && Keyboard.Modifiers == ModifierKeys.None)
{
// The sidebar gets the single key, house style. F9 used to jog the view mode, which
// was the one redundant F-key: F5-F8 already jump straight to all four modes and the
// wheel over the view still cycles them. There is deliberately no Ctrl+B alias:
// Ctrl+B is bold, which is what users pressing it were expecting all along.
SidebarToggle_Click(this, e);
e.Handled = true;
}
else if (e.Key == Key.Home && Keyboard.Modifiers == ModifierKeys.None && _doc is not null)
{
// First / last page (the Acrobat / Sumatra convention).
RecordNavJump();
PageList.SelectedIndex = 0;
e.Handled = true;
}
else if (e.Key == Key.End && Keyboard.Modifiers == ModifierKeys.None && _doc is not null)
{
RecordNavJump();
PageList.SelectedIndex = _doc.PageCount - 1;
e.Handled = true;
}
else if (e.Key == Key.D1 && Keyboard.Modifiers == ModifierKeys.Control && _doc is not null)
{
_fitMode = FitMode.None;
SetTrueZoom(1.0); // actual size (Acrobat Ctrl+1); Ctrl+0 stays the 100% reset
e.Handled = true;
}
else if (e.Key == Key.D2 && Keyboard.Modifiers == ModifierKeys.Control && _doc is not null)
{
App.SetSetting("DefaultFitMode", FitMode.Width.ToString());
FitToWidth(); // (Acrobat Ctrl+2)
e.Handled = true;
}
else if (e.Key == Key.D3 && Keyboard.Modifiers == ModifierKeys.Control && _doc is not null)
{
App.SetSetting("DefaultFitMode", FitMode.Page.ToString());
FitToPage();
e.Handled = true;
}
else if (e.Key == Key.Y && Keyboard.Modifiers == ModifierKeys.Control)
{
if (!e.IsRepeat) Redo_Click(this, e);
e.Handled = true;
}
else if (e.Key == Key.Z && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
if (!e.IsRepeat) Redo_Click(this, e); // Ctrl+Shift+Z, the editor-style redo
e.Handled = true;
}
else if (e.Key == Key.System && e.SystemKey == Key.Left && Keyboard.Modifiers == ModifierKeys.Alt)
{
NavHistoryGo(back: true); // retrace bookmark / link / jump-box jumps
e.Handled = true;
}
else if (e.Key == Key.System && e.SystemKey == Key.Right && Keyboard.Modifiers == ModifierKeys.Alt)
{
NavHistoryGo(back: false);
e.Handled = true;
}
else if ((e.Key == Key.Apps
|| (e.Key == Key.System && e.SystemKey == Key.F10 && Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)))
&& _doc is not null)
{
// Windows accessibility convention: the Menu key / Shift+F10 opens the context menu
// at the current selection without the mouse.
OpenContextMenuAtSelection();
e.Handled = true;
}
// Bare-key tool switches. Only when a document is open, no modifier is held, and no
// overlay is up (and not while typing - guarded at the top of this handler).
else if (Keyboard.Modifiers == ModifierKeys.None && _doc is not null
&& ShortcutOverlay.Visibility != Visibility.Visible
&& AboutOverlay.Visibility != Visibility.Visible
&& TryToolShortcut(e.Key))
{
e.Handled = true;
}
// Left/Right move one page - one two-page SPREAD in Two-Page mode (#120), so a press
// always changes what's on screen instead of stepping through both pages of a spread.
else if (e.Key == Key.Left && Keyboard.Modifiers == ModifierKeys.None)
{
if (NavigatePageStep(-1)) e.Handled = true;
}
else if (e.Key == Key.Right && Keyboard.Modifiers == ModifierKeys.None)
{
if (NavigatePageStep(1)) e.Handled = true;
}
// Up/Down scroll the view like the mouse wheel instead of jumping a page; at the top/
// bottom edge (or when the page fits the viewport, where there's nothing to scroll)
// they flip to the previous/next page, so at fit-to-page zoom this behaves exactly
// like the old page navigation. Left/Right and PgUp/PgDn stay hard page jumps.
// Handled at the window level so a focused sidebar thumbnail doesn't move its own
// selection instead (same reasoning as PgUp/PgDn above).
else if ((e.Key == Key.Up || e.Key == Key.Down) && Keyboard.Modifiers == ModifierKeys.None)
{
if (_doc is not null)
{
ScrollOrFlipByKey(up: e.Key == Key.Up);
e.Handled = true;
}
}
// #153: "the key that types + or =", whatever position that is on this layout. The
// Ctrl+Shift app-scale chords above are tested FIRST, so ignoring Shift here cannot
// swallow them - which is why this ordering matters and must not be rearranged.
else if (Services.KeyLayout.IsCtrlChar(e.Key, '+', '=')
|| ((e.Key == Key.OemPlus || e.Key == Key.Add) && Keyboard.Modifiers == ModifierKeys.Control))
{
if (_viewMode == ViewMode.Grid) GridZoomStep(false); else SetZoom(_zoomLevel + ZoomStep);
e.Handled = true;
}
else if (Services.KeyLayout.IsCtrlChar(e.Key, '-')
|| ((e.Key == Key.OemMinus || e.Key == Key.Subtract) && Keyboard.Modifiers == ModifierKeys.Control))
{
if (_viewMode == ViewMode.Grid) GridZoomStep(true); else SetZoom(_zoomLevel - ZoomStep);
e.Handled = true;
}
else if (e.Key == Key.D0 && Keyboard.Modifiers == ModifierKeys.Control)
{
SetTrueZoom(1.0);
e.Handled = true;
}
else if (e.Key == Key.Escape && _doc is not null && _currentTool != EditTool.Select)
{
// 1.6.6 (#127 Phase 3): Esc with nothing left to cancel drops back to the Select
// tool, Acrobat-style. With Select already active it keeps falling through to the
// app-exit handler below, unchanged.
SetTool(EditTool.Select);
e.Handled = true;
}
else if (e.Key == Key.Escape)
{
// No overlay active - ESC exits the app
Close();
e.Handled = true;
}
else if (e.Key == Key.Space && !_spaceHeld)
{
_spaceHeld = true;
PagePreviewPanel.Cursor = Cursors.Hand;
e.Handled = true;
}
}
// Full-window overlays are mutually exclusive: opening the shortcuts overlay dismisses
// About instead of stacking - ShowAboutOverlay does the converse.
private void ShowShortcutsOverlayExclusive()
{
if (AboutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(AboutOverlay);
ApplyPersistedShortcutView();
FadeOverlayIn(ShortcutOverlay);
}
// ── Jump history (Alt+Left / Alt+Right / mouse back-forward buttons) ─────────────────────
// Page-granular: recorded at the long-jump sites (bookmark click, internal link, the page
// jump box, Home/End) so a reader thrown 30 pages by a bookmark can retrace the hop.
/// <summary>Records the CURRENT page onto the back stack. Call BEFORE performing a jump.</summary>
private void RecordNavJump()
{
if (_doc is null) return;
int cur = Math.Max(0, PageList.SelectedIndex);
if (_navBack.Count > 0 && _navBack.Peek() == cur) { _navForward.Clear(); return; }
_navBack.Push(cur);
_navForward.Clear(); // a fresh jump invalidates the forward chain, like a browser
}
private void NavHistoryGo(bool back)
{
if (_doc is null) return;
var from = back ? _navBack : _navForward;
var to = back ? _navForward : _navBack;
if (from.Count == 0) return;
int cur = Math.Max(0, PageList.SelectedIndex);
int target = from.Pop();
to.Push(cur);
if (target >= 0 && target < _doc.PageCount)
PageList.SelectedIndex = target;
}
// Mouse back / forward buttons (XButton1 / XButton2) retrace the same history, like a
// browser. Registered on the window in the MainWindow constructor.
private void NavHistory_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.XButton1) { NavHistoryGo(back: true); e.Handled = true; }
else if (e.ChangedButton == MouseButton.XButton2) { NavHistoryGo(back: false); e.Handled = true; }
}
/// <summary>Keyboard access to the right-click menu (Menu key / Shift+F10): the selected
/// annotation's menu at its bounds, or the page-level menu centered on the current page's
/// canvas. Placement is set to Center just for this open and restored on close, so the
/// mouse path keeps its open-at-cursor behavior.</summary>
private void OpenContextMenuAtSelection()
{
if (_doc is null || _annotationCanvas.ContextMenu is not ContextMenu cm) return;
int pg = Math.Max(0, PageList.SelectedIndex);
Point pt;
if (_selectedAnnotation is not null)
{
pg = _selectedAnnotation.PageIndex;
var b = AnnotBounds(_selectedAnnotation);
pt = new Point(b.X + b.Width / 2, b.Y + b.Height / 2);
}
else
pt = _renderDims.TryGetValue(pg, out var rd)
? new Point(rd.w / 2.0, rd.h / 2.0)
: new Point(0, 0);
var canvas = VisibleCanvasForPage(pg) ?? CanvasForPage(pg);
if (canvas is null) return;
_activeCanvas = canvas;
PopulateContextMenu(pt, pg);
cm.PlacementTarget = canvas;
var prevPlacement = cm.Placement;
cm.Placement = System.Windows.Controls.Primitives.PlacementMode.Center;
void Restore(object? s, RoutedEventArgs a) { cm.Placement = prevPlacement; cm.Closed -= Restore; }
cm.Closed += Restore;
cm.IsOpen = true;
}
// One Up/Down arrow press scrolls this many DIP; key auto-repeat makes holding the key a
// smooth continuous scroll. Kept smaller than a wheel notch (144 DIP, see WheelScrollFactor)
// for fine reading control.
private const double ArrowScrollStep = 48.0;
// Up/Down arrow behavior, mirroring PagePreview_PreviewMouseWheel exactly: Grid and
// Continuous are one scroll over the whole document, so the keys always scroll; Single/
// Two-Page scroll within the page and flip to the previous/next page at the edges.
private void ScrollOrFlipByKey(bool up)
{
double step = up ? -ArrowScrollStep : ArrowScrollStep;
if (_viewMode == ViewMode.Grid || _viewMode == ViewMode.Continuous)
{
PagePreviewPanel.ScrollToVerticalOffset(PagePreviewPanel.VerticalOffset + step);
return;
}
// NavigatePageByWheel treats positive delta as "previous page" (wheel-up), so reuse
// the same convention here.
if (PagePreviewPanel.ScrollableHeight <= 0)
{
NavigatePageByWheel(up ? 120 : -120);
return;
}
bool atTop = PagePreviewPanel.VerticalOffset <= 0;
bool atBottom = PagePreviewPanel.VerticalOffset >= PagePreviewPanel.ScrollableHeight - 1;
if ((atTop && up) || (atBottom && !up))
{
NavigatePageByWheel(up ? 120 : -120);
return;
}
PagePreviewPanel.ScrollToVerticalOffset(PagePreviewPanel.VerticalOffset + step);
}
protected override void OnPreviewKeyUp(KeyEventArgs e)
{
base.OnPreviewKeyUp(e);
KbSyncLayerFromModifiers(); // releasing a modifier drops the keyboard view back a layer
if (e.Key == Key.Space && _spaceHeld)
{
_spaceHeld = false;
if (!ActiveViewer.IsPanning)
PagePreviewPanel.Cursor = Cursors.Arrow;
e.Handled = true;
}
}
// Maps a bare key to an editing tool. Returns false for any other key so the caller's
// shortcut chain continues. Mirrors the toolbar tool buttons exactly - Signature routes
// through its button handler so the signature picker opens (a bare SetTool would only arm
// the tool without showing the menu).
private bool TryToolShortcut(Key key)
{
switch (key)
{
// Tools are reachable by their toolbar position (digits 1-9, 0 mirror the toolbar,
// left to right); the original letter keys stay as fallbacks. Both the number-row and
// numpad digits map. 1.6.6 remap (#127 Phase 3): Select is V-only (the Photoshop /
// Illustrator / Figma convention) - its digit went to Text so Shapes could take 4 and
// the digits keep mirroring the toolbar order.
case Key.V: SetTool(EditTool.Select); return true;
case Key.T: case Key.D1: case Key.NumPad1: SetTool(EditTool.Text); return true;
case Key.H: case Key.D2: case Key.NumPad2: SetTool(EditTool.Highlight); return true;
case Key.L: case Key.U: case Key.D3: case Key.NumPad3: SetTool(EditTool.Line); return true;
case Key.D4: case Key.NumPad4: SetTool(EditTool.Shape); return true;
case Key.D: case Key.D5: case Key.NumPad5: SetTool(EditTool.Draw); return true;
case Key.I: case Key.D6: case Key.NumPad6: SetTool(EditTool.Image); return true;
case Key.G: case Key.D7: case Key.NumPad7: ToolSignature_Click(this, new RoutedEventArgs()); return true;
case Key.C: case Key.D8: case Key.NumPad8: SetTool(EditTool.Crop); return true;
case Key.R: case Key.D9: case Key.NumPad9: ToolRotate_Click(this, new RoutedEventArgs()); return true;
case Key.S: case Key.D0: case Key.NumPad0: ToolStamp_Click(this, new RoutedEventArgs()); return true;
default: return false;
}
}
// Appends each tool's key to its tooltip, e.g. "Highlight (2)". Digits mirror the toolbar
// position (1-9, 0); Select shows its letter (V) since the 1.6.6 remap made it V-only.
// Re-resolves the localized base text so a language switch keeps the right wording (from SelectLocale).
private void ApplyToolNumberTooltips()
{
// n null or empty = no key to advertise, so the tooltip is just the localized text
// rather than a stray empty bracket pair.
void Set(System.Windows.Controls.Button btn, string key, string? n)
{
if (btn == null || TryFindResource(key) is not string s) return;
btn.ToolTip = string.IsNullOrEmpty(n) ? s : $"{s} ({n})";
}
Set(ToolSelectBtn, "Str_TT_SelectTool", "V");
Set(ToolTextBtn, "Str_TT_TextTool", "1");
Set(ToolHighlightBtn, "Str_TT_HighlightTool", "2");
Set(ToolUnderlineBtn, "Str_TT_LineTool", "3"); // repurposed to the Line tool
Set(ToolShapeBtn, "Str_TT_ShapeTool", "4");
Set(ToolDrawBtn, "Str_TT_DrawTool", "5");
Set(ToolImageBtn, "Str_TT_ImageTool", "6");
Set(ToolSignatureBtn, "Str_TT_SignatureTool", "7");
Set(ToolCropBtn, "Str_TT_CropTool", "8");
Set(_toolRotateBtn, "Str_TT_RotateTool", "9");
// Not a tool, but the same treatment. Cycling has no key any more: F9 went to the
// sidebar, so the wheel over the view (or the button itself) is how you jog modes,
// and advertising a dead key here is worse than advertising none.
Set(ViewModeBtn, "Str_TT_ViewMode", null);
}
// Opens the online help / how-to page in the user's default browser.
private void OnlineHelp_Click(object sender, RoutedEventArgs e)
{
try { Process.Start(new ProcessStartInfo("https://killerpdf.net/help.html") { UseShellExecute = true }); }
catch { }
}
}
}
+452
View File
@@ -0,0 +1,452 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using KillerPDF.Features;
using KillerPDF.Services;
namespace KillerPDF
{
public partial class MainWindow : IOcrHost
{
// ============================================================
// OCR (Tesseract) - extract text from a rendered page
// ============================================================
// Non-null only while a cancellable long-running operation (OCR, repair) is in flight. Esc (see
// KeyboardShortcuts) offers to cancel it instead of closing the app; loops check the token so a long
// run stops promptly. _busyOpLabel names the op in the cancel prompt.
private CancellationTokenSource? _busyCts;
private string _busyOpLabel = "operation";
// Registers a cancellable long-running operation and returns its token to thread through the work.
// Disposing any prior source first keeps the strip->repair handoff (fire-and-forget) clean.
private CancellationToken BeginCancellableOp(string label)
{
_busyCts?.Dispose();
_busyCts = new CancellationTokenSource();
_busyOpLabel = label;
return _busyCts.Token;
}
private void EndCancellableOp()
{
_busyCts?.Dispose();
_busyCts = null;
}
// ============================================================
// OCR languages (multi-select, on-demand download)
// ============================================================
// The catalog, install checks and traineddata downloads live in Services/OcrLanguages.cs.
// The user's chosen OCR languages, persisted as a '+'-joined setting. Filtered to those actually
// installed (a deleted pack can't be passed to Tesseract) and never empty - English is the floor.
private List<string> GetSelectedOcrLanguages()
{
var stored = (App.GetSetting("OcrLanguages") ?? "eng")
.Split(['+'], StringSplitOptions.RemoveEmptyEntries);
var sel = new List<string>();
foreach (var c in stored)
if (OcrLanguages.IsLanguageInstalled(c) && !sel.Contains(c)) sel.Add(c);
if (sel.Count == 0) sel.Add("eng");
return sel;
}
private void SetSelectedOcrLanguages(List<string> langs) =>
App.SetSetting("OcrLanguages", string.Join("+", langs));
// The language string handed to Tesseract, e.g. "eng" or "eng+spa".
private string CurrentOcrLanguageString() => string.Join("+", GetSelectedOcrLanguages());
// High-quality (tessdata_best) vs standard model preference, persisted. When on, downloads pull the
// larger, more accurate "best" models and new languages keep using them.
private bool OcrHighQuality => App.GetSetting("OcrHighQuality") == "1";
private void SetOcrHighQuality(bool on) => App.SetSetting("OcrHighQuality", on ? "1" : "0");
// Builds the multi-select Language submenu. Installed languages are checkable and stay toggled in the
// open menu; not-yet-installed ones offer a one-time download. At least one language stays selected.
private MenuItem BuildLanguageMenu()
{
string tessDir = OcrNativeBootstrap.EnsureLanguageData(); // make sure bundled English is present
var selected = GetSelectedOcrLanguages();
bool hqPref = OcrHighQuality;
var root = new MenuItem
{
Header = Loc("Str_Ocr_Language"),
Icon = new TextBlock
{
Text = "",
FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
VerticalAlignment = VerticalAlignment.Center,
},
};
// Not-yet-installed language rows, so the HQ toggle can refresh their "(download)"
// suffixes in place while the menu stays open.
var downloadItems = new List<(MenuItem item, string code, string name)>();
// Header with the Tesseract language code right-aligned, mirroring the Settings language list.
FrameworkElement LangHeader(string name, string code, string? suffix = null)
{
var dp = new DockPanel { HorizontalAlignment = HorizontalAlignment.Stretch, MinWidth = 170 };
var codeTb = new TextBlock
{
Text = code, FontFamily = UiKit.MonoFont, FontSize = 11,
Foreground = (Brush)FindResource("MutedTextBrush"),
Margin = new Thickness(20, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center
};
DockPanel.SetDock(codeTb, Dock.Right);
dp.Children.Add(codeTb);
dp.Children.Add(new TextBlock { Text = suffix is null ? name : $"{name} {suffix}", VerticalAlignment = VerticalAlignment.Center });
return dp;
}
foreach (var (code, name) in OcrLanguages.OcrLanguageCatalog)
{
bool installed = File.Exists(Path.Combine(tessDir, code + ".traineddata"));
if (installed)
{
var item = new MenuItem
{
Header = LangHeader(name, code),
IsCheckable = true,
IsChecked = selected.Contains(code),
StaysOpenOnClick = true,
};
item.Click += (s, _) =>
{
var mi = (MenuItem)s!;
var sel = GetSelectedOcrLanguages();
if (mi.IsChecked) { if (!sel.Contains(code)) sel.Add(code); }
else
{
if (sel.Count <= 1) { mi.IsChecked = true; return; } // keep at least one selected
sel.Remove(code);
}
SetSelectedOcrLanguages(sel);
SetStatus(string.Format(Loc("Str_St_OcrLanguage"), string.Join("+", sel)));
};
root.Items.Add(item);
}
else
{
var item = new MenuItem { Header = LangHeader(name, code, hqPref ? Loc("Str_Ocr_SuffixDownloadHq") : Loc("Str_Ocr_SuffixDownload")) };
item.Click += (_, _) => DownloadOcrLanguage(code, name);
downloadItems.Add((item, code, name));
root.Items.Add(item);
}
}
// High-quality toggle. Enabling it upgrades the languages already selected and makes future
// downloads pull the "best" models too.
root.Items.Add(new Separator());
var hq = new MenuItem
{
Header = Loc("Str_Ocr_HighQuality"),
IsChecked = hqPref,
StaysOpenOnClick = true, // stay open like the language checkboxes above
};
// Flips the persisted preference directly so the setting can't drift from the visual
// state; the checkmark and the "(download)" suffixes refresh IN PLACE, so the menu can
// stay open instead of closing just to rebuild those labels.
hq.Click += (_, _) =>
{
bool now = !OcrHighQuality;
SetOcrHighQuality(now);
hq.IsChecked = now;
foreach (var (item, code, name) in downloadItems)
item.Header = LangHeader(name, code, now ? Loc("Str_Ocr_SuffixDownloadHq") : Loc("Str_Ocr_SuffixDownload"));
if (now) RedownloadSelectedHighQuality();
};
root.Items.Add(hq);
return root;
}
// Downloads a single language's traineddata (standard or HQ, per the toggle) and selects it.
private async void DownloadOcrLanguage(string code, string name)
{
var ct = BeginCancellableOp(Loc("Str_Op_LangDownload"));
var busy = ShowBusyOverlay(string.Format(Loc("Str_Busy_LangData"), name));
string tessDir = OcrNativeBootstrap.EnsureLanguageData();
string dest = Path.Combine(tessDir, code + ".traineddata");
try
{
using var http = OcrLanguages.MakeDownloadClient();
await OcrLanguages.DownloadTrainedDataAsync(http, OcrLanguages.LanguageDataUrl(code, OcrHighQuality), dest,
string.Format(Loc("Str_Busy_Downloading"), name), Loc("Str_Busy_CancelHint"),
msg => SetBusyMessage(busy, msg), ct);
OcrLanguages.MarkLanguageHq(code, OcrHighQuality);
var sel = GetSelectedOcrLanguages();
if (!sel.Contains(code)) { sel.Add(code); SetSelectedOcrLanguages(sel); }
HideBusyOverlay(busy);
SetStatus(string.Format(Loc("Str_St_LangInstalled"), name, string.Join("+", GetSelectedOcrLanguages())));
}
catch (OperationCanceledException)
{
HideBusyOverlay(busy);
OcrLanguages.TryDeleteFile(dest + ".part");
if (ct.IsCancellationRequested) SetStatus(string.Format(Loc("Str_St_LangCanceled"), name));
else KillerDialog.Show(this, string.Format(Loc("Str_Dlg_DownloadTimeout"), name),
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
catch (Exception ex)
{
HideBusyOverlay(busy);
OcrLanguages.TryDeleteFile(dest + ".part");
KillerDialog.Show(this, string.Format(Loc("Str_Err_LangDataFailed"), name) + "\n" + ex.Message, "KillerPDF",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
EndCancellableOp();
}
}
// Re-downloads every currently-selected language in high quality (tessdata_best), replacing the
// standard copies. Triggered when the user enables "Use High Quality Models". Cancellable; a single
// language's failure is reported but doesn't abort the rest, and a failed file never replaces a
// working one (temp+move).
private async void RedownloadSelectedHighQuality()
{
// Only UPGRADE languages that are actually installed and not already HQ. A language the user has
// selected but hasn't downloaded yet (e.g. the default English right after clearing data) must NOT
// be auto-downloaded here - that would surprise the user with no prompt. It is fetched on the first
// OCR instead, via EnsureOcrModelsReadyAsync, which shows the heads-up dialog and honors this HQ pref.
var hq = OcrLanguages.GetHqLanguages();
var toDownload = new List<string>();
foreach (var c in GetSelectedOcrLanguages())
if (OcrLanguages.IsLanguageInstalled(c) && !hq.Contains(c)) toDownload.Add(c);
if (toDownload.Count == 0)
{
bool anyInstalled = false;
foreach (var c in GetSelectedOcrLanguages()) if (OcrLanguages.IsLanguageInstalled(c)) { anyInstalled = true; break; }
SetStatus(anyInstalled
? Loc("Str_St_HqAlready")
: Loc("Str_St_HqNextTime"));
return;
}
var ct = BeginCancellableOp(Loc("Str_Op_LangDownload"));
var busy = ShowBusyOverlay(Loc("Str_Busy_HqModels"));
string tessDir = OcrNativeBootstrap.EnsureLanguageData();
var failed = new List<string>();
try
{
using var http = OcrLanguages.MakeDownloadClient();
int i = 0;
foreach (var code in toDownload)
{
if (ct.IsCancellationRequested) break;
i++;
string name = OcrLanguages.NameForCode(code);
string dest = Path.Combine(tessDir, code + ".traineddata");
string url = $"https://raw.githubusercontent.com/tesseract-ocr/tessdata_best/main/{code}.traineddata";
try
{
await OcrLanguages.DownloadTrainedDataAsync(http, url, dest,
string.Format(Loc("Str_Busy_DownloadingHq"), name, i, toDownload.Count), Loc("Str_Busy_CancelHint"),
msg => SetBusyMessage(busy, msg), ct);
OcrLanguages.MarkLanguageHq(code, true);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { failed.Add(name); OcrLanguages.TryDeleteFile(dest + ".part"); }
}
HideBusyOverlay(busy);
if (ct.IsCancellationRequested) SetStatus(Loc("Str_St_HqDownloadCanceled"));
else if (failed.Count > 0) SetStatus(string.Format(Loc("Str_St_HqFailed"), string.Join(", ", failed)));
else SetStatus(string.Format(Loc("Str_St_HqInstalled"), string.Join("+", toDownload)));
}
catch (Exception ex)
{
HideBusyOverlay(busy);
KillerDialog.Show(this, Loc("Str_Err_HqDownloadFailed") + "\n" + ex.Message, "KillerPDF",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
EndCancellableOp();
}
}
// Ensures the language models OCR is about to use are present on disk. Nothing is bundled, so on the
// first OCR (or after the user adds a new language) the model is downloaded here, behind a heads-up
// dialog. Returns true only when every required model is installed and OCR may proceed.
private async Task<bool> EnsureOcrModelsReadyAsync()
{
// Desired languages from the persisted setting (default English), regardless of install state.
var desired = new List<string>(
(App.GetSetting("OcrLanguages") ?? "eng").Split(['+'], StringSplitOptions.RemoveEmptyEntries));
if (desired.Count == 0) desired.Add("eng");
var missing = new List<string>();
foreach (var c in desired) if (!OcrLanguages.IsLanguageInstalled(c) && !missing.Contains(c)) missing.Add(c);
if (missing.Count == 0) return true;
string names = string.Join(", ", missing.ConvertAll(OcrLanguages.NameForCode));
var choice = KillerDialog.Show(this,
string.Format(Loc("Str_Dlg_LangDownloadAsk"), names),
"KillerPDF", MessageBoxButton.OKCancel, MessageBoxImage.Information);
if (choice != MessageBoxResult.OK) return false;
var ct = BeginCancellableOp(Loc("Str_Op_LangDownload"));
var busy = ShowBusyOverlay(Loc("Str_Busy_Model"));
try
{
string tessDir = OcrNativeBootstrap.EnsureLanguageData();
using var http = OcrLanguages.MakeDownloadClient();
for (int i = 0; i < missing.Count; i++)
{
string code = missing[i];
string name = OcrLanguages.NameForCode(code);
string dest = Path.Combine(tessDir, code + ".traineddata");
await OcrLanguages.DownloadTrainedDataAsync(http, OcrLanguages.LanguageDataUrl(code, OcrHighQuality), dest,
missing.Count == 1 ? string.Format(Loc("Str_Busy_Downloading"), name)
: string.Format(Loc("Str_Busy_DownloadingMany"), name, i + 1, missing.Count),
Loc("Str_Busy_CancelHint"),
msg => SetBusyMessage(busy, msg), ct);
OcrLanguages.MarkLanguageHq(code, OcrHighQuality);
if (ct.IsCancellationRequested) return false;
}
foreach (var c in missing) if (!OcrLanguages.IsLanguageInstalled(c)) return false;
return true;
}
catch (OperationCanceledException)
{
SetStatus(Loc("Str_St_LangDownloadCanceled"));
return false;
}
catch (Exception ex)
{
KillerDialog.Show(this, Loc("Str_Err_ModelDownloadFailed") + "\n" + ex.Message,
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
return false;
}
finally
{
HideBusyOverlay(busy);
EndCancellableOp();
}
}
// ============================================================
// Document OCR operations - the logic lives in Features/Ocr/OcrController.cs. These
// one-line forwarders keep every existing call site (toolbar, context menu, keyboard
// shortcuts, Annotations' region-drag) unchanged, and the IOcrHost implementation below
// is everything the controller needs from the window.
// ============================================================
private OcrController? _ocrController;
private OcrController Ocr => _ocrController ??= new OcrController(this);
private void OcrPageToClipboard(int pageIdx) => Ocr.OcrPageToClipboard(pageIdx);
private void OcrRegion(int pageIdx, Rect canvasBounds) => Ocr.OcrRegion(pageIdx, canvasBounds);
private void MakeSearchablePdf() => Ocr.MakeSearchablePdf();
private void ExtractAllText() => Ocr.ExtractAllText();
// ---- IOcrHost ------------------------------------------------------------------------
// (IShellServices - Window, Loc, SetStatus - is implemented once for the class in
// Shell/About.cs.)
bool IOcrHost.HasDocument => _doc is not null && _currentFile is not null;
int IOcrHost.PageCount => _doc?.PageCount ?? 0;
string? IOcrHost.CurrentFile => _currentFile;
string? IOcrHost.OriginalFile => _originalFile;
int IOcrHost.RotationFor(int pageIdx) => _pageRotations.TryGetValue(pageIdx, out var r) ? r : 0;
bool IOcrHost.TryGetRenderDims(int pageIdx, out int w, out int h)
{
if (_renderDims.TryGetValue(pageIdx, out var rd)) { w = rd.w; h = rd.h; return true; }
w = 0; h = 0; return false;
}
string IOcrHost.OcrLanguageString => CurrentOcrLanguageString();
Task<bool> IOcrHost.EnsureOcrModelsReadyAsync() => EnsureOcrModelsReadyAsync();
void IOcrHost.CommitActiveTextBox() => CommitActiveTextBox();
void IOcrHost.SaveDocumentTo(string path) => _doc!.Save(path);
// The busy overlay Border stays a shell detail: the host tracks the one live overlay so
// the controller can speak in intents (BeginOp / SetBusyMessage / HideBusy / EndOp). Only
// one cancellable op runs at a time (single _busyCts), so a single field is faithful.
private Border? _ocrBusy;
CancellationToken IOcrHost.BeginOp(string label, string busyMessage)
{
var ct = BeginCancellableOp(label);
_ocrBusy = ShowBusyOverlay(busyMessage);
return ct;
}
void IOcrHost.SetBusyMessage(string message)
{
if (_ocrBusy is not null) SetBusyMessage(_ocrBusy, message);
}
void IOcrHost.HideBusy()
{
if (_ocrBusy is null) return;
HideBusyOverlay(_ocrBusy);
_ocrBusy = null;
}
void IOcrHost.EndOp() => EndCancellableOp();
// OCR Region: armed by the menu item; the next box-drag (Select tool) crops that area of the page
// bitmap and OCRs only it to the clipboard. Works on scans that have no text layer to extract from.
private bool _ocrRegionMode { get => ActiveViewer.OcrRegionModeRef; set => ActiveViewer.OcrRegionModeRef = value; }
private void BeginOcrRegion()
{
if (_doc is null || _currentFile is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
SetTool(EditTool.Select);
_ocrRegionMode = true;
SetStatus(Loc("Str_St_OcrDragBox"));
}
// Primary OCR toolbar button: the common quick action, OCR the current page to the clipboard.
private void Ocr_Click(object sender, RoutedEventArgs e) => OcrPageToClipboard(PageList.SelectedIndex);
// Caret dropdown next to the OCR button - same split-button pattern as Save/Open. Page OCR is live;
// the remaining entries are stubs until their commands land (Region, Searchable PDF, Extract Text).
private void OcrMenu_Click(object sender, RoutedEventArgs e)
{
e.Handled = true; // also fired by right-click on the OCR button; don't let it bubble
var menu = MakeThemedMenu();
if (_doc is null)
{
menu.Items.Add(new MenuItem { Header = Loc("Str_Ocr_NoDoc"), IsEnabled = false });
}
else
{
int pageIdx = PageList.SelectedIndex;
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_OcrPage"), (_, _) => OcrPageToClipboard(pageIdx), "Ctrl+Shift+O", ""));
menu.Items.Add(MakeMenuItem(Loc("Str_Ocr_Region"), (_, _) => BeginOcrRegion(), "Ctrl+Shift+I", ""));
menu.Items.Add(new Separator());
menu.Items.Add(MakeMenuItem(Loc("Str_Ocr_SearchablePdf"), (_, _) => MakeSearchablePdf(), null, ""));
menu.Items.Add(MakeMenuItem(Loc("Str_Ocr_ExtractText"), (_, _) => ExtractAllText(), null, ""));
menu.Items.Add(new Separator());
menu.Items.Add(BuildLanguageMenu());
}
menu.PlacementTarget = (UIElement)sender;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
// Placeholder for OCR commands that are designed but not yet built; keeps the menu complete.
private void OcrComingSoon(string name) => SetStatus(string.Format(Loc("Str_Ocr_ComingSoon"), name));
// Updates the busy overlay's message line (its TextBlock) for per-page progress. UI thread only.
private static void SetBusyMessage(Border overlay, string msg)
{
if (overlay.Child is StackPanel sp)
foreach (var c in sp.Children)
if (c is TextBlock tb) { tb.Text = msg; return; }
}
}
}
+289
View File
@@ -0,0 +1,289 @@
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
{
public partial class MainWindow
{
private void RotatePages_Click(int delta)
{
if (_doc is null) return;
var selected = PageList.SelectedItems;
if (selected.Count == 0) return;
try
{
var indices = new List<int>();
foreach (PageThumbnailVm vm in selected) indices.Add(vm.PageIndex);
// #169: rotation must not destroy the overlay annotations - the reload's default
// keepAnnotations:false cleared them all, losing committed unsaved work on the
// second rotation after placing it. Remap each rotated page's annotations through
// the turn (render dims are still the pre-turn frame here; the reload clears them)
// and keep everything through the reload. A page with no cached render dims keeps
// its annotations unmapped - recoverable beats deleted.
foreach (var idx in indices)
if (_annotations.TryGetValue(idx, out var anns) && _renderDims.TryGetValue(idx, out var dims))
Services.AnnotationRotate.Remap(anns, delta, dims.w, dims.h);
foreach (var idx in indices)
_doc.Pages[idx].Rotate = ((_doc.Pages[idx].Rotate + delta) % 360 + 360) % 360;
int restoreIdx = PageList.SelectedIndex;
SaveTempAndReload(keepAnnotations: true);
PageList.SelectedIndex = Math.Min(restoreIdx, PageList.Items.Count - 1);
// After a rotation the page aspect ratio changes; always fit-to-page so the
// full rotated page is visible regardless of the previous zoom level.
FitToPage();
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() => FitToPage()));
SetStatus(string.Format(Loc("Str_Rotated"), indices.Count));
}
catch (Exception ex)
{
KillerDialog.Show(this, string.Format(Loc("Str_RotateFailed"), ex.Message), Loc("Str_Dlg_AppTitle"), MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void Split_Click(object sender, RoutedEventArgs e)
{
if (_doc is null || _currentFile is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
var currentFile = _currentFile;
var selected = PageList.SelectedItems;
if (selected.Count == 0) { KillerDialog.Show(this, Loc("Str_Dlg_SelectExtract")); return; }
var dlg = new Controls.FileDialog(Controls.FileDialogMode.Save)
{ Filter = Loc("Str_Filter_Pdf") + "|*.pdf", Title = Loc("Str_Dlg_SaveExtractedAs"),
CheckFileExists = false, CheckPathExists = true };
if (dlg.ShowDialog(this) != true) return;
try
{
var indices = new List<int>();
foreach (PageThumbnailVm vm in selected) indices.Add(vm.PageIndex);
using var importDoc = PdfReader.Open(currentFile, PdfDocumentOpenMode.Import);
var newDoc = new PdfDocument();
foreach (var idx in indices.OrderBy(i => i))
newDoc.AddPage(importDoc.Pages[idx]);
newDoc.Save(dlg.FileName);
SetStatus(string.Format(Loc("Str_Extracted"), indices.Count, System.IO.Path.GetFileName(dlg.FileName)));
}
catch (Exception ex)
{
KillerDialog.Show(this, Loc("Str_Err_SplitFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void Delete_Click(object sender, RoutedEventArgs e)
{
if (_doc is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
var doc = _doc;
var selected = PageList.SelectedItems;
if (selected.Count == 0) { KillerDialog.Show(this, Loc("Str_Dlg_SelectDelete")); return; }
var result = KillerDialog.Show(this, selected.Count == 1 ? Loc("Str_Dlg_DeletePage1") : string.Format(Loc("Str_Dlg_DeletePagesN"), selected.Count), "KillerPDF",
MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (result != MessageBoxResult.Yes) return;
try
{
var indices = new List<int>();
foreach (PageThumbnailVm vm in selected) indices.Add(vm.PageIndex);
foreach (var idx in indices.OrderByDescending(i => i))
doc.Pages.RemoveAt(idx);
SaveTempAndReload();
SetStatus(string.Format(Loc("Str_Deleted"), indices.Count, _doc?.PageCount));
}
catch (Exception ex)
{
KillerDialog.Show(this, Loc("Str_Err_DeleteFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void InsertBlankPage_Click(object sender, RoutedEventArgs e)
{
if (_doc is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
var doc = _doc;
int insertAfter = PageList.SelectedIndex >= 0 ? PageList.SelectedIndex : doc.PageCount - 1;
try
{
var blank = new PdfPage { Width = XUnit.FromPoint(595), Height = XUnit.FromPoint(842) };
doc.Pages.Insert(insertAfter + 1, blank);
SaveTempAndReload();
PageList.SelectedIndex = insertAfter + 1;
SetStatus(string.Format(Loc("Str_St_InsertedBlank"), insertAfter + 2));
}
catch (Exception ex)
{
KillerDialog.Show(this, Loc("Str_Err_InsertFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
// Appends a blank A4 page to the END of the document. Used by the page-agnostic context menu
// (sidebar empty area / outside the page), where there's no specific page to insert relative to.
private void AddBlankPageAtEnd()
{
if (_doc is null) { KillerDialog.Show(this, Loc("Str_Msg_OpenFirst")); return; }
var doc = _doc;
try
{
doc.Pages.Add(new PdfPage { Width = XUnit.FromPoint(595), Height = XUnit.FromPoint(842) });
SaveTempAndReload();
if (PageList.Items.Count > 0) PageList.SelectedIndex = PageList.Items.Count - 1;
SetStatus(string.Format(Loc("Str_St_AddedBlank"), _doc?.PageCount));
}
catch (Exception ex)
{
KillerDialog.Show(this, Loc("Str_Err_AddPageFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void MoveUp_Click(object sender, RoutedEventArgs e)
{
if (_doc is null || PageList.SelectedIndex <= 0) return;
var doc = _doc;
int idx = PageList.SelectedIndex;
var page = doc.Pages[idx];
doc.Pages.RemoveAt(idx);
doc.Pages.Insert(idx - 1, page);
SaveTempAndReload();
PageList.SelectedIndex = idx - 1;
}
private void MoveDown_Click(object sender, RoutedEventArgs e)
{
if (_doc is null || PageList.SelectedIndex < 0 || PageList.SelectedIndex >= _doc.PageCount - 1) return;
var doc = _doc;
int idx = PageList.SelectedIndex;
var page = doc.Pages[idx];
doc.Pages.RemoveAt(idx);
doc.Pages.Insert(idx + 1, page);
SaveTempAndReload();
PageList.SelectedIndex = idx + 1;
}
// Cancels the previous thumbnail background load when the file changes.
// The FOCUSED pane's thumbnail loader token. The panes keep their own (PdfViewer.ThumbCts):
// one window-wide token had each pane canceling the other's decode.
private System.Threading.CancellationTokenSource? _thumbCts
{
get => ActiveViewer.ThumbCts;
set => ActiveViewer.ThumbCts = value;
}
/// <summary>Re-seat the newly focused pane's thumbnails instead of rebuilding them.
///
/// Not folded into RefreshPageList as a general cache: every other caller is calling it
/// BECAUSE the pages changed, and a cache keyed on the file path would make those no-op and
/// leave stale thumbnails on screen. Only a focus switch knows nothing changed.
///
/// Falls back to a full refresh whenever the cache cannot be proven to match.</summary>
internal void RestorePageListForActivePane()
{
var cached = ActiveViewer.ThumbCache;
int preservedPage = ActiveViewer.CurrentPageIndex;
bool usable = cached != null
&& _doc != null
&& _currentFile != null
&& cached.Length == _doc.PageCount
&& string.Equals(ActiveViewer.ThumbCacheFile, _currentFile,
System.StringComparison.OrdinalIgnoreCase);
if (!usable) { RefreshPageList(); return; }
// No cancel here. The panes own their thumbnail lists and their loader tokens
// separately, so the other pane's decode is writing into ITS array and should be left
// to finish - canceling it was what left a pane showing page labels with no pictures
// after any focus change.
if (!ReferenceEquals(PageList.ItemsSource, cached)) PageList.ItemsSource = cached;
ActiveViewer.SyncPageListSelection(preservedPage);
}
internal void RefreshPageList()
{
// Cancel any in-flight thumbnail load for the previous file.
_thumbCts?.Cancel();
_thumbCts = new System.Threading.CancellationTokenSource();
var ct = _thumbCts.Token;
if (_doc is null || _currentFile is null)
{
PageList.ItemsSource = null;
return;
}
int pageCount = _doc.PageCount;
string filePath = _currentFile;
int preservedPage = ActiveViewer.CurrentPageIndex;
// Snapshot rotations on the UI thread before going to background.
var rotSnap = new Dictionary<int, int>(_pageRotations);
// Carry forward any existing thumbnails so the list never flashes blank
// during reload (e.g. after a rotation). New thumbnails replace them as
// the background loader finishes each page.
var oldItems = PageList.ItemsSource is PageThumbnailVm[] oi ? oi : null;
var items = new PageThumbnailVm[pageCount];
for (int i = 0; i < pageCount; i++)
{
rotSnap.TryGetValue(i, out int rot);
items[i] = new PageThumbnailVm(i, filePath, rot);
// Seed with stale thumbnail - better than blank while reloading
if (oldItems != null && i < oldItems.Length)
{
var prev = oldItems[i].Thumbnail;
if (prev != null) items[i].SetThumbnailDirect(prev);
}
}
PageList.ItemsSource = items;
ActiveViewer.SyncPageListSelection(preservedPage);
// Hand the array to the pane it belongs to, so focusing away and back can re-seat it
// rather than decode the document again. RestorePageListForActivePane is the only reader.
ActiveViewer.ThumbCache = items;
ActiveViewer.ThumbCacheFile = filePath;
// Load thumbnails sequentially on a background thread via a single doc reader.
_ = System.Threading.Tasks.Task.Run(() =>
{
try
{
using var docReader = DocLib.Instance.GetDocReader(filePath, new PageDimensions(128, 256));
for (int i = 0; i < pageCount; i++)
{
if (ct.IsCancellationRequested) return;
try
{
using var pr = docReader.GetPageReader(i);
int tw = pr.GetPageWidth();
int th = pr.GetPageHeight();
var raw = Services.PdfiumInterop.RenderPageWithAnnotations(filePath, i, tw, th)
?? pr.GetImage(); // #141
if (tw <= 0 || th <= 0 || raw == null || raw.Length < tw * th * 4)
continue;
rotSnap.TryGetValue(i, out int rot);
if (rot != 0)
(raw, tw, th) = BitmapHelpers.RotateBitmap(raw, tw, th, rot);
var src = PageThumbnailVm.BuildThumbFromRaw(raw, tw, th);
if (src != null && !ct.IsCancellationRequested)
items[i].SetThumbnail(src);
}
catch { /* skip failed thumbnail; item shows label-only */ }
}
}
catch { /* docReader open failed; all items remain label-only */ }
}, ct);
}
}
}
+175
View File
@@ -0,0 +1,175 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using KillerPDF.Controls;
namespace KillerPDF
{
/// <summary>
/// Moving a document tab from one pane to the other. Partial of MainWindow.
///
/// A DocumentSession is already self-contained - it owns its document, annotations, undo stack,
/// render cache and view state - so a move is a move: take it out of one pane's collection and
/// put it in the other's. Nothing is reloaded and nothing is re-parsed, which is what lets a
/// large PDF cross panes without a visible reload.
///
/// The reorder-WITHIN-a-pane drag lives in PdfViewer.TabStrip.cs and is untouched; this only
/// takes over when the drop lands somewhere that pane is not.
///
/// Ported from KillerShell's PaneDrag.cs.
/// </summary>
public partial class MainWindow
{
/// <summary>
/// The pane a drop landed on, or null when it landed on the pane it came from (or on
/// nothing). Only ever returns the OTHER pane, so a drop inside the source pane still goes
/// through the normal reorder path.
/// </summary>
/// <remarks>
/// Generous on purpose: anywhere in the other pane counts, not just its tab band. A tab band
/// is a 24px target and aiming for it mid-drag is fussy - if you let go over the other pane,
/// you meant the other pane.
/// </remarks>
internal PdfViewer? TabDropTargetPane(PdfViewer source, MouseEventArgs e)
{
if (!_isSplit) return null;
var other = ReferenceEquals(source, Viewer) ? ViewerB : Viewer;
if (other.Visibility != Visibility.Visible) return null;
var p = e.GetPosition(other);
return p.X >= 0 && p.Y >= 0 && p.X <= other.ActualWidth && p.Y <= other.ActualHeight
? other
: null;
}
// ── Drag feedback ───────────────────────────────────────────────────────────────────────
// Within a pane the REAL tab slides under the pointer, which is feedback enough. The moment
// the pointer crosses into the other pane that stops working: the tab is still parked in the
// strip it came from and nothing follows the hand. So a ghost takes over for the journey, and
// a caret shows where it would land.
private bool _tabGhostShown;
/// <summary>
/// Called on every drag move. Shows the ghost while the pointer is over the other pane and
/// hides it again the moment it comes home, so a drag that wanders out and back hands control
/// cleanly to the in-strip reorder.
/// </summary>
internal void UpdateTabDragFeedback(PdfViewer source, PdfViewer.DocumentSession s,
MouseEventArgs e, PdfViewer? over)
{
if (over == null) { HideTabDragFeedback(); return; }
if (!_tabGhostShown)
{
_tabGhostShown = true;
TabDragGhostText.Text = s.TabLabel;
DragLayer.Visibility = Visibility.Visible;
}
// Positioned by the same grab offset the in-strip drag uses, so the ghost sits under the
// pointer exactly where the tab did when it was picked up.
var p = e.GetPosition(DragLayer);
Canvas.SetLeft(TabDragGhost, p.X - source.TabGrabOffsetX);
Canvas.SetTop(TabDragGhost, p.Y - 10);
ShowTabDropCaret(over, e);
}
private void ShowTabDropCaret(PdfViewer target, MouseEventArgs e)
{
var band = target.TabBandCtl;
var strip = e.GetPosition(target.TabStripCtl);
bool onStrip = band.Visibility == Visibility.Visible
&& strip.Y >= 0 && strip.Y <= band.ActualHeight;
if (!onStrip) { TabDropCaret.Visibility = Visibility.Collapsed; return; }
int idx = TabInsertIndexFor(target, e);
double w = target.TabCount > 0 ? target.TabStripCtl.ActualWidth / target.TabCount : 0;
var at = target.TabStripCtl.TransformToVisual(DragLayer).Transform(new Point(idx * w, 0));
Canvas.SetLeft(TabDropCaret, at.X - 1);
Canvas.SetTop(TabDropCaret, at.Y);
TabDropCaret.Height = Math.Max(4, band.ActualHeight);
TabDropCaret.Visibility = Visibility.Visible;
}
internal void HideTabDragFeedback()
{
if (!_tabGhostShown) return;
_tabGhostShown = false;
DragLayer.Visibility = Visibility.Collapsed;
TabDropCaret.Visibility = Visibility.Collapsed;
}
/// <summary>
/// Move <paramref name="s"/> into <paramref name="target"/> at the position the drop implies,
/// and leave it active and focused there.
/// </summary>
/// <remarks>
/// The order below is the whole trick, and every step of it is load-bearing.
///
/// _doc, _annotations, _undoStack and the rest are WINDOW fields that both panes bridge to,
/// so they describe one pane's active document at a time. The tab being dragged is usually
/// the source pane's active one, which means its real state is in those shared fields rather
/// than in the session - so it is CAPTURED first, or the move carries a stale copy. Then the
/// source pane is pointed at whatever it has left, so the shared fields describe something
/// that still lives there before FocusPane captures them again on the way past.
///
/// The target renders explicitly: FocusPane deliberately does not, because each pane keeps
/// its own tile tree and a focus change is chrome rather than pixels. Here the pixels really
/// are new - this pane has never drawn this document.
///
/// The source re-renders inside WithOwnSession, which swaps BOTH its session fields and
/// ActiveViewer. Without the second half its work would measure and paint into the target
/// pane's tiles, which is the same trap SwapActiveViewer exists for.
/// </remarks>
internal void MoveTabToPane(PdfViewer source, PdfViewer target,
PdfViewer.DocumentSession s, MouseEventArgs e)
{
if (ReferenceEquals(source, target)) return;
int insert = TabInsertIndexFor(target, e);
source.CaptureActiveIfAny(); // the dragged tab's live state, into the session that is leaving
source.DetachSessionExt(s);
source.ApplyActiveSessionIfAny(); // shared fields now describe what the source has left
target.AdoptSessionExt(s, insert);
FocusPane(target); // swaps s into the shared fields and moves the chrome
target.RenderActiveSessionExt(); // this pane has never drawn this document
// And the source pane, with its own fields and its own tiles restored for the call.
source.WithOwnSession(source.RenderActiveSessionExt);
source.CleanupTabTransforms(); // drop the drag offset the grabbed tab still carries
source.RebuildTabStripExt();
target.RebuildTabStripExt();
}
/// <summary>
/// Where in the target strip the drop belongs. Dropping on the tab band inserts at the
/// position under the pointer; dropping anywhere else in the pane appends, because there is
/// no position being pointed at.
/// </summary>
private static int TabInsertIndexFor(PdfViewer target, MouseEventArgs e)
{
var band = target.TabBandCtl;
if (band.Visibility != Visibility.Visible) return target.TabCount;
var p = e.GetPosition(target.TabStripCtl);
if (p.Y < 0 || p.Y > band.ActualHeight) return target.TabCount;
double w = target.TabCount > 0 ? target.TabStripCtl.ActualWidth / target.TabCount : 0;
if (w <= 0) return target.TabCount;
// Rounded, so the boundary is the midpoint of a tab rather than its left edge - dropping
// on the right half of a tab means "after this one".
return Math.Max(0, Math.Min(target.TabCount, (int)Math.Round(p.X / w)));
}
}
}
+102
View File
@@ -0,0 +1,102 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Animation;
namespace KillerPDF
{
/// <summary>
/// The rail's flyout buttons (family order, locked 2026-07-30: app-specific toggles, then
/// ? / language / theme, theme bottom-most) and their flyouts. The theme, language and view
/// pickers all moved here OUT of the retired Settings panel - one implementation each, as
/// family-standard flyouts: ContextMenus with FlyoutCard/FlyoutGrain chrome, opened against
/// the content pane's bottom-left corner via FlyoutPlacement so they never cover the rail,
/// the footer, or the desktop. Their radio/dot sync is SyncPickerState (SettingsPanel.cs).
/// </summary>
public partial class MainWindow
{
// The shortcuts ? is the strip's ORIGINAL button (ShortcutHelp_Click) - it just moved
// into the family slot above language; no second implementation was added.
private void RailLang_Click(object sender, RoutedEventArgs e) => ToggleRailFlyout(LangFlyout);
private void RailTheme_Click(object sender, RoutedEventArgs e) => ToggleRailFlyout(ThemeFlyout);
private void RailView_Click(object sender, RoutedEventArgs e) => ToggleRailFlyout(ViewFlyout);
// Rolling the wheel over the view-mode rail button steps through the modes without
// opening the flyout: up = next, down = previous (2026-07-31 - down-as-next felt
// reversed). F9 jogs forward from the keyboard.
private void RailView_Wheel(object sender, System.Windows.Input.MouseWheelEventArgs e)
{
CycleViewMode(forward: e.Delta > 0);
e.Handled = true;
}
/// <summary>Steps to the neighboring view mode, wrapping at the ends. Cycle order is the
/// enum order: Single -> Continuous -> TwoPage -> Grid. Syncs the flyout radios in case
/// the flyout is open while the wheel or F9 drives the change.</summary>
private void CycleViewMode(bool forward = true)
{
var modes = (ViewMode[])Enum.GetValues(typeof(ViewMode));
// Step from the PENDING mode when a fade-wrapped switch is in flight: _viewMode only
// updates after the ~90ms fade-out, so wheel notches faster than that would otherwise
// recompute from the stale mode and retarget the same switch - several notches
// collapsing into one step (2-4 clicks per mode, as first built).
int idx = Array.IndexOf(modes, _pendingViewMode ?? _viewMode);
int next = (idx + (forward ? 1 : -1) + modes.Length) % modes.Length;
SetViewMode(modes[next]);
SyncPickerState();
}
private void ToggleRailFlyout(ContextMenu menu)
{
if (menu.IsOpen) { menu.IsOpen = false; return; }
// The theme strip always faces the document. Mirroring the two columns also makes its
// width animation grow outward from the rail on either side of the window.
if (menu == ThemeFlyout)
SyncThemeFlyoutSide();
// Radios and accent dots reflect live state before the card shows - the same single
// sync the Settings panel runs on open.
SyncPickerState();
// SplitHost is the full document region between the rail and the window edge. Its
// rail-adjacent bottom corner is left when the sidebar is left and right when the
// sidebar is right. Using one viewer pane here stranded the flyouts on the left after
// the sidebar moved right, and could place them in the middle of a split document.
if (SplitHost is FrameworkElement pane)
FlyoutPlacement.UsePane(pane, _sidebarRight);
FlyoutPlacement.Attach(menu, this);
menu.IsOpen = true;
// 150ms ease-out, the family fade (the flyout template replaces the implicit
// ContextMenu template, whose Loaded trigger normally drives this).
menu.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, new Duration(TimeSpan.FromMilliseconds(150)))
{
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
});
}
private void SyncThemeFlyoutSide()
{
if (ThemePickerLayout is null || ThemeSubmenu is null || AccentStripHost is null ||
AccentStripDivider is null || AccentStrip is null)
return;
Grid.SetColumn(ThemeSubmenu, _sidebarRight ? 1 : 0);
Grid.SetColumn(AccentStripHost, _sidebarRight ? 0 : 1);
AccentStripDivider.HorizontalAlignment = _sidebarRight
? HorizontalAlignment.Right
: HorizontalAlignment.Left;
AccentStrip.Margin = _sidebarRight
? new Thickness(2, 6, 7, 6)
: new Thickness(7, 6, 2, 6);
ThemePickerLayout.Margin = _sidebarRight
? new Thickness(3, 10, 12, 10)
: new Thickness(12, 10, 3, 10);
}
}
}
+318
View File
@@ -0,0 +1,318 @@
using System;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Docnet.Core;
using Docnet.Core.Models;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
using KillerPDF.Services;
namespace KillerPDF
{
public partial class MainWindow
{
// ============================================================
// Transform tool (rotate + scale; draggable corner handles + aspect-unlock next).
// The toolbar button opens a modal TransformWindow that renders the page on its own canvas (so the
// main view mode is irrelevant). Apply rasterizes at full resolution into an expanded white page
// (no cropped corners) and swaps the page in, with undo.
// ============================================================
private void ToolRotate_Click(object sender, RoutedEventArgs e)
{
if (_doc is null) { SetStatus(Loc("Str_Msg_OpenFirst")); return; }
OpenTransformWindow();
}
private void OpenTransformWindow()
{
if (_doc is null) return;
// Transform burns the live annotation layer into its preview and final page image. A text
// box that still has keyboard focus has not entered that layer yet, so opening Transform
// directly after typing used to preview (and apply) the page without the new text.
CommitActiveTextBox();
int pageIdx = PageList.SelectedIndex;
// Render the preview from a copy with this page's annotations baked in, so the preview matches
// what Apply will produce (otherwise annotations are invisible in the Transform window). Kept at a
// modest resolution (the preview only shows at ~600px) so the live scale/rotate compose stays
// fast; Apply re-renders at full resolution independently.
var src = RenderPageBitmap(pageIdx, 1100, BurnPageAnnotationsToTemp(pageIdx));
if (src is null) { SetStatus(Loc("Str_Tf_NoRender")); return; }
// First-use warning that a transform rasterizes the page; persists the opt-out.
if (App.GetSetting("RotateWarnAck") != "1")
{
var (res, dontWarn) = KillerDialog.ShowWithCheckbox(this,
Loc("Str_Tf_Warn"),
Loc("Str_Tf_DontWarn"), Loc("Str_Tf_Suffix"), MessageBoxButton.OKCancel);
if (res != MessageBoxResult.OK) return;
if (dontWarn) App.SetSetting("RotateWarnAck", "1");
}
var page = _doc.Pages[pageIdx];
var (pwpt, phpt) = EffectivePageSize(page); // CropBox-aware, so the readout matches the visible page
var win = new TransformWindow(this, src, pwpt, phpt);
win.ShowDialog();
if (win.Applied && (Math.Abs(win.Angle) > 0.01 || Math.Abs(win.Scale - 1.0) > 0.001 ||
win.FlipH || win.FlipV || !PerspectiveWarp.IsIdentity(win.PerspectiveCorners) ||
!TransformWindow.LevelsIdentity(win.LevelBlack, win.LevelWhite, win.LevelGamma)))
ApplyPageTransform(pageIdx, win.Angle, win.Scale, win.FixedPage, win.FlipH, win.FlipV,
win.PerspectiveCorners, win.LevelBlack, win.LevelWhite, win.LevelGamma);
}
// The page's visible size in points: the CropBox if one is set (so a cropped page reports its real,
// smaller size), otherwise the full MediaBox. PdfPage.Width/Height return the MediaBox only.
private static (double wpt, double hpt) EffectivePageSize(PdfPage page)
{
double wpt = page.Width.Point, hpt = page.Height.Point;
if (page.Elements.GetArray("/CropBox") is { Elements.Count: 4 } cb)
{
double x1 = cb.Elements.GetReal(0), y1 = cb.Elements.GetReal(1);
double x2 = cb.Elements.GetReal(2), y2 = cb.Elements.GetReal(3);
double cw = Math.Abs(x2 - x1), ch = Math.Abs(y2 - y1);
if (cw > 1 && ch > 1) { wpt = cw; hpt = ch; }
}
return (wpt, hpt);
}
// Rasterizes one page with the chosen rotate + scale and swaps it in for the original (undoable).
private void ApplyPageTransform(int pageIdx, double angleDeg, double scale, bool fixedPage,
bool flipH, bool flipV, Point[] perspectiveCorners,
int levelBlack = 0, int levelWhite = 255, double levelGamma = 1.0)
{
if (_doc is null || _currentFile is null) return;
if (pageIdx < 0 || pageIdx >= _doc.PageCount) return;
try
{
// Snapshot for undo BEFORE touching the document, so one Ctrl+Z reverts the transform.
PushDocUndo();
// If the page carries annotations, bake just that page's annotations into the PDF so they
// rotate/scale with the page (it is being rasterized anyway, and the user was warned). The
// helper is non-destructive (restores _doc); we then drop the now-baked annotations.
string? burned = BurnPageAnnotationsToTemp(pageIdx);
if (burned != null && _annotations.TryGetValue(pageIdx, out var pageAnns))
pageAnns.Clear(); // now part of the page image
var src = RenderPageBitmap(pageIdx, 2200, burned);
if (src is null) { SetStatus(Loc("Str_Tf_NoRender")); return; }
var perspective = PerspectiveWarp.IsIdentity(perspectiveCorners)
? src : PerspectiveWarp.Apply(src, perspectiveCorners);
var composed = ComposeTransform(perspective, angleDeg, scale, fixedPage, flipH, flipV);
// #174: levels last, on the final full-resolution pixels - same pass the preview shows.
composed = TransformWindow.ApplyLevels(composed, levelBlack, levelWhite, levelGamma);
byte[] png = EncodePng(composed);
var oldPage = _doc.Pages[pageIdx];
var (epw, eph) = EffectivePageSize(oldPage); // honor CropBox so a cropped page keeps its size
// #167: the bitmap is in VISUAL orientation - RenderPageBitmap applies the page's
// in-app rotation - but MediaBox/CropBox are always unrotated (the working file has
// /Rotate stripped into _pageRotations). On a quarter-turned page the two disagreed,
// so sx and sy came out different: the transformed page was squeezed back to portrait
// and stretched vertically. Swap the point dimensions to match what was rendered.
int visRot = _pageRotations.TryGetValue(pageIdx, out int vr) ? ((vr % 360) + 360) % 360 : 0;
if (visRot == 90 || visRot == 270) (epw, eph) = (eph, epw);
double sx = epw / src.PixelWidth;
double sy = eph / src.PixelHeight;
double newWpt = composed.PixelWidth * sx;
double newHpt = composed.PixelHeight * sy;
// Build a one-page PDF holding the transformed image (the proven image-page pattern).
string tmp = App.MakeTempFile("xfpage");
using (var one = new PdfDocument())
{
var np = one.AddPage();
np.Width = XUnit.FromPoint(newWpt);
np.Height = XUnit.FromPoint(newHpt);
using (var xi = XImage.FromStream(() => new MemoryStream(png)))
using (var gfx = XGraphics.FromPdfPage(np))
gfx.DrawImage(xi, 0, 0, np.Width.Point, np.Height.Point);
one.Save(tmp);
}
// Import that page and swap it in for the original (mirrors DuplicatePage's index dance).
using (var srcDoc = PdfReader.Open(tmp, PdfDocumentOpenMode.Import))
{
var imported = _doc.AddPage(srcDoc.Pages[0]);
_doc.Pages.RemoveAt(_doc.PageCount - 1);
_doc.Pages.Insert(pageIdx, imported);
_doc.Pages.RemoveAt(pageIdx + 1);
}
SaveTempAndReload(keepAnnotations: true);
SetStatus(string.Format(Loc("Str_Tf_Done"), pageIdx + 1));
}
catch (Exception ex)
{
KillerDialog.Show(this, string.Format(Loc("Str_Tf_Failed"), ex.Message), "KillerPDF",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
// Saves the document with ONE page's annotations burned in, to a temp PDF, and returns its path
// (null if the page has no annotations - the caller then renders the normal source). Non-destructive:
// _doc is restored to its pre-burn state by reopening from a clean snapshot, mirroring the proven
// Save-Flattened pattern, so this is safe for the preview as well as Apply.
private string? BurnPageAnnotationsToTemp(int pageIdx)
{
if (_doc is null) return null;
if (!(_annotations.TryGetValue(pageIdx, out var pa) && pa.Count > 0)) return null;
var tempClean = App.MakeTempFile("xfclean");
var tempBurned = App.MakeTempFile("xfburn");
_doc.Save(tempClean);
// #142: a failed burn (one bad annotation) must not crash the tool that asked for the
// preview. The clean snapshot is already on disk, so on failure fall through to the
// restore below and render without the annotation layer instead.
bool burnOk = true;
try
{
DrawAnnotationsOnDocument(pageIdx);
_doc.Save(tempBurned);
}
catch { burnOk = false; }
_doc.Close();
try
{
_doc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify);
}
catch (Exception xrefEx) when (PdfImport.IsXRefException(xrefEx))
{
var fixedPath = App.MakeTempFile("xffixed");
if (!PdfImport.TryImportRepairToPath(tempClean, fixedPath)
&& !PdfiumInterop.TryPdfiumSaveWithZeroRotations(tempClean, fixedPath))
throw;
tempClean = fixedPath;
_doc = PdfReader.Open(tempClean, PdfDocumentOpenMode.Modify);
}
_currentFile = tempClean;
return burnOk ? tempBurned : null;
}
// Renders a page to a white-backed bitmap (transparent page backgrounds show white, not the dark
// canvas), applying any in-app rotation so the preview matches the live view.
private BitmapSource? RenderPageBitmap(int pageIdx, int maxPx, string? sourceOverride = null)
{
if (_doc is null || _currentFile is null) return null;
if (pageIdx < 0 || pageIdx >= _doc.PageCount) return null;
try
{
string srcPath = sourceOverride ?? _currentFile;
using var docReader = DocLib.Instance.GetDocReader(srcPath, new PageDimensions(maxPx, maxPx));
using var pr = docReader.GetPageReader(pageIdx);
int w = pr.GetPageWidth();
int h = pr.GetPageHeight();
// #141: WithAnnotations - Transform rasterizes the page and REPLACES it, so
// without this the file's own markup would be dropped by transforming a page.
byte[] bgra = PdfiumInterop.RenderPageWithAnnotations(srcPath, pageIdx, w, h)
?? pr.GetImage();
if (_pageRotations.TryGetValue(pageIdx, out int prot) && prot != 0)
(bgra, w, h) = BitmapHelpers.RotateBitmap(bgra, w, h, prot);
if (bgra == null || bgra.Length == 0 || w <= 0 || h <= 0) return null;
var raw = BitmapSource.Create(w, h, 96, 96, PixelFormats.Bgra32, null, bgra, w * 4);
var dv = new DrawingVisual();
using (var dc = dv.RenderOpen())
{
dc.DrawRectangle(Brushes.White, null, new Rect(0, 0, w, h));
dc.DrawImage(raw, new Rect(0, 0, w, h));
}
var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
rtb.Render(dv);
rtb.Freeze();
return rtb;
}
catch { return null; }
}
// Scale (per page-size mode) then rotate. Used by both the window preview and full-resolution Apply.
internal static BitmapSource ComposeTransform(BitmapSource src, double angleDeg, double scale, bool fixedPage, bool flipH, bool flipV)
{
var s = ApplyFlip(src, flipH, flipV);
var scaled = Math.Abs(scale - 1.0) < 0.001 ? s : ScaleCompose(s, scale, fixedPage);
return Math.Abs(angleDeg) < 0.001 ? scaled : RotateExpand(scaled, angleDeg);
}
private static BitmapSource ApplyFlip(BitmapSource src, bool flipH, bool flipV)
{
if (!flipH && !flipV) return src;
var tb = new TransformedBitmap(src, new ScaleTransform(flipH ? -1 : 1, flipV ? -1 : 1));
tb.Freeze();
return tb;
}
// fixedPage=true: keep the canvas size, shrink the content with white margins. false: resize the page
// (fewer pixels at the same points-per-pixel = a physically smaller page).
private static BitmapSource ScaleCompose(BitmapSource src, double scale, bool fixedPage)
{
int w = src.PixelWidth, h = src.PixelHeight;
int sw = Math.Max(1, (int)Math.Round(w * scale));
int sh = Math.Max(1, (int)Math.Round(h * scale));
var dv = new DrawingVisual();
if (fixedPage)
{
using (var dc = dv.RenderOpen())
{
dc.DrawRectangle(Brushes.White, null, new Rect(0, 0, w, h));
dc.DrawImage(src, new Rect((w - sw) / 2.0, (h - sh) / 2.0, sw, sh));
}
var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
rtb.Render(dv);
rtb.Freeze();
return rtb;
}
else
{
using (var dc = dv.RenderOpen())
dc.DrawImage(src, new Rect(0, 0, sw, sh));
var rtb = new RenderTargetBitmap(sw, sh, 96, 96, PixelFormats.Pbgra32);
rtb.Render(dv);
rtb.Freeze();
return rtb;
}
}
// Rotates a bitmap by angleDeg about its center into a canvas grown to the rotated bounding box, with
// the new corners filled white.
internal static BitmapSource RotateExpand(BitmapSource src, double angleDeg)
{
double w = src.PixelWidth, h = src.PixelHeight;
double rad = angleDeg * Math.PI / 180.0;
double cos = Math.Abs(Math.Cos(rad));
double sin = Math.Abs(Math.Sin(rad));
int nw = (int)Math.Ceiling(w * cos + h * sin);
int nh = (int)Math.Ceiling(w * sin + h * cos);
var dv = new DrawingVisual();
using (var dc = dv.RenderOpen())
{
dc.DrawRectangle(Brushes.White, null, new Rect(0, 0, nw, nh));
dc.PushTransform(new TranslateTransform(nw / 2.0, nh / 2.0));
dc.PushTransform(new RotateTransform(angleDeg));
dc.DrawImage(src, new Rect(-w / 2.0, -h / 2.0, w, h));
dc.Pop();
dc.Pop();
}
var rtb = new RenderTargetBitmap(nw, nh, 96, 96, PixelFormats.Pbgra32);
rtb.Render(dv);
rtb.Freeze();
return rtb;
}
private static byte[] EncodePng(BitmapSource bmp)
{
var enc = new PngBitmapEncoder();
enc.Frames.Add(BitmapFrame.Create(bmp));
using var ms = new MemoryStream();
enc.Save(ms);
return ms.ToArray();
}
}
}
+423
View File
@@ -0,0 +1,423 @@
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.Features;
using KillerPDF.Services;
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
namespace KillerPDF
{
public partial class MainWindow : ISearchHost
{
// The search state machine (match list, cursor, next/prev) lives in
// Features/Search/SearchController.cs; the bar UI, debounce, and highlight painting stay
// here. Forwarders keep the bar buttons' and KeyboardShortcuts' call sites unchanged.
private SearchController? _searchController;
private SearchController Search => _searchController ??= new SearchController(this);
private void SearchNextResult() => Search.Next();
private void SearchPrevResult() => Search.Prev();
// ---- ISearchHost -----------------------------------------------------------------------
// (IShellServices - Window, Loc, SetStatus - is implemented once for the class in
// Shell/About.cs.)
string? ISearchHost.CurrentFile => _currentFile;
int ISearchHost.CurrentPageIndex => PageList.SelectedIndex;
void ISearchHost.GoToPage(int pageIdx) => PageList.SelectedIndex = pageIdx;
void ISearchHost.SetResultText(string text)
{
if (_searchStatus is not null) _searchStatus.Text = text;
}
void ISearchHost.SetResultCount(string text, string? tooltip)
{
if (_searchStatus is null) return;
_searchStatus.Text = text;
_searchStatus.ToolTip = tooltip;
}
void ISearchHost.ClearHighlights() => ClearSearchHighlights();
void ISearchHost.RepaintHighlights() => HighlightSearchResultsOnCurrentPage();
private void Search_Click(object sender, RoutedEventArgs e) => ToggleSearchBar();
private void ToggleSearchBar()
{
if (_searchBar is not null && _searchBar.Visibility == Visibility.Visible)
{
CloseSearchBar();
return;
}
ShowSearchBar();
}
private void ShowSearchBar()
{
if (_searchBar is null)
{
// Build search bar programmatically and inject into the preview area grid
_searchBox = new TextBox
{
Width = 200,
Height = 26,
FontFamily = UiKit.UiFont,
FontSize = 13,
SelectionBrush = AccentBrush(),
BorderThickness = new Thickness(1),
Padding = new Thickness(6, 2, 6, 2),
VerticalContentAlignment = VerticalAlignment.Center
};
// Live (DynamicResource-style) brushes so the box recolors on a theme switch while the
// bar is open, instead of baking colors in at build time. Background uses the dark
// toolbar/titlebar tone (BgSidebar).
_searchBox.SetResourceReference(Control.BackgroundProperty, "BackgroundBrush");
_searchBox.SetResourceReference(Control.ForegroundProperty, "TextBrush");
_searchBox.SetResourceReference(System.Windows.Controls.Primitives.TextBoxBase.CaretBrushProperty, "TextBrush");
_searchBox.SetResourceReference(Control.BorderBrushProperty, "CardBorderBrush");
_searchBox.KeyDown += SearchBox_KeyDown;
_searchBox.TextChanged += SearchBox_TextChanged;
// Custom template so the default WPF blue focus/hover border never shows; keep our themed border.
var tbTemplate = new ControlTemplate(typeof(TextBox));
var tbBorder = new FrameworkElementFactory(typeof(Border));
tbBorder.SetValue(Border.BackgroundProperty, new System.Windows.TemplateBindingExtension(Control.BackgroundProperty));
tbBorder.SetValue(Border.BorderBrushProperty, new System.Windows.TemplateBindingExtension(Control.BorderBrushProperty));
tbBorder.SetValue(Border.BorderThicknessProperty, new System.Windows.TemplateBindingExtension(Control.BorderThicknessProperty));
tbBorder.SetValue(Border.CornerRadiusProperty, new CornerRadius(3));
var tbHost = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
tbHost.SetValue(ScrollViewer.PaddingProperty, new System.Windows.TemplateBindingExtension(Control.PaddingProperty));
tbHost.SetValue(ScrollViewer.VerticalAlignmentProperty, VerticalAlignment.Center);
tbBorder.AppendChild(tbHost);
tbTemplate.VisualTree = tbBorder;
_searchBox.Template = tbTemplate;
_searchBox.FocusVisualStyle = null;
// Wide enough for translated empty/error states while keeping the count stable.
_searchStatus = new TextBlock
{
FontFamily = UiKit.UiFont,
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Center,
Width = 96,
Margin = new Thickness(2, 0, 2, 0)
};
_searchStatus.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
// Small VSCode-style prev / next / close buttons. Hover tooltips carry the shortcuts.
Button SearchNavBtn(string glyph, string tip, Action onClick, bool danger = false)
{
var b = new Button
{
Content = glyph,
FontFamily = UiKit.IconFont,
FontSize = 12,
Width = 26, Height = 24,
Padding = new Thickness(0), // ToolbarButton's 10,6 padding clips the glyph in a 26px button
// The close X uses the shared danger style so its glyph turns red on hover like
// every other close X (window chrome, tabs, overlay headers).
Style = (Style)FindResource(danger ? "DangerCloseButton" : "ToolbarButton"),
ToolTip = tip
};
b.Click += (_, _) => onClick();
return b;
}
var prevBtn = SearchNavBtn("", Loc("Str_Search_PreviousTT"), SearchPrevResult); // ChevronUp
var nextBtn = SearchNavBtn("", Loc("Str_Search_NextTT"), SearchNextResult); // ChevronDown
var closeBtn = SearchNavBtn("", Loc("Str_Search_CloseTT"), CloseSearchBar, danger: true); // Cancel
var searchIcon = new TextBlock
{
Text = "", // Segoe MDL2 Search / magnifying glass
FontFamily = UiKit.IconFont,
FontSize = 12,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
IsHitTestVisible = false
};
searchIcon.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
// Drag grip: two columns of three dots on the left (same look as the sidebar splitter and
// the annotate bars). Grabbing it moves the whole bar anywhere in the document area.
var gripBrush = TryFindResource("MutedTextBrush") as Brush ?? Brushes.Gray;
var gripDots = new StackPanel
{
Orientation = Orientation.Horizontal,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(2, 0, 6, 0)
};
for (int gcol = 0; gcol < 2; gcol++)
{
var colDots = new StackPanel { Orientation = Orientation.Vertical, VerticalAlignment = VerticalAlignment.Center };
for (int grow = 0; grow < 3; grow++)
colDots.Children.Add(new Ellipse { Width = 3, Height = 3, Margin = new Thickness(1.5), Fill = gripBrush });
gripDots.Children.Add(colDots);
}
var searchGrip = new Border
{
Background = Brushes.Transparent, // transparent yet hit-testable, so it can be grabbed
Cursor = Cursors.SizeAll,
VerticalAlignment = VerticalAlignment.Stretch,
Child = gripDots,
ToolTip = Loc("Str_Search_DragTT")
};
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 6, 8, 6)
};
panel.Children.Add(searchGrip);
panel.Children.Add(searchIcon);
panel.Children.Add(_searchBox);
panel.Children.Add(_searchStatus);
panel.Children.Add(prevBtn);
panel.Children.Add(nextBtn);
panel.Children.Add(closeBtn);
_searchBar = new Border
{
BorderThickness = new Thickness(1),
// Free-floating like the Signatures popup: positioned by Left/Top margin (set after
// layout from the saved spot) and draggable by the grip, so it can sit anywhere.
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(4),
Child = GrainWrap(panel),
Margin = new Thickness(0),
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 16, ShadowDepth = 3, Direction = 270, Opacity = 0.55 }
};
_searchBar.SetResourceReference(Border.BackgroundProperty, "BgFlyout");
// PaneBorderBrush, the same key the annotate settings bars use (AnnotationBars.cs).
// The find bar is the same class of floating tool surface, so it takes the same
// edge; on MenuBorderBrush the two sat side by side drawing different borders.
_searchBar.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush");
// Add to the preview area grid (parent of ScrollViewer)
var previewGrid = PagePreviewPanel.Parent as Grid;
if (previewGrid is not null)
{
// Above the annotate settings bars (ZIndex 100) so Ctrl+F is never hidden under
// the highlight/draw/text toolbar when both are open.
Panel.SetZIndex(_searchBar, 200);
previewGrid.Children.Add(_searchBar);
// Keep the whole bar on screen: when the preview area is resized, shrink the text box
// (not the buttons) so it never overflows, and re-clamp the floating bar back inside.
previewGrid.SizeChanged += (_, _) =>
{
FitSearchBox();
if (_searchBar is { Visibility: Visibility.Visible })
{
double cl = _searchBar.Margin.Left, ct = _searchBar.Margin.Top;
ClampPanelToBounds(_searchBar, previewGrid, ref cl, ref ct);
_searchBar.Margin = new Thickness(cl, ct, 0, 0);
}
};
// Place it (saved spot, or default near the old top-right position) and wire the grip
// for dragging once it's laid out and has a real width - mirrors the Signatures popup.
var bar = _searchBar;
var grip = searchGrip;
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() =>
{
ApplySavedPanelPosition(bar, previewGrid, "SearchBar", fallbackRightInset: 16, fallbackTop: 6);
EnablePanelDrag(grip, bar, previewGrid, "SearchBar");
}));
}
}
_searchBar.Visibility = Visibility.Visible;
_searchBox!.Text = "";
if (_searchStatus != null) _searchStatus.Text = "";
FitSearchBox();
_searchBox.Focus();
Keyboard.Focus(_searchBox);
}
// Sizes the search text box to whatever width is left after the icon, status, and nav
// buttons, capped at a comfortable 200px, so the full bar always fits the preview area.
private void FitSearchBox()
{
if (_searchBar is null || _searchBox is null) return;
double avail = (PagePreviewPanel.Parent as Grid)?.ActualWidth ?? 0;
const double reserved = 232; // grip + icon + status + 3 buttons + paddings/margins
_searchBox.Width = Math.Max(60, Math.Min(200, avail - reserved));
}
private void CloseSearchBar()
{
if (_searchBar is { Visibility: Visibility.Visible } bar)
{
// Fade out rather than blink away. On completion, collapse and restore opacity so the
// next open shows it cleanly.
var fade = new DoubleAnimation(bar.Opacity, 0, new Duration(TimeSpan.FromMilliseconds(150)))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } };
fade.Completed += (_, _) =>
{
bar.Visibility = Visibility.Collapsed;
bar.BeginAnimation(UIElement.OpacityProperty, null);
bar.Opacity = 1;
};
bar.BeginAnimation(UIElement.OpacityProperty, fade);
}
ClearSearchHighlights();
}
private void SearchBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
CloseSearchBar();
e.Handled = true;
}
else if (e.Key == Key.Enter)
{
if (Keyboard.Modifiers == ModifierKeys.Shift)
SearchPrevResult();
else
SearchNextResult();
e.Handled = true;
}
}
private System.Windows.Threading.DispatcherTimer? _searchDebounce;
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
var text = _searchBox?.Text ?? "";
if (text.Length < 2)
{
_searchDebounce?.Stop();
ClearSearchHighlights();
Search.ClearMatches();
if (_searchStatus is not null) _searchStatus.Text = "";
return;
}
// Debounce: wait for a brief pause in typing before searching, so the first keystrokes
// on a large document don't lock the UI while it searches partial queries.
if (_searchDebounce is null)
{
_searchDebounce = new System.Windows.Threading.DispatcherTimer
{ Interval = TimeSpan.FromMilliseconds(250) };
_searchDebounce.Tick += (_, _) =>
{
_searchDebounce!.Stop();
var q = _searchBox?.Text ?? "";
if (q.Length >= 2) Search.Run(q);
};
}
_searchDebounce.Stop();
_searchDebounce.Start();
}
// Paints match highlights onto EVERY page that's currently on screen and has results -
// the primary tile and all per-page overlays alike. Single Page shows the one page; Two-Page
// and Grid show every visible tile with hits; Continuous shows them down the whole scroll.
// Re-paints one page's match highlights onto its overlay using the in-memory page size (no
// file I/O), so it's cheap enough to call at the tail of every RenderAllAnnotations - which is
// what keeps highlights alive instead of being wiped by re-renders and continuous scrolling.
private void ApplySearchHighlights(int page, Canvas canvas)
{
if (_searchBar is null || _searchBar.Visibility != Visibility.Visible) return;
if (_doc is null || page < 0 || page >= _doc.PageCount) return;
if (!Search.TryGetPageRects(page, out var rects)) return;
if (!_renderDims.TryGetValue(page, out var rd)) return;
var (renderW, renderH) = rd;
double pdfW = _doc.Pages[page].Width.Point;
double pdfH = _doc.Pages[page].Height.Point;
if (pdfW <= 0 || pdfH <= 0) return;
double sx = renderW / pdfW;
double sy = renderH / pdfH;
var cur = Search.CurrentMatch;
foreach (var (left, bottom, right, top) in rects)
{
bool isCurrent = cur is { } c && c.page == page
&& c.left == left && c.bottom == bottom && c.right == right && c.top == top;
AddSearchHighlight(canvas, left, bottom, right, top, sx, sy, renderH, isCurrent);
}
}
// Repaints highlights on every page on screen right now (called when a search runs or the
// current page changes); per-page re-renders keep them alive via ApplySearchHighlights.
private void HighlightSearchResultsOnCurrentPage()
{
ClearSearchHighlights();
foreach (var page in Search.PagesWithResults)
{
var canvas = VisibleCanvasForPage(page);
if (canvas is not null) ApplySearchHighlights(page, canvas);
}
}
private void AddSearchHighlight(Canvas canvas, double left, double bottom, double right, double top,
double sx, double sy, double renderH, bool isCurrent)
{
double cw = (right - left) * sx;
double ch = (top - bottom) * sy;
// A little breathing room so the box (and the current-match outline) wraps the whole word -
// PdfPig's glyph bounds sit tight against the letters. Scales with text height so it looks
// consistent across font sizes.
double pad = ch * 0.30;
double cx = left * sx - pad;
double cy = renderH - (top * sy) - pad;
var rect = new Rectangle
{
// The current match (search cursor) gets a brighter, more opaque fill; the others are dim.
Fill = new SolidColorBrush(isCurrent
? Color.FromArgb(150, 255, 190, 0)
: Color.FromArgb(70, 255, 165, 0)),
StrokeThickness = isCurrent ? 3.5 : 1,
RadiusX = pad * 0.6,
RadiusY = pad * 0.6,
Width = Math.Max(cw + pad * 2, 4),
Height = Math.Max(ch + pad * 2, 4),
IsHitTestVisible = false,
Tag = "SearchHighlight"
};
if (isCurrent)
// Bind the current-match outline to the on-page accent resource so it recolors live on a
// theme switch (matching the selection chrome), rather than baking the color in at paint time.
rect.SetResourceReference(Shape.StrokeProperty, "SelectionAccent");
else
rect.Stroke = new SolidColorBrush(Color.FromArgb(140, 255, 165, 0));
Canvas.SetLeft(rect, cx);
Canvas.SetTop(rect, cy);
canvas.Children.Add(rect);
}
// Removes only the highlight rectangles from every page overlay. Deliberately does NOT touch
// the result counter - that's owned by SearchController's status updates and the empty-query path - so a
// repaint (which clears then re-adds highlights) can't wipe the "3 / 14" count.
private void ClearSearchHighlights()
{
foreach (var canvas in AllPageCanvases())
{
var toRemove = canvas.Children.OfType<Rectangle>()
.Where(r => r.Tag is string s && s == "SearchHighlight").ToList();
foreach (var r in toRemove)
canvas.Children.Remove(r);
}
}
}
}
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
namespace KillerPDF
{
public partial class MainWindow
{
// ============================================================
// Shapes tool (#127 Phase 3)
// ============================================================
// Three sub-modes (ShapeKind): Rectangle drags out the translucent filled box the old
// rectangle-highlight gesture used to make (a HighlightAnnotation, so move/resize/erase/
// flatten/undo all behave exactly as before); Ellipse drags out an outline ellipse; and
// Polygon places vertices click by click, closing on the first vertex (lit snap target),
// on double-click, or via Enter - Esc cancels, Backspace removes the last vertex.
// Ellipse and Polygon commit as CLOSED InkAnnotations (the last point repeats the first),
// so they ride the existing ink pipeline: render, hit-test, drag, resize, flatten, export.
// All three draw with the shared draw bar's color/size/opacity (_drawColor/_drawWidth).
private ShapeKind _shapeKind = ShapeKind.Rectangle;
private bool _shapeFill = true; // fill the inside; toggled in the shape bar
// In-progress polygon state. _shapePolyPoints non-empty = a polygon is being placed.
private List<Point> _shapePolyPoints => ActiveViewer.ShapePolyPointsRef;
private Polyline? _shapePolyPreview; // committed vertices
private Polyline? _shapePolyRubber; // last vertex -> cursor, dashed closing preview
private Ellipse? _shapePolySnapDot; // lit ring over the first vertex when close enough to close
private int _shapePolyPage = -1;
private Canvas? _shapePolyCanvas;
private const double ShapeSnapPx = 8; // close the polygon when a click lands this near the start
/// <summary>Interior color for filled ellipse/polygon shapes: the stroke color at half its
/// alpha, so the edge still reads against the fill (the filled Box keeps the legacy
/// full-alpha HighlightAnnotation look instead).</summary>
private Color ShapeFillColor()
=> Color.FromArgb((byte)Math.Max(20, _drawColor.A / 2), _drawColor.R, _drawColor.G, _drawColor.B);
/// <summary>Mouse-down entry for the Shapes tool (called from Canvas_MouseLeftButtonDown).</summary>
private void ShapeToolMouseDown(int pageIdx, Point pos, MouseButtonEventArgs e)
{
if (_shapeKind == ShapeKind.Polygon)
{
// Double-click closes an in-progress polygon (the first click of the pair already
// added a vertex at this spot; the commit dedupes trailing repeats).
if (e.ClickCount == 2 && _shapePolyPoints.Count >= 3) CommitShapePolygon();
else ShapePolyClick(pageIdx, pos);
e.Handled = true;
return;
}
// Rectangle / Ellipse: drag out a preview, commit on release (CommitShapeDrag).
ClearSelection();
_isDrawing = true;
_drawStart = pos;
Shape preview;
if (_shapeKind == ShapeKind.Rectangle && _shapeFill)
{
// The old rectangle-highlight look: translucent fill, no stroke.
preview = new Rectangle { Fill = new SolidColorBrush(_drawColor) };
}
else
{
preview = _shapeKind == ShapeKind.Rectangle ? new Rectangle() : new Ellipse();
preview.Stroke = new SolidColorBrush(_drawColor);
preview.StrokeThickness = _drawWidth;
preview.Fill = _shapeKind == ShapeKind.Ellipse && _shapeFill
? new SolidColorBrush(ShapeFillColor())
: Brushes.Transparent;
}
preview.Width = 0;
preview.Height = 0;
Canvas.SetLeft(preview, pos.X);
Canvas.SetTop(preview, pos.Y);
_activeCanvas.Children.Add(preview);
_activePreview = preview;
_activeCanvas.CaptureMouse();
e.Handled = true;
}
/// <summary>Mouse-up commit for the Rectangle / Ellipse drag (called from
/// Canvas_MouseLeftButtonUp). The preview element carries the final geometry.</summary>
private void CommitShapeDrag(int pageIdx)
{
if (_activePreview is not Shape shp) return;
double x = Canvas.GetLeft(shp), y = Canvas.GetTop(shp);
double w = shp.Width, h = shp.Height;
_activeCanvas?.Children.Remove(shp);
if (w <= 3 || h <= 3) return; // click or a sliver - nothing to keep
if (shp is Rectangle && _shapeFill)
{
// Same annotation the old rectangle-highlight gesture produced.
var ha = new HighlightAnnotation
{
PageIndex = pageIdx,
Bounds = new Rect(x, y, w, h),
Style = HighlightStyle.Fill
};
ha.SetColor(_drawColor);
AddAnnotation(ha);
}
else if (shp is Rectangle)
{
// Outline box: a closed 4-corner ink stroke.
var ink = new InkAnnotation { PageIndex = pageIdx, StrokeWidth = _drawWidth };
ink.SetColor(_drawColor);
ink.Points.Add(new Point(x, y));
ink.Points.Add(new Point(x + w, y));
ink.Points.Add(new Point(x + w, y + h));
ink.Points.Add(new Point(x, y + h));
ink.Points.Add(new Point(x, y));
AddAnnotation(ink);
}
else
{
// Ellipse: a closed 64-segment ink stroke centered in the drag rect.
var ink = new InkAnnotation { PageIndex = pageIdx, StrokeWidth = _drawWidth };
ink.SetColor(_drawColor);
if (_shapeFill) ink.SetFillColor(ShapeFillColor());
double cx = x + w / 2, cy = y + h / 2, rx = w / 2, ry = h / 2;
for (int k = 0; k <= 64; k++)
{
double a = k * 2 * Math.PI / 64;
ink.Points.Add(new Point(cx + rx * Math.Cos(a), cy + ry * Math.Sin(a)));
}
AddAnnotation(ink);
}
RenderAllAnnotations(pageIdx);
}
/// <summary>One polygon click: start the shape, add a vertex, or close when the click lands
/// on the start vertex's snap target.</summary>
private void ShapePolyClick(int pageIdx, Point pos)
{
if (_activeCanvas is null) return;
if (_shapePolyPoints.Count == 0)
{
ClearSelection();
_shapePolyPage = pageIdx;
_shapePolyCanvas = _activeCanvas;
_shapePolyPreview = new Polyline
{
Stroke = new SolidColorBrush(_drawColor),
StrokeThickness = _drawWidth,
StrokeLineJoin = PenLineJoin.Round,
IsHitTestVisible = false
};
_shapePolyPreview.Points.Add(pos);
_shapePolyRubber = new Polyline
{
Stroke = new SolidColorBrush(Color.FromArgb((byte)Math.Max(60, _drawColor.A / 2),
_drawColor.R, _drawColor.G, _drawColor.B)),
StrokeThickness = Math.Max(1, _drawWidth / 2),
StrokeDashArray = [4, 3],
IsHitTestVisible = false
};
_shapePolyRubber.Points.Add(pos);
_shapePolyRubber.Points.Add(pos);
// Snap ring over the start vertex - hidden until the polygon can actually close.
_shapePolySnapDot = new Ellipse
{
Width = 14,
Height = 14,
StrokeThickness = 2,
Fill = Brushes.Transparent,
IsHitTestVisible = false,
Visibility = Visibility.Collapsed
};
_shapePolySnapDot.SetResourceReference(Shape.StrokeProperty, "SelectionAccent");
Canvas.SetLeft(_shapePolySnapDot, pos.X - 7);
Canvas.SetTop(_shapePolySnapDot, pos.Y - 7);
_shapePolyCanvas.Children.Add(_shapePolyPreview);
_shapePolyCanvas.Children.Add(_shapePolyRubber);
_shapePolyCanvas.Children.Add(_shapePolySnapDot);
_shapePolyPoints.Add(pos);
SetStatus(Loc("Str_St_ShapeHint"));
return;
}
if (pageIdx != _shapePolyPage) return; // the polygon stays on the page it started on
if (_shapePolyPoints.Count >= 3 && (pos - _shapePolyPoints[0]).Length <= ShapeSnapPx)
{
CommitShapePolygon();
return;
}
_shapePolyPoints.Add(pos);
_shapePolyPreview!.Points.Add(pos);
_shapePolyRubber!.Points[0] = pos;
}
/// <summary>Mouse-move while a polygon is being placed: rubber-band from the last vertex,
/// and light the snap ring when the cursor is close enough to the start to close.</summary>
private void UpdateShapePolyRubber(MouseEventArgs e)
{
if (_shapePolyCanvas is null || _shapePolyRubber is null) return;
var p = e.GetPosition(_shapePolyCanvas);
_shapePolyRubber.Points[_shapePolyRubber.Points.Count - 1] = p;
if (_shapePolySnapDot is not null)
_shapePolySnapDot.Visibility =
_shapePolyPoints.Count >= 3 && (p - _shapePolyPoints[0]).Length <= ShapeSnapPx
? Visibility.Visible : Visibility.Collapsed;
}
/// <summary>Commit the in-progress polygon as a closed ink stroke.</summary>
private void CommitShapePolygon()
{
int page = _shapePolyPage;
var pts = new List<Point>(_shapePolyPoints);
ResetShapePolyState();
// Drop trailing points that repeat the last committed vertex (a double-click close adds
// one at the same spot as the click before it).
while (pts.Count >= 2 && (pts[pts.Count - 1] - pts[pts.Count - 2]).Length < 2) pts.RemoveAt(pts.Count - 1);
if (pts.Count < 3 || page < 0) return;
var ink = new InkAnnotation { PageIndex = page, StrokeWidth = _drawWidth };
ink.SetColor(_drawColor);
if (_shapeFill) ink.SetFillColor(ShapeFillColor());
foreach (var p in pts) ink.Points.Add(p);
ink.Points.Add(pts[0]); // close
AddAnnotation(ink);
RenderAllAnnotations(page);
}
/// <summary>Esc: abandon the in-progress polygon. Safe no-op when none is active.</summary>
private void CancelShapePolygon()
{
if (_shapePolyPoints.Count == 0) return;
ResetShapePolyState();
SetStatus(Loc("Str_St_ShapeCanceled"));
}
/// <summary>Backspace: remove the last placed vertex; removing the only one cancels.</summary>
private void ShapePolyBackspace()
{
if (_shapePolyPoints.Count == 0) return;
if (_shapePolyPoints.Count == 1) { CancelShapePolygon(); return; }
_shapePolyPoints.RemoveAt(_shapePolyPoints.Count - 1);
_shapePolyPreview!.Points.RemoveAt(_shapePolyPreview.Points.Count - 1);
_shapePolyRubber!.Points[0] = _shapePolyPoints[_shapePolyPoints.Count - 1];
}
private void ResetShapePolyState()
{
if (_shapePolyCanvas is not null)
{
if (_shapePolyPreview is not null) _shapePolyCanvas.Children.Remove(_shapePolyPreview);
if (_shapePolyRubber is not null) _shapePolyCanvas.Children.Remove(_shapePolyRubber);
if (_shapePolySnapDot is not null) _shapePolyCanvas.Children.Remove(_shapePolySnapDot);
}
_shapePolyPoints.Clear();
_shapePolyPreview = null;
_shapePolyRubber = null;
_shapePolySnapDot = null;
_shapePolyPage = -1;
_shapePolyCanvas = null;
}
}
}
+186
View File
@@ -0,0 +1,186 @@
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace KillerPDF
{
// ============================================================
// Keyboard-shortcuts overlay: the LIST view.
//
// The overlay card (ShortcutOverlay in MainWindow.xaml) is generated from ShortcutTable.KsAll
// rather than hand-authored row by row, so adding or changing a shortcut is a one-line edit
// there and can't drift out of sync with a parallel block of XAML - or with the keyboard map,
// which is built from the same array. The two empty hosts ShortcutLeftColumn /
// ShortcutRightColumn are filled by BuildShortcutsOverlay(), called once from the constructor.
//
// Keys are literal text (shown in Consolas, like a real keycap); labels are Str_* resource keys so
// they stay localized. Everything is wired with SetResourceReference so both the theme colors and
// the active locale keep updating live, exactly as the old DynamicResource markup did.
// ============================================================
public partial class MainWindow
{
// One row: the literal key text and the resource key for its translated description.
private readonly record struct KsRow(string Keys, string LabelKey);
// A titled group of rows. TitleKey is a Str_* resource key rendered as the subheader,
// colored by the section's KsCat* theme brush - the family neon set the keyboard map
// already uses (KillerShell's colored categories, taken as the reference).
private sealed class KsSection
{
public string TitleKey = "";
public string Cat = ""; // "" falls back to PrimaryBrush
public KsRow[] Rows = [];
}
/// <summary>The list's view of the table: sections in KsGroups order, rows in declaration
/// order. Caps are ignored here; they only matter to the keyboard map.</summary>
private static KsSection[] KsColumn(bool right) =>
ShortcutTable.KsGroups.Where(g => g.Right == right)
.Select(g => new KsSection
{
TitleKey = g.TitleKey,
Cat = g.Cat,
Rows = ShortcutTable.KsAll.Where(b => b.Cat == g.Cat)
.Select(b => new KsRow(b.Keys, b.LabelKey)).ToArray(),
})
.ToArray();
private static readonly KsSection[] KsLeftColumn = KsColumn(right: false);
private static readonly KsSection[] KsRightColumn = KsColumn(right: true);
// #153: the zoom keys are spelled differently per keyboard layout - "=" is a plain keypress
// on US but needs Shift on German, where "+" is the unshifted one instead. The bindings
// accept whichever key TYPES the character (Services/KeyLayout.cs), so these labels have to
// follow suit, or the overlay advertises a chord that does not work on that machine.
// %zin% / %zout% are substituted here; everything else passes through untouched. The token
// markers avoid braces on purpose - a brace in a char or string literal makes a plain
// brace-balance check on this file report a false mismatch.
private static string ResolveKeyLabel(string keys)
=> keys.IndexOf('%') < 0
? keys
: keys.Replace("%zin%", Services.KeyLayout.ZoomInChar())
.Replace("%zout%", Services.KeyLayout.ZoomOutChar());
// #230: the key column used to be raw English, so Shift, Delete, Home, End and the wheel
// gestures never reached a translator. They are tokens now (ShortcutTable.KeyTokens).
//
// Built as Runs rather than one resolved string on purpose. The description beside it uses
// SetResourceReference and so follows a language switch live; a string composed once in the
// constructor would not, and the overlay is built exactly once (MainWindow ctor). Giving
// each token its own Run with its own resource reference keeps the whole row live, and the
// literal parts - "Ctrl+", "/", F-numbers, letters - stay plain Runs.
//
// %zin% / %zout% are NOT tokens here: they depend on the keyboard layout rather than the
// locale, so ResolveKeyLabel substitutes them into the literal text first.
private static void FillKeyInlines(TextBlock target, string keys)
{
target.Inlines.Clear();
string text = ResolveKeyLabel(keys);
int pos = 0;
while (pos < text.Length)
{
int start = text.IndexOf('%', pos);
if (start < 0) break;
int end = text.IndexOf('%', start + 1);
if (end < 0) break;
string token = text.Substring(start, end - start + 1);
string? resourceKey = ShortcutTable.KeyTokens
.Where(t => t.Token == token)
.Select(t => t.Key)
.FirstOrDefault();
if (resourceKey == null) { pos = start + 1; continue; } // unknown, leave as text
if (start > pos) target.Inlines.Add(new System.Windows.Documents.Run(text.Substring(pos, start - pos)));
var run = new System.Windows.Documents.Run();
run.SetResourceReference(System.Windows.Documents.Run.TextProperty, resourceKey);
target.Inlines.Add(run);
pos = end + 1;
}
if (pos < text.Length) target.Inlines.Add(new System.Windows.Documents.Run(text.Substring(pos)));
}
// Fill the two overlay columns from the tables above. Called once from the constructor; the
// SetResourceReference calls keep every string and color live across theme + language changes.
private void BuildShortcutsOverlay()
{
BuildShortcutsColumn(ShortcutLeftColumn, KsLeftColumn);
BuildShortcutsColumn(ShortcutRightColumn, KsRightColumn);
}
private static void BuildShortcutsColumn(StackPanel host, KsSection[] sections)
{
host.Children.Clear();
// The key column sizes to its widest entry instead of a hardcoded width, and every row
// in this column shares that measurement so they stay aligned. Translated key names are
// longer than English - Shift becomes Umschalt, Enter becomes Eingabe - and a fixed
// 132px clipped them (#230). Each column host is its own scope, so the two columns size
// independently.
Grid.SetIsSharedSizeScope(host, true);
for (int s = 0; s < sections.Length; s++)
{
var section = sections[s];
// Section subheader: accent, semibold, 12px top gap except for the first section.
var header = new TextBlock
{
FontFamily = new FontFamily("Segoe UI, Microsoft JhengHei UI, Nirmala UI"),
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Margin = new Thickness(0, s == 0 ? 0 : 12, 0, 4),
};
header.SetResourceReference(TextBlock.TextProperty, section.TitleKey);
// Category color, the same KsCat* brushes the keyboard map lights its keys with,
// so a section reads as the same color in both views (KillerShell's layout).
header.SetResourceReference(TextBlock.ForegroundProperty,
section.Cat.Length > 0 ? "KsCat" + section.Cat : "PrimaryBrush");
host.Children.Add(header);
for (int r = 0; r < section.Rows.Length; r++)
{
var row = section.Rows[r];
bool last = r == section.Rows.Length - 1;
var rowGrid = new Grid { Margin = new Thickness(0, 0, 0, last ? 0 : 4) };
rowGrid.ColumnDefinitions.Add(new ColumnDefinition
{
Width = GridLength.Auto,
SharedSizeGroup = "KsKeys",
MinWidth = 132, // the old fixed width, now a floor rather than a ceiling
});
rowGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
// Keep the shortcut and description on the same vertical centerline. The wider
// key column also leaves a deliberate gap before longer translated labels.
var keys = new TextBlock
{
FontFamily = new FontFamily("Consolas"),
FontSize = 11,
Margin = new Thickness(0, 0, 12, 0),
VerticalAlignment = VerticalAlignment.Center,
};
FillKeyInlines(keys, row.Keys);
keys.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
Grid.SetColumn(keys, 0);
rowGrid.Children.Add(keys);
// Description: fills the rest, wraps when the window is narrow, and stays aligned
// with its shortcut regardless of the localized font's line metrics.
var label = new TextBlock
{
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = VerticalAlignment.Center,
};
label.SetResourceReference(TextBlock.TextProperty, row.LabelKey);
label.SetResourceReference(TextBlock.ForegroundProperty, "TextBrush");
label.SetResourceReference(TextBlock.FontSizeProperty, "Str_KS_FontSize");
Grid.SetColumn(label, 1);
rowGrid.Children.Add(label);
host.Children.Add(rowGrid);
}
}
}
}
}
+343
View File
@@ -0,0 +1,343 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
namespace KillerPDF
{
// Configurable sidebar placement (left or right). The layout uses three columns in
// MainContentGrid: one sized sidebar column, a 6px splitter, and a star document column.
// ApplySidebarSide swaps which outer column is the sidebar and repoints _sidebarCol so the
// existing collapse / resize logic keeps working unchanged.
public partial class MainWindow
{
private void ApplySidebarSide()
{
// SplitHost, NOT Viewer. Since the split landed, the element that sits in
// MainContentGrid is the SPLIT HOST - Viewer is one of its children. Setting
// Grid.Column on Viewer therefore moved pane A into SplitHost's column 2, which is
// PaneBCol and is ZERO WIDTH while unsplit, so the document rendered into a pane with
// no width and the whole content area went blank. Same for the margin below: the 8px
// gutter to the window edge belongs to the host, not to one pane.
//
// Do NOT look up TabStripBorder / TabScroll here: those elements live inside PdfViewer,
// so FindName cannot see them (a UserControl is its own namescope and FindName returns
// null SILENTLY). They would only re-span the band across the splitter column, which is
// meaningless when each strip lives inside its own pane and spans it exactly.
if (FindName("SidebarCol") is not ColumnDefinition sidebarColDef ||
FindName("DocCol") is not ColumnDefinition docColDef ||
FindName("SidebarOuterGrid") is not Grid sbOuter ||
FindName("SidebarBorder") is not Border sbContent ||
FindName("SidebarToggleStrip") is not Border sbToggle ||
SplitHost is not FrameworkElement docPane ||
FindName("SbContentCol") is not ColumnDefinition sbContentCol ||
FindName("SbToggleCol") is not ColumnDefinition sbToggleCol)
return;
// Carry the sized column's current width across a flip (24px when collapsed, else the
// user's width). A star length means it isn't the sized column yet, so fall back.
GridLength sized = _sidebarCollapsed
? new GridLength(SbPx(24))
: (_sidebarCol != null && _sidebarCol.Width.GridUnitType == GridUnitType.Pixel)
? _sidebarCol.Width
: new GridLength(SbPx(180));
double maxW = SbPx(_sidebarShowingOutlines ? SidebarMaxOutlines : SidebarMaxPages);
if (!_sidebarRight)
{
// Sidebar on the LEFT (column 0); document fills column 2.
sidebarColDef.MinWidth = SbPx(_sidebarCollapsed ? 24 : SidebarMinOpen); sidebarColDef.MaxWidth = maxW; sidebarColDef.Width = sized;
docColDef.MinWidth = 0; docColDef.MaxWidth = double.PositiveInfinity;
docColDef.Width = new GridLength(1, GridUnitType.Star);
Grid.SetColumn(sbOuter, 0);
Grid.SetColumn(docPane, 2);
_sidebarCol = sidebarColDef;
// Toggle strip faces the document: right edge of the sidebar.
sbContentCol.Width = new GridLength(1, GridUnitType.Star);
sbToggleCol.Width = new GridLength(24, GridUnitType.Pixel);
Grid.SetColumn(sbContent, 0);
Grid.SetColumn(sbToggle, 1);
}
else
{
// Sidebar on the RIGHT (column 2); document fills column 0.
docColDef.MinWidth = SbPx(_sidebarCollapsed ? 24 : SidebarMinOpen); docColDef.MaxWidth = maxW; docColDef.Width = sized;
sidebarColDef.MinWidth = 0; sidebarColDef.MaxWidth = double.PositiveInfinity;
sidebarColDef.Width = new GridLength(1, GridUnitType.Star);
Grid.SetColumn(sbOuter, 2);
Grid.SetColumn(docPane, 0);
_sidebarCol = docColDef;
// Toggle strip faces the document: left edge of the sidebar (inner column 0). The
// inner column defs are fixed in position, so size them by position, not by name.
sbContentCol.Width = new GridLength(24, GridUnitType.Pixel); // inner col 0 -> toggle
sbToggleCol.Width = new GridLength(1, GridUnitType.Star); // inner col 1 -> content
Grid.SetColumn(sbToggle, 0);
Grid.SetColumn(sbContent, 1);
}
// The splitter's edge-line and the SidebarShadow gradient were both handled here. The
// splitter draws a single centered line now, which is symmetric and so needs no side
// handling, and the fake elevation gradient is gone - DocPaneBorder casts a real
// PaneShadow on all four sides. (2026-07-31.)
// The DocTopAccent / DocBottomAccent repositioning was here. Those two 1px rules are
// gone with the squared layout - the card carries its own border on all four sides now,
// so there is nothing left to bridge to the toolbar and footer. (2026-07-31.)
// The document card's 8px inset always sits on its OUTER edge - the window side, away
// from the splitter - so the gap reads as a margin off the window rather than a gutter
// between the pane and the sidebar. Full screen clears the margin, so leave it alone
// there; ApplyFullScreen restores it from this same helper on exit.
// The grip rides the CONTENT column and faces the rail, so it always sits on the list's
// inner lip: right edge with the sidebar on the left, left edge with it on the right.
if (FindName("SidebarSplitter") is Thumb grip)
{
Grid.SetColumn(grip, _sidebarRight ? 1 : 0);
grip.HorizontalAlignment = _sidebarRight ? HorizontalAlignment.Left
: HorizontalAlignment.Right;
}
// The shadow caster is inside the PdfViewer control, so moving the control moves both -
// one less thing that can drift out of alignment than a separate grid child that has to
// be moved in step with the pane.
if (!_fullScreen)
{
// docPane is SplitHost, so this one margin now insets BOTH panes from the window
// edge together, which is what the 8px gutter always meant.
docPane.Margin = DocPaneInsetMargin();
// No tab-band or TabBarRing margins here. Those exist only to stop a window-level
// band overhanging the card's rounded outer corner, where the band runs the full
// width while the card is inset 8px. Each strip is inside its own pane and spans
// exactly that pane, so the band and the card share an edge by construction.
}
UpdateSidebarToggleGlyph();
SyncThemeFlyoutSide();
UpdateTabStripFade();
// The column swap repositions the document pane; re-anchor the footer shadow once layout
// settles (TransformToVisual needs the final positions).
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)UpdateFooterFade);
}
// Document on the right (sidebar left) -> 8px inset on the right; mirrored when the sidebar
// moves. Top and bottom stay 0: PaneShadow is Direction 270 and draws outside the element's
// bounds without taking layout space, so a bottom gap would be real padding lifting the
// card off the footer, not room for the shadow (KillerShell's ResultsPane comment).
//
// The sidebar side is 0, not a pull-back. That 6px column is a plain gap - KillerShell's
// TreeGapCol - because the grip lives inside the sidebar, so nothing sits there and the
// grain layer paints it like the rest of the surface.
// Top is -1, KillerShell's ResultsPane margin verbatim: the card's own top border tucks
// UNDER the tab band, which is opaque, so the active tab and the pane read as one surface
// instead of being split by a hairline under the active tab. With no tabs open the band is
// collapsed and the -1 just eats a pixel against the toolbar.
private Thickness DocPaneInsetMargin()
{
// This is layout geometry, not a palette value. Keep the zero-inset exception scoped
// to 98SE so it cannot leak into the rounded themes after a live theme switch.
double inset = Services.ThemeManager.Current == Services.Theme.SE98 ? 0 : 8;
return _sidebarRight ? new Thickness(inset, -1, 0, 0)
: new Thickness(0, -1, inset, 0);
}
// The grip drives SidebarCol's width directly (see the XAML comment). Dragging toward the
// document grows the sidebar when it is on the left and shrinks it when on the right, so
// the delta is signed by side. Clamped to the column's own Min/MaxWidth, which the collapse
// and outline/pages modes already maintain, so this cannot drag past a readable minimum.
private void SidebarGrip_DragStarted(object sender, DragStartedEventArgs e)
=> OnSidebarSplitterPress();
private void SidebarGrip_DragCompleted(object sender, DragCompletedEventArgs e)
=> OnSidebarResized();
private void SidebarGrip_DragDelta(object sender, DragDeltaEventArgs e)
{
if (_sidebarCol == null) return;
double w = _sidebarCol.ActualWidth + (_sidebarRight ? -e.HorizontalChange : e.HorizontalChange);
double min = _sidebarCol.MinWidth > 0 ? _sidebarCol.MinWidth : SbPx(24);
double max = double.IsPositiveInfinity(_sidebarCol.MaxWidth) ? double.MaxValue : _sidebarCol.MaxWidth;
_sidebarCol.Width = new GridLength(Math.Max(min, Math.Min(max, w)));
OnSidebarSplitterMove(sender, new System.Windows.Input.MouseEventArgs(
System.Windows.Input.Mouse.PrimaryDevice, Environment.TickCount)
{ RoutedEvent = System.Windows.Input.Mouse.MouseMoveEvent });
}
// A Border with a CornerRadius does not clip its child, so the canvas, the grain and the
// page itself all square the card's corners straight back off. Radius 5 = the card's 6
// less its 1px border, which is where the inner edge of the curve actually falls.
// internal: PdfViewer's XAML binds this and forwards to it.
internal void DocPane_SizeChanged(object sender, SizeChangedEventArgs e)
{
if (sender is not FrameworkElement el) return;
double outer = TryFindResource("PanelCornerRadius") is CornerRadius cr ? cr.TopLeft : 6;
double inner = Math.Max(0, outer - 1);
el.Clip = new RectangleGeometry(new Rect(0, 0, el.ActualWidth, el.ActualHeight), inner, inner);
}
// Clip the tab-strip shadow gradient to the document column so it never falls over the
// sidebar (on whichever side the sidebar sits).
private void UpdateTabStripFade()
{
// The tab-strip gradient band spans the splitter column + document column. BOTH ends are
// feathered. The document-facing end used to keep a hard stop on the reasoning that it
// was the window edge and should be crisp; with the split it is not the window edge at
// all, it is a seam in the middle of the window, and it read as a solid vertical line
// rising out of the pane. A shadow with a visible end is not a shadow.
if (TabStripFade != null)
{
TabStripFade.Margin = new Thickness(0);
double w = TabStripFade.ActualWidth;
if (w > 0)
{
double f = Math.Min(0.5, 32.0 / w); // wider (~32px) feather than the footer - the top
// shadow is darker, so a 15px fade still read as a
// hard vertical edge near the sidebar corner
var mask = new LinearGradientBrush { StartPoint = new Point(0, 0), EndPoint = new Point(1, 0) };
// Same ramp at both ends. The sidebar-facing side keeps the wider feather it
// already had; the document-facing side gets a shorter one, enough to kill the
// hard line without eating into the strip.
double fDoc = Math.Min(0.25, 14.0 / w);
double fNear = _sidebarRight ? fDoc : f;
double fFar = _sidebarRight ? f : fDoc;
mask.GradientStops.Add(new GradientStop(Colors.Transparent, 0));
mask.GradientStops.Add(new GradientStop(Colors.White, fNear));
mask.GradientStops.Add(new GradientStop(Colors.White, 1 - fFar));
mask.GradientStops.Add(new GradientStop(Colors.Transparent, 1));
TabStripFade.OpacityMask = mask;
}
else TabStripFade.OpacityMask = null;
}
UpdateFooterFade();
}
// Keep the portable install action centered on the real A/B boundary. The old footer centered
// the PORTABLE + button group across the whole status bar; after removing the label that left
// no relationship between the button and the pane divider, especially after either pane was
// resized. In a single-pane window, the document pane's center is the natural fallback.
private void UpdateFooterFade()
{
if (_portableBadge is null || SplitHost is null || SplitHost.ActualWidth <= 0) return;
if (_portableBadge.Parent is not Visual footerGrid) return;
try
{
double hostLeft = SplitHost.TransformToVisual(footerGrid)
.Transform(new Point(0, 0)).X;
double anchor = _isSplit && PaneBCol.ActualWidth > 0
? hostLeft + PaneACol.ActualWidth + PaneGutterCol.ActualWidth / 2
: hostLeft + SplitHost.ActualWidth / 2;
double width = _portableBadge.ActualWidth;
if (width <= 0)
{
_portableBadge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
width = _portableBadge.DesiredSize.Width;
}
_portableBadge.Margin = new Thickness(Math.Round(anchor - width / 2), 0, 0, 0);
// Cap the status line so it ellipsizes before reaching the Install action - at a
// small window width a long status painted straight through the button.
if (StatusText is not null)
{
if (_portableBadge.Visibility == Visibility.Visible)
{
double statusLeft = StatusText.TransformToVisual(footerGrid)
.Transform(new Point(0, 0)).X;
double badgeLeft = anchor - width / 2;
StatusText.MaxWidth = Math.Max(0, badgeLeft - statusLeft - 8);
}
else StatusText.MaxWidth = double.PositiveInfinity;
}
}
catch (InvalidOperationException)
{
// The two elements are briefly disconnected during startup/theme reconstruction.
// A queued layout refresh retries once they share the live visual tree again.
}
}
// The collapse arrow points toward where the page-list content goes when toggled, which
// depends on both the side and the collapsed state.
private void UpdateSidebarToggleGlyph()
{
if (_sidebarToggleBtn == null) return;
bool pointLeft = _sidebarRight ? _sidebarCollapsed : !_sidebarCollapsed;
_sidebarToggleBtn.Content = pointLeft ? "" : ""; // ChevronLeft / ChevronRight
}
private void SelectSidebarSide(bool right)
{
if (right == _sidebarRight) return; // no change (e.g. picking the side it's already on)
_sidebarRight = right;
App.SetSetting("SidebarSide", right ? "Right" : "Left");
ApplySidebarSide();
}
// Shift+F9 (pairs with F9, the sidebar collapse toggle).
private void ToggleSidebarSide() => SelectSidebarSide(!_sidebarRight);
// ── Page-list edge fades - KillerShell's transparent-content mask ──
// Each edge fades only while there is a row past it. PageList has no horizontal scrollbar,
// so KillerShell's narrow scrollbar-restoration stops are not needed here.
/// <summary>Called once from the window ctor, beside InitSplitPanes.</summary>
private void WirePageListEdgeFades()
{
// ScrollChanged bubbles, so the ListBox's inner ScrollViewer is reached without
// having to dig it out of the template first. Loaded and SizeChanged cover the
// passes where nothing scrolled but the extent moved (reseat, thumbnail load).
PageList.AddHandler(ScrollViewer.ScrollChangedEvent,
new ScrollChangedEventHandler((_, _) => SyncPageListEdgeFades()));
PageList.SizeChanged += (_, _) => SyncPageListEdgeFades();
PageList.Loaded += (_, _) => SyncPageListEdgeFades();
}
private const double PageListTopFadePx = 18;
private const double PageListBottomFadePx = 22;
private void SyncPageListEdgeFades()
{
var sv = FindSidebarDescendant<ScrollViewer>(PageList);
if (sv == null || PageListFadeHost == null) return;
double height = PageListFadeHost.ActualHeight;
if (height <= 1) return;
// Reveal the actual sidebar underneath; never paint a theme-colored strip over it.
// EdgeFadeOpacity is zero only in 98SE and one in every other theme.
double fade = TryFindResource("EdgeFadeOpacity") is double value ? value : 1.0;
double top = EdgeFadeRamp(sv.VerticalOffset, PageListTopFadePx) * fade;
double bottom = EdgeFadeRamp(
sv.ExtentHeight - sv.ViewportHeight - sv.VerticalOffset,
PageListBottomFadePx) * fade;
PageListFadeTopOuter.Color = FadeMaskAlpha(1 - top);
PageListFadeBottomOuter.Color = FadeMaskAlpha(1 - bottom);
PageListFadeTopInner.Offset = Math.Min(0.45, PageListTopFadePx / height);
PageListFadeBottomInner.Offset = Math.Max(0.5, 1 - PageListBottomFadePx / height);
}
private static double EdgeFadeRamp(double distance, double depth) =>
Math.Min(1, Math.Max(0, distance) / depth);
private static Color FadeMaskAlpha(double opacity) =>
Color.FromArgb(
(byte)Math.Round(Math.Min(1, Math.Max(0, opacity)) * 255),
0, 0, 0);
private static T? FindSidebarDescendant<T>(DependencyObject root) where T : DependencyObject
{
for (int i = 0; i < System.Windows.Media.VisualTreeHelper.GetChildrenCount(root); i++)
{
var c = System.Windows.Media.VisualTreeHelper.GetChild(root, i);
if (c is T t) return t;
var deeper = FindSidebarDescendant<T>(c);
if (deeper != null) return deeper;
}
return null;
}
}
}
+785
View File
@@ -0,0 +1,785 @@
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
{
public partial class MainWindow
{
// ============================================================
// Sidebar outline/bookmark panel
// ============================================================
private void SidebarPagesTab_Click(object sender, RoutedEventArgs e) => SwitchSidebarToPagesTab();
private void SidebarOutlinesTab_Click(object sender, RoutedEventArgs e) => SwitchSidebarToOutlinesTab();
private const double SidebarMaxPages = 234; // stops when the 200px-capped thumbnail fills (200 + margins + scrollbar)
private const double SidebarMaxOutlines = 480;
private const double SidebarMinOpen = 120; // narrowest readable width before labels/header clip
private void SwitchSidebarToPagesTab()
{
_sidebarShowingOutlines = false;
PageList.Visibility = Visibility.Visible;
OutlineScrollViewer.Visibility = Visibility.Collapsed;
PageControlsRow.Visibility = _doc != null ? Visibility.Visible : Visibility.Collapsed; // no empty box when nothing is open
SidebarPagesTab.Foreground = (Brush)FindResource("PrimaryBrush");
SidebarOutlinesTab.Foreground = (Brush)FindResource("MutedTextBrush");
// Save current outlines width before snapping back to pages.
if (!_sidebarCollapsed && _sidebarCol.ActualWidth > 0)
_savedOutlinesWidth = Math.Min(_sidebarCol.ActualWidth, SbPx(SidebarMaxOutlines));
SidebarSplitter.IsEnabled = true; // pages are resizable too now (drag the splitter)
_sidebarCol.MaxWidth = SbPx(SidebarMaxPages);
if (!_sidebarCollapsed)
{
double target = _savedPagesWidth;
Dispatcher.BeginInvoke(
System.Windows.Threading.DispatcherPriority.Render,
(Action)(() => _sidebarCol.Width = new GridLength(target)));
}
}
private void SwitchSidebarToOutlinesTab()
{
// Save current pages width, then restore (or auto-fit) the outlines width.
if (!_sidebarCollapsed && _sidebarCol.ActualWidth > 0)
_savedPagesWidth = Math.Min(_sidebarCol.ActualWidth, SbPx(SidebarMaxPages));
_sidebarShowingOutlines = true;
PageList.Visibility = Visibility.Collapsed;
OutlineScrollViewer.Visibility = Visibility.Visible;
PageControlsRow.Visibility = Visibility.Collapsed;
SidebarPagesTab.Foreground = (Brush)FindResource("MutedTextBrush");
SidebarOutlinesTab.Foreground = (Brush)FindResource("PrimaryBrush");
SidebarSplitter.IsEnabled = true;
_sidebarCol.MaxWidth = SbPx(SidebarMaxOutlines);
if (!_sidebarCollapsed)
{
if (!_outlinesFitted)
Dispatcher.BeginInvoke(
System.Windows.Threading.DispatcherPriority.Render,
(Action)AutoFitOutlineWidth);
else
{
double target = _savedOutlinesWidth;
Dispatcher.BeginInvoke(
System.Windows.Threading.DispatcherPriority.Render,
(Action)(() => _sidebarCol.Width = new GridLength(target)));
}
}
}
/// <summary>
/// Sizes the sidebar to fit the widest outline item by measuring each item's
/// text width via FormattedText plus its indentation depth.
/// </summary>
private void AutoFitOutlineWidth()
{
if (_sidebarCollapsed) return;
var typeface = new Typeface(
OutlineTree.FontFamily, OutlineTree.FontStyle,
OutlineTree.FontWeight, OutlineTree.FontStretch);
double em = OutlineTree.FontSize;
double max = 0;
void Walk(ItemCollection items, int depth)
{
foreach (TreeViewItem node in items)
{
if (node.Tag is not OutlineNodeRef) continue; // ghost add-row: no text to measure
var ft = new System.Windows.Media.FormattedText(
node.Header?.ToString() ?? string.Empty,
System.Globalization.CultureInfo.CurrentUICulture,
FlowDirection.LeftToRight, typeface, em, Brushes.White,
/*pixelsPerDip*/ 1.0);
// 19 px indent per level + 19 px toggle + text + 12 px item padding
double w = depth * 19.0 + 19.0 + ft.Width + 12.0;
if (w > max) max = w;
if (node.Items.Count > 0)
Walk(node.Items, depth + 1);
}
}
Walk(OutlineTree.Items, 0);
// TreeView outer padding (8 px) + sidebar margins + scrollbar gutter (~36 px).
// Measured widths are logical (the tree lives in the scaled grid); the column
// is screen px, so convert.
double target = SbPx(Math.Max(160.0, Math.Min(max + 44.0, SidebarMaxOutlines)));
_savedOutlinesWidth = target;
_outlinesFitted = true;
_sidebarCol.Width = new GridLength(target);
}
private void OutlineTree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
{
if (_suppressOutlineNav) return; // programmatic re-select (e.g. after a move) must not jump the view
if (e.NewValue is TreeViewItem item && item.Tag is OutlineNodeRef nref && nref.PageIndex >= 0 && _doc is not null)
{
if (nref.PageIndex < _doc.PageCount)
{
RecordNavJump(); // Alt+Left retraces the bookmark hop
PageList.SelectedIndex = nref.PageIndex;
}
}
}
// The TreeView's own scroll viewer swallows the wheel before the outer one sees it, so the
// Outlines list wouldn't scroll. Forward the wheel to the outer scroll viewer.
private void OutlineScroll_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
OutlineScrollViewer.ScrollToVerticalOffset(OutlineScrollViewer.VerticalOffset - e.Delta);
e.Handled = true;
}
internal void LoadOutlines()
{
_outlinesFitted = false; // triggers auto-fit on next tab switch
_bmExtraSel.Clear(); // outlines may be gone after a rebuild/undo - selection resets
CaptureOutlineExpandState(); // remember the outgoing tree's expanded branches (per file)
_outlineStateFile = _originalFile ?? _currentFile;
OutlineTree.Items.Clear();
try
{
// #103: _doc.Outlines lazily CREATES an empty outlines object on documents that
// have none, and PdfSharpCore's writer then emits the catalog's /Outlines
// reference without ever writing the object - a dangling xref entry that strict
// parsers (PdfSharpCore itself included) refuse to reopen. Peek at the catalog
// read-only and only touch .Outlines when the document really has one.
bool hasOutlines = _doc?.Internals.Catalog.Elements.ContainsKey("/Outlines") == true;
var outlines = hasOutlines ? _doc!.Outlines : null;
if (outlines is null || outlines.Count == 0)
{
// #133: stay enabled on an editable document so the user can open the panel and
// add a first bookmark (the ghost row is then the only entry); read-only
// documents keep the old gating.
SidebarOutlinesTab.IsEnabled = CanEditBookmarks;
if (CanEditBookmarks) OutlineTree.Items.Add(BuildAddBookmarkGhostRow());
return;
}
SidebarOutlinesTab.IsEnabled = true;
if (CanEditBookmarks) OutlineTree.Items.Add(BuildAddBookmarkGhostRow());
AddOutlineItems(OutlineTree.Items, outlines);
ApplyOutlineExpandState(); // re-apply the user's expand/collapse choices for this file
}
catch
{
// Malformed outline - show a placeholder and don't crash
SidebarOutlinesTab.IsEnabled = false;
}
}
private void AddOutlineItems(ItemCollection target, PdfSharpCore.Pdf.PdfOutlineCollection outlines, int depth = 0)
{
foreach (PdfSharpCore.Pdf.PdfOutline outline in outlines)
{
int pageIdx = GetOutlinePageIndex(outline);
string title = PdfOutlines.FixRawUnicodeTitle(outline.Title ?? string.Empty);
var item = new TreeViewItem
{
Header = string.IsNullOrEmpty(title) ? Loc("Str_Outline_Untitled") : title,
// Top level starts open, deeper levels start folded (the Acrobat default) - a
// deep outline is otherwise a wall on open. ApplyOutlineExpandState overrides
// this with the user's own choices once the file has been seen this session.
IsExpanded = depth == 0,
Tag = new OutlineNodeRef(outline, outlines, pageIdx),
ToolTip = pageIdx >= 0 ? string.Format(Loc("Str_PageLabel"), pageIdx + 1) : null,
Style = (Style)FindResource("OutlineItemStyle")
};
if (outline.Outlines is not null && outline.Outlines.Count > 0)
AddOutlineItems(item.Items, outline.Outlines, depth + 1);
target.Add(item);
}
}
// Sticky expand/collapse per file, keyed by index path ("2/0/1", ghost row excluded).
// LoadOutlines rebuilds the tree from scratch on every tab switch and temp-reload, which
// used to re-expand everything the user had folded - the tree read as force-expanded.
// Keyed by _originalFile (not _currentFile, which temp-reload repoints at a temp path).
private readonly Dictionary<string, HashSet<string>> _outlineExpandState = new();
private string? _outlineStateFile;
/// <summary>Records which outline nodes are expanded in the tree currently on screen,
/// against the file it belongs to. Runs before LoadOutlines clears the tree.</summary>
private void CaptureOutlineExpandState()
{
if (_outlineStateFile is null) return;
var expanded = new HashSet<string>();
bool any = false;
void Walk(ItemCollection items, string prefix)
{
int i = 0;
foreach (var o in items)
{
if (o is not TreeViewItem it || it.Tag is not OutlineNodeRef) continue;
string path = prefix.Length == 0 ? i.ToString() : prefix + "/" + i;
any = true;
if (it.IsExpanded) expanded.Add(path);
Walk(it.Items, path);
i++;
}
}
Walk(OutlineTree.Items, "");
if (any) _outlineExpandState[_outlineStateFile] = expanded;
}
/// <summary>Restores the recorded expand/collapse state for the freshly built tree. A file
/// not seen this session keeps the depth default from AddOutlineItems.</summary>
private void ApplyOutlineExpandState()
{
if (_outlineStateFile is null
|| !_outlineExpandState.TryGetValue(_outlineStateFile, out var expanded)) return;
void Walk(ItemCollection items, string prefix)
{
int i = 0;
foreach (var o in items)
{
if (o is not TreeViewItem it || it.Tag is not OutlineNodeRef) continue;
string path = prefix.Length == 0 ? i.ToString() : prefix + "/" + i;
it.IsExpanded = expanded.Contains(path);
Walk(it.Items, path);
i++;
}
}
Walk(OutlineTree.Items, "");
}
/// <summary>
/// PdfSharpCore only fills DestinationPage when the bookmark's /Dest is a literal array.
/// Bookmarks pointing at a *named* destination leave it null - wkhtmltopdf writes
/// /Dest /__WKANCHOR_n into a flat catalog /Dests dictionary, and since most HTML-to-PDF
/// invoice and statement generators are wkhtmltopdf underneath, that path is common.
/// Fall back to ResolveDest (Links.cs), which already walks /Dests and the /Names /Dests
/// name tree for the link layer.
/// </summary>
private int GetOutlinePageIndex(PdfSharpCore.Pdf.PdfOutline outline)
{
if (outline.DestinationPage is PdfSharpCore.Pdf.PdfPage destPage)
{
for (int i = 0; i < _doc!.PageCount; i++)
if (ReferenceEquals(_doc.Pages[i], destPage)) return i;
}
var dest = outline.Elements.GetValue("/Dest");
if (dest is null
&& outline.Elements.GetValue("/A") is PdfSharpCore.Pdf.PdfDictionary action
&& action.Elements.GetName("/S") == "/GoTo")
{
dest = action.Elements.GetValue("/D");
}
return ResolveDest(dest) ?? -1;
}
// ============================================================
// Bookmark editing (#133): add / rename / delete
// ============================================================
/// <summary>Ties a TreeViewItem to its PdfOutline and the collection that contains it.</summary>
private sealed class OutlineNodeRef
{
public readonly PdfSharpCore.Pdf.PdfOutline Outline;
public readonly PdfSharpCore.Pdf.PdfOutlineCollection Parent;
public readonly int PageIndex;
public OutlineNodeRef(PdfSharpCore.Pdf.PdfOutline outline,
PdfSharpCore.Pdf.PdfOutlineCollection parent, int pageIndex)
{ Outline = outline; Parent = parent; PageIndex = pageIndex; }
}
// PdfSharpCore cannot save a document opened read-only (owner-password or XRef-fallback
// opens), so bookmark editing is hidden there rather than failing at save time.
private bool CanEditBookmarks => _doc is not null && !_doc.IsReadOnly;
// Multi-select (#133 phase 2). WPF's TreeView is hard single-select, so its built-in
// selection stays the "primary" item and Ctrl/Shift clicks maintain this extra set on top.
// Keyed by PdfOutline object so the selection survives tree rebuilds within one document.
private readonly HashSet<PdfSharpCore.Pdf.PdfOutline> _bmExtraSel = new();
private bool _suppressOutlineNav;
/// <summary>All bookmark rows in visual order (optionally only rows currently visible,
/// i.e. with every ancestor expanded). The ghost add-row is never included.</summary>
private static void FlattenBookmarkItems(ItemCollection items, bool visibleOnly,
List<(TreeViewItem Item, OutlineNodeRef Ref)> into)
{
foreach (TreeViewItem it in items)
{
if (it.Tag is OutlineNodeRef r) into.Add((it, r));
if (!visibleOnly || it.IsExpanded)
FlattenBookmarkItems(it.Items, visibleOnly, into);
}
}
/// <summary>Paints/clears the extra-selection look. The item template's IsSelected trigger
/// drives Bd.Background/BorderBrush + Foreground; extras set the same three locally (local
/// values outrank template triggers) and ClearValue restores normal styling.</summary>
private void ApplyExtraSelectionVisuals()
{
var all = new List<(TreeViewItem Item, OutlineNodeRef Ref)>();
FlattenBookmarkItems(OutlineTree.Items, visibleOnly: false, all);
foreach (var (it, r) in all)
{
it.ApplyTemplate();
var bd = it.Template?.FindName("Bd", it) as Border;
if (_bmExtraSel.Contains(r.Outline))
{
if (bd is not null)
{
bd.Background = UiKit.Brush("SelectionBg");
bd.BorderBrush = UiKit.Brush("PrimaryBrush");
}
it.Foreground = Brushes.White; // matches the IsSelected trigger
}
else
{
if (bd is not null)
{
bd.ClearValue(Border.BackgroundProperty);
bd.ClearValue(Border.BorderBrushProperty);
}
it.ClearValue(ForegroundProperty);
}
}
}
private void ClearBookmarkMultiSelection()
{
if (_bmExtraSel.Count == 0) return;
_bmExtraSel.Clear();
ApplyExtraSelectionVisuals();
}
// True when the click landed on the expand/collapse toggle - those pass through untouched.
private static bool IsExpanderClick(DependencyObject? d)
{
while (d is not null && d is not TreeViewItem)
{
if (d is System.Windows.Controls.Primitives.ToggleButton) return true;
d = d is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(d)
: LogicalTreeHelper.GetParent(d);
}
return false;
}
private void OutlineTree_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (IsExpanderClick(e.OriginalSource as DependencyObject)) return;
var tvi = OutlineItemAt(e.OriginalSource as DependencyObject);
bool ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
bool shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
if (tvi?.Tag is not OutlineNodeRef nref || !CanEditBookmarks || (!ctrl && !shift))
{
// Plain click, ghost row, or empty space: default single-selection behavior.
ClearBookmarkMultiSelection();
return;
}
if (ctrl)
{
// Fold the primary into the set so the whole selection lives in one place, then toggle.
if (OutlineTree.SelectedItem is TreeViewItem prim && prim.Tag is OutlineNodeRef pr)
_bmExtraSel.Add(pr.Outline);
if (!_bmExtraSel.Add(nref.Outline)) _bmExtraSel.Remove(nref.Outline);
}
else
{
// Shift: range from the primary to the clicked row, in visible order.
_bmExtraSel.Clear();
var flat = new List<(TreeViewItem Item, OutlineNodeRef Ref)>();
FlattenBookmarkItems(OutlineTree.Items, visibleOnly: true, flat);
var primary = (OutlineTree.SelectedItem as TreeViewItem)?.Tag as OutlineNodeRef;
int ia = primary is null ? -1 : flat.FindIndex(t => ReferenceEquals(t.Ref, primary));
int ib = flat.FindIndex(t => ReferenceEquals(t.Item, tvi));
if (ib < 0) return;
if (ia < 0) ia = ib;
for (int k = Math.Min(ia, ib); k <= Math.Max(ia, ib); k++)
_bmExtraSel.Add(flat[k].Ref.Outline);
}
ApplyExtraSelectionVisuals();
e.Handled = true; // keep the built-in primary selection where it is
}
private void OutlineTree_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (!CanEditBookmarks) return;
if (e.OriginalSource is TextBox) return; // inline rename in progress: Delete edits text, not bookmarks
var primary = (OutlineTree.SelectedItem as TreeViewItem)?.Tag as OutlineNodeRef;
if (e.Key == Key.Delete && (primary is not null || _bmExtraSel.Count > 0))
{
e.Handled = true;
DeleteSelectedBookmarks(primary);
}
else if (e.Key == Key.F2 && primary is not null && OutlineTree.SelectedItem is TreeViewItem tvi)
{
e.Handled = true;
BeginInlineRename(tvi, primary);
}
}
/// <summary>The add action lives as a dim first row inside the tree itself (#133): a + glyph
/// and "Add bookmark", brightening on hover. Tag stays null so the selection handler, the
/// context menu, width auto-fit, and the refresh walks all treat it as a non-bookmark row.</summary>
private TreeViewItem BuildAddBookmarkGhostRow()
{
var icon = new TextBlock
{
Text = "\uE710", // Segoe MDL2 Add
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 5, 0)
};
var text = new TextBlock { Text = Loc("Str_Ctx_BmAdd"), VerticalAlignment = VerticalAlignment.Center };
var panel = new StackPanel { Orientation = Orientation.Horizontal, Opacity = 0.55 };
panel.Children.Add(icon);
panel.Children.Add(text);
var item = new TreeViewItem
{
Header = panel,
ToolTip = Loc("Str_TT_AddBookmark"),
Style = (Style)FindResource("OutlineItemStyle")
};
item.MouseEnter += (_, _2) => panel.Opacity = 1.0;
item.MouseLeave += (_, _2) => panel.Opacity = 0.55;
item.PreviewMouseLeftButtonUp += (_, e) => { e.Handled = true; AddBookmarkInto(null); };
return item;
}
/// <summary>Adds a bookmark pointing at the current page - to the root list, or as a child of
/// <paramref name="parent"/> - titled "Page N", then drops straight into an inline rename of
/// the new entry (no dialog). Esc keeps the default title.</summary>
private void AddBookmarkInto(OutlineNodeRef? parent)
{
if (!CanEditBookmarks) return;
if (parent is not null && !ReferenceEquals(parent.Outline.Owner, _doc)) { LoadOutlines(); return; } // stale ref (doc was reloaded)
int page = Math.Max(0, PageList.SelectedIndex);
if (page >= _doc!.PageCount) page = _doc.PageCount - 1;
if (page < 0) return;
PushDocUndo(); // bookmark ops ride the document-snapshot undo like crop/page ops do
var col = parent is null ? _doc.Outlines : parent.Outline.Outlines;
var added = col.Add(string.Format(Loc("Str_Bm_DefaultTitle"), page + 1), _doc.Pages[page], true);
PdfOutlines.ScrubStaleOutlineLinkKeys(_doc);
MarkDirty(true);
RefreshOutlines();
if (FindOutlineItem(OutlineTree.Items, added) is { } tvi && tvi.Tag is OutlineNodeRef nref)
{
tvi.BringIntoView();
BeginInlineRename(tvi, nref);
}
}
/// <summary>Swaps a tree item's header for an inline TextBox (rename-in-place; also used right
/// after adding). Enter or clicking elsewhere commits, Esc cancels.</summary>
private void BeginInlineRename(TreeViewItem tvi, OutlineNodeRef nref)
{
if (!CanEditBookmarks) return;
if (!ReferenceEquals(nref.Outline.Owner, _doc)) { LoadOutlines(); return; } // stale ref (doc was reloaded)
string current = PdfOutlines.FixRawUnicodeTitle(nref.Outline.Title ?? string.Empty);
// UiKit.Field: self-templated, so the OS-default white box / blue focus chrome never shows.
var box = UiKit.Field();
box.Text = current;
box.MinWidth = 110;
box.FontSize = OutlineTree.FontSize;
box.Padding = new Thickness(3, 1, 3, 1);
box.BorderBrush = UiKit.Brush("PrimaryBrush"); // accent border = active in-place edit
box.CaretBrush = UiKit.Brush("PrimaryBrush");
bool done = false;
void Commit()
{
if (done) return;
done = true;
string t = box.Text.Trim();
if (t.Length > 0 && t != current)
{
PushDocUndo();
nref.Outline.Title = t; // the setter writes a proper Unicode string, healing mojibake entries
MarkDirty(true);
RefreshOutlines();
}
else
tvi.Header = string.IsNullOrEmpty(current) ? "(untitled)" : current;
}
void Cancel()
{
if (done) return;
done = true;
tvi.Header = string.IsNullOrEmpty(current) ? "(untitled)" : current;
}
box.PreviewKeyDown += (_, ke) =>
{
if (ke.Key == Key.Enter) { ke.Handled = true; Commit(); }
if (ke.Key == Key.Escape) { ke.Handled = true; Cancel(); }
};
box.LostFocus += (_, _2) => Commit();
tvi.Header = box;
// The box can't take focus until it has been laid out - focus it after render.
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
(Action)(() => { box.Focus(); box.SelectAll(); }));
}
/// <summary>Finds the tree item for a PdfOutline, expanding collapsed ancestors on the way.</summary>
private static TreeViewItem? FindOutlineItem(ItemCollection items, object outline)
{
foreach (TreeViewItem it in items)
{
if (it.Tag is OutlineNodeRef r && ReferenceEquals(r.Outline, outline)) return it;
if (FindOutlineItem(it.Items, outline) is { } hit) { it.IsExpanded = true; return hit; }
}
return null;
}
/// <summary>Deletes the multi-selection if one exists, plus the clicked/primary item. One
/// confirm covers the whole set; one undo entry restores it.</summary>
private void DeleteSelectedBookmarks(OutlineNodeRef? clicked)
{
if (!CanEditBookmarks) return;
if (clicked is not null && !ReferenceEquals(clicked.Outline.Owner, _doc)) { LoadOutlines(); return; } // stale ref (doc was reloaded)
// Gather targets: the extra set, the primary, and the clicked item, deduplicated.
var all = new List<(TreeViewItem Item, OutlineNodeRef Ref)>();
FlattenBookmarkItems(OutlineTree.Items, visibleOnly: false, all);
var targets = new List<OutlineNodeRef>();
foreach (var (_, r) in all)
if (_bmExtraSel.Contains(r.Outline)) targets.Add(r);
void AddTarget(OutlineNodeRef? r)
{
if (r is not null && !targets.Any(t => ReferenceEquals(t.Outline, r.Outline))) targets.Add(r);
}
AddTarget((OutlineTree.SelectedItem as TreeViewItem)?.Tag as OutlineNodeRef);
AddTarget(clicked);
if (targets.Count == 0) return;
// A target with a selected ancestor is covered by deleting the ancestor - drop it so the
// remaining targets are independent (their parent collections stay valid during removal).
var chosen = new HashSet<object>(targets.Select(t => (object)t.Outline));
bool Covered(PdfSharpCore.Pdf.PdfOutline o)
{
for (var p = o.Parent; p is not null; p = p.Parent)
if (chosen.Contains(p)) return true;
return false;
}
targets = targets.Where(t => !Covered(t.Outline)).ToList();
int total = targets.Sum(t => 1 + PdfOutlines.CountOutlines(t.Outline.Outlines));
if (total > 1)
{
string msg = targets.Count == 1
? string.Format(Loc("Str_Bm_DeleteKids"), total - 1)
: string.Format(Loc("Str_Bm_DeleteMulti"), total);
var r = KillerDialog.Show(this, msg, Loc("Str_Dlg_AppTitle"),
MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (r != MessageBoxResult.Yes) return;
}
PushDocUndo(); // one Ctrl+Z restores the whole set
foreach (var t in targets)
PdfOutlines.RemoveOutlineRecursive(t.Parent, t.Outline);
PdfOutlines.ScrubStaleOutlineLinkKeys(_doc);
MarkDirty(true);
RefreshOutlines(); // also clears _bmExtraSel via LoadOutlines
}
/// <summary>Moves a bookmark one position up or down among its siblings.</summary>
private void MoveBookmark(OutlineNodeRef nref, int delta)
{
if (!CanEditBookmarks) return;
if (!ReferenceEquals(nref.Outline.Owner, _doc)) { LoadOutlines(); return; } // stale ref (doc was reloaded)
int i = nref.Parent.IndexOf(nref.Outline);
int j = i + delta;
if (i < 0 || j < 0 || j >= nref.Parent.Count) return;
PushDocUndo();
// RemoveAt drops the object from the xref table; Insert/Add puts it straight back.
nref.Parent.RemoveAt(i);
if (j >= nref.Parent.Count) nref.Parent.Add(nref.Outline);
else nref.Parent.Insert(j, nref.Outline);
PdfOutlines.ScrubStaleOutlineLinkKeys(_doc);
MarkDirty(true);
RefreshOutlines();
// Keep the moved item selected, without the page-jump side effect.
if (FindOutlineItem(OutlineTree.Items, nref.Outline) is { } moved)
{
_suppressOutlineNav = true;
try { moved.IsSelected = true; moved.BringIntoView(); }
finally { _suppressOutlineNav = false; }
}
}
/// <summary>Repoints a bookmark at the current page as a plain go-to-page destination.</summary>
private void SetBookmarkDestination(OutlineNodeRef nref)
{
if (!CanEditBookmarks) return;
if (!ReferenceEquals(nref.Outline.Owner, _doc)) { LoadOutlines(); return; } // stale ref (doc was reloaded)
int page = Math.Max(0, PageList.SelectedIndex);
if (page >= _doc!.PageCount) page = _doc.PageCount - 1;
if (page < 0) return;
PushDocUndo();
nref.Outline.DestinationPage = _doc.Pages[page];
// Plain jump: /XYZ null null null keeps the reader's current zoom/position behavior.
nref.Outline.PageDestinationType = PdfSharpCore.Pdf.PdfPageDestinationType.Xyz;
nref.Outline.Left = double.NaN;
nref.Outline.Top = double.NaN;
nref.Outline.Zoom = double.NaN;
MarkDirty(true);
RefreshOutlines();
}
/// <summary>Removes every bookmark in the document (one confirm, one undo entry).</summary>
private void DeleteAllBookmarks()
{
if (!CanEditBookmarks || _doc is null) return;
if (_doc.Internals.Catalog.Elements["/Outlines"] is null) return; // nothing to do, and never plant one
if (_doc.Outlines.Count == 0) return;
var r = KillerDialog.Show(this, Loc("Str_Bm_DeleteAllConfirm"), Loc("Str_Dlg_AppTitle"),
MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (r != MessageBoxResult.Yes) return;
PushDocUndo();
while (_doc.Outlines.Count > 0)
PdfOutlines.RemoveOutlineRecursive(_doc.Outlines, _doc.Outlines[_doc.Outlines.Count - 1]);
PdfOutlines.ScrubStaleOutlineLinkKeys(_doc);
MarkDirty(true);
RefreshOutlines();
}
// CountOutlines, RemoveOutlineRecursive and the stale-link-key scrubs live in
// Services/PdfOutlines.cs (KillerUI refactor) - pure functions over the outline tree.
/// <summary>Rebuilds the outline panel after an edit, keeping every branch's expand/collapse
/// state (the PdfOutline objects survive the rebuild, so they key the state).</summary>
private void RefreshOutlines()
{
// BOTH states, keyed by the surviving PdfOutline objects - index paths shift when a
// bookmark is added or removed, so the path-keyed state LoadOutlines restores can land
// on the wrong siblings after an edit. This object-keyed pass corrects every node that
// existed before the edit; only genuinely new nodes keep the build default.
var expandedBy = new Dictionary<object, bool>();
void Capture(ItemCollection items)
{
foreach (TreeViewItem it in items)
{
if (it.Tag is OutlineNodeRef r) expandedBy[r.Outline] = it.IsExpanded;
Capture(it.Items);
}
}
Capture(OutlineTree.Items);
// LoadOutlines re-arms the sidebar width auto-fit (_outlinesFitted = false), which is right
// for a NEW document but wrong here: after a bookmark edit the next tab switch would re-fit
// and override the width the user dragged the sidebar to. The panel must stay where the
// user put it - preserve the flag across the rebuild.
bool fitted = _outlinesFitted;
LoadOutlines();
_outlinesFitted = fitted;
if (expandedBy.Count == 0) return;
void Restore(ItemCollection items)
{
foreach (TreeViewItem it in items)
{
if (it.Tag is OutlineNodeRef r && expandedBy.TryGetValue(r.Outline, out bool ex))
it.IsExpanded = ex;
Restore(it.Items);
}
}
Restore(OutlineTree.Items);
}
/// <summary>Right-click on the outline panel: bookmark menu for the item under the cursor,
/// or the add-bookmark menu on empty space. Hidden entirely on read-only documents.</summary>
private void OutlineTree_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
if (!CanEditBookmarks) return;
var tvi = OutlineItemAt(e.OriginalSource as DependencyObject);
var menu = MakeThemedMenu();
if (tvi?.Tag is OutlineNodeRef nref)
{
// Right-click outside the multi-selection collapses it to the clicked item (the
// file-explorer convention); inside it, the menu acts on the whole set.
bool inMulti = _bmExtraSel.Contains(nref.Outline);
if (!inMulti) ClearBookmarkMultiSelection();
_suppressOutlineNav = true;
try { tvi.IsSelected = true; } // WPF doesn't select on right-click by itself
finally { _suppressOutlineNav = false; }
if (inMulti && _bmExtraSel.Count > 1)
{
menu.Items.Add(MakeMenuItem($"{Loc("Str_Ctx_BmDelete")} ({_bmExtraSel.Count})",
(_, _2) => DeleteSelectedBookmarks(nref), "Delete", ""));
}
else
{
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_BmRename"), (_, _2) => BeginInlineRename(tvi, nref), "F2", ""));
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_BmAddChild"), (_, _2) => AddBookmarkInto(nref), glyph: ""));
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_BmSetDest"), (_, _2) => SetBookmarkDestination(nref), glyph: ""));
menu.Items.Add(new Separator());
int idx = nref.Parent.IndexOf(nref.Outline);
var up = MakeMenuItem(Loc("Str_Ctx_BmMoveUp"), (_, _2) => MoveBookmark(nref, -1), glyph: "");
up.IsEnabled = idx > 0;
menu.Items.Add(up);
var down = MakeMenuItem(Loc("Str_Ctx_BmMoveDown"), (_, _2) => MoveBookmark(nref, +1), glyph: "");
down.IsEnabled = idx >= 0 && idx < nref.Parent.Count - 1;
menu.Items.Add(down);
menu.Items.Add(new Separator());
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_BmDelete"), (_, _2) => DeleteSelectedBookmarks(nref), "Delete", ""));
}
}
else
{
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_BmAdd"), (_, _2) => AddBookmarkInto(null), glyph: ""));
bool hasAny = _doc?.Internals.Catalog.Elements["/Outlines"] is not null
&& OutlineTree.Items.Count > 1; // ghost row + at least one real entry
if (hasAny)
{
menu.Items.Add(new Separator());
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_BmDeleteAll"), (_, _2) => DeleteAllBookmarks(), glyph: ""));
}
}
menu.PlacementTarget = OutlineTree;
menu.IsOpen = true;
e.Handled = true;
}
private static TreeViewItem? OutlineItemAt(DependencyObject? d)
{
while (d is not null && d is not TreeViewItem)
d = d is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(d)
: LogicalTreeHelper.GetParent(d); // e.g. a Run inside the header
return d as TreeViewItem;
}
private void ToolSelect_Click(object sender, RoutedEventArgs e) => SetTool(EditTool.Select);
private void ToolText_Click(object sender, RoutedEventArgs e) => SetTool(EditTool.Text);
private void ToolHighlight_Click(object sender, RoutedEventArgs e) => SetTool(EditTool.Highlight);
private void ToolLine_Click(object sender, RoutedEventArgs e) => SetTool(EditTool.Line);
private void ToolDraw_Click(object sender, RoutedEventArgs e) => SetTool(EditTool.Draw);
private void ToolShape_Click(object sender, RoutedEventArgs e) => SetTool(EditTool.Shape);
private void ToolImage_Click(object sender, RoutedEventArgs e) => SetTool(EditTool.Image);
private void ToolCrop_Click(object sender, RoutedEventArgs e) => SetTool(EditTool.Crop);
private void ToolSignature_Click(object sender, RoutedEventArgs e)
{
if (_signaturePopup is not null)
{
HideSignaturePopup();
if (_currentTool == EditTool.Signature && _pendingSignature is null)
SetTool(EditTool.Select);
return;
}
SetTool(EditTool.Signature);
ShowSignaturePopup();
}
}
}
+880
View File
@@ -0,0 +1,880 @@
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
{
public partial class MainWindow
{
private void LoadSignatures() => _signatureStore.Load();
private void PersistSignatures() => _signatureStore.Persist();
// Rebuild the signature popup (if open) so its Loc()-built labels - section headers, pen sizes -
// switch immediately on a language change rather than only on the next open.
private void RefreshSignaturePopupLanguage()
{
if (_signaturePopup is not null) ShowSignaturePopup();
}
private void PlaceSignature(Point pos, int pageIdx)
{
if (_pendingSignature is null) return;
var sig = _pendingSignature;
double scale = sig.Kind == SignatureKind.Initials ? 0.3 : 0.5;
var annot = new SignatureAnnotation
{
PageIndex = pageIdx,
Position = pos,
Scale = scale,
StrokeWidth = sig.StrokeWidth,
SourceWidth = sig.CanvasWidth,
SourceHeight = sig.CanvasHeight,
ImageData = sig.ImageData
};
// Drawn signature - convert serializable points to WPF points
if (sig.ImageData is null)
{
foreach (var stroke in sig.Strokes)
annot.Strokes.Add([..stroke.Select(p => new Point(p.X, p.Y))]);
}
AddAnnotation(annot);
RenderAllAnnotations(pageIdx);
// Auto-switch to Select and select the placed signature so the user
// can immediately reposition or resize without an extra click.
SetTool(EditTool.Select);
double sigW = sig.CanvasWidth * scale;
double sigH = sig.CanvasHeight * scale;
SelectAnnotation(annot, new Rect(pos.X, pos.Y, sigW, sigH));
SetStatus(Loc("Str_St_SignaturePlaced"));
}
// Already signed -> change/remove menu. Otherwise drop the reusable choice, or open the
// picker the first time and route the pick back to this field.
private void FillSignField(bool initials, int objNum, int pageIndex, double x, double y, double w, double h)
{
if (_signedFields.ContainsKey(objNum))
{
ShowSignedFieldMenu(initials, objNum, pageIndex, x, y, w, h);
return;
}
var choice = initials ? _activeInitialsChoice : _activeSignatureChoice;
if (choice is null)
{
_pendingSignField = (initials, objNum, pageIndex, x, y, w, h);
ShowSignaturePopup();
SetStatus(initials
? "Choose initials - they will be reused for every initials field"
: "Choose a signature - it will be reused for every signature field");
return;
}
DropSignatureInField(objNum, choice, pageIndex, x, y, w, h);
}
// Re-clicking a signed field: change (re-pick) or remove it.
private void ShowSignedFieldMenu(bool initials, int objNum, int pageIndex, double x, double y, double w, double h)
{
string what = initials ? "initials" : "signature";
var menu = MakeThemedMenu();
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint;
menu.Items.Add(MakeMenuItem("Change " + what, (_, _) =>
{
RemoveSignedField(objNum, pageIndex);
_pendingSignField = (initials, objNum, pageIndex, x, y, w, h);
ShowSignaturePopup();
}, glyph: ""));
menu.Items.Add(MakeMenuItem("Remove " + what, (_, _) => RemoveSignedField(objNum, pageIndex), glyph: ""));
menu.IsOpen = true;
}
// Deletes the signature placed in a field and clears its signed state.
private void RemoveSignedField(int objNum, int pageIndex)
{
if (!_signedFields.TryGetValue(objNum, out var annot)) return;
if (_annotations.TryGetValue(pageIndex, out var list)) list.Remove(annot);
_signedFields.Remove(objNum);
RenderAllAnnotations(pageIndex);
MarkDirty(true);
SetStatus(Loc("Str_St_FieldCleared"));
}
// Places a SignatureAnnotation centered in and scaled to fit the field rectangle.
private void DropSignatureInField(int objNum, SavedSignature sig, int pageIndex, double x, double y, double w, double h)
{
const double pad = 2;
double sw = sig.CanvasWidth, sh = sig.CanvasHeight;
// Older or damaged signature-store entries can carry zero or non-finite canvas
// dimensions. Dividing the field size by those values produces an infinite scale,
// which later crashes WPF when the annotation is assigned an infinite Height (#181).
if (double.IsNaN(sw) || double.IsInfinity(sw) || sw <= 0) sw = 400;
if (double.IsNaN(sh) || double.IsInfinity(sh) || sh <= 0) sh = 150;
double scale = Math.Min((w - 2 * pad) / sw, (h - 2 * pad) / sh);
if (scale <= 0) scale = Math.Min(w / sw, h / sh);
if (double.IsNaN(scale) || double.IsInfinity(scale) || scale <= 0) scale = 0.5;
double drawW = sw * scale, drawH = sh * scale;
double px = x + (w - drawW) / 2;
double py = y + (h - drawH) / 2;
var annot = new SignatureAnnotation
{
PageIndex = pageIndex,
Position = new Point(px, py),
Scale = scale,
StrokeWidth = sig.StrokeWidth,
SourceWidth = sw,
SourceHeight = sh,
ImageData = sig.ImageData,
};
if (sig.ImageData is null)
foreach (var stroke in sig.Strokes)
annot.Strokes.Add([.. stroke.Select(pt => new Point(pt.X, pt.Y))]);
_signedFields[objNum] = annot;
AddAnnotation(annot);
RenderAllAnnotations(pageIndex);
MarkDirty(true);
SetStatus(Loc("Str_St_FieldSigned"));
}
private void HideSignaturePopup()
{
if (_signaturePopup is not null)
{
var previewGrid = PagePreviewPanel.Parent as Grid;
var popup = _signaturePopup;
_signaturePopup = null; // detach now so a re-open builds a fresh popup
// Fade out (it's a Border, not a Window, so WindowFx doesn't apply) then remove.
var fade = new DoubleAnimation(popup.Opacity, 0,
new Duration(TimeSpan.FromMilliseconds(WindowFx.FadeMs)))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } };
fade.Completed += (_, _) => previewGrid?.Children.Remove(popup);
popup.BeginAnimation(UIElement.OpacityProperty, fade);
}
}
// ---- Draggable floating panels (signature popup, settings panel) -------------------------
/// <summary>
/// Clamps a Left/Top so a panel stays fully inside its container's bounds.
/// </summary>
private static void ClampPanelToBounds(FrameworkElement panel, FrameworkElement bounds,
ref double left, ref double top)
{
double w = panel.ActualWidth > 0 ? panel.ActualWidth : panel.Width;
double h = panel.ActualHeight > 0 ? panel.ActualHeight : panel.Height;
if (double.IsNaN(w)) w = 0;
if (double.IsNaN(h)) h = 0;
double maxLeft = Math.Max(0, bounds.ActualWidth - w);
double maxTop = Math.Max(0, bounds.ActualHeight - h);
left = Math.Max(0, Math.Min(maxLeft, left));
top = Math.Max(0, Math.Min(maxTop, top));
}
/// <summary>
/// Positions a Left/Top-aligned floating panel from its saved position, falling back to a
/// top-right inset when nothing is stored. Always clamped inside <paramref name="bounds"/>.
/// Must run after layout so the panel's ActualWidth/Height are known.
/// </summary>
private void ApplySavedPanelPosition(FrameworkElement panel, FrameworkElement bounds, string keyPrefix,
double fallbackRightInset, double fallbackTop)
{
double w = panel.ActualWidth > 0 ? panel.ActualWidth : (double.IsNaN(panel.Width) ? 0 : panel.Width);
double left, top;
if (int.TryParse(App.GetSetting(keyPrefix + "Left"), out int sl) &&
int.TryParse(App.GetSetting(keyPrefix + "Top"), out int st))
{
left = sl; top = st;
}
else
{
left = bounds.ActualWidth - w - fallbackRightInset;
top = fallbackTop;
}
ClampPanelToBounds(panel, bounds, ref left, ref top);
panel.Margin = new Thickness(left, top, 0, 0);
}
/// <summary>
/// Makes <paramref name="handle"/> drag <paramref name="panel"/> within <paramref name="bounds"/>,
/// clamped to stay inside, and persists the resulting position under <paramref name="keyPrefix"/>.
/// </summary>
private void EnablePanelDrag(FrameworkElement handle, FrameworkElement panel, FrameworkElement bounds,
string keyPrefix)
{
handle.Cursor = Cursors.SizeAll;
Point start = default;
Thickness orig = default;
bool dragging = false;
handle.MouseLeftButtonDown += (s, e) =>
{
dragging = true;
start = e.GetPosition(bounds);
orig = panel.Margin;
handle.CaptureMouse();
e.Handled = true;
};
handle.MouseMove += (s, e) =>
{
if (!dragging) return;
var p = e.GetPosition(bounds);
double nl = orig.Left + (p.X - start.X);
double nt = orig.Top + (p.Y - start.Y);
ClampPanelToBounds(panel, bounds, ref nl, ref nt);
panel.Margin = new Thickness(nl, nt, 0, 0);
};
handle.MouseLeftButtonUp += (s, e) =>
{
if (!dragging) return;
dragging = false;
handle.ReleaseMouseCapture();
App.SetSetting(keyPrefix + "Left", ((int)panel.Margin.Left).ToString());
App.SetSetting(keyPrefix + "Top", ((int)panel.Margin.Top).ToString());
e.Handled = true;
};
}
private void RenderSignaturePreview(Canvas canvas, SavedSignature sig, double targetW, double targetH)
{
double scaleX = targetW / sig.CanvasWidth;
double scaleY = targetH / sig.CanvasHeight;
double scale = Math.Min(scaleX, scaleY) * 0.9;
double offsetX = (targetW - sig.CanvasWidth * scale) / 2;
double offsetY = (targetH - sig.CanvasHeight * scale) / 2;
foreach (var stroke in sig.Strokes)
{
if (stroke.Count < 2) continue;
var poly = new Polyline
{
Stroke = Brushes.Black,
StrokeThickness = Math.Max(0.8, sig.StrokeWidth * scale),
StrokeLineJoin = PenLineJoin.Round,
StrokeStartLineCap = PenLineCap.Round,
StrokeEndLineCap = PenLineCap.Round
};
foreach (var pt in stroke)
poly.Points.Add(new Point(pt.X * scale + offsetX, pt.Y * scale + offsetY));
canvas.Children.Add(poly);
}
}
private void ShowSignaturePopup()
{
// NOTE: this popup is rebuilt on every open. All event handlers here are lambdas
// on the popup's own child elements - no external source subscriptions, so no leak.
// If SignatureStore.Signatures ever becomes ObservableCollection and this popup
// subscribes to CollectionChanged, use CollectionChangedEventManager instead of +=.
HideSignaturePopup();
bool classicCaption = FindResource("UseDialogCaption") is true;
var stack = new StackPanel { Margin = classicCaption ? new Thickness(0) : new Thickness(4) };
// Title doubles as a drag handle so the user can move the popup anywhere inside the
// document area (position is remembered). Wrapped in a transparent Border so the whole
// title strip is grabbable, not just the text glyphs.
var sigHeaderGrid = new Grid();
sigHeaderGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
sigHeaderGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var sigTitleText = new TextBlock
{
Text = Loc("Str_Sig_Title"),
Foreground = (Brush)FindResource(classicCaption ? "ChromeTextBrush" : "PrimaryBrush"),
FontFamily = classicCaption
? (FontFamily)FindResource("ChromeFontFamily")
: UiKit.MonoFont,
FontWeight = FontWeights.Bold,
FontSize = classicCaption ? 11 : 14,
Margin = classicCaption ? new Thickness(0) : new Thickness(4, 2, 4, 2),
VerticalAlignment = VerticalAlignment.Center,
Effect = classicCaption ? null : new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 2, ShadowDepth = 1, Direction = 270, Opacity = 0.7 }
};
Grid.SetColumn(sigTitleText, 0);
void ClosePicker()
{
HideSignaturePopup();
if (_currentTool == EditTool.Signature && _pendingSignature is null)
SetTool(EditTool.Select);
}
FrameworkElement sigCloseControl;
if (classicCaption)
{
var closeButton = new Button
{
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
FocusVisualStyle = null,
Style = (Style)FindResource("ChromeCloseButton")
};
closeButton.SetResourceReference(FrameworkElement.WidthProperty, "DialogCloseWidth");
closeButton.SetResourceReference(FrameworkElement.HeightProperty, "DialogCloseHeight");
closeButton.SetResourceReference(FrameworkElement.MarginProperty, "DialogCaptionButtonsMargin");
closeButton.PreviewMouseLeftButtonDown += (_, e) => { e.Handled = true; ClosePicker(); };
sigCloseControl = closeButton;
}
else
{
var closeText = new TextBlock
{
Text = "",
FontFamily = UiKit.IconFont,
FontSize = 11,
Foreground = (SolidColorBrush)FindResource("MutedTextBrush"),
Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
Padding = new Thickness(4)
};
closeText.MouseEnter += (_, _) => { closeText.Foreground = (SolidColorBrush)FindResource("DangerRed"); closeText.Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 4, ShadowDepth = 1, Direction = 270, Opacity = 0.5 }; };
closeText.MouseLeave += (_, _) => { closeText.Foreground = (SolidColorBrush)FindResource("MutedTextBrush"); closeText.Effect = null; };
closeText.MouseLeftButtonDown += (_, e) => { e.Handled = true; ClosePicker(); };
sigCloseControl = closeText;
}
Grid.SetColumn(sigCloseControl, 1);
sigHeaderGrid.Children.Add(sigTitleText);
sigHeaderGrid.Children.Add(sigCloseControl);
var sigHeader = new Border
{
Background = classicCaption ? (Brush)FindResource("DialogTitleBarBrush") : Brushes.Transparent,
Height = classicCaption && FindResource("DialogTitleBarHeight") is double captionHeight
? captionHeight : double.NaN,
Margin = classicCaption ? new Thickness(0) : new Thickness(0, 0, 0, 4),
Child = sigHeaderGrid
};
if (classicCaption)
sigHeaderGrid.SetResourceReference(FrameworkElement.MarginProperty, "TitleBarPadding");
stack.Children.Add(sigHeader);
// Saved signatures and initials, shown as two labeled sections so the HR-style
// "initial here, sign there" flow can pick each independently. One tile builder is shared.
UIElement MakeSigItem(SavedSignature sigCopy)
{
var item = new Border
{
Background = Brushes.White,
BorderBrush = _swatchDimBorder,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(0),
Margin = new Thickness(4, 2, 4, 2),
Padding = new Thickness(4),
Cursor = Cursors.Hand,
Height = 60,
HorizontalAlignment = HorizontalAlignment.Stretch
};
item.SetResourceReference(Border.CornerRadiusProperty, "ControlCornerRadius");
if (sigCopy.ImageData is not null)
{
try
{
var imgBytes = Convert.FromBase64String(sigCopy.ImageData);
var bmpImg = new System.Windows.Media.Imaging.BitmapImage();
bmpImg.BeginInit();
bmpImg.StreamSource = new System.IO.MemoryStream(imgBytes);
bmpImg.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
bmpImg.EndInit();
item.Child = new System.Windows.Controls.Image
{
Source = bmpImg,
Height = 50,
HorizontalAlignment = HorizontalAlignment.Stretch,
Stretch = System.Windows.Media.Stretch.Uniform,
IsHitTestVisible = false
};
}
catch { item.Child = new TextBlock { Text = "(image)", IsHitTestVisible = false }; }
}
else
{
var canvas = new Canvas
{
Width = 288, Height = 50,
Background = Brushes.Transparent,
IsHitTestVisible = false
};
RenderSignaturePreview(canvas, sigCopy, 288, 50);
item.Child = canvas;
}
item.MouseLeftButtonDown += (s, e) =>
{
HideSignaturePopup();
// If a signature field is waiting, fill it and remember the choice (pick once, reuse).
if (_pendingSignField is { } tgt)
{
if (tgt.Initials) _activeInitialsChoice = sigCopy; else _activeSignatureChoice = sigCopy;
var t = tgt; _pendingSignField = null;
DropSignatureInField(t.ObjNum, sigCopy, t.Page, t.X, t.Y, t.W, t.H);
return;
}
_pendingSignature = sigCopy;
_annotationCanvas.Cursor = Cursors.Cross;
SetStatus(sigCopy.Kind == SignatureKind.Initials
? "Click on the page to place your initials"
: "Click on the page to place your signature");
};
item.MouseEnter += (s, e) =>
((Border)s!).BorderBrush = (SolidColorBrush)FindResource("PrimaryBrush");
item.MouseLeave += (s, e) =>
((Border)s!).BorderBrush = _swatchDimBorder;
var itemGrid = new Grid();
itemGrid.Children.Add(item);
var delBtn = new Button
{
Content = "",
FontSize = 10,
Width = 18, Height = 18,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 0, 2, 0),
Background = new SolidColorBrush(Color.FromArgb(200, 30, 30, 30)),
Foreground = (SolidColorBrush)FindResource("DangerRed"),
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Padding = new Thickness(0),
Style = (Style)FindResource("ToolbarButton")
};
delBtn.Click += (s, e) =>
{
_signatureStore.Remove(sigCopy);
PersistSignatures();
ShowSignaturePopup(); // refresh
};
itemGrid.Children.Add(delBtn);
return itemGrid;
}
// Section = header + saved tiles (or a "none yet" hint) + Create/Import for that Kind.
void AddSigSection(string sectionTitle, SignatureKind kind)
{
stack.Children.Add(new TextBlock
{
Text = sectionTitle,
Foreground = (SolidColorBrush)FindResource("MutedTextBrush"),
FontFamily = UiKit.UiFont,
FontWeight = FontWeights.SemiBold,
FontSize = 11,
Margin = new Thickness(4, 6, 4, 2)
});
var items = _signatureStore.Signatures.Where(x => x.Kind == kind).ToList();
if (items.Count > 0)
{
var scroll = new ScrollViewer
{
MaxHeight = 170,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled
};
var listPanel = new StackPanel();
foreach (var sig in items)
listPanel.Children.Add(MakeSigItem(sig));
scroll.Content = listPanel;
stack.Children.Add(scroll);
}
else
{
stack.Children.Add(new TextBlock
{
Text = Loc("Str_Sig_None"),
Foreground = (SolidColorBrush)FindResource("MutedTextBrush"),
FontFamily = UiKit.UiFont,
FontSize = 11,
FontStyle = FontStyles.Italic,
Margin = new Thickness(4, 2, 4, 6),
HorizontalAlignment = HorizontalAlignment.Center
});
}
var rowBtns = new Grid { Margin = new Thickness(4, 8, 4, 2) };
rowBtns.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
rowBtns.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var createBtn = UiKit.Make(Loc("Str_Sig_Create"), accent: true);
createBtn.HorizontalAlignment = HorizontalAlignment.Stretch;
createBtn.Margin = new Thickness(0, 0, 3, 0);
createBtn.Click += (s, e) => { HideSignaturePopup(); OpenSignatureCreator(kind); ShowSignaturePopup(); };
var importBtn = UiKit.Make(Loc("Str_Sig_Import"), accent: false);
importBtn.HorizontalAlignment = HorizontalAlignment.Stretch;
importBtn.Margin = new Thickness(3, 0, 0, 0);
importBtn.Click += (s, e) => { HideSignaturePopup(); ImportImageSignature(kind); ShowSignaturePopup(); };
Grid.SetColumn(createBtn, 0);
Grid.SetColumn(importBtn, 1);
rowBtns.Children.Add(createBtn);
rowBtns.Children.Add(importBtn);
stack.Children.Add(rowBtns);
}
AddSigSection(Loc("Str_Sig_Signatures"), SignatureKind.Signature);
stack.Children.Add(new Rectangle
{
Height = 1,
Fill = (SolidColorBrush)FindResource("CardBorderBrush"),
Margin = new Thickness(4, 6, 4, 2)
});
AddSigSection(Loc("Str_Sig_Initials"), SignatureKind.Initials);
// Match the Settings/menu popups: themed modal surface + accent border + film grain.
var sigContent = new Grid();
var sigGrain = new Border
{
CornerRadius = new CornerRadius(0),
IsHitTestVisible = false,
Opacity = (double)FindResource("GrainOpacity"),
Background = (System.Windows.Media.Brush)FindResource("GrainBrushShared")
};
sigGrain.SetResourceReference(Border.CornerRadiusProperty, "FlyoutCornerRadius");
sigContent.Children.Add(sigGrain);
sigContent.Children.Add(stack);
var sigBody = new Border
{
Background = (Brush)FindResource("MenuBackgroundBrush"),
Padding = classicCaption ? new Thickness(0) : new Thickness(4),
Child = sigContent
};
sigBody.SetResourceReference(Border.MarginProperty, "DialogWindowFramePadding");
sigBody.SetResourceReference(Border.CornerRadiusProperty, "FlyoutCornerRadius");
Border SignatureFrameRing(string brushKey, string thicknessKey, string? marginKey = null)
{
var ring = new Border { IsHitTestVisible = false };
ring.SetResourceReference(Border.BorderBrushProperty, brushKey);
ring.SetResourceReference(Border.BorderThicknessProperty, thicknessKey);
if (marginKey is not null)
ring.SetResourceReference(Border.MarginProperty, marginKey);
return ring;
}
var sigFrame = new Grid();
sigFrame.Children.Add(sigBody);
sigFrame.Children.Add(SignatureFrameRing("WindowFrameBrush", "DialogWindowFrameThickness"));
sigFrame.Children.Add(SignatureFrameRing("FrameInnerLightBrush", "FrameInnerLightThickness", "FrameInnerMargin"));
sigFrame.Children.Add(SignatureFrameRing("FrameInnerDarkBrush", "FrameInnerDarkThickness", "FrameInnerMargin"));
sigFrame.Children.Add(SignatureFrameRing("FrameOuterLightBrush", "FrameOuterLightThickness"));
sigFrame.Children.Add(SignatureFrameRing("FrameOuterDarkBrush", "FrameOuterDarkThickness"));
_signaturePopup = new Border
{
Background = (SolidColorBrush)FindResource("MenuBackgroundBrush"),
BorderBrush = (SolidColorBrush)FindResource("MenuBorderBrush"),
BorderThickness = classicCaption ? new Thickness(0) : new Thickness(1),
CornerRadius = new CornerRadius(0),
Padding = new Thickness(0),
Child = sigFrame,
// Free-positioned (Left/Top) inside the document grid so it can be dragged; the
// exact spot is set after layout from the saved position (or a default top-right).
Width = 320,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 4, 0, 0),
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
Color = Colors.Black, BlurRadius = 16,
Opacity = FindResource("FlyoutShadowOpacity") is double shadowOpacity ? shadowOpacity : 0.55,
ShadowDepth = 3
}
};
_signaturePopup.SetResourceReference(Border.CornerRadiusProperty, "FlyoutCornerRadius");
var previewGrid = PagePreviewPanel.Parent as Grid;
if (previewGrid is not null)
{
Panel.SetZIndex(_signaturePopup, 200);
previewGrid.Children.Add(_signaturePopup);
// Freshly-inserted element: defer the fade until it's laid out, otherwise the
// animation is missed (unlike the always-present Settings/About overlays).
_signaturePopup.Opacity = 0;
var popup = _signaturePopup;
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() =>
{
if (popup is null) return;
// Place it (saved position, or default to the old top-right spot) and wire up
// dragging now that ActualWidth/Height are known.
ApplySavedPanelPosition(popup, previewGrid, "SigPopup", fallbackRightInset: 80, fallbackTop: 4);
EnablePanelDrag(sigHeader, popup, previewGrid, "SigPopup");
popup.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, new Duration(TimeSpan.FromMilliseconds(110)))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
}));
}
}
private void OpenSignatureCreator(SignatureKind kind = SignatureKind.Signature)
{
var win = new Window
{
Title = Loc("Str_Sig_Create"),
Width = 460,
SizeToContent = SizeToContent.Height // size to content so there's no empty padding below
};
DialogChrome.Configure(win, this);
// This separate window can't see MainWindow's ChromeCloseCorner, so the close button's
// {DynamicResource ChromeCloseCorner} fell back to 0 (square hover). Provide it here so the
// hover rounds the top-right corner to match the window.
win.Resources["ChromeCloseCorner"] = new CornerRadius(0, UiKit.RadWindow.TopRight, 0, 0);
var contentArea = new StackPanel();
// Drawing canvas
var canvasBorder = new Border
{
Background = Brushes.White,
// Faint outline so the white drawing pane reads as a distinct field on the modal.
BorderBrush = new SolidColorBrush(Color.FromRgb(0xcc, 0xcc, 0xcc)),
BorderThickness = new Thickness(1),
Margin = new Thickness(12, 12, 12, 4),
CornerRadius = UiKit.RadControl,
Height = 170
};
var drawCanvas = new Canvas
{
Background = Brushes.White,
ClipToBounds = true,
Cursor = Cursors.Pen
};
canvasBorder.Child = drawCanvas;
// Placeholder text
// In a Canvas, alignment is ignored, so position the hint with a little padding rather
// than leaving it jammed in the top-left corner. A script face suits a signature prompt.
var placeholder = new TextBlock
{
Text = Loc("Str_Sig_DrawHere"),
Foreground = new SolidColorBrush(Color.FromRgb(0xb0, 0xb0, 0xb0)),
FontFamily = new FontFamily("Segoe Script, Segoe UI"),
FontSize = 18,
IsHitTestVisible = false
};
Canvas.SetLeft(placeholder, 18);
Canvas.SetTop(placeholder, 14);
drawCanvas.Children.Add(placeholder);
// Drawing state
var strokes = new List<List<Point>>();
List<Point>? currentStroke = null;
Polyline? currentPoly = null;
double penWidth = 2.5; // medium; set by the pen-width selector below
drawCanvas.MouseLeftButtonDown += (s, e) =>
{
if (placeholder.Visibility == Visibility.Visible)
placeholder.Visibility = Visibility.Collapsed;
currentStroke = [];
var pos = e.GetPosition(drawCanvas);
currentStroke.Add(pos);
currentPoly = new Polyline
{
Stroke = Brushes.Black,
StrokeThickness = penWidth,
StrokeLineJoin = PenLineJoin.Round,
StrokeStartLineCap = PenLineCap.Round,
StrokeEndLineCap = PenLineCap.Round
};
currentPoly.Points.Add(pos);
drawCanvas.Children.Add(currentPoly);
drawCanvas.CaptureMouse();
};
drawCanvas.MouseMove += (s, e) =>
{
if (currentStroke is null || currentPoly is null) return;
var pos = e.GetPosition(drawCanvas);
pos.X = Math.Max(0, Math.Min(drawCanvas.ActualWidth, pos.X));
pos.Y = Math.Max(0, Math.Min(drawCanvas.ActualHeight, pos.Y));
currentStroke.Add(pos);
currentPoly.Points.Add(pos);
};
drawCanvas.MouseLeftButtonUp += (s, e) =>
{
if (currentStroke is not null && currentStroke.Count > 1)
strokes.Add(currentStroke);
else if (currentPoly is not null)
drawCanvas.Children.Remove(currentPoly);
currentStroke = null;
currentPoly = null;
drawCanvas.ReleaseMouseCapture();
};
contentArea.Children.Add(canvasBorder);
// Pen-width selector: three preset thicknesses, active one highlighted. On the left so the
// modal does not read bottom-right-heavy.
var penRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(14, 6, 12, 0), VerticalAlignment = VerticalAlignment.Center };
penRow.Children.Add(new TextBlock { Text = Loc("Str_Sig_Pen"), Foreground = (SolidColorBrush)FindResource("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0) });
var penOptions = new (string Label, double W)[] { (Loc("Str_Sig_Thin"), 2.0), (Loc("Str_Sig_Medium"), 4.5), (Loc("Str_Sig_Thick"), 9.0) };
var penBtns = new List<Button>();
void RefreshPen()
{
for (int bi = 0; bi < penBtns.Count; bi++)
{
bool active = Math.Abs(penOptions[bi].W - penWidth) < 0.01;
penBtns[bi].Background = active ? (SolidColorBrush)FindResource("SelectionBg") : (SolidColorBrush)FindResource("PaneBrush");
penBtns[bi].Foreground = active ? (SolidColorBrush)FindResource("SelectionFg") : (SolidColorBrush)FindResource("TextBrush");
penBtns[bi].BorderBrush = active ? (SolidColorBrush)FindResource("PrimaryBrush") : (SolidColorBrush)FindResource("CardBorderBrush");
}
}
foreach (var (lbl, w) in penOptions)
{
double ww = w;
var pb = new Button
{
Content = lbl,
Style = (Style)FindResource("DarkButton"),
Padding = new Thickness(12, 3, 12, 3),
Margin = new Thickness(0, 0, 6, 0),
BorderThickness = new Thickness(1),
Cursor = Cursors.Hand,
FontFamily = UiKit.UiFont,
FontSize = 11
};
pb.Click += (s2, e2) => { penWidth = ww; RefreshPen(); };
penBtns.Add(pb);
penRow.Children.Add(pb);
}
RefreshPen();
// Buttons
var btnPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right
};
var clearBtn = UiKit.Make(Loc("Str_Sig_Clear"), accent: false);
clearBtn.Margin = new Thickness(0, 0, 8, 0);
clearBtn.Click += (s, e) =>
{
strokes.Clear();
drawCanvas.Children.Clear();
placeholder.Visibility = Visibility.Visible;
drawCanvas.Children.Add(placeholder);
};
var saveBtn = UiKit.Make(Loc("Str_Sig_SaveSig"), accent: true);
saveBtn.Click += (s, e) =>
{
if (strokes.Count == 0)
{
KillerDialog.Show(this, Loc("Str_Dlg_DrawSignatureFirst"), "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
double cw = drawCanvas.ActualWidth > 0 ? drawCanvas.ActualWidth : 400;
double ch = drawCanvas.ActualHeight > 0 ? drawCanvas.ActualHeight : 150;
var saved = new SavedSignature
{
Kind = kind,
StrokeWidth = penWidth,
CanvasWidth = cw,
CanvasHeight = ch,
Name = $"{(kind == SignatureKind.Initials ? "Initials" : "Signature")} {_signatureStore.Signatures.Count(x => x.Kind == kind) + 1}"
};
foreach (var stroke in strokes)
{
var sPts = stroke.Select(p => new SerializablePoint { X = p.X, Y = p.Y }).ToList();
saved.Strokes.Add(sPts);
}
_signatureStore.Add(saved);
PersistSignatures();
// Auto-select the new signature for placement
_pendingSignature = saved;
_annotationCanvas.Cursor = Cursors.Cross;
SetStatus(Loc("Str_St_SignatureSaved"));
win.Close();
};
btnPanel.Children.Add(clearBtn);
btnPanel.Children.Add(saveBtn);
// Pen-size selector on its own row above the buttons. A single shared row doesn't survive
// longer translated labels (e.g. Bengali Clear/Save) - the last pen option ("Thick") clipped.
contentArea.Children.Add(penRow);
btnPanel.Margin = new Thickness(12, 4, 12, 12);
contentArea.Children.Add(btnPanel);
win.Content = DialogChrome.Frame(win, this, "KillerPDF - " + Loc("Str_Sig_Create"), () => win.Close(), contentArea);
win.ShowDialog();
}
private void ImportImageSignature(SignatureKind kind = SignatureKind.Signature)
{
var dlg = new Controls.FileDialog(Controls.FileDialogMode.Open)
{
Filter = Loc("Str_Filter_Images") + "|*.png;*.jpg;*.jpeg;*.bmp;*.gif|" + Loc("Str_Filter_AllFiles") + "|*.*",
Title = Loc("Str_Sign_ImportImage"),
ShowImagePreview = true
};
if (dlg.ShowDialog(this) != true) return;
try
{
var bmp = new System.Windows.Media.Imaging.BitmapImage(new Uri(dlg.FileName));
byte[] pngBytes;
using (var ms = new System.IO.MemoryStream())
{
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp));
encoder.Save(ms);
pngBytes = ms.ToArray();
}
var saved = new SavedSignature
{
Kind = kind,
Name = System.IO.Path.GetFileNameWithoutExtension(dlg.FileName),
CanvasWidth = bmp.PixelWidth,
CanvasHeight = bmp.PixelHeight,
ImageData = Convert.ToBase64String(pngBytes)
};
_signatureStore.Add(saved);
PersistSignatures();
_pendingSignature = saved;
_annotationCanvas.Cursor = Cursors.Cross;
SetStatus(Loc("Str_St_ImageLoaded"));
}
catch (Exception ex)
{
KillerDialog.Show(this, Loc("Str_Err_ImportImageFailed") + "\n" + ex.Message, "KillerPDF",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
}
+679
View File
@@ -0,0 +1,679 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using KillerPDF.Controls;
namespace KillerPDF
{
/// <summary>
/// Two document panes side by side in one window.
///
/// One toolbar, sidebar, status line and set of document fields serve both panes, so every
/// window -> viewer call resolves through <see cref="ActiveViewer"/> rather than naming a pane.
/// </summary>
public partial class MainWindow
{
/// <summary>Neither pane may go below this. Both handles clamp against both minimums, so
/// dragging either one stops at whichever pane would go under first.</summary>
private const double MinPaneWidth = 320;
/// <summary>Inset a pane keeps from the window edge.</summary>
private const double PaneEdge = 8;
/// <summary>Channel between the two cards, sized so the gap either side of a pane reads the
/// same whether its neighbor is the other pane or the window edge. Zero when unsplit.</summary>
private double SplitGutter => TryFindResource("SplitPaneGutterWidth") is double width ? width : PaneEdge;
/// <summary>The pane every window control acts on.</summary>
internal PdfViewer ActiveViewer { get; private set; } = null!;
private bool _isSplit;
/// <summary>Read by each pane's RebuildTabStrip: while split, both panes show their tab band
/// even with one document, or their card tops sit at different heights.</summary>
internal bool IsSplit => _isSplit;
private bool _draggingSplit;
private double _splitDragStartX;
private double _splitDragStartAWidth;
/// <summary>Pane A's chosen width. A is the FIXED column and B takes the remainder, so
/// narrowing the window eats into pane B first and pane A keeps the size you gave it. Only
/// once B is down to <see cref="MinPaneWidth"/> does A start giving ground, and it stops at
/// the same minimum. It was the other way round - A star-sized - which meant dragging the
/// window's right edge shrank the pane at the far LEFT of the window.</summary>
private double _paneAWidth;
/// <summary>Pane A's share of the host at the last layout. Window-state JUMPS (snap,
/// maximize, unmaximize, full screen) re-derive <see cref="_paneAWidth"/> from this so the
/// panes keep their proportions; an interactive edge drag keeps A fixed as documented
/// above. WM_ENTER/EXITSIZEMOVE (<see cref="_inWindowSizeMove"/>) tells the two apart.</summary>
private double _paneARatio;
/// <summary>True while the user is interactively moving or resizing the window
/// (WM_ENTERSIZEMOVE..WM_EXITSIZEMOVE, tracked in WndProc).</summary>
private bool _inWindowSizeMove;
/// <summary>Wire both panes up. Called from the constructor.</summary>
private void InitSplitPanes()
{
Viewer.AttachHost(this);
ViewerB.AttachHost(this);
ActiveViewer = Viewer;
// Each pane builds its own tile tree. Routing this through ActiveViewer would build
// pane A twice and leave pane B with a null annotation canvas.
Viewer.InitTiles();
ViewerB.InitTiles();
// Each pane's strip binds to its OWN session collection. Routing this through
// ActiveViewer would bind pane A's strip twice and leave pane B's showing nothing.
Viewer.InitTabStripExt();
ViewerB.InitTabStripExt();
// PreviewMouseDown, not MouseDown: the page overlays and annotation tools handle the
// bubbling event and would swallow it.
Viewer.PreviewMouseDown += (_, _) => FocusPane(Viewer);
ViewerB.PreviewMouseDown += (_, _) => FocusPane(ViewerB);
// The wheel focuses too. The zoom toolbar, the zoom box and Ctrl+wheel all act on the
// FOCUSED pane, so wheeling over a pane you had not clicked was zooming and scrolling
// the other one - which reads as "zooming pane B zoomed pane A". Preview, so focus has
// moved before the pane's own wheel handler runs.
Viewer.PreviewMouseWheel += (_, _) => FocusPane(Viewer);
ViewerB.PreviewMouseWheel += (_, _) => FocusPane(ViewerB);
// Re-lay the columns whenever the host resizes, so the window's own edge takes width
// out of pane B first. Without this A, being the fixed column, would simply keep its
// width and B would be clipped to nothing.
//
// SyncSplitMinWidth must NOT be called from here. Setting MinWidth can resize the
// window, which fires this handler again, which sets MinWidth again - an unbounded
// layout loop that hard-froze the app. The floor only changes when the split opens or
// closes, so that is the only place it is recomputed.
//
// Queued, never run inline. ApplyPaneWidths writes the column widths, which is itself a
// layout change - assigning straight from the handler re-enters the layout pass it was
// raised by. At Background priority it runs after that pass has finished.
SplitHost.SizeChanged += (_, _) =>
Dispatcher.BeginInvoke(new Action(OnSplitHostResized),
System.Windows.Threading.DispatcherPriority.Background);
ApplyFocusHalo();
}
/// <summary>Point the window's element accessors at a pane, with NONE of FocusPane's side
/// effects, and hand back the previous one. WithOwnSession needs this: it swaps the document
/// FIELDS to a pane, but every element the render path reaches for - PageGrid, PageHost,
/// ContinuousHost, PreviewScroller - still resolves through ActiveViewer, so an unfocused
/// pane's re-fit measured and painted into the OTHER pane's tiles. That is what zoomed a
/// pane wildly, and what put one pane's pages into the other.</summary>
internal PdfViewer SwapActiveViewer(PdfViewer pane)
{
var prev = ActiveViewer;
ActiveViewer = pane;
return prev;
}
/// <summary>F10 toggles the split. Bound from KeyboardShortcuts.</summary>
internal void ToggleSplit()
{
if (_isSplit) CloseSplit();
else OpenSplit();
}
/// <summary>Reopen the split and pane B's tabs from the last session. Runs at the tail of
/// the startup restore, after pane A is populated.
///
/// Pane B's tabs are restored the same lazy way pane A's are - placeholders, with only its
/// active tab loaded - but loading it has to happen with B focused, because the load path
/// writes through the window's shared document fields. Focus always returns to A; which
/// pane had focus at exit is not saved yet.</summary>
private bool _restoringSplit; // set only during that restore - see OpenSplit
private void RestorePaneB()
{
try { RestorePaneBCore(); }
catch (Exception ex)
{
// A failed restore must never take the window down with it. Whatever went wrong,
// the app still has to come up - worst case with one pane and no tabs in B.
_restoringSplit = false;
SetStatus(string.Format(Loc("Str_St_RestorePane"), ex.Message));
}
}
private void RestorePaneBCore()
{
if (App.GetSetting("SplitOpen") != "1") return;
// Seed pane A's fixed width from the saved setting BEFORE opening the split.
// OpenSplit's own fallback (Viewer.ActualWidth) is always 0 at this point - the window
// is still inside its own Loaded handler and has not laid out yet - so without this,
// pane A opened pinned to MinPaneWidth every launch no matter what width it was left
// at, which is what "the pane is always small when the app loads" was (#161).
var savedA = App.GetSetting("SplitPaneAWidth");
if (!string.IsNullOrEmpty(savedA)
&& double.TryParse(savedA, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double aw)
&& aw >= MinPaneWidth)
_paneAWidth = aw;
_restoringSplit = true;
try { OpenSplit(); }
finally { _restoringSplit = false; }
var saved = App.GetSetting("OpenTabsB");
if (string.IsNullOrEmpty(saved)) return;
var restored = new System.Collections.Generic.List<PdfViewer.DocumentSession>();
foreach (var f in saved!.Split('|'))
if (!string.IsNullOrEmpty(f) && System.IO.File.Exists(f))
restored.Add(PdfViewer.MakeDeferredSession(f));
if (restored.Count == 0) return;
var wantActive = App.GetSetting("ActiveTabB");
var target = (!string.IsNullOrEmpty(wantActive)
? restored.FirstOrDefault(s => string.Equals(s.OriginalFile, wantActive,
StringComparison.OrdinalIgnoreCase))
: null)
?? restored[0];
ViewerB.SetSessionsExt(restored, target);
FocusPane(ViewerB);
ViewerB.ApplySessionStateExt(target);
ViewerB.MaterializeDeferredExt(target);
ViewerB.RebuildTabStripExt();
FocusPane(Viewer);
// B's document was loaded against whatever width the column had at that instant, which
// during startup is not yet its final one. Re-fit both once the layout settles - same
// reason as the queued fit in OpenSplit, one pass later.
Dispatcher.BeginInvoke(new Action(() =>
{
Viewer.ReapplyGridOrFit();
ViewerB.ReapplyGridOrFit();
}), System.Windows.Threading.DispatcherPriority.Loaded);
}
/// <summary>Sidebar rail button, same action as F10.</summary>
private void SplitPaneRailBtn_Click(object sender, RoutedEventArgs e) => ToggleSplit();
/// <summary>Light the rail button while the split is open, the same tell the night-mode
/// moon beside it uses.</summary>
private void SyncSplitRailButton()
{
if (SplitPaneRailBtn != null) SplitPaneRailBtn.Tag = _isSplit ? "on" : null;
}
private void OpenSplit()
{
if (_isSplit) return;
_isSplit = true;
// Pane A keeps the width it already has and the WINDOW grows to make room for pane B,
// which opens matching it. Halving pane A instead would resize the document you are
// reading just to put a second one beside it - and it is the counterpart of the close,
// which shrinks the window back by exactly the same amount, so F10 round-trips to where
// it started. If there is no room left on the work area the window grows as far as it
// can and ApplyPaneWidths takes the shortfall out of pane A, down to the minimum.
// During the startup restore, pane A's width was already seeded from the saved setting
// by RestorePaneBCore, above, before this ran - Viewer.ActualWidth is not usable there,
// see the comment on that seed. Everywhere else, pane A keeps the width it already has.
double aNow = _restoringSplit && _paneAWidth > 0
? _paneAWidth
: Viewer.ActualWidth > 0 ? Viewer.ActualWidth : MinPaneWidth;
_paneAWidth = aNow;
// No window resize during the startup restore. The window is still inside its Loaded
// handler and has not rendered yet; changing Width there produced a blank, unpainted
// window. On restore the saved size already accounts for both panes anyway, so there is
// nothing to grow by - ApplyPaneWidths just divides what is there.
double grow = 0;
if (WindowState == WindowState.Normal && !_restoringSplit)
{
double room = SystemParameters.WorkArea.Right - Left;
grow = Math.Min(aNow + SplitGutter, Math.Max(0, room - Width));
}
// Pane B's final width is whatever the window could actually give it.
double bTarget = Math.Max(MinPaneWidth, grow - SplitGutter);
// Maximized, snapped, or already hard against the work area edge: there is no room to
// grow the window by pane A's full width, so keeping pane A at its size would squeeze
// pane B down to the bare minimum - a lopsided split nobody asked for on a window that
// is plainly big enough for two. When the window cannot grow that far, split what is
// already there evenly instead (2026-08-01). The exact round-trip (pane A keeps
// its size, the window grows to match) stays the behavior whenever there IS room.
bool evenSplit = !_restoringSplit && grow + 0.5 < aNow + SplitGutter;
if (evenSplit)
{
_paneAWidth = Math.Max(MinPaneWidth, (aNow - SplitGutter) / 2);
bTarget = Math.Max(MinPaneWidth, aNow - SplitGutter - _paneAWidth);
}
// Start closed and slide open. During the restore there is nothing to slide - the panes
// are simply there when the window paints - so the columns go straight to their places.
PaneACol.Width = new GridLength(aNow, GridUnitType.Pixel);
PaneBCol.Width = new GridLength(0, GridUnitType.Pixel);
PaneGutterCol.Width = new GridLength(0);
if (_restoringSplit)
{
PaneGutterCol.Width = new GridLength(SplitGutter);
// NOT called synchronously here. RestoreWindowSettings ran moments ago in the same
// Loaded handler, but WPF layout is asynchronous - SplitHost.ActualWidth at this
// point still reflects the window's PRE-restore size (or 0), not the size just
// assigned. Calling ApplyPaneWidths against that stale/zero width is what clamped
// pane A down and left pane B with whatever tiny remainder fell out - the saved
// split came back on every launch no matter what it was saved at (#161). Deferred to
// Loaded priority, same as the re-fit below and in RestorePaneBCore's tail, so it
// runs once the restored window size has actually been laid out. The window is still
// hidden behind RootClipGrid's opacity hold at this point (see ContentRendered),
// so there is nothing to see between the temporary zero-width column set two lines
// up and this correcting it.
// SyncSplitMinWidth reads SplitHost.ActualWidth too (via its "chrome" measurement),
// so it rides along in the same deferred call rather than running synchronously
// below against the same stale width.
Dispatcher.BeginInvoke(new Action(() =>
{
ApplyPaneWidths(); SyncSplitMinWidth();
// Same width-gate re-run as OpenSplit's done callback: on restore the panes
// reach their real widths only after this deferred layout pass.
Viewer.SyncRecentBoxWidth(); ViewerB.SyncRecentBoxWidth();
}), System.Windows.Threading.DispatcherPriority.Loaded);
}
ViewerB.Visibility = Visibility.Visible;
SplitHandleA.Visibility = Visibility.Visible;
SplitHandleB.Visibility = Visibility.Visible;
// Both bands, so both card tops line up - the band is a two-pane decision now.
Viewer.RebuildTabStripExt();
ViewerB.RebuildTabStripExt();
// Pane B's start screen has never been filled: its recent list is only populated when a
// pane shows its empty state, and B goes straight from hidden to visible without one.
// Without this its Recent box stayed blank until B first took focus.
PopulateRecentFilesList();
ApplyFocusHalo();
SyncSplitRailButton();
SetStatus(Loc("Str_St_SplitOn"));
if (_restoringSplit)
{
// Already in place, and no animation during startup: a timer-driven slide running
// while the window is still being brought up left it painting nothing at all.
// (ApplyPaneWidths/SyncSplitMinWidth already queued above, deferred past this point.)
return;
}
// Pane A only tweens when the even-split branch above actually moved its target; the
// exact-round-trip path leaves it null and AnimateSplitWidth skips it, unchanged from
// before.
AnimateSplitWidth(opening: true, bTarget, grow,
aFrom: evenSplit ? aNow : (double?)null, aTo: evenSplit ? _paneAWidth : (double?)null,
done: () =>
{
ApplyPaneWidths(); // hand pane B back to the star column
SyncSplitMinWidth();
// Both panes just changed width, so both have to re-fit. Queued at Loaded so it
// runs once the columns are real - on the startup restore the split opens before
// the first layout pass has settled, and pane A kept the zoom it was fitted at
// full width and opened clipped, with a horizontal scrollbar.
Dispatcher.BeginInvoke(new Action(() =>
{
Viewer.ReapplyGridOrFit();
if (_isSplit) ViewerB.ReapplyGridOrFit();
// Re-gate the start screens' Recent boxes against the SETTLED widths. The
// populate above ran while pane B's column was still 0 (the slide had not
// started), so SyncRecentBoxWidth's width gate collapsed B's box and nothing
// re-ran it - pane B showed an empty start screen until the next open action
// repopulated the list (2026-08-01).
Viewer.SyncRecentBoxWidth();
ViewerB.SyncRecentBoxWidth();
}), System.Windows.Threading.DispatcherPriority.Loaded);
});
}
private System.Windows.Threading.DispatcherTimer? _splitAnim;
/// <summary>True while the open/close slide is running. ApplyPaneWidths sits it out - it is
/// driven from SplitHost's SizeChanged, which fires on every frame of the slide, and it
/// would put pane B straight back on the star column and undo the animation.</summary>
private bool _splitAnimating;
/// <summary>Slide the split open or shut. Pane B's column and the gutter animate between 0
/// and <paramref name="bTarget"/>; the window's width changes by <paramref name="widthDelta"/>
/// in one step. Pane A normally never changes size - the window absorbs the whole
/// difference - but the even-split open (OpenSplit, when the window cannot grow) also has to
/// shrink pane A down to its half-share, and <paramref name="aFrom"/>/<paramref name="aTo"/>
/// tween it alongside B so the two panes move together instead of A sitting frozen at full
/// width until ApplyPaneWidths snaps it down on the final frame - which is what made the
/// panes slide erratically before settling (2026-08-01).
/// GridLength has no built-in animation, so the columns are stepped off a timer.</summary>
private void AnimateSplitWidth(bool opening, double bTarget, double widthDelta, Action done,
double? aFrom = null, double? aTo = null)
{
_splitAnim?.Stop();
_splitAnimating = false;
// The WINDOW's width changes in ONE step, never animated. Stepping Width frame by frame
// off a timer put the app in an unusable state: it fights whatever the window manager
// is doing, and a tick landing while Windows was in its own modal resize loop could
// stall the animation - leaving _splitAnimating latched, the columns frozen part-open
// with the drag handles still live over the pane, the mouse captured and the window
// unresizable. Only the columns slide.
if (WindowState == WindowState.Normal && Math.Abs(widthDelta) > 0.5)
{
double w = Width + (opening ? widthDelta : -widthDelta);
Width = Math.Max(MinWidth, w);
}
double bStart = opening ? 0 : ViewerB.ActualWidth;
double bEnd = opening ? bTarget : 0;
double gStart = opening ? 0 : SplitGutter;
double gEnd = opening ? SplitGutter : 0;
// Only set on the even-split open; every other call leaves both null, and the tick below
// then never touches PaneACol at all - identical to the pre-existing behavior.
bool tweenA = aFrom.HasValue && aTo.HasValue && Math.Abs(aFrom.Value - aTo.Value) > 0.5;
double aStart = aFrom ?? 0;
double aEnd = aTo ?? 0;
if (bStart <= 0 && bEnd <= 0 && !tweenA) { done(); return; } // nothing to slide
_splitAnimating = true;
var clock = System.Diagnostics.Stopwatch.StartNew();
const double durationMs = 160;
_splitAnim = new System.Windows.Threading.DispatcherTimer
{ Interval = TimeSpan.FromMilliseconds(15) };
_splitAnim.Tick += (_, _) =>
{
// Belt and braces: finish on elapsed time, so a dropped or delayed tick can never
// strand the split half-open with its state latched.
double t = Math.Min(1, clock.Elapsed.TotalMilliseconds / durationMs);
double e = 1 - Math.Pow(1 - t, 3); // ease out, so it settles rather than stopping dead
PaneGutterCol.Width = new GridLength(gStart + (gEnd - gStart) * e);
PaneBCol.Width = new GridLength(bStart + (bEnd - bStart) * e, GridUnitType.Pixel);
if (tweenA) PaneACol.Width = new GridLength(aStart + (aEnd - aStart) * e, GridUnitType.Pixel);
if (t >= 1)
{
_splitAnim!.Stop();
_splitAnimating = false;
done();
}
};
_splitAnim.Start();
}
private void CloseSplit()
{
if (!_isSplit) return;
_isSplit = false;
// Focus returns to A before B is hidden: leaving ActiveViewer pointing at a collapsed
// pane would leave the toolbar driving something invisible.
FocusPane(Viewer);
double aStart = Viewer.ActualWidth;
double bStart = ViewerB.ActualWidth;
// The WINDOW shrinks back by pane B plus the gutter; pane A keeps the width it has.
// Expanding pane A to swallow both panes was the wrong half of the trade - closing the
// second pane should put the window back where opening it found it, not resize the
// document you were reading. Drop the floor first, with _isSplit already false, or the
// two-pane minimum blocks the shrink.
SyncSplitMinWidth();
// Handles go first, not in FinishCloseSplit. They sit in the gutter column and stay
// hit-testable while it is closing, so a click during the slide could grab a divider
// that is on its way out and capture the mouse with nothing left to drag.
SplitHandleA.Visibility = Visibility.Collapsed;
SplitHandleB.Visibility = Visibility.Collapsed;
EndSplitDrag(SplitHandleA);
EndSplitDrag(SplitHandleB);
if (aStart <= 0 || bStart <= 0) { FinishCloseSplit(); return; } // never laid out
PaneACol.Width = new GridLength(aStart, GridUnitType.Pixel);
PaneBCol.Width = new GridLength(bStart, GridUnitType.Pixel);
// THE RULE IS THE CORNERS (2026-08-01, after several rounds of narrower
// conditions each missing a case):
// - SQUARED corners (_chromeSquared: maximized OR snapped) - the window is pinned to
// screen edges and must not move, so pane A expands to fill the space pane B gives
// up. WindowState alone is NOT this test: a snapped window stays WindowState.Normal
// (see OnWindowLocationChanged), which is exactly the case every earlier version of
// this condition got wrong.
// - ROUNDED corners (floating) - closing the second pane closes the second pane: the
// window shrinks by pane B plus the gutter and pane A keeps the size it had.
// The widthDelta is 0 in the squared case so AnimateSplitWidth cannot shrink a snapped
// window (it skips only MAXIMIZED ones on its own, since they are not Normal).
bool fillA = _chromeSquared;
double aTarget = fillA ? aStart + bStart + SplitGutter : aStart;
_paneAWidth = aTarget;
AnimateSplitWidth(opening: false, 0, fillA ? 0 : bStart + SplitGutter, FinishCloseSplit,
aFrom: fillA ? aStart : (double?)null, aTo: fillA ? aTarget : (double?)null);
}
/// <summary>Tail of CloseSplit: everything that must be true once the slide has finished.
/// Split apart so the animation and the never-laid-out shortcut share it.</summary>
private void FinishCloseSplit()
{
PaneGutterCol.Width = new GridLength(0);
PaneBCol.Width = new GridLength(0);
PaneACol.Width = new GridLength(1, GridUnitType.Star); // one pane takes it all
SyncSplitMinWidth(); // release the split floor
ViewerB.Visibility = Visibility.Collapsed;
SplitHandleA.Visibility = Visibility.Collapsed;
SplitHandleB.Visibility = Visibility.Collapsed;
Viewer.RebuildTabStripExt(); // back to the single-pane rule: hide the band under two tabs
Viewer.SyncRecentBoxWidth(); // pane A may have just widened past the Recent box's gate
ApplyFocusHalo();
SyncSplitRailButton();
SetStatus(Loc("Str_St_SplitOff"));
}
/// <summary>Point the window's chrome at a pane and move the halo. Cheap and idempotent, so
/// it is safe to call from every mouse-down. Internal (not private): PdfViewer's own
/// SwitchToTab calls this too, to re-assert ownership of the shared fields before a tab
/// switch inside a pane that is not (yet) ActiveViewer - see the comment there.</summary>
internal void FocusPane(PdfViewer pane)
{
if (ReferenceEquals(ActiveViewer, pane)) return;
// _doc, _currentFile, _annotations and the rest are window fields that both panes bridge
// to, so they describe one pane at a time. A pane's documents live in its session list
// and swap into those fields when it takes focus - the same handshake tab switching
// uses. Without it the sidebar, page count and status line keep describing the pane you
// just left. Capture before the swap, apply after, or the outgoing pane's scroll and
// zoom land in the incoming pane's session.
ActiveViewer.CaptureActiveIfAny();
ActiveViewer = pane;
pane.ApplyActiveSessionIfAny();
ApplyFocusHalo();
// The moon lights for the FOCUSED pane's invert state (invert is per pane).
DocInvertBtn.Tag = pane.DocInvert ? "on" : null;
// Title-bar filename follows the focused pane (it stayed on the previous pane's
// document, 2026-08-15). _originalFile holds this pane's file after the session
// swap above; an empty pane clears the label.
FileNameLabel.Text = System.IO.Path.GetFileName(_originalFile ?? "");
// Restore rather than refresh: RefreshPageList re-decodes every page, which on a large
// document costs seconds on every click between panes.
RestorePageListForActivePane();
LoadOutlines();
SyncZoomBox();
SetTool(_currentTool, restoringPane: true);
// No render here. Each pane keeps its own tile tree, so its document stays painted
// whether or not it has focus - focus moves the chrome, not the pixels. A
// RenderActiveSession() call here also fires on any mouse-down that reaches a pane,
// including while the file dialog is open, painting a document into it before the user
// has picked one.
}
/// <summary>Accent border on the focused pane, normal border on the other. Only while split:
/// with one pane there is nothing to disambiguate.</summary>
private void ApplyFocusHalo()
{
if (!_isSplit)
{
Viewer.SetFocusHalo(false);
ViewerB.SetFocusHalo(false);
return;
}
Viewer.SetFocusHalo(ReferenceEquals(ActiveViewer, Viewer));
ViewerB.SetFocusHalo(ReferenceEquals(ActiveViewer, ViewerB));
}
/// <summary>Lay the two columns out from <see cref="_paneAWidth"/>: A is the fixed column,
/// B is the star that takes what is left. Run on every split-host resize as well as on the
/// gutter drag, so narrowing the window comes out of B until B is at the minimum, then out
/// of A until A is too. Neither pane can be squeezed below MinPaneWidth from any direction.</summary>
/// <summary>SplitHost.SizeChanged lands here. A size change OUTSIDE an interactive
/// move/resize is a window-state jump - snap, maximize, unmaximize, full screen - and those
/// keep the panes' RATIO; a 50/50 split must not come out of maximize as 75/25. An edge
/// drag keeps pane A fixed so the window edge eats pane B only (see _paneAWidth).</summary>
private void OnSplitHostResized()
{
if (_isSplit && !_inWindowSizeMove && !_draggingSplit && _paneARatio > 0)
{
double avail = SplitHost.ActualWidth - SplitGutter;
if (avail > 0) _paneAWidth = Math.Max(MinPaneWidth, avail * _paneARatio);
}
ApplyPaneWidths();
}
private void ApplyPaneWidths()
{
if (!_isSplit || _splitAnimating) return; // the slide owns the columns while it runs
double avail = SplitHost.ActualWidth - SplitGutter;
if (avail <= 0) return;
double aW = _paneAWidth > 0 ? _paneAWidth : avail / 2;
// Give B its minimum first, then let A have what it asked for out of the rest.
double maxA = avail - MinPaneWidth;
aW = Math.Min(aW, maxA);
aW = Math.Max(aW, MinPaneWidth);
// Window too narrow to honor both: A keeps the minimum and B takes the remainder. The
// window's own MinWidth (SyncSplitMinWidth) is what normally stops this happening.
if (aW > avail) aW = Math.Max(0, avail);
// Only write when it actually moves, or every layout pass has a fresh value to react to.
if (PaneACol.Width.IsStar || Math.Abs(PaneACol.Width.Value - aW) > 0.5)
PaneACol.Width = new GridLength(aW, GridUnitType.Pixel);
if (!PaneBCol.Width.IsStar)
PaneBCol.Width = new GridLength(1, GridUnitType.Star);
// Remember the proportion this layout settled on - the value the state-jump path
// (OnSplitHostResized) restores. Updated here so a divider drag, an edge resize, and a
// programmatic width all refresh it consistently.
_paneARatio = aW / avail;
}
/// <summary>The window has no minimum of its own. The only floor is the PANE minimum, which
/// is the same number in both modes - one pane's worth unsplit, two plus the gutter when
/// split. Everything outside the panes (the sidebar, the margins) is MEASURED rather than
/// assumed, so collapsing or moving the sidebar lowers the floor by exactly its width
/// instead of the pane having to give the space up.</summary>
private void SyncSplitMinWidth()
{
double chrome = Math.Max(0, ActualWidth - SplitHost.ActualWidth);
double floor = _isSplit
? chrome + SplitGutter + MinPaneWidth * 2
: chrome + MinPaneWidth;
// Only when it actually moves. An unconditional assignment can resize the window, and
// anything that re-enters this from a layout event then has a loop to run round.
if (Math.Abs(MinWidth - floor) > 0.5) MinWidth = floor;
}
// One boundary, two handles: whichever you grab is the pane you are sizing. Both resolve to
// pane A's width - B is the star and takes the remainder - so they cannot disagree.
private void SplitHandle_MouseDown(object sender, MouseButtonEventArgs e)
{
if (!_isSplit || sender is not Border h) return;
_draggingSplit = true;
_splitDragStartX = e.GetPosition(SplitHost).X;
_splitDragStartAWidth = Viewer.ActualWidth;
h.CaptureMouse();
e.Handled = true;
}
private void SplitHandle_MouseMove(object sender, MouseEventArgs e)
{
if (!_draggingSplit || sender is not Border h || !h.IsMouseCaptured) return;
// Self-heal a lost button-up. If capture survives past the release - which happens when
// the up lands on the window's own resize border - the handle keeps the mouse, the
// resize cursor stays on screen and nothing else can be clicked until something else
// steals capture. Checking the real button state on every move ends the drag anyway.
if (e.LeftButton != MouseButtonState.Pressed) { EndSplitDrag(h); return; }
double dx = e.GetPosition(SplitHost).X - _splitDragStartX;
double total = SplitHost.ActualWidth;
double aimA = _splitDragStartAWidth + dx;
double maxA = total - SplitGutter - MinPaneWidth;
// Out of slack: pane B is already at its minimum and cannot give up any more. Rather
// than the divider going dead, GROW THE WINDOW by what pane A still wants - pane A
// gets bigger, pane B keeps exactly its width and simply travels right with the
// window's edge. Stops at the edge of the work area, and does nothing while maximized.
if (aimA > maxA && WindowState == WindowState.Normal)
{
double room = SystemParameters.WorkArea.Right - Left;
double grow = Math.Min(aimA - maxA, Math.Max(0, room - Width));
if (grow > 0)
{
Width += grow;
maxA += grow;
}
}
if (maxA < MinPaneWidth) return; // window too narrow to split meaningfully
_paneAWidth = Math.Max(MinPaneWidth, Math.Min(maxA, aimA));
ApplyPaneWidths();
e.Handled = true;
}
/// <summary>Release the drag and the mouse together. Both the normal button-up and the
/// self-heal in MouseMove come through here, so capture cannot be left behind.</summary>
private void EndSplitDrag(Border? h)
{
if (h is { IsMouseCaptured: true }) h.ReleaseMouseCapture();
_draggingSplit = false;
}
/// <summary>Anything at all that takes capture away mid-drag - an alt-tab, a system move,
/// another control grabbing it - has to leave the drag state consistent, or the next click
/// is swallowed by a drag that thinks it is still running.</summary>
private void SplitHandle_LostMouseCapture(object sender, MouseEventArgs e)
=> _draggingSplit = false;
private void SplitHandle_MouseUp(object sender, MouseButtonEventArgs e)
{
EndSplitDrag(sender as Border);
e.Handled = true;
// Re-fit once the drag settles, not per mouse-move: a re-render every frame stutters
// on a large document.
Viewer.ReapplyGridOrFit();
if (_isSplit) ViewerB.ReapplyGridOrFit();
// The drag changed both panes' widths; re-gate their Recent boxes (an empty pane
// dragged wide enough should gain the list, one squeezed narrow should shed it).
Viewer.SyncRecentBoxWidth();
if (_isSplit) ViewerB.SyncRecentBoxWidth();
}
}
}
+261
View File
@@ -0,0 +1,261 @@
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using PdfSharpCore.Drawing;
using KillerPDF.Services;
namespace KillerPDF
{
public partial class MainWindow
{
// The document's current stamp configuration (one spec drives page numbers and/or a watermark).
// Reopening the Stamp tool edits this; Apply rebuilds _stamps from it. Stamps live on their own
// layer, painted BELOW annotations in RenderAllAnnotations.
private StampSpec? _docStampSpec;
private readonly Dictionary<int, List<StampInstance>> _stamps = [];
// Per-page rendered stamp bounds (render-dim/canvas space) so a double-click can hit-test a stamp and
// reopen the editor. Repopulated every RenderStamps; the stamp visuals themselves stay non-hit-testable.
private readonly Dictionary<int, List<Rect>> _stampHitRects = [];
private void ToolStamp_Click(object sender, RoutedEventArgs e)
{
if (_doc is null) { SetStatus(Loc("Str_Tf_NoRender")); return; }
OpenStampTool();
}
// Opens the Stamp window seeded with the current spec (so it edits the existing stamps).
private void OpenStampTool()
{
if (_doc is null) return;
int pageIdx = PageList.SelectedIndex < 0 ? 0 : PageList.SelectedIndex;
var src = RenderPageBitmap(pageIdx, 1100, BurnPageAnnotationsToTemp(pageIdx));
if (src is null) { SetStatus(Loc("Str_Tf_NoRender")); return; }
var page = _doc.Pages[pageIdx];
var (pwpt, phpt) = EffectivePageSize(page);
var win = new StampWindow(this, src, pwpt, phpt, _doc.PageCount, pageIdx, _docStampSpec,
idx => // page-render callback for the preview stepper
{
var s = RenderPageBitmap(idx, 1100, BurnPageAnnotationsToTemp(idx));
var (w, h) = EffectivePageSize(_doc!.Pages[idx]);
return (s, w, h);
});
win.ShowDialog();
if (win.Applied) ApplyStampSpec(win.Result);
}
private void ApplyStampSpec(StampSpec spec)
{
_docStampSpec = (spec.NumbersEnabled || spec.WmEnabled) ? spec : null;
UpdateStampIndicator();
RebuildStamps();
RerenderAllVisiblePages();
MarkDirty();
int pages = 0;
foreach (var kv in _stamps) if (kv.Value.Count > 0) pages++;
SetStatus(string.Format(Loc("Str_Stamp_Applied"), pages));
}
// Regenerates the per-page stamp instances from the active spec.
private void RebuildStamps()
{
_stamps.Clear();
if (_docStampSpec is null || _doc is null) return;
int n = _doc.PageCount;
if (_docStampSpec.NumbersEnabled)
foreach (int p in PdfBurn.StampPageRange(_docStampSpec.NumRange, n))
AddStamp(p, StampKind.PageNumber);
if (_docStampSpec.WmEnabled)
foreach (int p in PdfBurn.StampPageRange(_docStampSpec.WmRange, n))
AddStamp(p, StampKind.Watermark);
}
// Keeps the Stamp toolbar button showing a persistent "hovered" (gray) background while the document
// has active stamps, as a subtle indicator. Cleared when there are no stamps.
private void UpdateStampIndicator()
{
if (ToolStampBtn is null) return;
if (_docStampSpec is not null)
ToolStampBtn.SetResourceReference(Control.BackgroundProperty, "RowHoverBrush");
else
ToolStampBtn.ClearValue(Control.BackgroundProperty);
}
private void AddStamp(int page, StampKind kind)
{
if (!_stamps.TryGetValue(page, out var list)) { list = []; _stamps[page] = list; }
list.Add(new StampInstance { PageIndex = page, Kind = kind, Spec = _docStampSpec! });
}
private void RerenderAllVisiblePages()
{
if (_doc is null) return;
// Re-render every currently-mapped page (the primary tile plus all multi-page tiles), so stamps
// show on every visible page in Grid / Two-Page / Continuous, not just the selected one.
foreach (int p in new List<int>(_pages.Keys)) RenderAllAnnotations(p);
}
// Painted by RenderAllAnnotations onto the page's annotation canvas, BEFORE the annotations, so
// stamps sit visually beneath them. Coordinates are the same 2048-based render-dim space the page
// numbers used originally, so placement matches the rest of the annotation layer.
private void RenderStamps(int pageIndex)
{
_stampHitRects[pageIndex] = []; // reset; repopulated below as stamps render
if (_docStampSpec is null || _doc is null) return;
if (!_stamps.TryGetValue(pageIndex, out var list) || list.Count == 0) return;
var (rdW, rdH, _, phpt) = StampRenderDims(pageIndex);
if (rdW <= 0 || rdH <= 0) return;
double mx = rdW * 0.05, my = rdH * 0.04;
var spec = _docStampSpec;
// First page that carries a number, so numbering starts at StartNumber there.
int firstNumPage = -1;
if (spec.NumbersEnabled)
foreach (int p in PdfBurn.StampPageRange(spec.NumRange, _doc.PageCount)) { firstNumPage = p; break; }
foreach (var st in list)
{
if (st.Kind == StampKind.Watermark) RenderWatermark(spec, pageIndex, rdW, rdH, phpt, mx, my);
else RenderPageNumber(spec, pageIndex, firstNumPage, rdW, rdH, phpt, mx, my);
}
}
private void RenderPageNumber(StampSpec spec, int pageIndex, int firstNumPage, double rdW, double rdH, double phpt, double mx, double my)
{
double fontCanvas = spec.NumFontPt * rdH / Math.Max(1, phpt);
int number = spec.StartNumber + Math.Max(0, pageIndex - Math.Max(0, firstNumPage));
string text = (string.IsNullOrEmpty(spec.Format) ? "{n}" : spec.Format)
.Replace("{n}", number.ToString())
.Replace("{N}", (_doc?.PageCount ?? 1).ToString());
if (text.Length == 0) return;
var tb = new TextBlock { Text = text, FontFamily = UiKit.UiFont, FontSize = Math.Max(1, fontCanvas), Foreground = new SolidColorBrush(spec.NumColor), IsHitTestVisible = false };
var sz = MeasureEl(tb);
int posH = spec.NumPosH;
double x, y;
if (posH < 0) // custom position (center as a fraction of the page)
{
double cx = spec.NumCustomX;
if (spec.NumMirror && (pageIndex % 2 == 1)) cx = 1 - cx; // mirror flips the x-fraction
x = cx * rdW - sz.Width / 2;
y = spec.NumCustomY * rdH - sz.Height / 2;
}
else
{
// Mirror: on alternating pages flip left<->right so the number sits on the outer edge of a spread.
if (spec.NumMirror && posH != 1 && (pageIndex % 2 == 1)) posH = 2 - posH;
x = posH == 0 ? mx : posH == 2 ? rdW - sz.Width - mx : (rdW - sz.Width) / 2;
y = spec.NumPosV == 0 ? my : spec.NumPosV == 1 ? (rdH - sz.Height) / 2 : rdH - sz.Height - my;
}
Canvas.SetLeft(tb, x);
Canvas.SetTop(tb, y);
_activeCanvas.Children.Add(tb);
if (_stampHitRects.TryGetValue(pageIndex, out var rects)) rects.Add(new Rect(x, y, sz.Width, sz.Height));
}
private void RenderWatermark(StampSpec spec, int pageIndex, double rdW, double rdH, double phpt, double mx, double my)
{
FrameworkElement el;
double w, h;
if (spec.WmIsImage && !string.IsNullOrEmpty(spec.WmImagePath) && System.IO.File.Exists(spec.WmImagePath))
{
BitmapImage? bmp = LoadImageFile(spec.WmImagePath!);
if (bmp is null) return;
w = rdW * 0.5 * spec.WmScale;
h = w * bmp.PixelHeight / Math.Max(1, bmp.PixelWidth);
el = new Image { Source = bmp, Width = w, Height = h, Opacity = spec.WmOpacity, Stretch = Stretch.Fill, IsHitTestVisible = false };
}
else
{
if (string.IsNullOrEmpty(spec.WmText)) return;
double fontCanvas = spec.WmFontPt * rdH / Math.Max(1, phpt);
var tb = new TextBlock { Text = spec.WmText, FontFamily = new FontFamily(string.IsNullOrWhiteSpace(spec.WmFont) ? "Segoe UI" : spec.WmFont), FontWeight = FontWeights.Bold, FontSize = Math.Max(1, fontCanvas), Foreground = new SolidColorBrush(spec.WmColor), Opacity = spec.WmOpacity, IsHitTestVisible = false };
var sz = MeasureEl(tb);
w = sz.Width; h = sz.Height;
el = tb;
}
double x, y;
if (spec.WmPosH < 0) // custom position (center as a fraction of the page)
{
x = spec.WmCustomX * rdW - w / 2;
y = spec.WmCustomY * rdH - h / 2;
}
else
{
x = spec.WmPosH == 0 ? mx : spec.WmPosH == 2 ? rdW - w - mx : (rdW - w) / 2;
y = spec.WmPosV == 0 ? my : spec.WmPosV == 1 ? (rdH - h) / 2 : rdH - h - my;
}
el.RenderTransformOrigin = new Point(0.5, 0.5);
el.RenderTransform = new RotateTransform(-spec.WmAngle);
Canvas.SetLeft(el, x);
Canvas.SetTop(el, y);
_activeCanvas.Children.Add(el);
if (_stampHitRects.TryGetValue(pageIndex, out var rects)) rects.Add(new Rect(x, y, w, h));
}
// True if a point (render-dim/canvas space) falls on a rendered stamp on this page - used to reopen
// the Stamp Pages editor on double-click.
private bool StampHitTest(int pageIndex, Point pos)
{
if (_stampHitRects.TryGetValue(pageIndex, out var rects))
foreach (var r in rects) if (r.Contains(pos)) return true;
return false;
}
// (rdW, rdH) in the 2048-based render-dim space; phpt/pwpt the page size in points (rotation-aware).
private (double rdW, double rdH, double pwpt, double phpt) StampRenderDims(int pageIndex)
{
if (_doc is null) return (0, 0, 0, 0);
double pw = _doc.Pages[pageIndex].Width.Point;
double ph = _doc.Pages[pageIndex].Height.Point;
if (_pageRotations.TryGetValue(pageIndex, out int rot) && (rot == 90 || rot == 270)) (pw, ph) = (ph, pw);
double maxDim = Math.Max(1, Math.Max(pw, ph));
return (2048.0 * pw / maxDim, 2048.0 * ph / maxDim, pw, ph);
}
private static Size MeasureEl(FrameworkElement el)
{
el.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
return el.DesiredSize;
}
private static BitmapImage? LoadImageFile(string path)
{
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.UriSource = new Uri(path);
bmp.EndInit();
bmp.Freeze();
return bmp;
}
catch { return null; }
}
// StampPageRange (shared with the burned output) lives in Services/PdfBurn.cs.
// ---- Export: burn the stamp layer into the PDF (below annotations) on save/flatten ----
// Draws the active stamps into the doc via XGraphics, in PDF-point space. Called BEFORE
// DrawAnnotationsOnDocument at each save site so stamps sit beneath annotations.
private void DrawStampsOnDocument(int? onlyPage = null)
=> PdfBurn.DrawStampsIntoDoc(_doc, _docStampSpec, onlyPage, _pageRotations);
// True when the document carries stamps that must be burned on save. The save sites used to
// gate the whole burn block on the ANNOTATION count alone, so a document whose only markup
// was stamps (page numbers / watermark on a fresh doc) saved without them (#147).
private bool HasActiveStamps => _docStampSpec is { } s && (s.NumbersEnabled || s.WmEnabled);
// DrawStampsIntoDoc and its DrawNumberPdf / DrawWatermarkPdf / LoadStampImage workers
// live in Services/PdfBurn.cs with the annotation burn core.
}
}
+179
View File
@@ -0,0 +1,179 @@
using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using System.Windows.Media.Animation;
// ============================================================
// THEMED WINDOWS SYSTEM MENU (from KillerUI/Shell/SystemMenu.cs)
//
// WindowChrome turns the title bar into a real native caption, which is what buys free snap,
// drag and double-click-maximize - but it also means Windows answers a caption right-click
// (and Alt+Space) with its own stock white HMENU. That menu is drawn by Win32, not WPF, so no
// brush, style or theme in the app can reach it. The ONLY way to theme it is to suppress the
// two messages that raise it and show our own.
//
// So: swallow them and pop a normal WPF ContextMenu, which picks up the app's implicit
// ContextMenu / MenuItem / Separator styles for free. Each item posts back the exact
// WM_SYSCOMMAND the native menu would have sent, so behavior is identical - including Move and
// Size, which hand off to Windows' own modal drag loops.
//
// KillerPDF adaptations: HTCAPTION already lives in Shell/WindowChrome.cs (not redeclared
// here), the menu comes from MakeThemedMenu() so its TextOptions match the app's other
// code-built menus, and the kit's Anim.FadeIn is inlined (KillerPDF carries no Anim class).
// ============================================================
namespace KillerPDF
{
public partial class MainWindow
{
private const int WM_NCRBUTTONUP = 0x00A5;
private const int WM_SYSCOMMAND = 0x0112;
private const int SC_SIZE = 0xF000;
private const int SC_MOVE = 0xF010;
private const int SC_MINIMIZE = 0xF020;
private const int SC_MAXIMIZE = 0xF030;
private const int SC_CLOSE = 0xF060;
private const int SC_KEYMENU = 0xF100;
private const int SC_RESTORE = 0xF120;
private ContextMenu? _sysMenu;
/// <summary>
/// Call from WndProc. Returns true when the message was ours and the native menu should
/// be suppressed.
/// </summary>
private bool TryHandleSystemMenu(int msg, IntPtr wParam, IntPtr lParam)
{
// Right-click on the caption.
if (msg == WM_NCRBUTTONUP && wParam.ToInt32() == HTCAPTION)
{
ShowSystemMenu(ScreenPointFromLParam(lParam));
return true;
}
// Alt+Space. SC_KEYMENU with a space is the keyboard route to the same menu; masking
// the low nibble is required because Windows packs state into it.
if (msg == WM_SYSCOMMAND && (wParam.ToInt64() & 0xFFF0) == SC_KEYMENU && lParam.ToInt64() == ' ')
{
ShowSystemMenu(null);
return true;
}
return false;
}
/// <summary>lParam of a non-client mouse message packs screen coords as two shorts.</summary>
private static Point ScreenPointFromLParam(IntPtr lParam)
{
int v = lParam.ToInt32();
return new Point((short)(v & 0xFFFF), (short)((v >> 16) & 0xFFFF));
}
private void ShowSystemMenu(Point? screenPoint)
{
_sysMenu ??= BuildSystemMenu();
bool maximized = WindowState == WindowState.Maximized;
// Windows grays out what does not apply: you cannot Restore a normal window, cannot
// Maximize an already-maximized one, and cannot Move or Size while maximized.
foreach (object o in _sysMenu.Items)
{
if (o is not MenuItem mi || mi.Tag is not int cmd) continue;
mi.IsEnabled = cmd switch
{
SC_RESTORE => maximized,
SC_MAXIMIZE => !maximized,
SC_MOVE or SC_SIZE => !maximized,
_ => true,
};
}
// KillerPDF keeps its implicit ContextMenu/MenuItem styles in MainWindow.xaml's
// WINDOW resources (not App.xaml), and an Absolute-placement menu has no inheritance
// context to find them through - it rendered stock white. PlacementTarget supplies
// that context; with PlacementMode.Absolute it does not affect position.
_sysMenu.PlacementTarget = this;
_sysMenu.Placement = System.Windows.Controls.Primitives.PlacementMode.Absolute;
if (screenPoint is { } p)
{
// Screen pixels -> DIP, so the menu lands under the cursor at any DPI.
var src = PresentationSource.FromVisual(this);
if (src?.CompositionTarget is { } ct) p = ct.TransformFromDevice.Transform(p);
_sysMenu.HorizontalOffset = p.X;
_sysMenu.VerticalOffset = p.Y;
}
else
{
// Keyboard route: hang it under the top-left of the window, like Windows does.
_sysMenu.HorizontalOffset = Left + 8;
_sysMenu.VerticalOffset = Top + 36;
}
_sysMenu.IsOpen = true;
// Inline of the kit's Anim.FadeIn: 150ms opacity ease-out.
_sysMenu.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, new Duration(TimeSpan.FromMilliseconds(150)))
{
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
});
}
private ContextMenu BuildSystemMenu()
{
var menu = MakeThemedMenu();
// Every codepoint below was verified present in segmdl2.ttf by RENDERING it, not
// trusted from documentation - this repo has shipped a missing-glyph box before.
// E923/E921/E922/E8BB are the same four the title-bar caption buttons draw, so the
// menu and the caption agree. E7C2 is the four-way move arrow, E740 the diagonal
// resize arrow.
MenuItem Add(string key, string fallback, int cmd, int glyph, bool danger = false)
{
var mi = new MenuItem { Tag = cmd, Padding = new Thickness(12, 7, 24, 7) };
mi.SetResourceReference(HeaderedItemsControl.HeaderProperty, key);
// Loc() returns the key itself when a string is missing; fall back to English so a
// half-translated locale never shows "Str_Sys_Move" in the menu.
if (Loc(key) == key) mi.Header = fallback;
var ico = new TextBlock
{
Text = char.ConvertFromUtf32(glyph),
FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
ico.SetResourceReference(TextBlock.ForegroundProperty,
danger ? "DangerRed" : "MutedTextBrush");
mi.Icon = ico;
mi.Click += (_, _) => SendSysCommand(cmd);
menu.Items.Add(mi);
return mi;
}
Add("Str_Sys_Restore", "Restore", SC_RESTORE, 0xE923);
Add("Str_Sys_Move", "Move", SC_MOVE, 0xE7C2);
Add("Str_Sys_Size", "Size", SC_SIZE, 0xE740);
Add("Str_Sys_Minimize", "Minimize", SC_MINIMIZE, 0xE921);
Add("Str_Sys_Maximize", "Maximize", SC_MAXIMIZE, 0xE922);
menu.Items.Add(new Separator());
Add("Str_Sys_Close", "Close", SC_CLOSE, 0xE8BB, danger: true);
return menu;
}
private void SendSysCommand(int cmd)
{
var hwnd = new WindowInteropHelper(this).Handle;
if (hwnd == IntPtr.Zero) return;
// Post rather than Send: the menu is still closing, and SC_MOVE / SC_SIZE start a
// modal loop that must not run inside the click handler.
PostMessage(hwnd, WM_SYSCOMMAND, new IntPtr(cmd), IntPtr.Zero);
}
[DllImport("user32.dll")]
private static extern bool PostMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam);
}
}
+159
View File
@@ -0,0 +1,159 @@
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
{
public partial class MainWindow
{
// ============================================================
// Temp save/reload
// ============================================================
private void SaveTempAndReload(bool keepAnnotations = false, bool preserveZoom = false)
{
if (_doc is null || _currentFile is null) return;
// We're about to replace the working file with a fresh temp; release the cached PDFium link
// handle for the outgoing file now (it reopens for the new temp on the post-reload re-render).
CloseLinkPdfiumDoc();
// Stop render workers tied to the outgoing file before clearing the cache. Without this,
// an already-running Grid or Continuous task can finish after the clear and put an old
// page bitmap straight back into the active session.
_secondaryRenderCts?.Cancel();
_continuousRenderCts?.Cancel();
_continuousSharpenCts?.Cancel();
// Overlay annotations are unsaved, still-editable user work. Callers that don't change
// page identity (crop) pass keepAnnotations:true so annotations on other pages survive
// the reload and stay selectable/movable; they are re-rendered after the doc reopens.
if (!keepAnnotations) _annotations.Clear();
_renderDims.Clear();
ActiveViewer.InvalidateRenderCacheExt(_active); // pages changed pixels / order: drop this tab's cached bitmaps
_renderedPrimaryPage = -1; // force a re-render after reload even if the same page stays selected (e.g. rotate)
ClearSelection();
MarkDirty();
var doc = _doc;
int selectedIdx = PageList.SelectedIndex;
// Capture page rotations, then strip them from the document before saving.
// Docnet uses FPDF_GetPageWidth/Height (MediaBox, no rotation) to size the bitmap,
// then renders with PDFium's page CTM which *does* include /Rotate. For 90/270
// the rendered landscape content overflows the portrait-sized bitmap and gets clipped.
// Stripping /Rotate to 0 before saving means Docnet renders clean unrotated content
// that fits the bitmap; RotateBitmap is applied in each render path instead.
_pageRotations.Clear();
for (int i = 0; i < doc.PageCount; i++)
{
int rot = ((doc.Pages[i].Rotate % 360) + 360) % 360;
_pageRotations[i] = rot;
doc.Pages[i].Rotate = 0;
}
var tempPath = App.MakeTempFile("temp");
try
{
PdfScrub.ScrubEmptyOutlines(doc); // #103: never write a dangling /Outlines reference
PdfScrub.ScrubDegenerateCropBoxes(doc); // never write a zero-size /CropBox (Adobe out-of-range)
doc.Save(tempPath);
doc.Close();
}
catch (Exception saveEx) when (PdfImport.IsXRefException(saveEx))
{
// PdfSharpCore fails to re-save encrypted PDFs (e.g. owner-restricted RC4 files)
// because it encounters cross-reference tokens while serializing dirty objects.
// Primary fallback: use PDFium (already initialized for the page preview) to
// load the source, strip all /Rotate values, remove encryption, and save.
// Secondary fallback: PdfSharpCore Import mode (works on some non-encrypted xref
// issues but fails on encrypted files; kept as a last resort).
doc.Close();
_doc = null;
if (!PdfiumInterop.TryPdfiumSaveWithZeroRotations(_currentFile!, tempPath) &&
!PdfImport.TryImportRepairToPath(_currentFile!, tempPath, stripRotations: true))
throw; // re-throw original if both fallbacks fail
}
// PdfSharpCore sometimes saves a file where one object's xref offset points at the
// xref table itself (object N offset = xref table position). When PdfSharp then tries
// to re-open that file in Modify mode it seeks to the xref table, reads the keyword
// "xref" as a token in an object context, and throws "Unexpected token 'xref'".
// Fix: catch the reopen failure, pipe the saved file through PDFium (which has
// robust error recovery and will rewrite a correct xref), then retry the open.
try
{
_doc = PdfReader.Open(tempPath, PdfDocumentOpenMode.Modify);
}
catch (Exception openEx) when (PdfImport.IsXRefException(openEx))
{
var fixedPath = App.MakeTempFile("fixed");
if (!PdfiumInterop.TryPdfiumSaveWithZeroRotations(tempPath, fixedPath))
throw; // PDFium also failed - re-throw original reopen error
tempPath = fixedPath;
_doc = PdfReader.Open(tempPath, PdfDocumentOpenMode.Modify);
}
_currentFile = tempPath;
// Clear once more after the old workers have observed cancellation. This closes the race
// where a worker was already inside PDFium when the first clear happened and published its
// stale result while the edited document was being saved and reopened.
ActiveViewer.InvalidateRenderCacheExt(_active);
// Restore rotations in the reopened in-memory doc so saves, form fields,
// and all other operations see the correct rotation values.
foreach (var kv in _pageRotations)
_doc.Pages[kv.Key].Rotate = kv.Value;
RefreshPageList();
if (selectedIdx >= 0 && selectedIdx < PageList.Items.Count)
PageList.SelectedIndex = selectedIdx;
else if (PageList.Items.Count > 0)
PageList.SelectedIndex = 0;
// In Continuous view the strip caches one rendered slot per page. After a
// page-modifying reload (e.g. crop) it must be rebuilt so the main view reflects the
// new pages; the slot-sizing in RenderContinuousPages makes cropped pages fit cleanly.
if (_viewMode == ViewMode.Continuous)
{
int contIdx = PageList.SelectedIndex;
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
(Action)(() => ActiveViewer.SetupContinuousView(contIdx)));
return;
}
// Refit synchronously so the first rendered frame uses the correct zoom. For crop we instead
// keep the current zoom (preserveZoom) so the page doesn't jump to fit the smaller cropped size -
// the user just wanted the cropped-away area removed, not a zoom change.
PagePreviewPanel.ScrollToHorizontalOffset(0);
if (preserveZoom) { _fitMode = FitMode.None; ActiveViewer.ApplyZoom(); }
else ActiveViewer.ReapplyGridOrFit();
// Deferred refit after layout settles for accurate ActualWidth.
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() =>
{
PagePreviewPanel.ScrollToHorizontalOffset(0);
// RefreshPageView only manages secondary tiles and links. It does not repaint the
// primary Image, which is why the thumbnail changed after an edit while the document
// stayed stale until a view-mode switch called RenderPage. Render the primary first,
// then fit the new page dimensions.
int refreshPage = _viewMode == ViewMode.Grid ? 0 : _currentPage;
if (refreshPage >= 0) ActiveViewer.RenderPage(refreshPage);
if (preserveZoom) ActiveViewer.ApplyZoom();
else ActiveViewer.ReapplyGridOrFit();
}));
}
}
}
+772
View File
@@ -0,0 +1,772 @@
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
{
public partial class MainWindow
{
// ============================================================
// Text tool settings bar
// ============================================================
// Underline and/or strikethrough as one decoration collection (either, both, or none).
private static TextDecorationCollection? BuildDecorations(bool underline, bool strike)
{
if (!underline && !strike) return null;
var d = new TextDecorationCollection();
if (underline) foreach (var x in TextDecorations.Underline) d.Add(x);
if (strike) foreach (var x in TextDecorations.Strikethrough) d.Add(x);
return d;
}
// Apply the current typeface + Bold/Italic/Underline/Strikethrough to an in-canvas edit box, so
// editing stays WYSIWYG with the text bar. Bad font names fall back to Segoe UI rather than throwing.
private void StyleEditBox(TextBox tb)
{
try { tb.FontFamily = new FontFamily(_textFontName); } catch { tb.FontFamily = UiKit.UiFont; }
tb.FontWeight = _textBold ? FontWeights.Bold : FontWeights.Normal;
tb.FontStyle = _textItalic ? FontStyles.Italic : FontStyles.Normal;
tb.TextDecorations = BuildDecorations(_textUnderline, _textStrike);
}
private void ApplyTextStyleToActiveBox()
{
if (_activeTextBox is null) return;
_activeTextBox.Foreground = new SolidColorBrush(_textColor);
_activeTextBox.Background = TextEditBackground(); // reflect the chosen fill live
StyleEditBox(_activeTextBox); // typeface + B/I/S live
int pg = _activeTextBox.Tag is int tp ? tp : PageList.SelectedIndex;
double fontCanvas = _textFontSize;
if (_doc is not null && pg >= 0 && _renderDims.TryGetValue(pg, out var rd) && rd.h > 0)
{
double sy = _doc.Pages[pg].Height.Point / rd.h;
if (sy > 0) fontCanvas = _textFontSize / sy;
}
_activeTextBox.FontSize = fontCanvas;
}
// Applies the current text style to whatever is active: the live edit box if one is open,
// otherwise the selected text box (so its color / fill / size can be changed after placing it).
// Opens the full RGB color picker seeded with the current color; applies the result on OK.
private void OpenColorPicker(Color current, Action<Color> apply, Action? refreshBar = null)
{
var dlg = new ColorPickerDialog(this, Color.FromRgb(current.R, current.G, current.B));
// Live-update the annotate bar behind the (modal) dialog whenever the shared palette is edited.
if (refreshBar is not null) dlg.SwatchesChanged += refreshBar;
dlg.ShowDialog();
// dlg.Accepted, NEVER ShowDialog's return: the eyedropper's nested capture modal can
// corrupt the outer dialog frame so ShowDialog returns false after a real OK, and the
// pick was silently dropped - shapes then drew whatever color last got through
// (the "purple/gray rectangles" bug, trace-proven 2026-08-01).
if (dlg.Accepted) apply(dlg.SelectedColor);
}
// Diagonal rainbow fill for the "more colors" swatches that open the picker.
private static LinearGradientBrush RainbowBrush() => new()
{
StartPoint = new Point(0, 0),
EndPoint = new Point(1, 1),
GradientStops =
{
new GradientStop(Colors.Red, 0), new GradientStop(Colors.Yellow, 0.25),
new GradientStop(Colors.Lime, 0.5), new GradientStop(Colors.Cyan, 0.7),
new GradientStop(Colors.Blue, 1)
}
};
private void ApplyTextStyleToSelection()
{
if (_activeTextBox is not null)
{
ApplyTextStyleToActiveBox();
return;
}
// Apply to the primary selection AND every shift-selected text annotation (these can span pages).
var touched = new HashSet<int>();
void Apply(TextAnnotation ta)
{
ta.SetColor(_textColor);
ta.SetFill(_textFillColor);
ta.FontName = _textFontName;
ta.Bold = _textBold;
ta.Italic = _textItalic;
ta.Strike = _textStrike;
ta.Underline = _textUnderline;
double sy = 1.0;
if (_doc is not null && _renderDims.TryGetValue(ta.PageIndex, out var rd) && rd.h > 0)
sy = _doc.Pages[ta.PageIndex].Height.Point / rd.h;
if (sy > 0 && _textFontSize > 0) ta.FontSize = _textFontSize / sy;
touched.Add(ta.PageIndex);
}
if (_selectedAnnotation is TextAnnotation primary) Apply(primary);
foreach (var a in _selectedSet)
if (a is TextAnnotation ta && !ReferenceEquals(ta, _selectedAnnotation)) Apply(ta);
if (touched.Count == 0) return;
MarkDirty();
int primPage = (_selectedAnnotation as TextAnnotation)?.PageIndex ?? -1;
foreach (int p in touched) if (p != primPage) RenderAllAnnotations(p);
if (primPage >= 0)
{
RenderAllAnnotations(primPage); // render primary's page last so its chrome lands on _activeCanvas
if (_selectionBorder is not null) _activeCanvas.Children.Add(_selectionBorder);
foreach (var hd in _resizeHandles) _activeCanvas.Children.Add(hd);
}
}
// Draw-bar counterpart to ApplyTextStyleToSelection: when a highlight / line / ink annotation
// is selected (not just being freshly drawn), push the bar's current color, opacity and width
// onto it and repaint - so editing an existing annotation works the same as setting up a new one.
private void ApplyDrawStyleToSelection()
{
// Apply to the primary selection AND every shift-selected highlight / line / ink annotation.
var touched = new HashSet<int>();
void Apply(PageAnnotation a)
{
if (a is HighlightAnnotation ha)
{
ha.SetColor(ha.Style == HighlightStyle.Fill ? _highlightColor : _lineAnnotColor);
touched.Add(ha.PageIndex);
}
else if (a is InkAnnotation ia)
{
ia.SetColor(_drawColor);
ia.StrokeWidth = _drawWidth;
touched.Add(ia.PageIndex);
}
}
if (_selectedAnnotation is not null) Apply(_selectedAnnotation);
foreach (var a in _selectedSet)
if (!ReferenceEquals(a, _selectedAnnotation)) Apply(a);
if (touched.Count == 0) return;
MarkDirty();
int primPage = _selectedAnnotation?.PageIndex ?? -1;
foreach (int p in touched) if (p != primPage) RenderAllAnnotations(p);
if (primPage >= 0)
{
RenderAllAnnotations(primPage);
ReattachSelectionVisuals();
}
}
private void ShowTextSettings()
{
bool appearing = _annotBarTool != EditTool.Text; // real appear/switch vs same-tool refresh
if (_textSettingsBar is not null)
{
if (appearing) FadeOutAndRemoveBar(_textSettingsBar);
else (PagePreviewPanel.Parent as Grid)?.Children.Remove(_textSettingsBar);
_textSettingsBar = null;
}
// Six self-contained single-row groups. The finished text bar deliberately pairs them
// into two aligned rows: font/style over size, text color over fill color, and text
// opacity over fill opacity. This avoids a lone Fill Opacity group looking like an
// accidental wrap at otherwise comfortable window widths.
StackPanel Group()
{
return new StackPanel
{
Orientation = Orientation.Horizontal,
VerticalAlignment = VerticalAlignment.Center,
// Right margin = the gap to the next group; top/bottom = the gap between wrapped rows.
Margin = new Thickness(0, 3, 16, 3)
};
}
TextBlock DimLabel(string text, int top, bool rightAlign = false)
{
var t = new TextBlock
{
Text = text,
FontFamily = UiKit.UiFont,
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = rightAlign ? HorizontalAlignment.Right : HorizontalAlignment.Left,
Margin = new Thickness(0, top, 6, 0)
};
t.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
return t;
}
Border ColorSwatch(Color c, bool isActive, MouseButtonEventHandler onClick)
{
var sw = new Border
{
Width = 18,
Height = 18,
Background = new SolidColorBrush(c),
BorderThickness = new Thickness(isActive ? 2 : 1),
CornerRadius = new CornerRadius(3),
Margin = new Thickness(1),
Cursor = Cursors.Hand
};
if (isActive) sw.SetResourceReference(Border.BorderBrushProperty, "PrimaryBrush");
else sw.BorderBrush = _swatchDimBorder;
sw.MouseLeftButtonDown += onClick;
return sw;
}
// Opens the full RGB picker. When the current color isn't one of the presets it shows that
// color with an accent ring (and a small rainbow corner), so the bar reflects a custom pick.
Grid MoreColorsSwatch(Color current, bool customActive, MouseButtonEventHandler onClick)
{
var grid = new Grid { Width = 18, Height = 18, Margin = new Thickness(1), Cursor = Cursors.Hand, ToolTip = Loc("Str_Bar_MoreColors") };
var bg = new Border
{
CornerRadius = new CornerRadius(3),
BorderThickness = new Thickness(customActive ? 2 : 1),
Background = customActive
? (Brush)new SolidColorBrush(Color.FromRgb(current.R, current.G, current.B))
: RainbowBrush()
};
if (customActive) bg.SetResourceReference(Border.BorderBrushProperty, "PrimaryBrush"); else bg.BorderBrush = _swatchDimBorder;
grid.Children.Add(bg);
if (customActive)
grid.Children.Add(new System.Windows.Shapes.Polygon
{
Points = [new Point(18, 7), new Point(18, 18), new Point(7, 18)],
Fill = RainbowBrush(),
IsHitTestVisible = false
});
grid.MouseLeftButtonDown += onClick;
return grid;
}
// Drag grip, centered vertically against whatever height the wrapped rows produce.
var textGrip = MakeBarGrip(4);
textGrip.VerticalAlignment = VerticalAlignment.Center;
// (grip is added to the WrapPanel host directly, below.)
var swatchRow1 = new StackPanel { Orientation = Orientation.Horizontal };
foreach (var color in SwatchColors)
{
var c = color;
bool isActive = c.R == _textColor.R && c.G == _textColor.G && c.B == _textColor.B;
swatchRow1.Children.Add(ColorSwatch(c, isActive, (_, _) =>
{
_textColor = Color.FromArgb(_textOpacity, c.R, c.G, c.B);
ApplyTextStyleToSelection();
ShowTextSettings();
}));
}
bool textCustom = !SwatchColors.Any(sc => sc.R == _textColor.R && sc.G == _textColor.G && sc.B == _textColor.B);
swatchRow1.Children.Add(MoreColorsSwatch(_textColor, textCustom, (_, _) => OpenColorPicker(_textColor, c =>
{
_textColor = Color.FromArgb(_textOpacity, c.R, c.G, c.B);
ApplyTextStyleToSelection();
ShowTextSettings();
}, () => ShowTextSettings())));
var grpColor = Group();
grpColor.Children.Add(DimLabel(Loc("Str_Bar_Color"), 0));
grpColor.Children.Add(swatchRow1);
var swatchRow2 = new StackPanel { Orientation = Orientation.Horizontal };
bool noneActive = _textFillColor.A == 0;
var noneGrid = new Grid { Width = 18, Height = 18, Margin = new Thickness(1), Cursor = Cursors.Hand };
var noneBg = new Border { CornerRadius = new CornerRadius(3), Background = Brushes.White, BorderThickness = new Thickness(noneActive ? 2 : 1) };
if (noneActive) noneBg.SetResourceReference(Border.BorderBrushProperty, "PrimaryBrush"); else noneBg.BorderBrush = _swatchDimBorder;
noneGrid.Children.Add(noneBg);
noneGrid.Children.Add(new System.Windows.Shapes.Line { X1 = 3, Y1 = 15, X2 = 15, Y2 = 3, Stroke = Brushes.Red, StrokeThickness = 1.5 });
noneGrid.MouseLeftButtonDown += (_, _) =>
{
_textFillColor = Color.FromArgb(0, _textFillColor.R, _textFillColor.G, _textFillColor.B);
ApplyTextStyleToSelection();
ShowTextSettings();
};
swatchRow2.Children.Add(noneGrid);
foreach (var color in SwatchColors)
{
var c = color;
bool isActive = _textFillColor.A > 0 && c.R == _textFillColor.R && c.G == _textFillColor.G && c.B == _textFillColor.B;
swatchRow2.Children.Add(ColorSwatch(c, isActive, (_, _) =>
{
byte a = _textFillColor.A == 0 ? (byte)255 : _textFillColor.A; // enable at full/current opacity
_textFillColor = Color.FromArgb(a, c.R, c.G, c.B);
ApplyTextStyleToSelection();
ShowTextSettings();
}));
}
bool fillCustom = _textFillColor.A > 0 && !SwatchColors.Any(sc => sc.R == _textFillColor.R && sc.G == _textFillColor.G && sc.B == _textFillColor.B);
swatchRow2.Children.Add(MoreColorsSwatch(_textFillColor.A == 0 ? Colors.White : _textFillColor, fillCustom, (_, _) => OpenColorPicker(_textFillColor.A == 0 ? Colors.White : _textFillColor, c =>
{
byte a = _textFillColor.A == 0 ? (byte)255 : _textFillColor.A;
_textFillColor = Color.FromArgb(a, c.R, c.G, c.B);
ApplyTextStyleToSelection();
ShowTextSettings();
}, () => ShowTextSettings())));
var grpFill = Group();
grpFill.Children.Add(DimLabel(Loc("Str_Bar_Fill"), 0));
grpFill.Children.Add(swatchRow2);
// Size group: its own single-row group, packed beside Font whenever the width allows.
var sizeStack = Group();
sizeStack.Children.Add(DimLabel(Loc("Str_Bar_Size"), 0));
var sizeSlider = new Slider
{
Minimum = 8,
Maximum = 72,
Value = Math.Max(8, Math.Min(72, _textFontSize)),
Width = 90,
VerticalAlignment = VerticalAlignment.Center,
TickFrequency = 1,
IsSnapToTickEnabled = true,
Style = (Style)FindResource("DarkSlider")
};
// Editable size box (type an exact value; the slider stays for quick coarse adjustment).
var sizeBox = new TextBox
{
Text = $"{_textFontSize:F0}",
FontFamily = UiKit.UiFont,
FontSize = 11,
Width = 32,
MaxLength = 4,
VerticalAlignment = VerticalAlignment.Center,
VerticalContentAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Center,
Margin = new Thickness(4, 0, 0, 0),
BorderThickness = new Thickness(1),
Template = FlatTextBoxTemplate()
};
sizeBox.SetResourceReference(TextBox.BackgroundProperty, "PaneBrush");
sizeBox.SetResourceReference(TextBox.ForegroundProperty, "TextBrush");
sizeBox.SetResourceReference(TextBox.BorderBrushProperty, "CardBorderBrush");
sizeBox.SetResourceReference(TextBox.CaretBrushProperty, "PrimaryBrush");
sizeBox.SetResourceReference(TextBox.SelectionBrushProperty, "RowSelectedBrush"); // no WPF-default blue
var ptLabel = new TextBlock
{
Text = "pt",
FontFamily = UiKit.UiFont,
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(2, 0, 0, 0)
};
ptLabel.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
// Slider drives the box; the box drives the slider. _suppressSizeSync breaks the feedback loop.
sizeSlider.ValueChanged += (s, e) =>
{
if (_suppressSizeSync) return;
_textFontSize = e.NewValue;
sizeBox.Text = $"{e.NewValue:F0}";
ApplyTextStyleToSelection();
};
void CommitSizeBox()
{
if (double.TryParse(sizeBox.Text, out double v))
{
_textFontSize = Math.Max(1, Math.Min(400, Math.Round(v)));
_suppressSizeSync = true;
sizeSlider.Value = Math.Max(8, Math.Min(72, _textFontSize)); // thumb clamps; box keeps exact
_suppressSizeSync = false;
ApplyTextStyleToSelection();
}
sizeBox.Text = $"{_textFontSize:F0}"; // normalize / revert invalid input
}
// Set an exact size and keep the slider + box in step (slider thumb clamps to 8-72).
void SetSize(double v)
{
_textFontSize = Math.Max(1, Math.Min(400, Math.Round(v)));
_suppressSizeSync = true;
sizeSlider.Value = Math.Max(8, Math.Min(72, _textFontSize));
_suppressSizeSync = false;
sizeBox.Text = $"{_textFontSize:F0}";
ApplyTextStyleToSelection();
}
// Tiny stepper button ( / +) for one-point nudges next to the slider.
Border StepButton(string glyph, Action onClick)
{
var st = new TextBlock
{
Text = glyph,
FontFamily = UiKit.UiFont,
FontSize = 13,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center
};
st.SetResourceReference(TextBlock.ForegroundProperty, "TextBrush");
var sb = new Border
{
Width = 18,
Height = 20,
CornerRadius = new CornerRadius(3),
Margin = new Thickness(3, 0, 0, 0),
Cursor = Cursors.Hand,
BorderThickness = new Thickness(1),
Background = Brushes.Transparent,
Child = st,
BorderBrush = _swatchDimBorder
};
sb.MouseLeftButtonDown += (_, _) => onClick();
return sb;
}
sizeBox.PreviewKeyDown += (s, e) =>
{
if (e.Key == Key.Enter) { CommitSizeBox(); e.Handled = true; }
else if (e.Key == Key.Escape) { sizeBox.Text = $"{_textFontSize:F0}"; e.Handled = true; }
};
sizeBox.LostFocus += (s, e) => CommitSizeBox();
sizeBox.GotFocus += (s, e) => sizeBox.SelectAll();
sizeStack.Children.Add(sizeSlider);
sizeStack.Children.Add(StepButton("", () => SetSize(_textFontSize - 1))); // minus
sizeStack.Children.Add(StepButton("+", () => SetSize(_textFontSize + 1)));
sizeStack.Children.Add(sizeBox);
sizeStack.Children.Add(ptLabel);
// Font group: typeface selector + Bold / Italic / Strikethrough / Underline.
// A small square toggle whose glyph previews its own effect (bold B, italic I, struck-through S).
Border StyleToggle(string glyph, string tip, bool active, FontWeight fw, FontStyle fs, TextDecorationCollection? deco, Action onClick)
{
var gt = new TextBlock
{
Text = glyph,
FontFamily = UiKit.UiFont,
FontSize = 12,
FontWeight = fw,
FontStyle = fs,
TextDecorations = deco,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center
};
gt.SetResourceReference(TextBlock.ForegroundProperty, "TextBrush");
var b = new Border
{
Width = 22,
Height = 20,
CornerRadius = new CornerRadius(3),
Margin = new Thickness(2, 0, 0, 0),
Cursor = Cursors.Hand,
ToolTip = tip,
BorderThickness = new Thickness(active ? 2 : 1),
Background = active ? AccentBrush(40) : Brushes.Transparent,
Child = gt
};
if (active) b.SetResourceReference(Border.BorderBrushProperty, "PrimaryBrush"); else b.BorderBrush = _swatchDimBorder;
b.MouseLeftButtonDown += (_, _) => onClick();
return b;
}
var fontStack = Group();
fontStack.Children.Add(DimLabel(Loc("Str_Bar_Font"), 0));
var fontBox = new ComboBox
{
Width = 132,
Height = 22,
VerticalAlignment = VerticalAlignment.Center,
FontFamily = UiKit.UiFont,
FontSize = 11,
MaxDropDownHeight = 320,
Margin = new Thickness(0, 0, 4, 0)
};
if (FindResource("DarkComboBox") is Style cbStyle) fontBox.Style = cbStyle;
if (FindResource("DarkComboItem") is Style ciStyle) fontBox.ItemContainerStyle = ciStyle;
foreach (var fn in SystemFontNames) fontBox.Items.Add(fn);
fontBox.SelectedItem = _textFontName;
fontBox.SelectionChanged += (s, e) =>
{
if (fontBox.SelectedItem is string fn) { _textFontName = fn; ApplyTextStyleToSelection(); }
};
fontStack.Children.Add(fontBox);
fontStack.Children.Add(StyleToggle("B", Loc("Str_Lbl_Bold"), _textBold, FontWeights.Bold, FontStyles.Normal, null,
() => { _textBold = !_textBold; ApplyTextStyleToSelection(); ShowTextSettings(); }));
fontStack.Children.Add(StyleToggle("I", Loc("Str_Lbl_Italic"), _textItalic, FontWeights.Normal, FontStyles.Italic, null,
() => { _textItalic = !_textItalic; ApplyTextStyleToSelection(); ShowTextSettings(); }));
fontStack.Children.Add(StyleToggle("S", Loc("Str_Lbl_Strike"), _textStrike, FontWeights.Normal, FontStyles.Normal, TextDecorations.Strikethrough,
() => { _textStrike = !_textStrike; ApplyTextStyleToSelection(); ShowTextSettings(); }));
fontStack.Children.Add(StyleToggle("U", Loc("Str_Lbl_Underline"), _textUnderline, FontWeights.Normal, FontStyles.Normal, TextDecorations.Underline,
() => { _textUnderline = !_textUnderline; ApplyTextStyleToSelection(); ShowTextSettings(); }));
// Opacity and Fill Opacity: two independent single-row groups.
var grpOpacity = Group();
grpOpacity.Children.Add(DimLabel(Loc("Str_Bar_Opacity"), 0));
var opacitySlider = new Slider
{
Minimum = 10,
Maximum = 255,
Value = _textOpacity,
Width = 90,
VerticalAlignment = VerticalAlignment.Center,
Style = (Style)FindResource("DarkSlider")
};
grpOpacity.Children.Add(opacitySlider);
var opacityLabel = new TextBlock
{
Text = $"{(int)(_textOpacity / 255.0 * 100)}%",
FontFamily = UiKit.UiFont,
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 0, 0),
Width = 40,
TextAlignment = TextAlignment.Right
};
opacityLabel.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
grpOpacity.Children.Add(opacityLabel);
opacitySlider.ValueChanged += (s, e) =>
{
byte a = (byte)e.NewValue;
opacityLabel.Text = $"{(int)(a / 255.0 * 100)}%";
_textOpacity = a;
_textColor = Color.FromArgb(a, _textColor.R, _textColor.G, _textColor.B);
ApplyTextStyleToSelection();
};
var grpFillOp = Group();
grpFillOp.Children.Add(DimLabel(Loc("Str_Bar_FillOpacity"), 0));
byte curFillA = _textFillColor.A == 0 ? (byte)255 : _textFillColor.A;
var fillOpSlider = new Slider
{
Minimum = 10,
Maximum = 255,
Value = curFillA,
Width = 90,
VerticalAlignment = VerticalAlignment.Center,
Style = (Style)FindResource("DarkSlider")
};
grpFillOp.Children.Add(fillOpSlider);
var fillOpLabel = new TextBlock
{
Text = $"{(int)(curFillA / 255.0 * 100)}%",
FontFamily = UiKit.UiFont,
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 0, 0),
Width = 40,
TextAlignment = TextAlignment.Right
};
fillOpLabel.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
grpFillOp.Children.Add(fillOpLabel);
fillOpSlider.ValueChanged += (s, e) =>
{
byte a = (byte)e.NewValue;
fillOpLabel.Text = $"{(int)(a / 255.0 * 100)}%";
// Dragging opacity turns the fill on (defaults to the current color, white for whiteout).
_textFillColor = Color.FromArgb(a, _textFillColor.R, _textFillColor.G, _textFillColor.B);
ApplyTextStyleToSelection();
};
// Intentional two-row interim layout for 1.7.5, assembled as three vertical pairs so
// related controls share an exact left edge: Font over Size, Text Color over Fill, and
// Text Opacity over Fill Opacity. The outer WrapPanel moves a whole pair at narrow split-
// pane widths instead of separating a label from the control directly beneath it.
var fontPair = new StackPanel();
fontPair.Children.Add(fontStack);
fontPair.Children.Add(sizeStack);
var colorPair = new StackPanel();
colorPair.Children.Add(grpColor);
colorPair.Children.Add(grpFill);
var opacityPair = new StackPanel();
opacityPair.Children.Add(grpOpacity);
opacityPair.Children.Add(grpFillOp);
var pairHost = new WrapPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(8, 2, 8, 2),
Background = Brushes.Transparent
};
pairHost.Children.Add(textGrip);
pairHost.Children.Add(fontPair);
pairHost.Children.Add(colorPair);
pairHost.Children.Add(opacityPair);
_annotBarDragInners.Clear();
_textSettingsBar = new Border
{
BorderThickness = new Thickness(1, 0, 1, 1), // no top border - the toolbar above already separates
HorizontalAlignment = HorizontalAlignment.Right, // right-anchored; slid via the grip
VerticalAlignment = VerticalAlignment.Top,
CornerRadius = new CornerRadius(0),
Padding = new Thickness(4),
Effect = AnnotBarShadow(),
Child = BuildBarHost(pairHost),
Margin = new Thickness(0, 0, 0, 0)
};
_textSettingsBar.SetResourceReference(Border.BackgroundProperty, "BgFlyout");
_textSettingsBar.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush");
_textSettingsBar.SetResourceReference(Border.CornerRadiusProperty, "AnnotationBarCornerRadius");
var previewArea = PagePreviewPanel.Parent as Grid;
if (previewArea is not null)
{
Panel.SetZIndex(_textSettingsBar, 100);
previewArea.Children.Add(_textSettingsBar);
// Cap the paired layout to the document area. At split-pane widths whole columns wrap
// together; at normal widths the two rows retain their deliberate vertical alignment.
pairHost.SetBinding(FrameworkElement.MaxWidthProperty, new System.Windows.Data.Binding("ActualWidth")
{ Source = previewArea, Converter = _barWidthInset });
WireBarWrapAdaptation(pairHost, textGrip, fontPair, previewArea);
PlaceAnnotationBar(_textSettingsBar, textGrip, fadeIn: appearing);
}
_annotBarTool = EditTool.Text;
_annotBarMinimized = false; // a freshly built bar is full-size
}
// (The shared overflow machinery - MakeBarOverflow and WireBarOverflow - lives in
// AnnotationBars.cs, used by this bar and the draw/highlight/line/shape bar alike.)
private void HideTextSettings()
{
FadeOutAndRemoveBar(_textSettingsBar);
_textSettingsBar = null;
if (_annotBarTool == EditTool.Text) _annotBarTool = null;
}
private void PlaceImageFromDialog(Point pos, int pageIdx)
{
var dlg = new Controls.FileDialog(Controls.FileDialogMode.Open)
{
Title = Loc("Str_Dlg_InsertImage"),
Filter = Loc("Str_Filter_Images") + "|*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.tiff;*.tif|" + Loc("Str_Filter_AllFiles") + "|*.*",
ShowImagePreview = true
};
if (dlg.ShowDialog(this) != true) return;
try
{
var imgBytes = File.ReadAllBytes(dlg.FileName);
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.StreamSource = new MemoryStream(imgBytes);
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
double srcW = bmp.PixelWidth > 0 ? bmp.PixelWidth : 400;
double srcH = bmp.PixelHeight > 0 ? bmp.PixelHeight : 300;
// Default the placed image to ~50% of the page's longest side (in render-dim
// units) so it is a usable size regardless of page dimensions, never upscaling
// beyond the source's native resolution.
double pageMax = _renderDims.TryGetValue(pageIdx, out var rdImg)
? Math.Max(rdImg.w, rdImg.h) : 2048.0;
double MaxCanvasDim = pageMax * 0.5;
double scale = Math.Min(1.0, Math.Min(MaxCanvasDim / srcW, MaxCanvasDim / srcH));
var imgAnnot = new ImageAnnotation
{
PageIndex = pageIdx,
Position = pos,
Scale = scale,
SourceWidth = srcW,
SourceHeight = srcH,
ImageData = Convert.ToBase64String(imgBytes)
};
// Switch to Select FIRST so placement renders last and nothing wipes the image
// (calling SetTool between render and select was what made the image vanish).
SetTool(EditTool.Select);
AddAnnotation(imgAnnot);
RenderAllAnnotations(pageIdx);
double w = srcW * scale;
double h = srcH * scale;
SelectAnnotation(imgAnnot, new Rect(pos.X, pos.Y, w, h));
SetStatus(Loc("Str_St_ImagePlaced"));
}
catch (Exception ex)
{
KillerDialog.Show(this, Loc("Str_Err_LoadImageFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
// Ctrl+V: drop a clipboard image (as an image annotation) or clipboard text (as a text
// annotation) onto the current page, centered, then select it. Coordinates are in the page's
// render-dim space (== _renderDims[page]), matching how clicks place annotations.
private void PasteFromClipboard()
{
if (_doc is null) return;
int pageIdx = PageList.SelectedIndex;
if (pageIdx < 0) pageIdx = 0;
if (pageIdx >= _doc.PageCount) return;
double pw = _renderDims.TryGetValue(pageIdx, out var rd) ? rd.w : 2048.0;
double ph = _renderDims.TryGetValue(pageIdx, out var rd2) ? rd2.h : 2048.0;
try
{
if (Clipboard.ContainsImage())
{
var src = Clipboard.GetImage();
if (src is null) { SetStatus(Loc("Str_St_ClipImageUnreadable")); return; }
// Encode to PNG so ImageAnnotation stores standard bytes (same as file import).
byte[] imgBytes;
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(src));
using (var ms = new MemoryStream()) { encoder.Save(ms); imgBytes = ms.ToArray(); }
double srcW = src.PixelWidth > 0 ? src.PixelWidth : 400;
double srcH = src.PixelHeight > 0 ? src.PixelHeight : 300;
double pageMax = Math.Max(pw, ph);
double maxCanvasDim = pageMax * 0.5;
double scale = Math.Min(1.0, Math.Min(maxCanvasDim / srcW, maxCanvasDim / srcH));
double w = srcW * scale, h = srcH * scale;
var pos = new Point((pw - w) / 2, (ph - h) / 2);
var imgAnnot = new ImageAnnotation
{
PageIndex = pageIdx,
Position = pos,
Scale = scale,
SourceWidth = srcW,
SourceHeight = srcH,
ImageData = Convert.ToBase64String(imgBytes)
};
SetTool(EditTool.Select);
AddAnnotation(imgAnnot);
RenderAllAnnotations(pageIdx);
SelectAnnotation(imgAnnot, new Rect(pos.X, pos.Y, w, h));
SetStatus(Loc("Str_St_PastedImage"));
}
else if (Clipboard.ContainsText())
{
string content = Clipboard.GetText().Trim();
if (string.IsNullOrEmpty(content)) { SetStatus(Loc("Str_St_ClipNoText")); return; }
// Convert the point size to the page's canvas units (see PlaceTextBox).
double fontCanvas = _textFontSize;
double sy = _doc.Pages[pageIdx].Height.Point / Math.Max(1.0, ph);
if (sy > 0) fontCanvas = _textFontSize / sy;
var ta = new TextAnnotation
{
PageIndex = pageIdx,
Position = new Point(pw * 0.25, ph * 0.45),
Content = content,
FontSize = fontCanvas
};
ta.SetColor(_textColor);
SetTool(EditTool.Select);
AddAnnotation(ta);
RenderAllAnnotations(pageIdx);
SelectAnnotation(ta, AnnotBounds(ta));
SetStatus(Loc("Str_St_PastedText"));
}
else
{
SetStatus(Loc("Str_St_ClipEmpty"));
}
}
catch (Exception ex)
{
KillerDialog.Show(this, Loc("Str_Err_PasteFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
}
+382
View File
@@ -0,0 +1,382 @@
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
{
public partial class MainWindow
{
// ============================================================
// Tool selection
// ============================================================
// Maps an editing tool to its mouse cursor. Shared by SetTool and by the
// per-page overlay creation so freshly rendered tiles get the right cursor.
internal static Cursor CursorForTool(EditTool tool) => tool switch
{
EditTool.Text => Cursors.IBeam,
EditTool.Highlight => Cursors.Cross,
EditTool.Strikethrough => Cursors.Cross,
EditTool.Underline => Cursors.Cross,
EditTool.Draw => Cursors.Pen,
EditTool.Line => Cursors.Cross,
EditTool.Shape => Cursors.Cross,
EditTool.Signature => Cursors.Pen,
EditTool.Image => Cursors.Hand,
EditTool.Crop => Cursors.Cross,
EditTool.Rotate => Cursors.Cross,
_ => Cursors.Arrow
};
private void SetTool(EditTool tool, bool restoringPane = false)
{
// Re-clicking the tool that owns the visible annotate bar tucks the bar away (or brings it
// back) instead of rebuilding it - no flicker, and a quick way to get it out of the way.
bool reclickedAnnotTool = !restoringPane && tool == _currentTool && tool == _annotBarTool
&& (_textSettingsBar is not null || _drawSettingsBar is not null || _cropConfirmBar is not null);
if (reclickedAnnotTool)
{
ToggleAnnotBarMinimized();
return;
}
// Continuous view now supports annotation tools inline via per-page overlays.
CommitActiveTextBox();
ClearTextSelection();
CancelShapePolygon(); // abandon an in-progress polygon when switching tools
if (tool != EditTool.Draw) HideBrushPreview(); // drop the brush cursor when leaving Draw
_currentTool = tool;
var map = new (Button btn, EditTool t)[]
{
(_toolSelectBtn, EditTool.Select),
(_toolTextBtn, EditTool.Text),
(_toolHighlightBtn, EditTool.Highlight),
(_toolUnderlineBtn, EditTool.Line), // the old Underline button is now the Line tool
(_toolDrawBtn, EditTool.Draw),
(_toolShapeBtn, EditTool.Shape),
(_toolSignatureBtn, EditTool.Signature),
(_toolImageBtn, EditTool.Image),
(_toolCropBtn, EditTool.Crop),
(_toolRotateBtn, EditTool.Rotate)
};
foreach (var (btn, t) in map)
{
if (t == tool)
{
btn.SetResourceReference(Control.BackgroundProperty, "SelectionBg");
btn.SetResourceReference(Control.ForegroundProperty, "SelectionFg");
}
else
{
// Clear local values so the ToolbarButton style (incl. its hover trigger) applies.
// Setting Background locally here would override the style and kill the hover.
btn.ClearValue(Control.BackgroundProperty);
btn.ClearValue(Control.ForegroundProperty);
}
}
// Apply the tool cursor to every page surface, not just the primary page.
// In Grid / Two-Page / Continuous modes the secondary tiles are separate
// overlay canvases tracked in _continuousCanvases; without this they keep
// the default arrow cursor while only page 1 (_annotationCanvas) updates.
var toolCursor = CursorForTool(tool);
_annotationCanvas.Cursor = toolCursor;
foreach (var overlay in _continuousCanvases.Values)
overlay.Cursor = toolCursor;
// Show/hide draw settings bar
if ((tool is EditTool.Draw or EditTool.Line or EditTool.Shape) || tool == EditTool.Highlight
|| tool == EditTool.Strikethrough || tool == EditTool.Underline)
ShowDrawSettings(tool);
else
HideDrawSettings();
// Show/hide text tool settings bar
if (tool == EditTool.Text)
ShowTextSettings();
else
HideTextSettings();
// Hide signature popup when switching away
if (tool != EditTool.Signature)
{
HideSignaturePopup();
_pendingSignature = null;
}
// Dismiss crop confirm bar when switching away from Crop; entering Crop drops a default box + bar.
if (tool != EditTool.Crop)
HideCropConfirmBar();
else if (_cropConfirmBar is null)
ShowDefaultCropBox();
// NOTE: deliberately NOT reflowing the toolbar here. Reflowing on every tool switch at a narrow
// width visibly thrashes the whole bar. The active-tool protection still runs on resize (the
// next time the window changes width the active tool is pulled back onto the bar), which the
// user preferred over the jank.
UpdateOverflowActiveHighlight(); // mark the active tool in the overflow menu (cheap, no reflow)
}
// Tints the active tool's row in the overflow menu with the selection colors, so when a tool lives
// in the chevron (collapsed off the bar) you can still see which one is active - same cue as the
// highlighted icon on the bar. No-op for the rows that aren't tools.
private void UpdateOverflowActiveHighlight()
{
var map = new (Button mi, EditTool t)[]
{
(MiText, EditTool.Text), (MiUnderline, EditTool.Line), (MiHighlight, EditTool.Highlight),
(MiDraw, EditTool.Draw), (MiImage, EditTool.Image), (MiCrop, EditTool.Crop),
(MiSignature, EditTool.Signature),
};
foreach (var (mi, t) in map)
{
if (mi is null) continue;
bool active = t == _currentTool;
if (active) mi.SetResourceReference(Control.BackgroundProperty, "SelectionBg");
else mi.ClearValue(Control.BackgroundProperty);
if (mi.Content is Panel sp)
foreach (var ch in sp.Children)
if (ch is TextBlock tb)
tb.SetResourceReference(TextBlock.ForegroundProperty, active ? "SelectionFg" : "TextBrush");
}
}
private void SidebarToggle_Click(object sender, RoutedEventArgs e)
{
// A toggle from the USER makes the state deliberate: a closed sidebar then stays
// closed until the user opens it - SyncSidebarToDocState may only undo its own
// automatic collapse (see _sidebarAutoCollapsed).
if (!_sidebarAutoToggling) _sidebarAutoCollapsed = false;
_sidebarCollapsed = !_sidebarCollapsed;
if (_sidebarCollapsed)
{
// Save current width before collapsing so expand restores it.
if (_sidebarCol.ActualWidth > 24)
{
if (_sidebarShowingOutlines)
_savedOutlinesWidth = Math.Min(_sidebarCol.ActualWidth, SbPx(SidebarMaxOutlines));
else
_savedPagesWidth = Math.Min(_sidebarCol.ActualWidth, SbPx(SidebarMaxPages));
}
_sidebarToggleBtn.ToolTip = Loc("Str_TT_ExpandSidebar");
// Glide shut sliding, not squishing: freeze the content at its open width so
// the shrinking border CLIPS it (thumbnails hold their size), then hide the
// border once the strip width is reached. MinWidth drops first so the
// animation isn't clamped at the readable floor.
BeginSidebarSlide(SidebarContentPanel.ActualWidth);
_sidebarCol.MinWidth = SbPx(24);
AnimateSidebarWidth(SbPx(24), () =>
{
_sidebarBorder.Visibility = Visibility.Collapsed;
EndSidebarSlide();
});
// Splitter stays enabled so the user can grab it and drag the sidebar back open.
}
else
{
_sidebarBorder.Visibility = Visibility.Visible;
double restore = _sidebarShowingOutlines ? _savedOutlinesWidth : _savedPagesWidth;
// Slide in at full size: content fixed at the target width from the first
// frame, revealed by the growing border instead of reflowing up to size.
BeginSidebarSlide(_sbSlideContentW > 0 ? _sbSlideContentW
: Math.Max(0, restore / Math.Max(0.01, _appScale) - 24));
AnimateSidebarWidth(restore, () =>
{
_sidebarCol.MinWidth = SbPx(SidebarMinOpen); // open: clamp so the list can't be dragged below readable
EndSidebarSlide();
});
_sidebarToggleBtn.ToolTip = Loc("Str_TT_CollapseSidebar");
SidebarSplitter.IsEnabled = true;
}
UpdateSidebarToggleGlyph();
}
// Sidebar slide freeze: while the column animates, the content keeps a FIXED width
// (anchored at the outer edge) and the border clips it - so thumbnails and labels
// hold their size and slide out of view instead of reflowing every frame. Restored
// to normal stretch layout when the glide lands.
private double _sbSlideContentW;
private void BeginSidebarSlide(double contentW)
{
if (contentW <= 0) return;
_sbSlideContentW = contentW;
SidebarContentPanel.Width = contentW;
SidebarContentPanel.HorizontalAlignment =
_sidebarRight ? HorizontalAlignment.Right : HorizontalAlignment.Left;
_sidebarBorder.ClipToBounds = true;
}
private void EndSidebarSlide()
{
SidebarContentPanel.Width = double.NaN;
SidebarContentPanel.HorizontalAlignment = HorizontalAlignment.Stretch;
_sidebarBorder.ClipToBounds = false;
}
// Animates the sidebar column between widths (toggle collapse / expand). No explicit
// re-render here: every frame fires PagePreviewPanel_SizeChanged, whose lite fit keeps
// the page tracking the pane smoothly and whose settle timer runs ONE crisp pass a beat
// after the last frame - exactly the splitter-drag pipeline. An extra RefreshPageView at
// Completed doubled up with that settle pass and read as a two-step stutter. Completion
// clears the animation and writes the target as a plain local value so the splitter,
// side flip, and full screen can keep setting Width directly.
private void AnimateSidebarWidth(double target, Action? onDone = null)
{
var anim = new GridLengthAnimation
{
From = new GridLength(Math.Max(0, _sidebarCol.ActualWidth)),
To = new GridLength(target),
Duration = TimeSpan.FromMilliseconds(280),
Easing = new System.Windows.Media.Animation.CubicEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut },
};
anim.Completed += (_, _) =>
{
_sidebarCol.BeginAnimation(ColumnDefinition.WidthProperty, null);
_sidebarCol.Width = new GridLength(target);
onDone?.Invoke();
};
_sidebarCol.BeginAnimation(ColumnDefinition.WidthProperty, anim);
}
// Pressing the splitter while the sidebar is collapsed begins pulling it open: reveal the page
// list (still 0-width against the 24px strip, so there's no flash) so the drag grows it live. If
// the user doesn't drag past the threshold, OnSidebarResized snaps it shut again on release.
private bool _sidebarDragOpening; // true while dragging the splitter open from the collapsed strip
private bool _sidebarWantClose; // set during an open-resize drag when pulled past the close edge
private void OnSidebarSplitterPress()
{
if (!_sidebarCollapsed) return;
// Begin pulling open from the strip. Show the sidebar background + grain right away so the
// growing strip matches the sidebar, but keep the content (header + list) hidden until it's
// pulled to the readable minimum (OnSidebarSplitterMove) - so nothing shows clipped, and the
// column grows from the 24px edge tracking the mouse with no dead zone.
_sidebarCollapsed = false;
_sidebarDragOpening = true;
_sidebarBorder.Visibility = Visibility.Visible;
SidebarContentPanel.Visibility = Visibility.Collapsed;
_sidebarToggleBtn.ToolTip = Loc("Str_TT_CollapseSidebar");
UpdateSidebarToggleGlyph();
}
// Drives the splitter drag. While opening from the strip, reveal the list only once it's past the
// readable minimum. Once open, pulling the mouse well past the minimum closes the sidebar (the
// MinWidth clamp already stops the column from ever shrinking below the readable floor).
private void OnSidebarSplitterMove(object sender, System.Windows.Input.MouseEventArgs e)
{
if (_sidebarCollapsed || e.LeftButton != System.Windows.Input.MouseButtonState.Pressed) return;
if (_sidebarDragOpening)
{
SidebarContentPanel.Visibility = _sidebarCol.ActualWidth >= SbPx(SidebarMinOpen)
? Visibility.Visible : Visibility.Collapsed;
return;
}
// The column is clamped at the readable minimum (MinWidth), so it can't clip and the document
// isn't resized as you pull. Just record intent; the actual close (and its single re-render)
// happens on release in OnSidebarResized - so dragging the splitter never re-renders the page.
double mx = e.GetPosition(MainContentGrid).X;
const double closeBuffer = 36; // pull this far past the minimum edge to close on release
_sidebarWantClose = _sidebarRight
? mx > MainContentGrid.ActualWidth - SbPx(SidebarMinOpen) + closeBuffer
: mx < SbPx(SidebarMinOpen) - closeBuffer;
}
// Called when the user finishes dragging the sidebar splitter. If they dragged it narrower than
// the close threshold, snap it fully closed (to the toggle strip) instead of leaving an unusable
// sliver; otherwise remember the new width for the current mode.
private void OnSidebarResized()
{
_sidebarDragOpening = false;
if (_sidebarCollapsed) { _sidebarWantClose = false; return; }
if (_sidebarWantClose) { _sidebarWantClose = false; CollapseSidebarToStrip(); return; }
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() =>
{
if (_sidebarCollapsed) return;
double w = _sidebarCol.ActualWidth;
if (w < SbPx(SidebarMinOpen))
{
// Released below the readable minimum: snap to whichever end the user let go nearer
// to - fully closed, or the minimum open width.
double mid = SbPx((24 + SidebarMinOpen) / 2.0);
if (w < mid) { CollapseSidebarToStrip(); return; }
_sidebarCol.Width = new GridLength(SbPx(SidebarMinOpen));
w = SbPx(SidebarMinOpen);
}
_sidebarBorder.Visibility = Visibility.Visible; // ensure the list shows when settled open
SidebarContentPanel.Visibility = Visibility.Visible;
_sidebarCol.MinWidth = SbPx(SidebarMinOpen); // clamp future resizes so they can't clip
if (_sidebarShowingOutlines) _savedOutlinesWidth = Math.Min(w, SbPx(SidebarMaxOutlines));
else _savedPagesWidth = Math.Min(w, SbPx(SidebarMaxPages));
}));
}
// Collapse to the 24px toggle strip without overwriting the saved width, so re-expand restores the
// last good size rather than the thin dragged one. Mirrors SidebarToggle_Click's collapse branch.
private void CollapseSidebarToStrip()
{
_sidebarCollapsed = true;
_sidebarToggleBtn.ToolTip = Loc("Str_TT_ExpandSidebar");
_sidebarBorder.Visibility = Visibility.Collapsed;
SidebarContentPanel.Visibility = Visibility.Visible; // reset so the next border-show has content
_sidebarCol.Width = new GridLength(SbPx(24));
_sidebarCol.MinWidth = SbPx(24);
UpdateSidebarToggleGlyph(); // splitter stays enabled so it can be dragged back open
}
// The rail auto-collapses when no PDF is open and re-opens when one is (2026-07-23): with
// nothing loaded there are no thumbnails to show, so the strip gets out of the way. The empty
// page-jump box + "/ -" hide alongside. Only fires on the open/close transition (FinishOpenFile /
// ShowEmptyState) and at startup - NOT on tab switches - so a manual toggle mid-session sticks.
// startup=true collapses instantly (no glide before the first paint); runtime transitions animate.
//
// A sidebar the USER closed stays closed: the auto-open may only undo this method's OWN
// collapse. Materializing a lazy tab runs the document-open transition too, so without the
// distinction a tab click was popping a deliberately closed sidebar back open.
private bool _sidebarAutoCollapsed; // the current collapse was made HERE, not by the user
private bool _sidebarAutoToggling; // this method is driving SidebarToggle_Click
private void SyncSidebarToDocState(bool hasDoc, bool startup)
{
if (PageControlsRow != null)
PageControlsRow.Visibility = (hasDoc && !_sidebarShowingOutlines)
? Visibility.Visible : Visibility.Collapsed;
_sidebarAutoToggling = true;
try
{
if (hasDoc && _sidebarCollapsed && _sidebarAutoCollapsed)
{
_sidebarAutoCollapsed = false;
SidebarToggle_Click(this, new RoutedEventArgs()); // open the rail for the document
}
else if (!hasDoc && !_sidebarCollapsed)
{
_sidebarAutoCollapsed = true;
if (startup) CollapseSidebarToStrip(); // instant at launch
else SidebarToggle_Click(this, new RoutedEventArgs()); // animated on last-tab close
}
}
finally { _sidebarAutoToggling = false; }
}
}
}
+679
View File
@@ -0,0 +1,679 @@
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
{
public partial class MainWindow
{
// ============================================================
// Window proc / Win32 interop (custom chrome, resize, DPI)
// ============================================================
private const int WM_GETMINMAXINFO = 0x0024;
private const int WM_DPICHANGED = 0x02E0;
private const int WM_MOUSEHWHEEL = 0x020E;
private const int WM_ENTERSIZEMOVE = 0x0231;
private const int WM_EXITSIZEMOVE = 0x0232;
private const int WM_ERASEBKGND = 0x0014;
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
private const uint SWP_NOZORDER = 0x0004;
private const uint SWP_NOACTIVATE = 0x0010;
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
// Themed system menu (Shell/SystemMenu.cs): swallow the caption right-click and
// Alt+Space before anything else, or Windows draws its stock white HMENU.
if (TryHandleSystemMenu(msg, wParam, lParam)) { handled = true; return IntPtr.Zero; }
if (msg == WM_ERASEBKGND)
{
// WPF paints the whole client area itself, so let nothing erase the background to a flat
// fill underneath it during a resize - that erase is a flash that reads as part of the
// edge "jitter". Claim the message as handled and report success (1) without painting.
handled = true;
return new IntPtr(1);
}
if (msg == WM_GETMINMAXINFO)
{
WmGetMinMaxInfo(hwnd, lParam);
handled = true;
}
else if (msg == 0x0231) _inWindowSizeMove = true; // WM_ENTERSIZEMOVE
else if (msg == 0x0232) _inWindowSizeMove = false; // WM_EXITSIZEMOVE
else if (msg == WM_MOUSEHWHEEL)
{
// #196: WPF has no MouseHWheel event, so a precision touchpad's two-finger
// horizontal scroll (and a mouse's tilt wheel) died at the HwndSource and the
// document never panned sideways. Positive delta scrolls right.
int hDelta = unchecked((short)((wParam.ToInt64() >> 16) & 0xFFFF));
ActiveViewer.ScrollHorizontalExt(hDelta);
handled = true;
}
else if (msg == WM_DPICHANGED)
{
// Apply Windows' suggested rect so the window's apparent size is preserved
// on the new monitor. handled stays false so WPF's HwndSource also processes
// the message - updating its internal DPI scale and firing Window.DpiChanged.
var r = Marshal.PtrToStructure<RECT>(lParam);
SetWindowPos(hwnd, IntPtr.Zero, r.left, r.top,
r.right - r.left, r.bottom - r.top,
SWP_NOZORDER | SWP_NOACTIVATE);
// Re-render at the new DPI. DispatcherPriority.Loaded fires after WPF has
// finished its own DPI update, so VisualTreeHelper.GetDpi already reflects
// the new scale factor when RenderPage calls it.
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
(Action)(() =>
{
if (_doc is null) return;
if (_viewMode == ViewMode.Grid)
{
// Grid's primary tile (and the page-width basis the column math uses) is
// ALWAYS page 0 - rendering the selected page here would corrupt that basis
// and could collapse the grid to one column. Re-render page 0, then re-fit the
// columns to the new DPI/size so the grid is preserved across the monitor move.
ActiveViewer.RenderPage(0);
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
(Action)(() => ActiveViewer.ReapplyGridOrFit()));
return;
}
int idx = PageList.SelectedIndex;
if (idx >= 0) ActiveViewer.RenderPage(idx);
}));
}
// WM_NCHITTEST is handled natively by WindowChrome.ResizeBorderThickness. The document scrollbar
// is kept grabbable over the right resize band via WindowChrome.IsHitTestVisibleInChrome on the
// ScrollBar style (App.xaml), which is the WindowChrome-native way to exempt an element from the
// resize border - no manual hit-test override (that fought WindowChrome and killed scrollbar clicks).
return IntPtr.Zero;
}
private int WmNcHitTest(IntPtr hwnd, IntPtr lParam)
{
// lParam is screen coords: lo-word = X, hi-word = Y.
// Cast through short to preserve sign (handles negative coords on left/above primary monitor).
long lp = lParam.ToInt64();
int mx = unchecked((short)(lp & 0xFFFF));
int my = unchecked((short)((lp >> 16) & 0xFFFF));
if (!GetWindowRect(hwnd, out RECT rc)) return 0;
// The floating window has a transparent ShadowMargin around the visible content, so the resize
// grips must sit at the CONTENT edge (inset by the margin), not the window edge - otherwise you
// have to reach out into the shadow to resize. Maximized/snapped has no margin.
int sm = _chromeSquared ? 0 : (int)ShadowMargin;
bool onLeft = mx >= rc.left + sm && mx < rc.left + sm + ResizeBorder;
bool onRight = mx < rc.right - sm && mx >= rc.right - sm - ResizeBorder;
bool onTop = my >= rc.top + sm && my < rc.top + sm + ResizeBorder;
bool onBottom = my < rc.bottom - sm && my >= rc.bottom - sm - ResizeBorder;
// Never hijack a scrollbar for window resizing. The vertical scrollbar sits flush
// against the window's right edge, so the resize border used to swallow it - the
// cursor showed the resize arrow and dragging resized the window instead of moving
// the thumb. If a ScrollBar is under the cursor, report client area so it stays grabbable.
if ((onLeft || onRight || onTop || onBottom) && IsOverScrollBar(mx, my))
return HTCLIENT;
if (onTop && onLeft) return HTTOPLEFT;
if (onTop && onRight) return HTTOPRIGHT;
if (onBottom && onLeft) return HTBOTTOMLEFT;
if (onBottom && onRight) return HTBOTTOMRIGHT;
if (onLeft) return HTLEFT;
if (onRight) return HTRIGHT;
if (onTop) return HTTOP;
if (onBottom) return HTBOTTOM;
return 0;
}
// Hit-tests the visual tree at a screen point (physical pixels from WM_NCHITTEST)
// and reports whether a ScrollBar sits under the cursor.
private bool IsOverScrollBar(int screenX, int screenY)
{
try
{
var pt = PointFromScreen(new Point(screenX, screenY));
var res = VisualTreeHelper.HitTest(this, pt);
DependencyObject? hit = res?.VisualHit;
while (hit != null)
{
if (hit is System.Windows.Controls.Primitives.ScrollBar) return true;
hit = VisualTreeHelper.GetParent(hit);
}
}
catch { /* best-effort; fall through to normal resize handling */ }
return false;
}
private void WmGetMinMaxInfo(IntPtr hwnd, IntPtr lParam)
{
var mmi = Marshal.PtrToStructure<MINMAXINFO>(lParam);
IntPtr monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if (monitor != IntPtr.Zero)
{
var info = new MONITORINFO { cbSize = Marshal.SizeOf(typeof(MONITORINFO)) };
GetMonitorInfo(monitor, ref info);
RECT work = info.rcWork;
RECT mon = info.rcMonitor;
// Normal maximize respects the taskbar (work area). F11 full screen needs the whole monitor:
// ptMaxTrackSize caps how large the window can ever be sized, so without this the explicit
// full-screen bounds get silently clamped back to the work area (taskbar stays visible).
RECT bounds = _fullScreen ? mon : work;
mmi.ptMaxPosition.x = Math.Abs(bounds.left - mon.left);
mmi.ptMaxPosition.y = Math.Abs(bounds.top - mon.top);
mmi.ptMaxSize.x = Math.Abs(bounds.right - bounds.left);
mmi.ptMaxSize.y = Math.Abs(bounds.bottom - bounds.top);
mmi.ptMaxTrackSize.x = mmi.ptMaxSize.x;
mmi.ptMaxTrackSize.y = mmi.ptMaxSize.y;
// Enforce the window's MinWidth/MinHeight during user resize. The custom chrome
// marks WM_GETMINMAXINFO handled, so WPF's own minimum enforcement is bypassed.
try
{
var dpi = VisualTreeHelper.GetDpi(this);
if (MinWidth > 0 && !double.IsInfinity(MinWidth)) mmi.ptMinTrackSize.x = (int)Math.Ceiling(MinWidth * dpi.DpiScaleX);
if (MinHeight > 0 && !double.IsInfinity(MinHeight)) mmi.ptMinTrackSize.y = (int)Math.Ceiling(MinHeight * dpi.DpiScaleY);
}
catch { /* DPI not available yet; skip min enforcement for this pass */ }
Marshal.StructureToPtr(mmi, lParam, true);
}
}
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr handle, uint flags);
[DllImport("user32.dll")]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool SetWindowPos(
IntPtr hWnd, IntPtr hWndInsertAfter,
int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")]
private static extern bool GetWindowRect(IntPtr hwnd, out RECT rect);
private const int WM_NCLBUTTONDOWN = 0x00A1;
private const int WM_NCHITTEST = 0x0084;
private const int HTCLIENT = 1;
private const int HTCAPTION = 2;
private const int HTLEFT = 10;
private const int HTRIGHT = 11;
private const int HTTOP = 12;
private const int HTTOPLEFT = 13;
private const int HTTOPRIGHT = 14;
private const int HTBOTTOM = 15;
private const int HTBOTTOMLEFT = 16;
private const int HTBOTTOMRIGHT = 17;
private const int ResizeBorder = 8;
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int x; public int y; }
[StructLayout(LayoutKind.Sequential)]
private struct RECT { public int left, top, right, bottom; }
[StructLayout(LayoutKind.Sequential)]
private struct MINMAXINFO
{
public POINT ptReserved;
public POINT ptMaxSize;
public POINT ptMaxPosition;
public POINT ptMinTrackSize;
public POINT ptMaxTrackSize;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
}
// ============================================================
// Window chrome
// ============================================================
// internal: each pane's tab strip forwards its empty-space drag here.
internal void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ClickCount == 2)
{
MaximizeBtn_Click(sender, e);
return;
}
// Delegate drag to Windows via WM_NCLBUTTONDOWN(HTCAPTION).
// This gives native restore-from-maximized-and-drag behavior:
// if the window is maximized, Windows restores it and follows the cursor
// exactly as a native title bar would.
e.Handled = true;
var hwnd = new WindowInteropHelper(this).Handle;
SendMessage(hwnd, WM_NCLBUTTONDOWN, new IntPtr(HTCAPTION), IntPtr.Zero);
}
// Custom bottom-right grip: forward a native bottom-right resize so it behaves exactly like the OS
// border resize (and stays smooth). Only when floating; maximized/snapped don't resize.
private void ResizeGrip_MouseDown(object sender, MouseButtonEventArgs e)
{
if (WindowState != WindowState.Normal) return;
e.Handled = true;
var hwnd = new WindowInteropHelper(this).Handle;
SendMessage(hwnd, WM_NCLBUTTONDOWN, new IntPtr(HTBOTTOMRIGHT), IntPtr.Zero);
}
private void Install_Click(object sender, RoutedEventArgs e)
{
bool machineInstallExists = App.MachineInstallExists();
bool userInstallExists = App.UserInstallExists();
bool updating = machineInstallExists || userInstallExists;
// Two checkboxes, matching Killendar and KillerShell: the desktop shortcut (on by
// default, as it always was) and the all-users install. If an all-users copy already
// exists, keep that scope selected: KillerPDF deliberately supports one installed
// copy, not competing Program Files and per-user installations.
var (confirmed, wantDesktop, allUsers) = KillerDialog.ShowTwoCheckPrompt(this,
Loc(machineInstallExists ? "Str_Dlg_UpdateMachineMsg" :
userInstallExists ? "Str_Dlg_UpdateUserMsg" : "Str_Dlg_InstallMsg"),
Loc("Str_Chk_Desktop"), check1Initial: true,
Loc("Str_Chk_AllUsers"), check2Initial: machineInstallExists,
Loc(updating ? "Str_Btn_DoUpdate" : "Str_Btn_DoInstall"),
Loc("Str_Btn_Cancel"));
if (!confirmed) return;
if (machineInstallExists && !allUsers)
{
KillerDialog.Show(this, Loc("Str_Dlg_OneInstallOnly"),
Loc("Str_Dlg_InstallTitle"), MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
// Hide the badge immediately so it doesn't flash if relaunch is slow
_portableBadge.Visibility = Visibility.Collapsed;
if (!App.InstallAndRelaunch(_currentFile, wantDesktop, allUsers))
{
// Elevation refused, or the copy failed - both already reported. Put the badge back
// so the session carries on rather than silently looking installed.
_portableBadge.Visibility = App.IsPortable() ? Visibility.Visible : Visibility.Collapsed;
SetStatus(Loc("Str_Status_InstallFailed"));
}
}
private void MinimizeBtn_Click(object sender, RoutedEventArgs e) =>
WindowState = WindowState.Minimized;
private void MaximizeBtn_Click(object sender, RoutedEventArgs e) =>
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
private void CloseBtn_Click(object sender, RoutedEventArgs e) => Close();
// Rounded window corners look right only when floating; a maximized OR snapped window must
// square off or the rounded corners reveal the desktop / adjacent window behind them.
protected override void OnStateChanged(EventArgs e)
{
base.OnStateChanged(e);
UpdateWindowChrome();
RepositionAnnotationBars();
}
protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo)
{
base.OnRenderSizeChanged(sizeInfo);
UpdateWindowChrome();
RepositionAnnotationBars();
}
// Re-applies the saved placement to every visible annotation bar. Called synchronously from the
// window events that resize/move the content area (resize, maximize/restore, move), so
// the bar tracks its anchored edge and stays fully on-screen through all of them.
internal void RepositionAnnotationBars()
{
if (PagePreviewPanel?.Parent is not Grid area) return;
foreach (var bar in new[] { _drawSettingsBar, _textSettingsBar })
if (bar is not null && bar.Visibility == Visibility.Visible)
PositionAnnotationBar(bar, area);
}
// Anchors a bar to whichever edge it sits nearer and clamps it fully inside the document area:
// the gap from the anchored edge is honored when there's room, otherwise reduced so the bar
// never crosses the opposite edge. No-op until the bar has a measured width (PlaceAnnotationBar's
// deferred pass positions it once laid out).
private void PositionAnnotationBar(Border bar, Grid area)
{
// 98SE: classic toolbar band - flush, truly full width, no floating gaps and no slide
// parking. The document scroller is inset by the band's height (SyncSe98BarInset), so
// the vertical scrollbar starts BELOW the band instead of poking up beside its right
// end. Restores the theme's own edge thickness and padding: SetBarDockedBorder below
// writes hardcoded 1px borders and 4px padding straight over the BarEdgeThickness /
// BarPadding resource references, which is what flattened the classic 2px light bevel
// into a thin misplaced line.
if (ThemeManager.Current == Theme.SE98)
{
bar.HorizontalAlignment = HorizontalAlignment.Stretch;
bar.Margin = new Thickness(0);
bar.SetResourceReference(Border.BorderThicknessProperty, "BarEdgeThickness");
bar.SetResourceReference(Border.PaddingProperty, "BarPadding");
bar.SetResourceReference(Border.CornerRadiusProperty, "AnnotationBarCornerRadius");
// First pass runs before layout has measured the band; re-run once it has a height
// so the scroller inset below lands on the real value.
if (bar.ActualHeight <= 0)
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
(Action)(() => { if (bar.Parent is Grid a) PositionAnnotationBar(bar, a); }));
SyncSe98BarInset(bar, area);
return;
}
SyncSe98BarInset(bar, area); // clears a leftover 98SE inset after a theme switch
double w = bar.ActualWidth;
// The document's vertical scrollbar lives on the right edge of the area. Keep the bar clear
// of it when it's showing; when it isn't, the bar can use the full edge.
double sb = VerticalScrollBarInset();
double maxLeft = Math.Max(0, area.ActualWidth - w);
if (_annotBarCenterFrac is double frac)
{
// Center-parking needs a real measured width to place; edge anchors below don't, so they
// must still run on a freshly-rebuilt (unmeasured) bar - otherwise a same-tool refresh
// (e.g. clicking Bold) reveals the new bar at the default right edge, over the scrollbar.
if (w <= 0) return;
// Parked away from both edges: keep the same fraction of the width so it scales smoothly
// with the window instead of lurching toward an edge. Clamp so it never slides under the
// scrollbar on the right.
double maxLeftCentered = Math.Max(0, maxLeft - sb);
double left = Math.Max(0, Math.Min(maxLeftCentered, frac * area.ActualWidth - w / 2));
bar.HorizontalAlignment = HorizontalAlignment.Left;
bar.Margin = new Thickness(left, bar.Margin.Top, 0, 0);
SetBarDockedBorder(bar, dockedLeft: false, dockedRight: false);
}
else if (_annotBarAnchorRight)
{
// Sit the bar against the scrollbar's left edge when it's present (gap + scrollbar width),
// otherwise honor the plain gap right up to the pane edge.
double g = Math.Min(maxLeft, (_annotBarGap ?? 8) + sb);
bar.HorizontalAlignment = HorizontalAlignment.Right;
bar.Margin = new Thickness(0, bar.Margin.Top, g, 0);
// Only merge with the pane's edge line when nothing (no scrollbar) sits between them.
SetBarDockedBorder(bar, dockedLeft: false, dockedRight: sb <= 0 && g <= 0.5);
}
else
{
double g = Math.Min(maxLeft, _annotBarGap ?? 8);
bar.HorizontalAlignment = HorizontalAlignment.Left;
bar.Margin = new Thickness(g, bar.Margin.Top, 0, 0);
SetBarDockedBorder(bar, dockedLeft: g <= 0.5, dockedRight: false);
}
}
// Width reserved by the document pane's vertical scrollbar (matches the ScrollBar style's fixed
// 12px in MainWindow.xaml). Zero when the scrollbar isn't currently shown, so a docked bar can
// reach the pane edge; otherwise the bar stops at the scrollbar's left edge.
private const double DocScrollBarWidth = 12;
private double VerticalScrollBarInset() =>
PagePreviewPanel?.ComputedVerticalScrollBarVisibility == Visibility.Visible ? DocScrollBarWidth : 0;
// When the bar is docked flush against a side, drop its own 1px border on that side and swap it
// for 1px of padding. The document pane's border (same brush) then serves as the single shared
// edge line - no 2px double border, and no size or position change (so nothing jumps).
private static void SetBarDockedBorder(Border bar, bool dockedLeft, bool dockedRight)
{
bar.BorderThickness = new Thickness(dockedLeft ? 0 : 1, 0, dockedRight ? 0 : 1, 1);
bar.Padding = new Thickness(dockedLeft ? 5 : 4, 4, dockedRight ? 5 : 4, 4);
}
// 98SE reserves the docked band's height as top margin on the pane's document scroller, so
// the page and its vertical scrollbar start below the band (a classic toolbar strip) instead
// of the bar floating over them. Every other theme (and a removed bar) resolves to 0, which
// also clears a leftover inset after a theme switch. The scroller is looked up from the
// bar's own area so the inset always lands on the pane the bar actually lives in.
private static void SyncSe98BarInset(Border bar, Grid area, bool removing = false)
{
ScrollViewer? sv = null;
foreach (object child in area.Children)
if (child is ScrollViewer s) { sv = s; break; }
if (sv is null) return;
double inset = !removing && ThemeManager.Current == Theme.SE98 ? bar.ActualHeight : 0;
if (Math.Abs(sv.Margin.Top - inset) > 0.5)
sv.Margin = new Thickness(0, inset, 0, 0);
}
// Re-anchor a bar when its own size settles or changes (first measure, or the WrapPanel
// dropping to a second row on a narrow pane) - the 98SE scroller inset must track the
// band's real height, and the floating themes re-clamp against the new width.
private void AnnotBarSizeChanged(object sender, SizeChangedEventArgs e)
{
if (sender is Border b && b.Parent is Grid a) PositionAnnotationBar(b, a);
}
// Snapping changes the window's position/size but NOT its WindowState (it stays Normal), so
// re-evaluate the chrome on move too - otherwise a window snapped to a screen half keeps its
// rounded corners. (Hooked once in the constructor.)
private void OnWindowLocationChanged(object? sender, EventArgs e)
{
UpdateWindowChrome();
RepositionAnnotationBars();
}
// Applies the frame border and corner treatment for the current window layout.
// Under WindowChrome the window is a real (opaque, GPU-composited) HWND: the OS draws the
// drop shadow, and on Windows 11 the OS rounds the window corners (via DwmSetWindowAttribute
// below). So the app content fills a SQUARE client rect - the old transparent shadow margin,
// the fake WindowShadowBorder silhouette, and the internal rounded clip are all retired.
private bool? _appliedSquared; // last state pushed to the chrome; guards per-frame churn
private void UpdateWindowChrome()
{
bool max = WindowState == WindowState.Maximized || _fullScreen;
bool squared = max || IsSnapped() || ThemeManager.Current == Theme.SE98;
_chromeSquared = squared;
// The chrome treatment depends ONLY on the maximized/snapped state, not on the live size
// (the size-dependent rounded clip was retired with the WindowChrome migration). So skip the
// whole body - including the DwmSetWindowAttribute call and the property writes - while the
// state is unchanged. This is what was firing a native DWM corner call on every resize frame
// and making the toolbar/sidebar jump as content fell behind the window edge.
if (_appliedSquared == squared) return;
_appliedSquared = squared;
// Content fills the window rectangle. Rounding is done by the OS on the HWND, not here,
// so internal corners stay square to avoid dark nubs peeking past the rounded window edge.
if (RootBorder != null)
{
if (squared)
RootBorder.CornerRadius = new CornerRadius(0);
else
RootBorder.SetResourceReference(Border.CornerRadiusProperty, "WindowCornerRadius");
RootBorder.Margin = new Thickness(0);
// Only a maximized window drops the 1px frame (it's flush to every screen edge); a
// snapped window keeps it so it still reads against the window beside it.
RootBorder.BorderThickness = new Thickness(max || ThemeManager.Current == Theme.SE98 ? 0 : 1);
}
if (TitleBarBorder != null)
{
if (squared) TitleBarBorder.CornerRadius = new CornerRadius(0);
else TitleBarBorder.SetResourceReference(Border.CornerRadiusProperty, "TitleBarCornerRadius");
}
if (FooterBorder != null)
{
if (squared) FooterBorder.CornerRadius = new CornerRadius(0);
else FooterBorder.SetResourceReference(Border.CornerRadiusProperty, "FooterCornerRadius");
}
// The close tile owns only the window's top-right corner. Reusing the title bar's
// full (top-left + top-right) radius rounded the tile's interior left edge too,
// making its hover state look like a detached red cap instead of the window corner.
var titleCorners = TryFindResource("TitleBarCornerRadius") is CornerRadius tc
? tc : new CornerRadius(0, 7, 0, 0);
Resources["ChromeCloseCorner"] = squared
? new CornerRadius(0)
: new CornerRadius(0, titleCorners.TopRight, 0, 0);
// Retired: native OS shadow replaces the hand-cast one.
if (WindowShadowBorder != null)
{
WindowShadowBorder.Visibility = Visibility.Collapsed;
WindowShadowBorder.Effect = null;
}
// Ask Windows 11 to round the HWND when floating, square when maximized/snapped. No-op
// (caught) on Windows 10 and earlier, which simply keep square corners.
ApplyWindowCorners(rounded: !squared);
// This is the permanent resize hit target. Its child canvases choose dots or the
// Win98 hatch; collapsing the parent also hid the hatch.
if (ResizeGripDots != null)
ResizeGripDots.Visibility = Visibility.Visible;
UpdateRootClip(squared);
}
// Windows 11 native rounded-corner toggle (DWMWA_WINDOW_CORNER_PREFERENCE = 33).
private void ApplyWindowCorners(bool rounded)
{
try
{
var hwnd = new WindowInteropHelper(this).Handle;
if (hwnd == IntPtr.Zero) return;
int pref = rounded ? DWMWCP_ROUND : DWMWCP_DONOTROUND;
DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ref pref, sizeof(int));
}
catch { /* pre-Win11 DWM: attribute unsupported, square corners */ }
}
private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33;
private const int DWMWCP_DONOTROUND = 1;
private const int DWMWCP_ROUND = 2;
[DllImport("dwmapi.dll", EntryPoint = "DwmSetWindowAttribute")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
private const double ShadowMargin = 10;
private bool _chromeSquared; // true when maximized/snapped
// Under WindowChrome the OS rounds the HWND itself, so content fills a square client rect and
// needs no internal rounded clip. (A rounded clip here would expose dark corner triangles
// against the now-square frame.) Kept as a no-op hook so existing call sites stay valid.
private void UpdateRootClip(bool squared)
{
if (RootClipGrid is null) return;
RootClipGrid.Clip = null;
}
// True when the window is Aero-Snapped (half/quarter screen). Snapping leaves WindowState
// == Normal, so it's detected by comparing the window rect to the monitor work area: a
// snapped window is flush to a work-area edge and smaller than the full work area.
private bool IsSnapped()
{
if (WindowState != WindowState.Normal) return false;
var hwnd = new WindowInteropHelper(this).Handle;
if (hwnd == IntPtr.Zero || !GetWindowRect(hwnd, out RECT w)) return false;
IntPtr mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if (mon == IntPtr.Zero) return false;
var info = new MONITORINFO { cbSize = Marshal.SizeOf(typeof(MONITORINFO)) };
if (!GetMonitorInfo(mon, ref info)) return false;
RECT a = info.rcWork;
const int tol = 2; // device-pixel tolerance for "flush to edge"
bool flushLeft = Math.Abs(w.left - a.left) <= tol;
bool flushRight = Math.Abs(w.right - a.right) <= tol;
bool flushTop = Math.Abs(w.top - a.top) <= tol;
bool flushBottom = Math.Abs(w.bottom - a.bottom) <= tol;
bool fillsWidth = Math.Abs((w.right - w.left) - (a.right - a.left)) <= tol;
bool fillsHeight = Math.Abs((w.bottom - w.top) - (a.bottom - a.top)) <= tol;
// Exactly the work area (sized full but not maximized) is not a snap.
if (fillsWidth && fillsHeight) return false;
// Left/right half: full height, flush to one vertical edge, narrower than the work area.
if (flushTop && flushBottom && (flushLeft || flushRight) && !fillsWidth) return true;
// Quarter snap: flush into a corner and smaller than the work area in at least one axis.
if ((flushLeft || flushRight) && (flushTop || flushBottom) && (!fillsWidth || !fillsHeight))
return true;
return false;
}
private bool _fadingOut;
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
// Second pass (our own Close after the fade): let it through.
if (_fadingOut) { base.OnClosing(e); return; }
// Fold the live (active-tab) dirty flag back into its session, then prompt once if
// any open tab has unsaved changes.
// Capture BOTH panes' live state, then ask across BOTH. `_sessions` is only the focused
// pane, so testing it alone quits without a word while the other pane holds unsaved
// edits - a data-loss bug.
Viewer.CaptureActiveIfAny();
ViewerB.CaptureActiveIfAny();
bool anyDirty = _isDirty || AllSessions().Any(s => s.IsDirty);
if (anyDirty)
{
// fadeClose:false so the prompt closes instantly instead of adding its own 150ms fade
// before the app's fade-out starts - otherwise the two run back-to-back (300ms of waiting).
// Default to No so a stray Enter can't silently discard unsaved work.
var res = KillerDialog.Show(this,
Loc("Str_Dlg_UnsavedExit"),
Loc("Str_Dlg_AppTitle"), MessageBoxButton.YesNo, MessageBoxImage.Warning, fadeClose: false,
defaultResult: MessageBoxResult.No);
if (res != MessageBoxResult.Yes)
{
e.Cancel = true;
return;
}
}
// #105, KillerFind-style (family standard): ONE quit prompt with two opt-out
// checkboxes replaces the old Yes=forget / No=reopen question. Unchecked
// "Close my open tabs" = the session reopens next launch; "Remember my choice"
// locks the answer so we stop asking. Cancel keeps the app open.
// Only asked when a document is actually open (loaded, or a lazy not-yet-loaded
// restored tab) - with nothing open there are no tabs to close or reopen, so the
// empty window just quits.
bool anyOpenDoc = AllSessions().Any(s =>
s.Doc != null || !string.IsNullOrEmpty(s.CurrentFile) || !string.IsNullOrEmpty(s.DeferredPath));
// A confirmed "close without saving" already IS the quit confirmation - never stack
// the quit prompt on top of it (one dialog max per close). The open-tabs / remember
// preference just keeps its saved value for that close.
if (!anyDirty && anyOpenDoc && App.GetSetting("RememberChoiceLocked") != "1")
{
var (confirmed, closeTabs, remember) = KillerDialog.ShowQuitPrompt(this,
Loc("Str_Dlg_QuitMsg"),
Loc("Str_Chk_CloseTabs"), App.GetSetting("RememberOpenFiles") == "0",
Loc("Str_Dlg_RememberChoice"),
Loc("Str_Btn_Quit"), Loc("Str_Btn_CancelDlg"));
if (!confirmed)
{
e.Cancel = true;
return;
}
App.SetSetting("RememberOpenFiles", closeTabs ? "0" : "1");
if (remember) App.SetSetting("RememberChoiceLocked", "1");
}
SaveWindowSettings();
// Fade the whole app out before it really closes (matches the dialog fade-out).
e.Cancel = true;
_fadingOut = true;
var anim = new System.Windows.Media.Animation.DoubleAnimation(
Opacity, 0, new Duration(TimeSpan.FromMilliseconds(WindowFx.FadeMs)))
{
EasingFunction = new System.Windows.Media.Animation.QuadraticEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }
};
anim.Completed += (_, _) => Close();
BeginAnimation(OpacityProperty, anim);
}
}
}