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