383 lines
20 KiB
C#
383 lines
20 KiB
C#
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; }
|
|
}
|
|
}
|
|
}
|