2698 lines
138 KiB
C#
2698 lines
138 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.Controls
|
|
{
|
|
// Annotation rendering - draws the per-page annotation overlays (text, cover, highlight, ink,
|
|
// signature, image). Moved from Shell/Annotations.cs; the namespace and class line are the only
|
|
// changes. Window members spelled bare here resolve through PdfViewer.Bridge.cs.
|
|
public partial class PdfViewer
|
|
{
|
|
private static double MeasureTextBoxHeight(string text, double width, double fontSize)
|
|
{
|
|
double inner = Math.Max(1, width - 4); // minus left+right padding (2 + 2)
|
|
var ft = new FormattedText(
|
|
string.IsNullOrEmpty(text) ? " " : text,
|
|
System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
|
|
new Typeface("Segoe UI"), Math.Max(1, fontSize), Brushes.Black, 1.0)
|
|
{ MaxTextWidth = inner };
|
|
return Math.Ceiling(ft.Height) + 4; // plus top + bottom padding
|
|
}
|
|
|
|
private void RenderTextAnnotation(TextAnnotation ta)
|
|
{
|
|
if (!IsFinitePositive(ta.Width) || !IsFinitePositive(ta.Height)) return;
|
|
|
|
// A fixed W x H box: optional fill background, text wrapped to the width and clipped to the
|
|
// height (so a free-form-resized box behaves like an image/crop frame).
|
|
FontFamily famsel;
|
|
try { famsel = new FontFamily(string.IsNullOrEmpty(ta.FontName) ? "Segoe UI" : ta.FontName); }
|
|
catch { famsel = UiKit.UiFont; }
|
|
var tb = new TextBlock
|
|
{
|
|
Text = ta.Content,
|
|
Foreground = new SolidColorBrush(ta.GetColor()),
|
|
FontFamily = famsel,
|
|
FontWeight = ta.Bold ? FontWeights.Bold : FontWeights.Normal,
|
|
FontStyle = ta.Italic ? FontStyles.Italic : FontStyles.Normal,
|
|
TextDecorations = BuildDecorations(ta.Underline, ta.Strike),
|
|
FontSize = ta.FontSize,
|
|
Padding = new Thickness(2),
|
|
TextWrapping = TextWrapping.Wrap,
|
|
VerticalAlignment = VerticalAlignment.Top
|
|
};
|
|
// Crisp glyphs: pixel-snapped layout + grayscale AA (ClearType can't subpixel on the
|
|
// transparent overlay, and the default left the placed text looking aliased).
|
|
TextOptions.SetTextFormattingMode(tb, TextFormattingMode.Display);
|
|
TextOptions.SetTextRenderingMode(tb, TextRenderingMode.Grayscale);
|
|
var box = new Border
|
|
{
|
|
Width = Math.Max(1, ta.Width),
|
|
Height = Math.Max(1, ta.Height),
|
|
Background = ta.HasFill ? new SolidColorBrush(ta.GetFill()) : Brushes.Transparent,
|
|
ClipToBounds = true,
|
|
IsHitTestVisible = false,
|
|
Child = tb
|
|
};
|
|
Canvas.SetLeft(box, ta.Position.X);
|
|
Canvas.SetTop(box, ta.Position.Y);
|
|
_activeCanvas.Children.Add(box);
|
|
}
|
|
|
|
// HighlightEraseGeometry (shared by on-screen rendering and PDF export) lives in
|
|
// Services/PdfBurn.cs with the rest of the burn-to-document core.
|
|
|
|
internal void RenderAllAnnotations(int pageIndex)
|
|
{
|
|
// Resolve this page's annotation surface from the unified per-page overlay map, which
|
|
// every multi-page view populates; fall back to the single-page canvas. View-mode
|
|
// independent on purpose so the tools behave identically in all four modes.
|
|
_activeCanvas = CanvasForPage(pageIndex);
|
|
_activeCanvas.Children.Clear();
|
|
|
|
RenderStamps(pageIndex); // stamp layer (page numbers / watermark) sits beneath annotations
|
|
|
|
if (_annotations.TryGetValue(pageIndex, out var annotList))
|
|
foreach (var annot in annotList)
|
|
{
|
|
switch (annot)
|
|
{
|
|
case TextAnnotation ta:
|
|
RenderTextAnnotation(ta);
|
|
break;
|
|
case CoverAnnotation cov:
|
|
if (!IsFinitePositive(cov.Bounds.Width) || !IsFinitePositive(cov.Bounds.Height)) continue;
|
|
var covRect = new Rectangle
|
|
{
|
|
Fill = new SolidColorBrush(cov.GetColor()),
|
|
Width = cov.Bounds.Width, Height = cov.Bounds.Height
|
|
};
|
|
// While being typed into, dash-outline the cover so it's visible behind the live
|
|
// text box. Otherwise just the opaque fill - its outline only appears on selection
|
|
// (drawn as selection chrome), so a deselected cover stays clean. Screen-only; the
|
|
// flattened/saved PDF draws just the fill.
|
|
if (ReferenceEquals(cov, _pendingCover))
|
|
{
|
|
covRect.Stroke = DarkerAccentBrush();
|
|
covRect.StrokeThickness = 1;
|
|
covRect.StrokeDashArray = [4, 3];
|
|
}
|
|
Canvas.SetLeft(covRect, cov.Bounds.X);
|
|
Canvas.SetTop(covRect, cov.Bounds.Y);
|
|
_activeCanvas.Children.Add(covRect);
|
|
break;
|
|
case HighlightAnnotation ha:
|
|
if (PdfBurn.HighlightEraseGeometry(ha) is { } hgeo)
|
|
{
|
|
// Carved highlight: rectangle minus the eraser strokes, one anti-aliased fill.
|
|
_activeCanvas.Children.Add(new System.Windows.Shapes.Path
|
|
{ Fill = new SolidColorBrush(ha.GetColor()), Data = hgeo, IsHitTestVisible = false });
|
|
}
|
|
else
|
|
{
|
|
var hr = ha.DrawRect();
|
|
if (!IsFinitePositive(hr.Width) || !IsFinitePositive(hr.Height)) continue;
|
|
var rect = new Rectangle
|
|
{
|
|
Fill = new SolidColorBrush(ha.GetColor()),
|
|
Width = hr.Width,
|
|
Height = hr.Height
|
|
};
|
|
Canvas.SetLeft(rect, hr.X);
|
|
Canvas.SetTop(rect, hr.Y);
|
|
_activeCanvas.Children.Add(rect);
|
|
}
|
|
break;
|
|
case InkAnnotation ia:
|
|
if (ia.Points.Count < 2) continue;
|
|
if (ia.HasFill)
|
|
{
|
|
// Filled shape (#127 Phase 3): closed outline - one Polygon, filled + stroked.
|
|
var pg = new Polygon
|
|
{
|
|
Stroke = new SolidColorBrush(ia.GetColor()),
|
|
StrokeThickness = ia.StrokeWidth,
|
|
StrokeLineJoin = PenLineJoin.Round,
|
|
Fill = new SolidColorBrush(ia.GetFillColor())
|
|
};
|
|
foreach (var pt in ia.Points) pg.Points.Add(pt);
|
|
_activeCanvas.Children.Add(pg);
|
|
break;
|
|
}
|
|
var poly = new Polyline
|
|
{
|
|
Stroke = new SolidColorBrush(ia.GetColor()),
|
|
StrokeThickness = ia.StrokeWidth,
|
|
StrokeLineJoin = PenLineJoin.Round,
|
|
StrokeStartLineCap = PenLineCap.Round,
|
|
StrokeEndLineCap = PenLineCap.Round
|
|
};
|
|
foreach (var pt in ia.Points) poly.Points.Add(pt);
|
|
_activeCanvas.Children.Add(poly);
|
|
break;
|
|
case SignatureAnnotation sa:
|
|
if (sa.ImageData is not null)
|
|
{
|
|
// Image-based signature (decoded once, then cached on the annotation)
|
|
var bmp = GetAnnotationBitmap(sa, sa.ImageData);
|
|
if (bmp != null && TryGetPlacedSize(sa, out double sigWidth, out double sigHeight))
|
|
{
|
|
var imgCtrl = new System.Windows.Controls.Image
|
|
{
|
|
Source = bmp,
|
|
Width = sigWidth,
|
|
Height = sigHeight,
|
|
Stretch = System.Windows.Media.Stretch.Uniform,
|
|
IsHitTestVisible = false
|
|
};
|
|
Canvas.SetLeft(imgCtrl, sa.Position.X);
|
|
Canvas.SetTop(imgCtrl, sa.Position.Y);
|
|
_activeCanvas.Children.Add(imgCtrl);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
foreach (var stroke in sa.Strokes)
|
|
{
|
|
if (stroke.Count < 2) continue;
|
|
var sigPoly = new Polyline
|
|
{
|
|
Stroke = Brushes.Black,
|
|
StrokeThickness = sa.StrokeWidth * sa.Scale,
|
|
StrokeLineJoin = PenLineJoin.Round,
|
|
StrokeStartLineCap = PenLineCap.Round,
|
|
StrokeEndLineCap = PenLineCap.Round
|
|
};
|
|
foreach (var pt in stroke)
|
|
sigPoly.Points.Add(new Point(
|
|
sa.Position.X + pt.X * sa.Scale,
|
|
sa.Position.Y + pt.Y * sa.Scale));
|
|
_activeCanvas.Children.Add(sigPoly);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case ImageAnnotation ia:
|
|
var iaBmp = GetAnnotationBitmap(ia, ia.ImageData);
|
|
if (iaBmp != null && TryGetPlacedSize(ia, out double imageWidth, out double imageHeight))
|
|
{
|
|
var iaCtrl = new System.Windows.Controls.Image
|
|
{
|
|
Source = iaBmp,
|
|
Width = imageWidth,
|
|
Height = imageHeight,
|
|
Stretch = System.Windows.Media.Stretch.Uniform,
|
|
IsHitTestVisible = false
|
|
};
|
|
Canvas.SetLeft(iaCtrl, ia.Position.X);
|
|
Canvas.SetTop(iaCtrl, ia.Position.Y);
|
|
_activeCanvas.Children.Add(iaCtrl);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Re-add form field overlays - RenderAllAnnotations clears the canvas so they must be restored.
|
|
if (_renderDims.TryGetValue(pageIndex, out var dims))
|
|
RenderFormFields(pageIndex, dims.w, dims.h);
|
|
|
|
// Search highlights live on this same canvas and were wiped by the clear above; repaint
|
|
// them last so they sit on top and survive every re-render and continuous scroll.
|
|
ApplySearchHighlights(pageIndex, _activeCanvas);
|
|
// Flowing text selection quads (#127) live here too - same deal, repaint last.
|
|
ApplyTextSelectionQuads(pageIndex, _activeCanvas);
|
|
}
|
|
|
|
// WPF rejects NaN and infinity for FrameworkElement dimensions. Keep malformed persisted
|
|
// annotations from taking down the whole viewer while their source data is being repaired.
|
|
private static bool TryGetPlacedSize(PlacedAnnotation annot, out double width, out double height)
|
|
{
|
|
width = annot.SourceWidth * annot.Scale;
|
|
height = annot.SourceHeight * annot.Scale;
|
|
return IsFinitePositive(width) && IsFinitePositive(height);
|
|
}
|
|
|
|
private static bool IsFinite(double value)
|
|
=> !double.IsNaN(value) && !double.IsInfinity(value);
|
|
|
|
private static bool IsFinitePositive(double value)
|
|
=> IsFinite(value) && value > 0;
|
|
|
|
// Decode a placed annotation's Base64 image once and cache the frozen result on the annotation,
|
|
// so repeated renders (e.g. every mousemove of a resize-drag) reuse it instead of re-decoding.
|
|
private static System.Windows.Media.Imaging.BitmapSource? GetAnnotationBitmap(PlacedAnnotation a, string? data)
|
|
{
|
|
if (a.CachedBitmap != null) return a.CachedBitmap;
|
|
if (string.IsNullOrEmpty(data)) return null;
|
|
try
|
|
{
|
|
var bmp = new System.Windows.Media.Imaging.BitmapImage();
|
|
bmp.BeginInit();
|
|
bmp.StreamSource = new System.IO.MemoryStream(Convert.FromBase64String(data));
|
|
bmp.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
|
|
bmp.EndInit();
|
|
bmp.Freeze();
|
|
a.CachedBitmap = bmp;
|
|
return bmp;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
internal void ClearSelection()
|
|
{
|
|
if (_selectionBorder is not null)
|
|
{
|
|
(_selectionBorder.Parent as Canvas)?.Children.Remove(_selectionBorder);
|
|
_selectionBorder = null;
|
|
}
|
|
foreach (var hd in _resizeHandles)
|
|
(hd.Parent as Canvas)?.Children.Remove(hd);
|
|
_resizeHandles.Clear();
|
|
if (_pairedCoverOutline is not null)
|
|
{
|
|
(_pairedCoverOutline.Parent as Canvas)?.Children.Remove(_pairedCoverOutline);
|
|
_pairedCoverOutline = null;
|
|
}
|
|
ClearMultiSelection();
|
|
_isResizingSig = false;
|
|
_resizeSigAnnot = null;
|
|
_resizeTextAnnot = null;
|
|
_resizeHlAnnot = null;
|
|
_resizeInkAnnot = null;
|
|
_resizeInkOrigPoints = null;
|
|
_isDraggingAnnot = false;
|
|
_dragAnnot = null;
|
|
_dragGroupOrig.Clear();
|
|
_selectedAnnotation = null;
|
|
// If the text bar was opened for a now-cleared text-box selection (not because the Text tool
|
|
// is active), close it again.
|
|
if (_currentTool != EditTool.Text && _annotBarTool == EditTool.Text)
|
|
HideTextSettings();
|
|
// Likewise the draw bar: if it was opened to edit a selected highlight / line / ink
|
|
// annotation (the active tool isn't a draw-family tool), close it when the selection clears.
|
|
if (_currentTool is not (EditTool.Draw or EditTool.Highlight or EditTool.Strikethrough or EditTool.Underline)
|
|
&& _annotBarTool is EditTool.Draw or EditTool.Highlight or EditTool.Strikethrough or EditTool.Underline)
|
|
HideDrawSettings();
|
|
}
|
|
|
|
// ---- Shift+click multi-selection (Select tool) -------------------------------------------
|
|
|
|
/// <summary>Removes every shift-selection outline and empties the multi-selection set.</summary>
|
|
private void ClearMultiSelection()
|
|
{
|
|
foreach (var o in _selectionOutlines)
|
|
(o.Parent as Canvas)?.Children.Remove(o);
|
|
_selectionOutlines.Clear();
|
|
_selectedSet.Clear();
|
|
}
|
|
|
|
/// <summary>The overlay canvas that hosts a given page's annotations. Reads the unified page
|
|
/// map (primary included); falls back to the primary canvas only for a page with no tile.</summary>
|
|
private Canvas CanvasForPage(int pageIndex)
|
|
=> _pages.TryGetValue(pageIndex, out var c) ? c : _annotationCanvas;
|
|
|
|
/// <summary>The overlay for a page that is actually on screen right now, or null when the page
|
|
/// has no live tile (so callers don't paint a page's content onto the wrong canvas). The
|
|
/// unified map includes the primary, so there's no primary special case here anymore.</summary>
|
|
private Canvas? VisibleCanvasForPage(int pageIndex)
|
|
=> _pages.TryGetValue(pageIndex, out var c) ? c : null;
|
|
|
|
/// <summary>Every page overlay currently in the visual tree (primary + per-page tiles).</summary>
|
|
private IEnumerable<Canvas> AllPageCanvases()
|
|
{
|
|
yield return _annotationCanvas;
|
|
foreach (var c in _pages.Values)
|
|
if (!ReferenceEquals(c, _annotationCanvas)) yield return c;
|
|
}
|
|
|
|
/// <summary>Total annotations currently selected (primary + shift-selected set).</summary>
|
|
private int SelectionCount()
|
|
=> _selectedSet.Count
|
|
+ (_selectedAnnotation is not null && !_selectedSet.Contains(_selectedAnnotation) ? 1 : 0);
|
|
|
|
/// <summary>Draws a selection outline for a shift-selected annotation on its page canvas.</summary>
|
|
private void AddSelectionOutline(Rect bounds, Canvas canvas)
|
|
{
|
|
// Match SelectAnnotation: counter a continuous/grid overlay's LayoutTransform so the
|
|
// outline keeps a constant on-screen thickness regardless of the tile scale.
|
|
double inv = 1.0;
|
|
if (canvas.LayoutTransform is ScaleTransform st && st.ScaleX > 0.0001)
|
|
inv = 1.0 / st.ScaleX;
|
|
var outline = new Border
|
|
{
|
|
BorderBrush = AccentBrush(),
|
|
BorderThickness = new Thickness(2 * inv),
|
|
Background = AccentBrush(40),
|
|
Width = bounds.Width + 8,
|
|
Height = bounds.Height + 8,
|
|
IsHitTestVisible = false
|
|
};
|
|
Canvas.SetLeft(outline, bounds.X - 4);
|
|
Canvas.SetTop(outline, bounds.Y - 4);
|
|
canvas.Children.Add(outline);
|
|
_selectionOutlines.Add(outline);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shift+click handler: toggle an annotation in or out of the multi-selection. The first
|
|
/// shift+click folds any existing single selection into the set so nothing is lost.
|
|
/// </summary>
|
|
private void ToggleMultiSelect(PageAnnotation annot, Rect bounds, Canvas canvas)
|
|
{
|
|
// Fold an existing single (primary) selection into the set so it stays selected.
|
|
if (_selectedAnnotation is not null && !_selectedSet.Contains(_selectedAnnotation))
|
|
{
|
|
var prim = _selectedAnnotation;
|
|
// Drop the single-selection chrome (border + resize handles) but keep the annotation.
|
|
if (_selectionBorder is not null)
|
|
{
|
|
(_selectionBorder.Parent as Canvas)?.Children.Remove(_selectionBorder);
|
|
_selectionBorder = null;
|
|
}
|
|
foreach (var hd in _resizeHandles)
|
|
(hd.Parent as Canvas)?.Children.Remove(hd);
|
|
_resizeHandles.Clear();
|
|
_selectedAnnotation = null;
|
|
_selectedSet.Add(prim);
|
|
AddSelectionOutline(AnnotBounds(prim), CanvasForPage(prim.PageIndex));
|
|
}
|
|
|
|
if (_selectedSet.Remove(annot))
|
|
{
|
|
// Was selected -> rebuild the outlines without it.
|
|
foreach (var o in _selectionOutlines)
|
|
(o.Parent as Canvas)?.Children.Remove(o);
|
|
_selectionOutlines.Clear();
|
|
foreach (var a in _selectedSet)
|
|
AddSelectionOutline(AnnotBounds(a), CanvasForPage(a.PageIndex));
|
|
}
|
|
else
|
|
{
|
|
_selectedSet.Add(annot);
|
|
AddSelectionOutline(bounds, canvas);
|
|
}
|
|
|
|
int n = SelectionCount();
|
|
SetStatus(n == 0 ? "Selection cleared"
|
|
: $"{n} annotations selected - press Delete to remove");
|
|
}
|
|
|
|
private void DeleteSelected()
|
|
{
|
|
// Gather the primary selection plus any shift-selected annotations, de-duplicated.
|
|
var toDelete = new List<PageAnnotation>();
|
|
if (_selectedAnnotation is not null) toDelete.Add(_selectedAnnotation);
|
|
foreach (var a in _selectedSet)
|
|
if (!toDelete.Contains(a)) toDelete.Add(a);
|
|
if (toDelete.Count == 0) return;
|
|
PushPagesSnapshotUndo(toDelete.Select(a => a.PageIndex));
|
|
|
|
var pages = new HashSet<int>();
|
|
foreach (var a in toDelete)
|
|
if (_annotations.TryGetValue(a.PageIndex, out var list) && list.Remove(a))
|
|
pages.Add(a.PageIndex);
|
|
|
|
// If a paired replacement text was deleted, unpair its cover so it stops rendering dashed and
|
|
// becomes a plain solid box (the original "two fields" hint is gone, just the cover remains).
|
|
foreach (var a in toDelete)
|
|
if (a is TextAnnotation t && t.PairId.Length > 0 && _annotations.TryGetValue(t.PageIndex, out var pl))
|
|
foreach (var cov in pl.OfType<CoverAnnotation>())
|
|
if (cov.PairId == t.PairId) cov.PairId = "";
|
|
|
|
ClearSelection();
|
|
foreach (var p in pages) RenderAllAnnotations(p);
|
|
SetStatus(string.Format(Loc(toDelete.Count == 1
|
|
? "Str_St_DeletedAnnotationOne" : "Str_St_DeletedAnnotationMany"), toDelete.Count));
|
|
}
|
|
|
|
private bool HitTestAnnotation(PageAnnotation annot, Point pos, out Rect bounds)
|
|
{
|
|
switch (annot)
|
|
{
|
|
case CoverAnnotation cov:
|
|
// A text cover gets a forgiving grab margin (and sits below its text, which is checked
|
|
// first), so you can click the colored background to select/move it without hunting.
|
|
bounds = cov.Bounds;
|
|
var coverHit = bounds; coverHit.Inflate(6, 6);
|
|
return coverHit.Contains(pos);
|
|
|
|
case HighlightAnnotation ha:
|
|
bounds = ha.Bounds;
|
|
return bounds.Contains(pos);
|
|
|
|
case TextAnnotation ta:
|
|
bounds = new Rect(ta.Position.X, ta.Position.Y,
|
|
Math.Max(8, ta.Width), Math.Max(8, ta.Height));
|
|
// Forgiving grab area: clicking anywhere in the box - plus a small margin around it,
|
|
// since a one-line box is a thin band - selects and drags the text, so you don't have
|
|
// to land on a glyph. The selection loop still checks topmost-first, so any annotation
|
|
// stacked above the text takes the click before this does (no conflict).
|
|
var textHit = bounds; textHit.Inflate(8, 10);
|
|
return textHit.Contains(pos);
|
|
|
|
case InkAnnotation ia when ia.Points.Count > 0:
|
|
bool near = ia.Points.Any(p =>
|
|
Math.Sqrt((p.X - pos.X) * (p.X - pos.X) + (p.Y - pos.Y) * (p.Y - pos.Y)) < 15);
|
|
// Also hit anywhere along each segment, so a straight line is grabbable along its whole
|
|
// length (not just its two endpoints) and freehand strokes select between sample points.
|
|
for (int si = 0; si < ia.Points.Count - 1 && !near; si++)
|
|
if (DistPointToSegment(pos, ia.Points[si], ia.Points[si + 1]) < 10) near = true;
|
|
if (near)
|
|
{
|
|
double minX = ia.Points.Min(p => p.X);
|
|
double minY = ia.Points.Min(p => p.Y);
|
|
double maxX = ia.Points.Max(p => p.X);
|
|
double maxY = ia.Points.Max(p => p.Y);
|
|
bounds = new Rect(minX, minY, Math.Max(maxX - minX, 4), Math.Max(maxY - minY, 4));
|
|
return true;
|
|
}
|
|
bounds = Rect.Empty;
|
|
return false;
|
|
|
|
case SignatureAnnotation sa:
|
|
double sigW = sa.SourceWidth * sa.Scale;
|
|
double sigH = sa.SourceHeight * sa.Scale;
|
|
bounds = new Rect(sa.Position.X, sa.Position.Y, sigW, sigH);
|
|
return bounds.Contains(pos);
|
|
|
|
case ImageAnnotation ia:
|
|
double iaW = ia.SourceWidth * ia.Scale;
|
|
double iaH = ia.SourceHeight * ia.Scale;
|
|
bounds = new Rect(ia.Position.X, ia.Position.Y, iaW, iaH);
|
|
return bounds.Contains(pos);
|
|
|
|
default:
|
|
bounds = Rect.Empty;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Recolor any live selection / crop visuals to the current theme's SelectionAccent.
|
|
// Their brushes are plain (not resource references), so a theme swap won't update them
|
|
// until reselected unless we repaint them here.
|
|
private void RefreshSelectionAccent()
|
|
{
|
|
if (_selectionBorder is not null)
|
|
{
|
|
_selectionBorder.BorderBrush = AccentBrush();
|
|
_selectionBorder.Background = AccentBrush(40);
|
|
}
|
|
foreach (var hd in _resizeHandles)
|
|
hd.Fill = AccentBrush();
|
|
if (_cropPreviewRect is not null)
|
|
_cropPreviewRect.Fill = AccentBrush(55);
|
|
}
|
|
|
|
// Positions the four corner handles around an annotation's bounds (top-left x,y and size w,h).
|
|
private void LayoutResizeHandles(double x, double y, double w, double h)
|
|
{
|
|
foreach (var hd in _resizeHandles)
|
|
{
|
|
double hs = hd.Width;
|
|
(double cx, double cy) = (hd.Tag as string) switch
|
|
{
|
|
"NW" => (x, y),
|
|
"NE" => (x + w, y),
|
|
"SW" => (x, y + h),
|
|
_ => (x + w, y + h) // SE
|
|
};
|
|
Canvas.SetLeft(hd, cx - hs / 2);
|
|
Canvas.SetTop(hd, cy - hs / 2);
|
|
}
|
|
}
|
|
|
|
private void SelectAnnotation(PageAnnotation annot, Rect bounds)
|
|
{
|
|
_selectedAnnotation = annot;
|
|
// Recovery: if an annotation's corners drifted off-page (placed before the on-page guard),
|
|
// pull it back on selection so its handles become reachable again. Only acts when it's
|
|
// actually off-page, and re-derives bounds for the selection visuals below.
|
|
if (IsDraggable(annot))
|
|
{
|
|
var onPage = ClampAnnotPos(annot);
|
|
if (onPage != AnnotGetPos(annot))
|
|
{
|
|
AnnotSetPos(annot, onPage);
|
|
RenderAllAnnotations(annot.PageIndex);
|
|
bounds = AnnotBounds(annot);
|
|
MarkDirty();
|
|
}
|
|
}
|
|
// Continuous-view overlays are scaled down by their LayoutTransform, which would
|
|
// shrink the selection outline and resize handle to near-invisibility. Compensate
|
|
// so they render at the same on-screen size as single-page view.
|
|
double inv = 1.0;
|
|
if (_activeCanvas.LayoutTransform is ScaleTransform _selScale && _selScale.ScaleX > 0.0001)
|
|
inv = 1.0 / _selScale.ScaleX;
|
|
// A cover gets a darker accent so its handles/border stand apart from the text box over it.
|
|
bool isCover = annot is CoverAnnotation;
|
|
var selBrush = isCover ? DarkerAccentBrush() : AccentBrush();
|
|
_selectionBorder = new Border
|
|
{
|
|
BorderBrush = selBrush,
|
|
BorderThickness = new Thickness(2 * inv),
|
|
Background = isCover ? DarkerAccentBrush(40) : AccentBrush(40),
|
|
Width = bounds.Width + 8,
|
|
Height = bounds.Height + 8,
|
|
IsHitTestVisible = false
|
|
};
|
|
Canvas.SetLeft(_selectionBorder, bounds.X - 4);
|
|
Canvas.SetTop(_selectionBorder, bounds.Y - 4);
|
|
_activeCanvas.Children.Add(_selectionBorder);
|
|
|
|
// Selecting EITHER paired box dash-outlines its partner so both boxes stay visible: clicking
|
|
// the cover keeps the text box shown, and vice versa. Cleared in ClearSelection.
|
|
PageAnnotation? partner = null;
|
|
if (annot.PairId.Length > 0 && _annotations.TryGetValue(annot.PageIndex, out var ppl))
|
|
partner = annot is CoverAnnotation
|
|
? ppl.OfType<TextAnnotation>().FirstOrDefault(t => t.PairId == annot.PairId)
|
|
: ppl.OfType<CoverAnnotation>().FirstOrDefault(c => c.PairId == annot.PairId);
|
|
if (partner is not null)
|
|
{
|
|
var pb = AnnotBounds(partner);
|
|
_pairedCoverOutline = new Rectangle
|
|
{
|
|
Width = pb.Width + 4, Height = pb.Height + 4,
|
|
Stroke = DarkerAccentBrush(), StrokeThickness = 1.5 * inv,
|
|
StrokeDashArray = [4, 3],
|
|
Fill = Brushes.Transparent, IsHitTestVisible = false
|
|
};
|
|
Canvas.SetLeft(_pairedCoverOutline, pb.X - 2);
|
|
Canvas.SetTop(_pairedCoverOutline, pb.Y - 2);
|
|
_activeCanvas.Children.Add(_pairedCoverOutline);
|
|
}
|
|
|
|
// Add four corner resize handles for resizable annotations (signature, image, text box,
|
|
// highlight/strikethrough/underline, and ink).
|
|
if (annot is PlacedAnnotation or TextAnnotation or HighlightAnnotation or InkAnnotation)
|
|
{
|
|
double hSize = 14 * inv;
|
|
_resizeHandles.Clear();
|
|
foreach (string tag in new[] { "NW", "NE", "SE", "SW" })
|
|
{
|
|
var hd = new Rectangle
|
|
{
|
|
Width = hSize, Height = hSize,
|
|
Fill = selBrush,
|
|
Stroke = Brushes.White, StrokeThickness = 1 * inv,
|
|
Cursor = (tag is "NW" or "SE") ? Cursors.SizeNWSE : Cursors.SizeNESW,
|
|
IsHitTestVisible = true,
|
|
Tag = tag
|
|
};
|
|
_resizeHandles.Add(hd);
|
|
_activeCanvas.Children.Add(hd);
|
|
}
|
|
LayoutResizeHandles(bounds.X, bounds.Y, bounds.Width, bounds.Height);
|
|
string label = annot switch
|
|
{
|
|
SignatureAnnotation => Loc("Str_Annot_Signature"),
|
|
ImageAnnotation => Loc("Str_Annot_Image"),
|
|
TextAnnotation => Loc("Str_Annot_TextBox"),
|
|
CoverAnnotation => Loc("Str_Annot_Cover"),
|
|
HighlightAnnotation { Style: HighlightStyle.Strikethrough } => Loc("Str_Annot_Strikethrough"),
|
|
HighlightAnnotation { Style: HighlightStyle.Underline } => Loc("Str_Annot_Underline"),
|
|
HighlightAnnotation => Loc("Str_Annot_Highlight"),
|
|
InkAnnotation => Loc("Str_Annot_Drawing"),
|
|
_ => Loc("Str_Annot_Item")
|
|
};
|
|
string how = annot is TextAnnotation
|
|
? Loc("Str_St_AnnotHowText")
|
|
: Loc("Str_St_AnnotHowGeneric");
|
|
SetStatus(string.Format(Loc("Str_St_AnnotSelected"), label, how));
|
|
}
|
|
else
|
|
{
|
|
SetStatus(string.Format(Loc("Str_St_AnnotSelectedSimple"), annot.GetType().Name.Replace("Annotation", "").ToLower()));
|
|
}
|
|
|
|
// Selecting a text box opens the text bar (synced to that box) so its color, fill and size
|
|
// can be changed without re-typing. The bar's swatches/sliders then apply to the selection.
|
|
if (annot is TextAnnotation tsel)
|
|
{
|
|
var col = tsel.GetColor();
|
|
_textColor = col;
|
|
_textOpacity = col.A;
|
|
_textFillColor = tsel.GetFill();
|
|
double sy = 1.0;
|
|
if (_doc is not null && _renderDims.TryGetValue(tsel.PageIndex, out var rd) && rd.h > 0)
|
|
sy = _doc.Pages[tsel.PageIndex].Height.Point / rd.h;
|
|
_textFontSize = Math.Max(1, Math.Round(tsel.FontSize * sy));
|
|
ShowTextSettings();
|
|
}
|
|
// Selecting a highlight / strikethrough / underline opens the draw bar synced to it, so its
|
|
// color and opacity can be edited in place. The annotation's style picks the matching tool.
|
|
else if (annot is HighlightAnnotation hsel)
|
|
{
|
|
if (hsel.Style == HighlightStyle.Fill) _highlightColor = hsel.GetColor();
|
|
else _lineAnnotColor = hsel.GetColor();
|
|
ShowDrawSettings(hsel.Style switch
|
|
{
|
|
HighlightStyle.Strikethrough => EditTool.Strikethrough,
|
|
HighlightStyle.Underline => EditTool.Underline,
|
|
_ => EditTool.Highlight
|
|
});
|
|
}
|
|
// Selecting a freehand stroke opens the draw bar synced to it (color, opacity and width).
|
|
else if (annot is InkAnnotation isel)
|
|
{
|
|
_drawColor = isel.GetColor();
|
|
_drawOpacity = isel.GetColor().A;
|
|
_drawWidth = isel.StrokeWidth;
|
|
ShowDrawSettings(EditTool.Draw);
|
|
}
|
|
}
|
|
|
|
private static bool IsDraggable(PageAnnotation a) => a is PlacedAnnotation or TextAnnotation or HighlightAnnotation or InkAnnotation;
|
|
|
|
// Shortest distance from point p to the segment a-b. Used to hit-test straight lines and ink
|
|
// strokes along their length, not just at their sample points.
|
|
private static double DistPointToSegment(Point p, Point a, Point b)
|
|
{
|
|
double dx = b.X - a.X, dy = b.Y - a.Y;
|
|
double len2 = dx * dx + dy * dy;
|
|
if (len2 < 1e-9) { dx = p.X - a.X; dy = p.Y - a.Y; return Math.Sqrt(dx * dx + dy * dy); }
|
|
double t = ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / len2;
|
|
t = Math.Max(0, Math.Min(1, t));
|
|
double qx = a.X + t * dx, qy = a.Y + t * dy;
|
|
double ex = p.X - qx, ey = p.Y - qy;
|
|
return Math.Sqrt(ex * ex + ey * ey);
|
|
}
|
|
private static Point AnnotGetPos(PageAnnotation a) => a switch
|
|
{
|
|
PlacedAnnotation p => p.Position,
|
|
TextAnnotation t => t.Position,
|
|
HighlightAnnotation h => h.Bounds.Location,
|
|
// Ink has no single origin; use the stroke's bounding-box top-left.
|
|
InkAnnotation ink when ink.Points.Count > 0
|
|
=> new Point(ink.Points.Min(p => p.X), ink.Points.Min(p => p.Y)),
|
|
_ => default
|
|
};
|
|
private static void AnnotSetPos(PageAnnotation a, Point pos)
|
|
{
|
|
switch (a)
|
|
{
|
|
case PlacedAnnotation p: p.Position = pos; break;
|
|
case TextAnnotation t: t.Position = pos; break;
|
|
case HighlightAnnotation h:
|
|
{
|
|
double hdx = pos.X - h.Bounds.X, hdy = pos.Y - h.Bounds.Y;
|
|
h.Bounds = new Rect(pos, h.Bounds.Size);
|
|
if (h.Erases is { } hes)
|
|
foreach (var e in hes)
|
|
for (int i = 0; i < e.Points.Count; i++)
|
|
e.Points[i] = new Point(e.Points[i].X + hdx, e.Points[i].Y + hdy);
|
|
break;
|
|
}
|
|
case InkAnnotation ink when ink.Points.Count > 0:
|
|
// Move the whole stroke by the delta from its current bounding-box origin.
|
|
double ox = ink.Points.Min(p => p.X), oy = ink.Points.Min(p => p.Y);
|
|
double dx = pos.X - ox, dy = pos.Y - oy;
|
|
for (int i = 0; i < ink.Points.Count; i++)
|
|
ink.Points[i] = new Point(ink.Points[i].X + dx, ink.Points[i].Y + dy);
|
|
break;
|
|
}
|
|
}
|
|
private Rect AnnotBounds(PageAnnotation a)
|
|
{
|
|
// Ink isn't a simple rect in HitTestAnnotation (it's a proximity test), so derive its bounds
|
|
// from the stroke points; everything else reuses HitTestAnnotation's out-bounds via a far probe.
|
|
if (a is InkAnnotation ia)
|
|
{
|
|
if (ia.Points.Count == 0) return Rect.Empty;
|
|
double minX = ia.Points.Min(p => p.X), minY = ia.Points.Min(p => p.Y);
|
|
double maxX = ia.Points.Max(p => p.X), maxY = ia.Points.Max(p => p.Y);
|
|
return new Rect(minX, minY, Math.Max(1, maxX - minX), Math.Max(1, maxY - minY));
|
|
}
|
|
HitTestAnnotation(a, new Point(double.MinValue, double.MinValue), out Rect b);
|
|
return b;
|
|
}
|
|
|
|
// Draws the selection marquee on the top-most MarqueeLayer (above every page) so a drag can span
|
|
// pages. Maps the two corners from the start page's canvas into the layer's coordinate space.
|
|
internal void UpdateMarquee(Point startInStartCanvas, Point currentInStartCanvas)
|
|
{
|
|
var gc = _gestureCanvas ?? _activeCanvas;
|
|
if (_selectRect is null || gc is null) return;
|
|
var t = gc.TransformToVisual(MarqueeLayer);
|
|
Point a = t.Transform(startInStartCanvas), b = t.Transform(currentInStartCanvas);
|
|
Canvas.SetLeft(_selectRect, Math.Min(a.X, b.X));
|
|
Canvas.SetTop(_selectRect, Math.Min(a.Y, b.Y));
|
|
_selectRect.Width = Math.Abs(a.X - b.X);
|
|
_selectRect.Height = Math.Abs(a.Y - b.Y);
|
|
}
|
|
|
|
// Maps a rectangle from one visual's coordinate space into another's, for cross-page hit-testing.
|
|
private static Rect MapRect(UIElement from, System.Windows.Media.Visual to, Rect r)
|
|
{
|
|
var t = from.TransformToVisual(to);
|
|
return new Rect(t.Transform(new Point(r.Left, r.Top)), t.Transform(new Point(r.Right, r.Bottom)));
|
|
}
|
|
|
|
// Constrains a rectangle (annotation-canvas coordinates) to the page so its corners can't land
|
|
// off the page, where resize handles become unreachable. The page occupies (0,0)-(w,h) in the
|
|
// same coordinate space as annotations, taken from _renderDims. A box bigger than the page is
|
|
// pinned to the top-left rather than shrunk.
|
|
private Rect ClampRectToPage(int pageIdx, Rect r)
|
|
{
|
|
if (!_renderDims.TryGetValue(pageIdx, out var d)) return r;
|
|
double pw = d.w, ph = d.h;
|
|
double x = Math.Max(0, Math.Min(r.X, pw - r.Width));
|
|
double y = Math.Max(0, Math.Min(r.Y, ph - r.Height));
|
|
return new Rect(x, y, r.Width, r.Height);
|
|
}
|
|
|
|
// Clamps an annotation's position so its whole bounding box stays on its page. Returns the
|
|
// top-left to feed back through AnnotSetPos (which knows how to move each annotation type).
|
|
private Point ClampAnnotPos(PageAnnotation a)
|
|
{
|
|
var b = AnnotBounds(a);
|
|
return ClampRectToPage(a.PageIndex, b).Location;
|
|
}
|
|
|
|
// Clamps a point to the page rectangle. Used during resize so a dragged corner can't leave the
|
|
// page (with the opposite corner already on-page, that keeps the whole box on-page).
|
|
private Point ClampPointToPage(int pageIdx, Point p)
|
|
=> _renderDims.TryGetValue(pageIdx, out var d)
|
|
? new Point(Math.Max(0, Math.Min(p.X, d.w)), Math.Max(0, Math.Min(p.Y, d.h)))
|
|
: p;
|
|
|
|
// Returns the page index + canvas under the mouse across every per-page overlay
|
|
// (grid / two-page / continuous tiles) and the primary page canvas. Used to drop a placed
|
|
// annotation onto a different page than the one it started on.
|
|
private (int page, Canvas canvas)? PageCanvasUnderPointer(MouseEventArgs e)
|
|
{
|
|
foreach (var kv in _continuousCanvases)
|
|
{
|
|
var c = kv.Value;
|
|
if (c.ActualWidth <= 0 || c.ActualHeight <= 0) continue;
|
|
var p = e.GetPosition(c);
|
|
if (p.X >= 0 && p.X <= c.ActualWidth && p.Y >= 0 && p.Y <= c.ActualHeight)
|
|
return (kv.Key, c);
|
|
}
|
|
if (_annotationCanvas.ActualWidth > 0 && _annotationCanvas.ActualHeight > 0)
|
|
{
|
|
var pp = e.GetPosition(_annotationCanvas);
|
|
if (pp.X >= 0 && pp.X <= _annotationCanvas.ActualWidth &&
|
|
pp.Y >= 0 && pp.Y <= _annotationCanvas.ActualHeight)
|
|
return (_viewMode == ViewMode.Grid ? 0 : _currentPage, _annotationCanvas);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
internal void Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (_doc is null) return;
|
|
if (sender is Canvas srcCanvas) _activeCanvas = srcCanvas;
|
|
// A click on a live text-edit corner handle starts a free-form resize. Checked FIRST, before
|
|
// the "click inside the editing box" guard below: that guard tests OriginalSource, which is
|
|
// unreliable across the nested transparent canvases, so it can otherwise swallow a corner
|
|
// click right after placement. This is a reliable position-based hit test.
|
|
if (_textEditHandles.Count > 0 && _tehBox is not null)
|
|
{
|
|
var hpos = e.GetPosition(_activeCanvas);
|
|
string? corner = TextEditHandleAt(hpos);
|
|
if (corner is not null)
|
|
{
|
|
_tehCorner = corner;
|
|
double bx = Canvas.GetLeft(_tehBox), by = Canvas.GetTop(_tehBox);
|
|
double bw = _tehBox.ActualWidth > 0 ? _tehBox.ActualWidth : _tehBox.Width;
|
|
double bh = _tehBox.ActualHeight > 0 ? _tehBox.ActualHeight : Math.Max(_tehBox.MinHeight, 24);
|
|
_tehAnchor = _tehCorner switch
|
|
{
|
|
"NW" => new Point(bx + bw, by + bh),
|
|
"NE" => new Point(bx, by + bh),
|
|
"SW" => new Point(bx + bw, by),
|
|
_ => new Point(bx, by) // SE
|
|
};
|
|
_draggingTextEditHandle = true;
|
|
_gestureCanvas = _activeCanvas;
|
|
_gesturePage = _activeCanvas.Tag is int tp ? tp : _currentPage;
|
|
_activeCanvas.CaptureMouse();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
}
|
|
// Don't intercept clicks on an active text editing box
|
|
if (_activeTextBox is not null && e.OriginalSource is DependencyObject src &&
|
|
IsDescendantOf(src, _activeTextBox))
|
|
return;
|
|
// Don't intercept clicks on the crop confirm bar (canvas uses Preview events which
|
|
// tunnel before child Button clicks fire - we must not swallow them here).
|
|
if (_cropConfirmBar is not null && e.OriginalSource is DependencyObject cropSrc &&
|
|
IsDescendantOf(cropSrc, _cropConfirmBar))
|
|
return;
|
|
// Don't intercept clicks on form field overlay controls (TextBox, CheckBox, etc.)
|
|
// - WPF must handle those natively so focus, toggling, and text entry work.
|
|
if (e.OriginalSource is DependencyObject formSrc && IsFormFieldElement(formSrc))
|
|
return;
|
|
// Check if click lands inside a PDF link overlay.
|
|
// We do an explicit bounds check rather than relying on WPF hit-testing through
|
|
// nested transparent canvases, which is unreliable. Links are only followed with the
|
|
// Select tool (so drawing/typing over a link region edits instead of navigating), and
|
|
// never when the click is on an annotation - annotations stay selectable over a link
|
|
// (those orphan-scan PDFs embed a page-spanning site link that otherwise eats every click).
|
|
if (_currentTool == EditTool.Select && _linkOverlays.Count > 0)
|
|
{
|
|
var clickPos = e.GetPosition(_activeCanvas);
|
|
int linkPage = _activeCanvas.Tag is int ltp ? ltp : _currentPage;
|
|
bool onAnnot = _annotations.TryGetValue(linkPage, out var lal)
|
|
&& lal.Any(a => HitTestAnnotation(a, clickPos, out _));
|
|
foreach (var lo in onAnnot ? Enumerable.Empty<Canvas>() : _linkOverlays)
|
|
{
|
|
double lx = Canvas.GetLeft(lo);
|
|
double ly = Canvas.GetTop(lo);
|
|
if (clickPos.X >= lx && clickPos.X <= lx + lo.Width &&
|
|
clickPos.Y >= ly && clickPos.Y <= ly + lo.Height)
|
|
{
|
|
var lTarget = lo.Tag is LinkAnnotInfo lai ? lai.Target : lo.Tag;
|
|
FollowLinkTarget(lTarget);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
// Tiled views (continuous / grid / two-page): resolve link clicks by a position bounds-check
|
|
// against the page's stored rects. A per-link overlay can't be used here - it swallows the click
|
|
// but its own handler never fires - so no overlay exists; _continuousLinks is the source of truth.
|
|
// (Single-page view is handled by the _linkOverlays check above.)
|
|
if (_currentTool == EditTool.Select && _viewMode != ViewMode.Single)
|
|
{
|
|
var cpos = e.GetPosition(_activeCanvas);
|
|
int cpage = _activeCanvas.Tag is int cltp ? cltp : _currentPage;
|
|
bool cOnAnnot = _annotations.TryGetValue(cpage, out var clal)
|
|
&& clal.Any(a => HitTestAnnotation(a, cpos, out _));
|
|
if (!cOnAnnot && _continuousLinks.TryGetValue(cpage, out var clinks))
|
|
{
|
|
const double pad = LinkHitPad; // shared with hover + the single-page overlay so all views match
|
|
foreach (var lnk in clinks)
|
|
{
|
|
if (cpos.X >= lnk.Cx - pad && cpos.X <= lnk.Cx + lnk.Cw + pad &&
|
|
cpos.Y >= lnk.Cy - pad && cpos.Y <= lnk.Cy + lnk.Ch + pad)
|
|
{
|
|
FollowLinkTarget(lnk.Tag);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
var pos = e.GetPosition(_activeCanvas);
|
|
int pageIdx = _activeCanvas.Tag is int tagPage ? tagPage : _currentPage;
|
|
if (pageIdx < 0) return;
|
|
// Grid: ANY left interaction with a tile selects that page - drawing, highlighting, and
|
|
// clicking or dragging an annotation, not just the plain empty-page click below (which
|
|
// stays as a harmless duplicate in its branch). Shift-click keeps building the
|
|
// multi-selection without moving the page, matching the existing marquee guard.
|
|
if (_viewMode == ViewMode.Grid && (Keyboard.Modifiers & ModifierKeys.Shift) == 0)
|
|
_currentPage = pageIdx;
|
|
// Pin the surface/page this gesture started on so async re-renders (grid tile streaming)
|
|
// can't redirect the in-progress draw/select to another page. See _gestureCanvas.
|
|
_gestureCanvas = _activeCanvas;
|
|
_gesturePage = pageIdx;
|
|
|
|
// Crop corner handles live in the outer panel and have direct MouseLeftButtonDown
|
|
// handlers attached in AddCropHandles() - no detection needed here.
|
|
|
|
// Check if click is on any of the four corner resize handles (signature, image, or text box)
|
|
if (_resizeHandles.Count > 0 && _selectedAnnotation is not null)
|
|
{
|
|
foreach (var hd in _resizeHandles)
|
|
{
|
|
double hx = Canvas.GetLeft(hd), hy = Canvas.GetTop(hd);
|
|
if (pos.X >= hx && pos.X <= hx + hd.Width &&
|
|
pos.Y >= hy && pos.Y <= hy + hd.Height)
|
|
{
|
|
_resizeCorner = hd.Tag as string ?? "SE";
|
|
// Anchor on the opposite corner so it stays put while the dragged corner moves.
|
|
if (_selectedAnnotation is PlacedAnnotation rsa)
|
|
{
|
|
_isResizingSig = true;
|
|
_resizeSigStart = pos;
|
|
_resizeSigStartScale = rsa.Scale;
|
|
_resizeSigAnnot = rsa;
|
|
double w0 = rsa.SourceWidth * rsa.Scale, h0 = rsa.SourceHeight * rsa.Scale;
|
|
double ax = rsa.Position.X, ay = rsa.Position.Y;
|
|
_resizeAnchor = _resizeCorner switch
|
|
{
|
|
"NW" => new Point(ax + w0, ay + h0),
|
|
"NE" => new Point(ax, ay + h0),
|
|
"SW" => new Point(ax + w0, ay),
|
|
_ => new Point(ax, ay) // SE
|
|
};
|
|
_activeCanvas.CaptureMouse();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
if (_selectedAnnotation is TextAnnotation rta)
|
|
{
|
|
_isResizingSig = true; // shared "resize in progress" flag + capture
|
|
_resizeTextAnnot = rta;
|
|
double ax = rta.Position.X, ay = rta.Position.Y;
|
|
double w0 = rta.Width, h0 = rta.Height;
|
|
_resizeAnchor = _resizeCorner switch
|
|
{
|
|
"NW" => new Point(ax + w0, ay + h0),
|
|
"NE" => new Point(ax, ay + h0),
|
|
"SW" => new Point(ax + w0, ay),
|
|
_ => new Point(ax, ay) // SE
|
|
};
|
|
_activeCanvas.CaptureMouse();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
if (_selectedAnnotation is HighlightAnnotation rha)
|
|
{
|
|
_isResizingSig = true; // shared "resize in progress" flag + capture
|
|
_resizeHlAnnot = rha;
|
|
double ax = rha.Bounds.X, ay = rha.Bounds.Y;
|
|
double w0 = rha.Bounds.Width, h0 = rha.Bounds.Height;
|
|
_resizeAnchor = _resizeCorner switch
|
|
{
|
|
"NW" => new Point(ax + w0, ay + h0),
|
|
"NE" => new Point(ax, ay + h0),
|
|
"SW" => new Point(ax + w0, ay),
|
|
_ => new Point(ax, ay) // SE
|
|
};
|
|
_activeCanvas.CaptureMouse();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
if (_selectedAnnotation is InkAnnotation rink && rink.Points.Count > 0)
|
|
{
|
|
_isResizingSig = true; // shared "resize in progress" flag + capture
|
|
_resizeInkAnnot = rink;
|
|
_resizeInkOrigPoints = [.. rink.Points];
|
|
double minX = rink.Points.Min(p => p.X), minY = rink.Points.Min(p => p.Y);
|
|
double maxX = rink.Points.Max(p => p.X), maxY = rink.Points.Max(p => p.Y);
|
|
_resizeInkOrigBounds = new Rect(minX, minY, Math.Max(1, maxX - minX), Math.Max(1, maxY - minY));
|
|
_resizeAnchor = _resizeCorner switch
|
|
{
|
|
"NW" => new Point(maxX, maxY),
|
|
"NE" => new Point(minX, maxY),
|
|
"SW" => new Point(maxX, minY),
|
|
_ => new Point(minX, minY) // SE
|
|
};
|
|
_activeCanvas.CaptureMouse();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
switch (_currentTool)
|
|
{
|
|
case EditTool.Select:
|
|
if (e.ClickCount == 2)
|
|
{
|
|
// Double-clicking a stamp (page number / watermark) reopens the Stamp Pages editor.
|
|
if (StampHitTest(pageIdx, pos)) { OpenStampTool(); e.Handled = true; return; }
|
|
ClearSelection();
|
|
ClearTextSelection();
|
|
EditTextAtPosition(pos, pageIdx);
|
|
e.Handled = true;
|
|
}
|
|
else
|
|
{
|
|
// Shift+click builds a multi-selection instead of replacing it.
|
|
bool shiftSel = (Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift;
|
|
// Flowing text selection (#127): when the press lands ON text, the text run
|
|
// owns the DRAG even if an unselected annotation (e.g. a paragraph-covering
|
|
// highlight) sits underneath - a plain CLICK still selects that annotation,
|
|
// resolved on mouse-up via _txtSelClickAnnot. An already-selected annotation
|
|
// keeps drag priority so click-then-drag moves it. Empty page, Shift, and
|
|
// OCR region capture all keep the classic marquee below.
|
|
bool selectedUnderPress = _selectedAnnotation is not null
|
|
&& _selectedAnnotation.PageIndex == pageIdx
|
|
&& HitTestAnnotation(_selectedAnnotation, pos, out _);
|
|
if (!_ocrRegionMode && !shiftSel && !selectedUnderPress
|
|
&& TryBeginTextSelection(pageIdx, pos))
|
|
{
|
|
ClearSelection();
|
|
if (_annotations.TryGetValue(pageIdx, out var underPress))
|
|
for (int i = underPress.Count - 1; i >= 0; i--)
|
|
if (IsDraggable(underPress[i]) && HitTestAnnotation(underPress[i], pos, out Rect ub))
|
|
{ _txtSelClickAnnot = underPress[i]; _txtSelClickAnnotBounds = ub; break; }
|
|
if (_viewMode == ViewMode.Grid) _currentPage = pageIdx;
|
|
_activeCanvas.CaptureMouse();
|
|
e.Handled = true;
|
|
break;
|
|
}
|
|
// Single click: check if hitting a PlacedAnnotation first - select and drag
|
|
bool hitPlaced = false;
|
|
if (_annotations.TryGetValue(pageIdx, out var pageAnnotsList))
|
|
{
|
|
for (int i = pageAnnotsList.Count - 1; i >= 0; i--)
|
|
{
|
|
if (IsDraggable(pageAnnotsList[i]) &&
|
|
HitTestAnnotation(pageAnnotsList[i], pos, out Rect paBounds))
|
|
{
|
|
var pa = pageAnnotsList[i];
|
|
if (shiftSel)
|
|
{
|
|
// Toggle in/out of the multi-selection; no drag while shifting.
|
|
ToggleMultiSelect(pa, paBounds, _gestureCanvas ?? _activeCanvas);
|
|
e.Handled = true;
|
|
hitPlaced = true;
|
|
break;
|
|
}
|
|
// A grouped annotation selects + drags its whole group together;
|
|
// otherwise just this one. _dragGroupOrig holds the companions' start
|
|
// positions so the group translates rigidly during the drag.
|
|
_dragGroupOrig.Clear();
|
|
if (pa.GroupId.Length > 0)
|
|
{
|
|
SelectGroup(pa);
|
|
foreach (var m in _selectedSet) _dragGroupOrig.Add((m, AnnotGetPos(m)));
|
|
}
|
|
else
|
|
{
|
|
ClearSelection();
|
|
RenderAllAnnotations(pageIdx);
|
|
SelectAnnotation(pa, paBounds);
|
|
}
|
|
_isDraggingAnnot = true;
|
|
_dragAnnotStart = pos;
|
|
_dragAnnotOrigPos = AnnotGetPos(pa);
|
|
_dragAnnot = pa;
|
|
_activeCanvas.CaptureMouse();
|
|
e.Handled = true;
|
|
hitPlaced = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!hitPlaced)
|
|
{
|
|
// Keep the existing multi-selection when shift is held (a click on a
|
|
// non-draggable annotation is added on mouse-up); only a plain click clears.
|
|
if (!shiftSel) ClearSelection();
|
|
ClearTextSelection();
|
|
if (_viewMode == ViewMode.Grid && !shiftSel) _currentPage = pageIdx; // grid: click selects the page
|
|
_isSelecting = true;
|
|
_selectStart = pos;
|
|
_selectRect = new Rectangle
|
|
{
|
|
Fill = AccentBrush(40),
|
|
Stroke = AccentBrush(150),
|
|
StrokeThickness = 1,
|
|
Width = 0, Height = 0,
|
|
IsHitTestVisible = false
|
|
};
|
|
MarqueeLayer.Children.Add(_selectRect);
|
|
UpdateMarquee(pos, pos);
|
|
_activeCanvas.CaptureMouse();
|
|
e.Handled = true;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case EditTool.Text:
|
|
// A click inside the box that's already being edited must NOT commit-and-replace it
|
|
// (that makes the box appear to jump to the cursor). In Grid view a per-tile overlay
|
|
// can sit above the TextBox, so the OriginalSource guard near the top of this method
|
|
// misses the hit; a bounds check on the box's own canvas catches it reliably.
|
|
if (ClickInsideActiveTextBox(pos))
|
|
{
|
|
e.Handled = true;
|
|
break;
|
|
}
|
|
CommitActiveTextBox();
|
|
PlaceTextBox(pos, pageIdx);
|
|
e.Handled = true;
|
|
break;
|
|
|
|
case EditTool.Highlight:
|
|
case EditTool.Strikethrough:
|
|
case EditTool.Underline:
|
|
ClearSelection();
|
|
// #127 Phase 2: on text these tools FLOW along the character runs exactly like
|
|
// text selection; the release turns the selected lines into per-line annotations.
|
|
// The rectangle path below survives ONLY for the highlight eraser. A press on a
|
|
// page with no text under it hints instead of silently laying down a rect,
|
|
// pointing at the Shapes tool (digit 4) as the deliberate rectangle path.
|
|
if (!(_currentTool == EditTool.Highlight && _highlightErase))
|
|
{
|
|
ClearTextSelection();
|
|
if (TryBeginTextSelection(pageIdx, pos))
|
|
{
|
|
_txtSelCommitTool = _currentTool;
|
|
_activeCanvas.CaptureMouse();
|
|
}
|
|
else
|
|
{
|
|
SetStatus(Loc("Str_St_NoTextUseShapes"));
|
|
}
|
|
e.Handled = true;
|
|
break;
|
|
}
|
|
_isDrawing = true;
|
|
_drawStart = pos;
|
|
var previewFill = _currentTool == EditTool.Highlight
|
|
? _highlightColor
|
|
: Color.FromArgb(70, _lineAnnotColor.R, _lineAnnotColor.G, _lineAnnotColor.B);
|
|
var rect = new Rectangle { Width = 0, Height = 0 };
|
|
if (_currentTool == EditTool.Highlight && _highlightErase)
|
|
{
|
|
// Eraser: red marching-ants box so it reads as "delete inside" and stays visible on
|
|
// any page color (the old faint white dashes vanished on light scans). The dash
|
|
// offset animates so the ants "walk".
|
|
rect.Fill = new SolidColorBrush(Color.FromArgb(36, 255, 60, 60));
|
|
rect.Stroke = new SolidColorBrush(Color.FromArgb(235, 220, 40, 40));
|
|
rect.StrokeThickness = 2;
|
|
rect.StrokeDashArray = [4, 3];
|
|
rect.BeginAnimation(Shape.StrokeDashOffsetProperty,
|
|
new DoubleAnimation(0, 7, new Duration(TimeSpan.FromSeconds(0.5)))
|
|
{ RepeatBehavior = RepeatBehavior.Forever });
|
|
}
|
|
else rect.Fill = new SolidColorBrush(previewFill);
|
|
Canvas.SetLeft(rect, pos.X);
|
|
Canvas.SetTop(rect, pos.Y);
|
|
_activeCanvas.Children.Add(rect);
|
|
_activePreview = rect;
|
|
_activeCanvas.CaptureMouse();
|
|
break;
|
|
|
|
case EditTool.Shape:
|
|
// #127 Phase 3: rectangle/ellipse drag or polygon vertex click - see Shapes.cs.
|
|
ShapeToolMouseDown(pageIdx, pos, e);
|
|
break;
|
|
|
|
case EditTool.Draw:
|
|
ClearSelection();
|
|
_isDrawing = true;
|
|
_activeInk = new InkAnnotation { PageIndex = pageIdx, StrokeWidth = _drawWidth };
|
|
_activeInk.SetColor(_drawColor);
|
|
_activeInk.Points.Add(pos);
|
|
var poly = new Polyline
|
|
{
|
|
// Eraser brush shows a translucent gray stroke so it reads as erasing, not inking.
|
|
Stroke = _drawErase ? new SolidColorBrush(Color.FromArgb(120, 200, 200, 200))
|
|
: new SolidColorBrush(_drawColor),
|
|
StrokeThickness = _drawWidth,
|
|
StrokeLineJoin = PenLineJoin.Round,
|
|
StrokeStartLineCap = PenLineCap.Round,
|
|
StrokeEndLineCap = PenLineCap.Round
|
|
};
|
|
poly.Points.Add(pos);
|
|
_activeCanvas.Children.Add(poly);
|
|
_activePreview = poly;
|
|
_activeCanvas.CaptureMouse();
|
|
break;
|
|
|
|
case EditTool.Line:
|
|
// A straight line is a 2-point ink stroke - reuses ink rendering/export/selection and
|
|
// gets round end caps (and antialiased edges) for free. The 2nd point tracks the drag.
|
|
ClearSelection();
|
|
_isDrawing = true;
|
|
_drawStart = pos;
|
|
_activeInk = new InkAnnotation { PageIndex = pageIdx, StrokeWidth = _drawWidth };
|
|
_activeInk.SetColor(_drawColor);
|
|
_activeInk.Points.Add(pos);
|
|
_activeInk.Points.Add(pos);
|
|
var lpoly = new Polyline
|
|
{
|
|
Stroke = new SolidColorBrush(_drawColor),
|
|
StrokeThickness = _drawWidth,
|
|
StrokeStartLineCap = PenLineCap.Round,
|
|
StrokeEndLineCap = PenLineCap.Round
|
|
};
|
|
lpoly.Points.Add(pos);
|
|
lpoly.Points.Add(pos);
|
|
_activeCanvas.Children.Add(lpoly);
|
|
_activePreview = lpoly;
|
|
_activeCanvas.CaptureMouse();
|
|
break;
|
|
|
|
case EditTool.Signature:
|
|
if (_pendingSignature is not null)
|
|
{
|
|
PlaceSignature(pos, pageIdx);
|
|
e.Handled = true;
|
|
}
|
|
else
|
|
{
|
|
ShowSignaturePopup();
|
|
}
|
|
break;
|
|
|
|
case EditTool.Image:
|
|
PlaceImageFromDialog(pos, pageIdx);
|
|
e.Handled = true;
|
|
break;
|
|
|
|
case EditTool.Crop:
|
|
ClearSelection();
|
|
_isDrawing = true;
|
|
_drawStart = pos;
|
|
_cropPageIndex = _activeCanvas.Tag is int cpi ? cpi : (_viewMode == ViewMode.Grid ? 0 : _currentPage);
|
|
// Draw the NEW box as a separate rect (above the existing one). The current box, its
|
|
// handles, and the bar all stay put until this draw is committed on mouse-up - so a
|
|
// mouse-down never makes the box or the bar vanish.
|
|
var cropDrawRect = new Rectangle
|
|
{
|
|
Stroke = Brushes.White,
|
|
StrokeThickness = 1.5,
|
|
StrokeDashArray = [5, 3],
|
|
Fill = AccentBrush(55),
|
|
Width = 0, Height = 0,
|
|
IsHitTestVisible = false,
|
|
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
|
{ Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.7 },
|
|
};
|
|
Canvas.SetLeft(cropDrawRect, pos.X);
|
|
Canvas.SetTop(cropDrawRect, pos.Y);
|
|
Panel.SetZIndex(cropDrawRect, 2); // above the existing box
|
|
_activeCanvas.Children.Add(cropDrawRect);
|
|
_activePreview = cropDrawRect;
|
|
_activeCanvas.CaptureMouse();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Ink/eraser brush cursor preview: a circle the size of the brush, shown while hovering with the
|
|
// Draw tool so the brush footprint is visible before a stroke. Ink = color-tinted ring; eraser =
|
|
// gray dashed ring. Lives on the page overlay under the cursor; removed on tool change / stroke /
|
|
// when the pointer is no longer hovering the Draw tool.
|
|
private System.Windows.Shapes.Ellipse? _brushPreview;
|
|
|
|
private void ShowBrushPreview(Canvas canvas, Point center)
|
|
{
|
|
double d = Math.Max(2, _drawWidth); // brush diameter in canvas (render-dim) units
|
|
_brushPreview ??= new System.Windows.Shapes.Ellipse { IsHitTestVisible = false };
|
|
Panel.SetZIndex(_brushPreview, 9000);
|
|
_brushPreview.StrokeThickness = 1;
|
|
_brushPreview.Width = d;
|
|
_brushPreview.Height = d;
|
|
if (_drawErase)
|
|
{
|
|
_brushPreview.Stroke = new SolidColorBrush(Color.FromArgb(220, 150, 150, 150));
|
|
_brushPreview.StrokeDashArray = [3, 2];
|
|
_brushPreview.Fill = new SolidColorBrush(Color.FromArgb(28, 255, 255, 255));
|
|
}
|
|
else
|
|
{
|
|
_brushPreview.Stroke = new SolidColorBrush(_drawColor);
|
|
_brushPreview.StrokeDashArray = null;
|
|
_brushPreview.Fill = new SolidColorBrush(Color.FromArgb(40, _drawColor.R, _drawColor.G, _drawColor.B));
|
|
}
|
|
if (!ReferenceEquals(_brushPreview.Parent, canvas))
|
|
{
|
|
(_brushPreview.Parent as Canvas)?.Children.Remove(_brushPreview);
|
|
canvas.Children.Add(_brushPreview);
|
|
}
|
|
Canvas.SetLeft(_brushPreview, center.X - d / 2);
|
|
Canvas.SetTop(_brushPreview, center.Y - d / 2);
|
|
}
|
|
|
|
private void HideBrushPreview()
|
|
{
|
|
if (_brushPreview is not null)
|
|
(_brushPreview.Parent as Canvas)?.Children.Remove(_brushPreview);
|
|
}
|
|
|
|
// The pointer left a page surface: drop the brush cursor so it doesn't hang frozen at the page
|
|
// edge (MouseMove stops firing off-canvas, so it can't clear itself there).
|
|
internal void Canvas_MouseLeave(object sender, MouseEventArgs e)
|
|
{
|
|
HideBrushPreview();
|
|
ShowLinkHoverStatus(null); // restore the status bar when the pointer leaves the page
|
|
}
|
|
|
|
internal void Canvas_MouseMove(object sender, MouseEventArgs e)
|
|
{
|
|
// Don't interfere with mouse interaction inside form field overlays.
|
|
if (e.OriginalSource is DependencyObject moveSrc && IsFormFieldElement(moveSrc))
|
|
return;
|
|
|
|
// Shapes tool (#127 Phase 3): rubber-band the in-progress polygon (no button held).
|
|
if (_currentTool == EditTool.Shape && _shapePolyPoints.Count > 0)
|
|
{
|
|
UpdateShapePolyRubber(e);
|
|
return;
|
|
}
|
|
|
|
// Tiled views: hand cursor + the link's target in the status bar while hovering (links have no
|
|
// clickable overlay here - see Canvas_MouseLeftButtonDown - so hover is resolved by hit-testing
|
|
// the stored rects, same 20px pad as the click).
|
|
if (_currentTool == EditTool.Select && _viewMode != ViewMode.Single && sender is Canvas linkHoverCv)
|
|
{
|
|
int hpage = linkHoverCv.Tag is int htp ? htp : -1;
|
|
var hpos = e.GetPosition(linkHoverCv);
|
|
string? hoverTarget = null;
|
|
if (hpage >= 0 && _continuousLinks.TryGetValue(hpage, out var hlinks))
|
|
foreach (var l in hlinks)
|
|
if (hpos.X >= l.Cx - LinkHitPad && hpos.X <= l.Cx + l.Cw + LinkHitPad &&
|
|
hpos.Y >= l.Cy - LinkHitPad && hpos.Y <= l.Cy + l.Ch + LinkHitPad)
|
|
{ hoverTarget = l.Tip; break; }
|
|
linkHoverCv.Cursor = hoverTarget != null ? System.Windows.Input.Cursors.Hand : null;
|
|
ShowLinkHoverStatus(hoverTarget);
|
|
}
|
|
|
|
// Brush cursor: with the Draw tool active and no button down, show a circle the size of the
|
|
// brush (ink or eraser) at the pointer so it's obvious where the next stroke will land.
|
|
if (sender is Canvas hoverCv)
|
|
{
|
|
if (_currentTool == EditTool.Draw && !_isDrawing && e.LeftButton != MouseButtonState.Pressed)
|
|
ShowBrushPreview(hoverCv, e.GetPosition(hoverCv));
|
|
else
|
|
HideBrushPreview();
|
|
}
|
|
|
|
// Safety: a drag/resize is in progress but the left button is no longer down - the mouse-up was
|
|
// lost (typically because the app was in the background when the user released). Finish the
|
|
// gesture now so the annotation stops following the cursor and the canvas frees its capture.
|
|
if (e.LeftButton != MouseButtonState.Pressed && (_isDraggingAnnot || _isResizingSig))
|
|
{
|
|
FinishStuckGesture();
|
|
return;
|
|
}
|
|
|
|
// Resolve the pointer against the surface the gesture started on, not _activeCanvas,
|
|
// which RenderAllAnnotations (and async grid tile streaming) can re-point mid-gesture.
|
|
var gc = _gestureCanvas ?? _activeCanvas;
|
|
var pos = e.GetPosition(gc);
|
|
pos.X = Math.Max(0, Math.Min(gc.ActualWidth, pos.X));
|
|
pos.Y = Math.Max(0, Math.Min(gc.ActualHeight, pos.Y));
|
|
|
|
// Live text-edit box resize (mid-edit corner handles): free-form, opposite corner held fixed.
|
|
if (_draggingTextEditHandle && _tehBox is not null)
|
|
{
|
|
double newW = Math.Max(40, Math.Abs(pos.X - _tehAnchor.X));
|
|
double newH = Math.Max(24, Math.Abs(pos.Y - _tehAnchor.Y));
|
|
double nx = (_tehCorner is "NW" or "SW") ? _tehAnchor.X - newW : _tehAnchor.X;
|
|
double ny = (_tehCorner is "NW" or "NE") ? _tehAnchor.Y - newH : _tehAnchor.Y;
|
|
Canvas.SetLeft(_tehBox, nx);
|
|
Canvas.SetTop(_tehBox, ny);
|
|
_tehBox.Width = newW;
|
|
_tehBox.Height = newH;
|
|
LayoutTextEditHandles();
|
|
return;
|
|
}
|
|
|
|
// Text box resize drag: width follows the dragged corner; height auto-fits the wrapped text.
|
|
if (_isResizingSig && _resizeTextAnnot is not null)
|
|
{
|
|
var rta = _resizeTextAnnot;
|
|
// Free-form: the dragged corner sets both width and height (the opposite corner is fixed),
|
|
// exactly like resizing an image or crop rectangle. Text wraps to the width and is clipped
|
|
// to the height. The corner is clamped to the page so it can't leave it.
|
|
var cp = ClampPointToPage(rta.PageIndex, pos);
|
|
double newW = Math.Max(40, Math.Abs(cp.X - _resizeAnchor.X));
|
|
double newH = Math.Max(20, Math.Abs(cp.Y - _resizeAnchor.Y));
|
|
double nx = (_resizeCorner is "NW" or "SW") ? _resizeAnchor.X - newW : _resizeAnchor.X;
|
|
double ny = (_resizeCorner is "NW" or "NE") ? _resizeAnchor.Y - newH : _resizeAnchor.Y;
|
|
rta.Width = newW;
|
|
rta.Height = newH;
|
|
rta.Position = new Point(nx, ny);
|
|
if (_selectionBorder is not null)
|
|
{
|
|
_selectionBorder.Width = newW + 8;
|
|
_selectionBorder.Height = newH + 8;
|
|
Canvas.SetLeft(_selectionBorder, nx - 4);
|
|
Canvas.SetTop(_selectionBorder, ny - 4);
|
|
}
|
|
LayoutResizeHandles(nx, ny, newW, newH);
|
|
RenderAllAnnotations(rta.PageIndex);
|
|
ReattachSelectionVisuals(); // re-add border + handles + the paired cover outline (stays put during resize/move)
|
|
return;
|
|
}
|
|
|
|
// Signature resize drag
|
|
if (_isResizingSig && _resizeSigAnnot is not null)
|
|
{
|
|
// Uniform-scale resize from the dragged corner; the opposite corner (_resizeAnchor)
|
|
// stays fixed. Aspect is preserved by taking whichever axis demands the larger scale.
|
|
double desiredW = Math.Abs(pos.X - _resizeAnchor.X);
|
|
double desiredH = Math.Abs(pos.Y - _resizeAnchor.Y);
|
|
double sw = Math.Max(1.0, _resizeSigAnnot.SourceWidth);
|
|
double sh = Math.Max(1.0, _resizeSigAnnot.SourceHeight);
|
|
double newScale = Math.Max(0.05, Math.Max(desiredW / sw, desiredH / sh));
|
|
_resizeSigAnnot.Scale = newScale;
|
|
|
|
double newW = _resizeSigAnnot.SourceWidth * newScale;
|
|
double newH = _resizeSigAnnot.SourceHeight * newScale;
|
|
// Reposition the top-left so the anchor corner is preserved.
|
|
double nx = (_resizeCorner is "NW" or "SW") ? _resizeAnchor.X - newW : _resizeAnchor.X;
|
|
double ny = (_resizeCorner is "NW" or "NE") ? _resizeAnchor.Y - newH : _resizeAnchor.Y;
|
|
_resizeSigAnnot.Position = new Point(nx, ny);
|
|
|
|
if (_selectionBorder is not null)
|
|
{
|
|
_selectionBorder.Width = newW + 8;
|
|
_selectionBorder.Height = newH + 8;
|
|
Canvas.SetLeft(_selectionBorder, nx - 4);
|
|
Canvas.SetTop(_selectionBorder, ny - 4);
|
|
}
|
|
LayoutResizeHandles(nx, ny, newW, newH);
|
|
|
|
// Re-render annotations to show updated size
|
|
RenderAllAnnotations(_resizeSigAnnot.PageIndex);
|
|
// Restore selection visuals (RenderAllAnnotations clears canvas children including our overlays)
|
|
ReattachSelectionVisuals(); // re-add border + handles + the paired cover outline (stays put during resize/move)
|
|
return;
|
|
}
|
|
|
|
// Highlight / strikethrough / underline resize drag (modifies the Bounds rectangle).
|
|
if (_isResizingSig && _resizeHlAnnot is not null)
|
|
{
|
|
var cp = ClampPointToPage(_resizeHlAnnot.PageIndex, pos); // keep the dragged corner on-page
|
|
double newW = Math.Max(4, Math.Abs(cp.X - _resizeAnchor.X));
|
|
double newH = Math.Max(4, Math.Abs(cp.Y - _resizeAnchor.Y));
|
|
double nx = (_resizeCorner is "NW" or "SW") ? _resizeAnchor.X - newW : _resizeAnchor.X;
|
|
double ny = (_resizeCorner is "NW" or "NE") ? _resizeAnchor.Y - newH : _resizeAnchor.Y;
|
|
_resizeHlAnnot.Bounds = new Rect(nx, ny, newW, newH);
|
|
_resizeHlAnnot.Erases = null; // resizing remakes a solid highlight; old carve no longer fits
|
|
if (_selectionBorder is not null)
|
|
{
|
|
_selectionBorder.Width = newW + 8;
|
|
_selectionBorder.Height = newH + 8;
|
|
Canvas.SetLeft(_selectionBorder, nx - 4);
|
|
Canvas.SetTop(_selectionBorder, ny - 4);
|
|
}
|
|
LayoutResizeHandles(nx, ny, newW, newH);
|
|
RenderAllAnnotations(_resizeHlAnnot.PageIndex);
|
|
ReattachSelectionVisuals(); // re-add border + handles + the paired cover outline (stays put during resize/move)
|
|
return;
|
|
}
|
|
|
|
// Ink resize drag: scale every stroke point about the fixed anchor corner.
|
|
if (_isResizingSig && _resizeInkAnnot is not null && _resizeInkOrigPoints is not null)
|
|
{
|
|
var cp = ClampPointToPage(_resizeInkAnnot.PageIndex, pos); // keep the dragged corner on-page
|
|
double newW = Math.Max(4, Math.Abs(cp.X - _resizeAnchor.X));
|
|
double newH = Math.Max(4, Math.Abs(cp.Y - _resizeAnchor.Y));
|
|
double sx = newW / _resizeInkOrigBounds.Width;
|
|
double sy = newH / _resizeInkOrigBounds.Height;
|
|
for (int i = 0; i < _resizeInkOrigPoints.Count && i < _resizeInkAnnot.Points.Count; i++)
|
|
{
|
|
var p = _resizeInkOrigPoints[i];
|
|
_resizeInkAnnot.Points[i] = new Point(
|
|
_resizeAnchor.X + (p.X - _resizeAnchor.X) * sx,
|
|
_resizeAnchor.Y + (p.Y - _resizeAnchor.Y) * sy);
|
|
}
|
|
var ib = AnnotBounds(_resizeInkAnnot);
|
|
if (_selectionBorder is not null)
|
|
{
|
|
_selectionBorder.Width = ib.Width + 8;
|
|
_selectionBorder.Height = ib.Height + 8;
|
|
Canvas.SetLeft(_selectionBorder, ib.X - 4);
|
|
Canvas.SetTop(_selectionBorder, ib.Y - 4);
|
|
}
|
|
LayoutResizeHandles(ib.X, ib.Y, ib.Width, ib.Height);
|
|
RenderAllAnnotations(_resizeInkAnnot.PageIndex);
|
|
ReattachSelectionVisuals(); // re-add border + handles + the paired cover outline (stays put during resize/move)
|
|
return;
|
|
}
|
|
|
|
// Annotation drag-to-move
|
|
if (_isDraggingAnnot && _dragAnnot is not null)
|
|
{
|
|
double dx = pos.X - _dragAnnotStart.X;
|
|
double dy = pos.Y - _dragAnnotStart.Y;
|
|
if (_dragGroupOrig.Count > 0)
|
|
{
|
|
// Rigid group move: clamp ONE shared delta against the group's combined bounds so the
|
|
// whole group stops at the page edge together - clamping members individually would let
|
|
// some stop while others slide on, distorting the layout. Applies the same delta to all.
|
|
double minLeft = _dragAnnotOrigPos.X, minTop = _dragAnnotOrigPos.Y;
|
|
double maxRight = _dragAnnotOrigPos.X + AnnotBounds(_dragAnnot).Width;
|
|
double maxBottom = _dragAnnotOrigPos.Y + AnnotBounds(_dragAnnot).Height;
|
|
foreach (var (m, orig) in _dragGroupOrig)
|
|
{
|
|
var sz = AnnotBounds(m).Size;
|
|
minLeft = Math.Min(minLeft, orig.X);
|
|
minTop = Math.Min(minTop, orig.Y);
|
|
maxRight = Math.Max(maxRight, orig.X + sz.Width);
|
|
maxBottom = Math.Max(maxBottom, orig.Y + sz.Height);
|
|
}
|
|
if (_renderDims.TryGetValue(_dragAnnot.PageIndex, out var rdg))
|
|
{
|
|
// Math.Max(lo, ...) wins when the group is larger than the page, pinning it to the
|
|
// top-left edge instead of letting it drift partly off.
|
|
dx = Math.Max(-minLeft, Math.Min(rdg.w - maxRight, dx));
|
|
dy = Math.Max(-minTop, Math.Min(rdg.h - maxBottom, dy));
|
|
}
|
|
AnnotSetPos(_dragAnnot, new Point(_dragAnnotOrigPos.X + dx, _dragAnnotOrigPos.Y + dy));
|
|
foreach (var (m, orig) in _dragGroupOrig)
|
|
AnnotSetPos(m, new Point(orig.X + dx, orig.Y + dy));
|
|
}
|
|
else
|
|
{
|
|
AnnotSetPos(_dragAnnot, new Point(_dragAnnotOrigPos.X + dx, _dragAnnotOrigPos.Y + dy));
|
|
AnnotSetPos(_dragAnnot, ClampAnnotPos(_dragAnnot)); // keep the whole annotation on-page
|
|
}
|
|
var db = AnnotBounds(_dragAnnot);
|
|
if (_selectionBorder is not null)
|
|
{
|
|
Canvas.SetLeft(_selectionBorder, db.X - 4);
|
|
Canvas.SetTop(_selectionBorder, db.Y - 4);
|
|
}
|
|
if (_dragAnnot is PlacedAnnotation or TextAnnotation or HighlightAnnotation or InkAnnotation)
|
|
LayoutResizeHandles(db.X, db.Y, db.Width, db.Height);
|
|
RenderAllAnnotations(_dragAnnot.PageIndex);
|
|
ReattachSelectionVisuals(); // re-add border + handles + the paired cover outline (stays put during resize/move)
|
|
if (_dragGroupOrig.Count > 0) ReattachMultiOutlines(); // keep group outlines live during the drag
|
|
return;
|
|
}
|
|
|
|
// Flowing text selection drag (#127): move the focus caret and repaint the quads.
|
|
if (_txtSelActive)
|
|
{
|
|
UpdateTextSelectionDrag(e);
|
|
return;
|
|
}
|
|
|
|
// Text selection drag
|
|
if (_isSelecting && _selectRect is not null)
|
|
{
|
|
// Raw (un-clamped) pointer so the marquee can extend past the start page onto the others;
|
|
// pos above is clamped to the page, which is right for dragging annotations but not for this.
|
|
UpdateMarquee(_selectStart, e.GetPosition(gc));
|
|
return;
|
|
}
|
|
|
|
// Crop corner handle drag - must be before the _isDrawing guard since handle drag
|
|
// runs with _isDrawing = false and _activePreview = null.
|
|
if (_activeCropHandleTag is not null && _cropPreviewRect is not null)
|
|
{
|
|
double dx = pos.X - _cropHandleDragStart.X;
|
|
double dy = pos.Y - _cropHandleDragStart.Y;
|
|
var r = _cropRectAtHandleDrag;
|
|
double newX = r.X, newY = r.Y, newW = r.Width, newH = r.Height;
|
|
switch (_activeCropHandleTag)
|
|
{
|
|
case "NW":
|
|
newX = Math.Min(r.Right - 10, r.X + dx);
|
|
newY = Math.Min(r.Bottom - 10, r.Y + dy);
|
|
newW = r.Right - newX;
|
|
newH = r.Bottom - newY;
|
|
break;
|
|
case "NE":
|
|
newY = Math.Min(r.Bottom - 10, r.Y + dy);
|
|
newW = Math.Max(10, r.Width + dx);
|
|
newH = r.Bottom - newY;
|
|
break;
|
|
case "SE":
|
|
newW = Math.Max(10, r.Width + dx);
|
|
newH = Math.Max(10, r.Height + dy);
|
|
break;
|
|
case "SW":
|
|
newX = Math.Min(r.Right - 10, r.X + dx);
|
|
newW = r.Right - newX;
|
|
newH = Math.Max(10, r.Height + dy);
|
|
break;
|
|
}
|
|
_cropCanvasRect = new Rect(newX, newY, newW, newH);
|
|
UpdateCropRectVisuals();
|
|
return;
|
|
}
|
|
|
|
if (!_isDrawing || _activePreview is null) return;
|
|
|
|
switch (_currentTool)
|
|
{
|
|
case EditTool.Shape when _activePreview is Rectangle:
|
|
case EditTool.Highlight when _activePreview is Rectangle:
|
|
case EditTool.Strikethrough when _activePreview is Rectangle:
|
|
case EditTool.Underline when _activePreview is Rectangle:
|
|
var rect = (Rectangle)_activePreview;
|
|
Canvas.SetLeft(rect, Math.Min(pos.X, _drawStart.X));
|
|
Canvas.SetTop(rect, Math.Min(pos.Y, _drawStart.Y));
|
|
rect.Width = Math.Abs(pos.X - _drawStart.X);
|
|
rect.Height = Math.Abs(pos.Y - _drawStart.Y);
|
|
break;
|
|
|
|
case EditTool.Shape when _activePreview is Ellipse sell:
|
|
Canvas.SetLeft(sell, Math.Min(pos.X, _drawStart.X));
|
|
Canvas.SetTop(sell, Math.Min(pos.Y, _drawStart.Y));
|
|
sell.Width = Math.Abs(pos.X - _drawStart.X);
|
|
sell.Height = Math.Abs(pos.Y - _drawStart.Y);
|
|
break;
|
|
|
|
case EditTool.Draw when _activePreview is Polyline poly && _activeInk is not null:
|
|
_activeInk.Points.Add(pos);
|
|
poly.Points.Add(pos);
|
|
break;
|
|
|
|
case EditTool.Line when _activePreview is Polyline lp && _activeInk is not null && _activeInk.Points.Count >= 2:
|
|
// Straight line: the second point follows the cursor (endpoint), no points added.
|
|
// "Level" snaps to the nearest axis - horizontal when the drag is wider than tall,
|
|
// vertical otherwise - so both level horizontal and vertical lines just work.
|
|
Point lend;
|
|
if (_lineLevel)
|
|
{
|
|
var sp = _activeInk.Points[0];
|
|
lend = Math.Abs(pos.X - sp.X) >= Math.Abs(pos.Y - sp.Y)
|
|
? new Point(pos.X, sp.Y) // horizontal
|
|
: new Point(sp.X, pos.Y); // vertical
|
|
}
|
|
else lend = pos;
|
|
_activeInk.Points[1] = lend;
|
|
lp.Points[1] = lend;
|
|
break;
|
|
|
|
case EditTool.Crop when _activePreview is Rectangle crect:
|
|
Canvas.SetLeft(crect, Math.Min(pos.X, _drawStart.X));
|
|
Canvas.SetTop(crect, Math.Min(pos.Y, _drawStart.Y));
|
|
crect.Width = Math.Abs(pos.X - _drawStart.X);
|
|
crect.Height = Math.Abs(pos.Y - _drawStart.Y);
|
|
// Live-update the bar's X/Y/W/H as the box is dragged out.
|
|
_cropCanvasRect = new Rect(Canvas.GetLeft(crect), Canvas.GetTop(crect), crect.Width, crect.Height);
|
|
SyncCropBoxInputs();
|
|
break;
|
|
}
|
|
}
|
|
|
|
internal void Canvas_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
|
{
|
|
// Don't process release events that originate inside the crop confirm bar.
|
|
if (_cropConfirmBar is not null && e.OriginalSource is DependencyObject cropSrc &&
|
|
IsDescendantOf(cropSrc, _cropConfirmBar))
|
|
return;
|
|
|
|
// The gesture may have re-pointed _activeCanvas via RenderAllAnnotations since mouse-down
|
|
// (in Grid view, async tile streaming does this), so restore it to the surface the
|
|
// gesture started on. This makes page resolution, capture release, and preview cleanup
|
|
// below all act on the correct page - the root cause of highlights landing on the wrong
|
|
// page and select/delete failing in Grid view.
|
|
if (_gestureCanvas is not null) _activeCanvas = _gestureCanvas;
|
|
|
|
// Use the page the gesture started on (continuous/grid have per-page canvases tagged
|
|
// with their index), matching the mouse-down handler. Falls back to the selected page
|
|
// for the single-page canvas. Prevents committing an annotation to the wrong page.
|
|
int pageIdx = _gesturePage >= 0
|
|
? _gesturePage
|
|
: (_activeCanvas?.Tag is int tagPage ? tagPage : _currentPage);
|
|
|
|
// Finish a live text-edit box resize and hand focus back so typing continues.
|
|
if (_draggingTextEditHandle)
|
|
{
|
|
_draggingTextEditHandle = false;
|
|
_activeCanvas?.ReleaseMouseCapture();
|
|
_tehBox?.Focus();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
// Finish crop handle drag
|
|
if (_activeCropHandleTag is not null)
|
|
{
|
|
_activeCropHandleTag = null;
|
|
_activeCanvas?.ReleaseMouseCapture();
|
|
return;
|
|
}
|
|
|
|
// Finish annotation drag-to-move
|
|
if (_isDraggingAnnot)
|
|
{
|
|
_isDraggingAnnot = false;
|
|
_activeCanvas?.ReleaseMouseCapture();
|
|
if (_dragAnnot is not null)
|
|
{
|
|
var da = _dragAnnot;
|
|
_dragAnnot = null;
|
|
int oldPage = da.PageIndex;
|
|
// Released over a different page? Move it there (position was updated live during drag).
|
|
var drop = PageCanvasUnderPointer(e);
|
|
if (drop is { } d && d.page != oldPage && _doc is not null
|
|
&& d.page >= 0 && d.page < _doc.PageCount)
|
|
{
|
|
var pt = e.GetPosition(d.canvas);
|
|
AnnotSetPos(da, new Point(pt.X - (_dragAnnotStart.X - _dragAnnotOrigPos.X),
|
|
pt.Y - (_dragAnnotStart.Y - _dragAnnotOrigPos.Y)));
|
|
if (_annotations.TryGetValue(oldPage, out var oldList)) oldList.Remove(da);
|
|
da.PageIndex = d.page;
|
|
if (!_annotations.TryGetValue(d.page, out var newList)) { newList = []; _annotations[d.page] = newList; }
|
|
newList.Add(da);
|
|
ClearSelection();
|
|
RenderAllAnnotations(oldPage);
|
|
RenderAllAnnotations(d.page);
|
|
SelectAnnotation(da, AnnotBounds(da));
|
|
MarkDirty();
|
|
return;
|
|
}
|
|
RenderAllAnnotations(da.PageIndex);
|
|
// Keep the whole group selected after a group move (not just the dragged member).
|
|
if (da.GroupId.Length > 0) SelectGroup(da);
|
|
else SelectAnnotation(da, AnnotBounds(da));
|
|
MarkDirty();
|
|
}
|
|
_dragGroupOrig.Clear();
|
|
return;
|
|
}
|
|
|
|
// Finish text box resize
|
|
if (_isResizingSig && _resizeTextAnnot is not null)
|
|
{
|
|
_isResizingSig = false;
|
|
_activeCanvas?.ReleaseMouseCapture();
|
|
var rta = _resizeTextAnnot;
|
|
_resizeTextAnnot = null;
|
|
RenderAllAnnotations(rta.PageIndex);
|
|
SelectAnnotation(rta, new Rect(rta.Position.X, rta.Position.Y, rta.Width, rta.Height));
|
|
MarkDirty();
|
|
return;
|
|
}
|
|
|
|
// Finish highlight / strikethrough / underline resize
|
|
if (_isResizingSig && _resizeHlAnnot is not null)
|
|
{
|
|
_isResizingSig = false;
|
|
_activeCanvas?.ReleaseMouseCapture();
|
|
var rha = _resizeHlAnnot;
|
|
_resizeHlAnnot = null;
|
|
RenderAllAnnotations(rha.PageIndex);
|
|
SelectAnnotation(rha, rha.Bounds);
|
|
MarkDirty();
|
|
return;
|
|
}
|
|
|
|
// Finish ink resize
|
|
if (_isResizingSig && _resizeInkAnnot is not null)
|
|
{
|
|
_isResizingSig = false;
|
|
_activeCanvas?.ReleaseMouseCapture();
|
|
var rink = _resizeInkAnnot;
|
|
_resizeInkAnnot = null;
|
|
_resizeInkOrigPoints = null;
|
|
RenderAllAnnotations(rink.PageIndex);
|
|
SelectAnnotation(rink, AnnotBounds(rink));
|
|
MarkDirty();
|
|
return;
|
|
}
|
|
|
|
// Finish signature resize
|
|
if (_isResizingSig)
|
|
{
|
|
_isResizingSig = false;
|
|
_activeCanvas?.ReleaseMouseCapture();
|
|
if (_resizeSigAnnot is not null)
|
|
{
|
|
// Final re-render and re-select to reposition handle cleanly
|
|
var sa = _resizeSigAnnot;
|
|
_resizeSigAnnot = null;
|
|
RenderAllAnnotations(sa.PageIndex);
|
|
double newW = sa.SourceWidth * sa.Scale;
|
|
double newH = sa.SourceHeight * sa.Scale;
|
|
SelectAnnotation(sa, new Rect(sa.Position.X, sa.Position.Y, newW, newH));
|
|
MarkDirty();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Flowing text selection release (#127): commit the run, copy it, keep the quads on screen.
|
|
if (_txtSelActive)
|
|
{
|
|
(_gestureCanvas ?? _activeCanvas)?.ReleaseMouseCapture();
|
|
FinishTextSelection();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
// Handle text selection release
|
|
if (_isSelecting)
|
|
{
|
|
_isSelecting = false;
|
|
var gc = _gestureCanvas ?? _activeCanvas; // the canvas the marquee was drawn against
|
|
gc?.ReleaseMouseCapture();
|
|
// Drop the shaded marquee rectangle now that the drag is over - the selection it made is
|
|
// kept, but the box itself shouldn't linger on the page.
|
|
if (_selectRect is not null)
|
|
{
|
|
(_selectRect.Parent as Canvas)?.Children.Remove(_selectRect);
|
|
_selectRect = null;
|
|
}
|
|
var pos = e.GetPosition(gc);
|
|
double dragW = Math.Abs(pos.X - _selectStart.X);
|
|
double dragH = Math.Abs(pos.Y - _selectStart.Y);
|
|
|
|
if (_ocrRegionMode)
|
|
{
|
|
_ocrRegionMode = false;
|
|
if (dragW >= 4 && dragH >= 4)
|
|
OcrRegion(pageIdx, new Rect(Math.Min(pos.X, _selectStart.X), Math.Min(pos.Y, _selectStart.Y), dragW, dragH));
|
|
else SetStatus(Loc("Str_St_OcrRegionCanceled"));
|
|
return;
|
|
}
|
|
|
|
if (dragW < 5 && dragH < 5)
|
|
{
|
|
// Tiny drag = single click -> try annotation selection
|
|
ClearTextSelection();
|
|
bool shiftSel = (Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift;
|
|
if (pageIdx >= 0 && _annotations.ContainsKey(pageIdx))
|
|
{
|
|
for (int i = _annotations[pageIdx].Count - 1; i >= 0; i--)
|
|
{
|
|
if (HitTestAnnotation(_annotations[pageIdx][i], _selectStart, out Rect bounds))
|
|
{
|
|
if (shiftSel)
|
|
// Add/remove this annotation from the multi-selection.
|
|
ToggleMultiSelect(_annotations[pageIdx][i], bounds,
|
|
_gestureCanvas ?? CanvasForPage(pageIdx));
|
|
else
|
|
SelectAnnotation(_annotations[pageIdx][i], bounds);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var startRect = new Rect(
|
|
Math.Min(pos.X, _selectStart.X), Math.Min(pos.Y, _selectStart.Y),
|
|
dragW, dragH);
|
|
// Box-select across EVERY visible page: map the marquee (in the start page's coords) into
|
|
// each page's own coords and collect the annotations it covers. Falls back to region text
|
|
// extraction on the start page only when nothing is caught anywhere.
|
|
var hits = new List<(PageAnnotation a, Rect b, Canvas cv)>();
|
|
foreach (int p in new List<int>(_pages.Keys))
|
|
{
|
|
if (!_annotations.TryGetValue(p, out var apg) || apg.Count == 0) continue;
|
|
var pc = _pages[p];
|
|
Rect inP = MapRect(gc!, pc, startRect);
|
|
foreach (var a in apg)
|
|
{
|
|
if (!IsDraggable(a)) continue;
|
|
var ab = AnnotBounds(a);
|
|
if (!ab.IsEmpty && inP.IntersectsWith(ab)) hits.Add((a, ab, pc));
|
|
}
|
|
}
|
|
if (hits.Count > 0)
|
|
{
|
|
ClearSelection();
|
|
foreach (var (a, b, cv) in hits) ToggleMultiSelect(a, b, cv);
|
|
SetStatus(hits.Count == 1 ? Loc("Str_St_SelectedOne") : string.Format(Loc("Str_St_SelectedMany"), hits.Count));
|
|
}
|
|
else
|
|
{
|
|
ExtractTextFromRegion(pageIdx, startRect);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!_isDrawing) return;
|
|
_isDrawing = false;
|
|
_activeCanvas?.ReleaseMouseCapture();
|
|
|
|
switch (_currentTool)
|
|
{
|
|
case EditTool.Highlight when _activePreview is Rectangle:
|
|
case EditTool.Strikethrough when _activePreview is Rectangle:
|
|
case EditTool.Underline when _activePreview is Rectangle:
|
|
{
|
|
var rect = (Rectangle)_activePreview;
|
|
// Eraser mode (Highlight tool): the box deletes annotations inside it instead of
|
|
// laying down a highlight.
|
|
if (_currentTool == EditTool.Highlight && _highlightErase)
|
|
{
|
|
if (rect.Width > 3 && rect.Height > 3)
|
|
{
|
|
var region = new Rect(Canvas.GetLeft(rect), Canvas.GetTop(rect), rect.Width, rect.Height);
|
|
_activeCanvas?.Children.Remove(rect);
|
|
// Partial erase: cut strokes inside the box and subtract it from highlights.
|
|
ErasePartial(pageIdx, p => region.Contains(p), region, 4.0);
|
|
}
|
|
else _activeCanvas?.Children.Remove(rect);
|
|
}
|
|
else if (rect.Width > 3 && rect.Height > 3)
|
|
{
|
|
var ha = new HighlightAnnotation
|
|
{
|
|
PageIndex = pageIdx,
|
|
Bounds = new Rect(Canvas.GetLeft(rect), Canvas.GetTop(rect), rect.Width, rect.Height),
|
|
Style = _currentTool == EditTool.Strikethrough ? HighlightStyle.Strikethrough
|
|
: _currentTool == EditTool.Underline ? HighlightStyle.Underline
|
|
: HighlightStyle.Fill
|
|
};
|
|
ha.SetColor(_currentTool == EditTool.Highlight ? _highlightColor : _lineAnnotColor);
|
|
AddAnnotation(ha);
|
|
_activeCanvas?.Children.Remove(rect);
|
|
RenderAllAnnotations(pageIdx);
|
|
}
|
|
else
|
|
{
|
|
_activeCanvas?.Children.Remove(rect);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case EditTool.Shape when _activePreview is Shape:
|
|
// #127 Phase 3: rectangle/ellipse drag commit - see Shapes.cs.
|
|
CommitShapeDrag(pageIdx);
|
|
break;
|
|
|
|
case EditTool.Draw when _activeInk is not null:
|
|
if (_drawErase)
|
|
{
|
|
// Brush eraser: partial-erase only what the brush passes over (within its radius).
|
|
double radius = _drawWidth / 2.0;
|
|
var pts = _activeInk.Points;
|
|
_activeCanvas?.Children.Remove(_activePreview);
|
|
bool Covered(Point p)
|
|
{
|
|
if (pts.Count == 1)
|
|
return (p - pts[0]).Length <= radius;
|
|
for (int i = 0; i < pts.Count - 1; i++)
|
|
if (DistPointToSegment(p, pts[i], pts[i + 1]) <= radius) return true;
|
|
return false;
|
|
}
|
|
ErasePartial(pageIdx, Covered, null, Math.Max(1.0, radius / 2.0), pts, radius);
|
|
}
|
|
else if (_activeInk.Points.Count > 2)
|
|
{
|
|
// Commit, drop the preview, and render the stroke from _annotations - the same three
|
|
// steps the Highlight and Line cases do. Relying on the preview to "stay" only ever
|
|
// worked in single-page mode; continuous re-renders the overlay from _annotations,
|
|
// so a committed stroke that was never rendered there just disappears.
|
|
AddAnnotation(_activeInk);
|
|
_activeCanvas?.Children.Remove(_activePreview);
|
|
RenderAllAnnotations(pageIdx);
|
|
}
|
|
else
|
|
{
|
|
_activeCanvas?.Children.Remove(_activePreview);
|
|
}
|
|
_activeInk = null;
|
|
break;
|
|
|
|
case EditTool.Line when _activeInk is not null:
|
|
// Commit only a line with real length; a click without a drag is discarded.
|
|
if (_activeInk.Points.Count >= 2 &&
|
|
(Math.Abs(_activeInk.Points[1].X - _activeInk.Points[0].X) > 3 ||
|
|
Math.Abs(_activeInk.Points[1].Y - _activeInk.Points[0].Y) > 3))
|
|
{
|
|
AddAnnotation(_activeInk);
|
|
_activeCanvas?.Children.Remove(_activePreview);
|
|
RenderAllAnnotations(pageIdx);
|
|
}
|
|
else
|
|
{
|
|
_activeCanvas?.Children.Remove(_activePreview);
|
|
}
|
|
_activeInk = null;
|
|
break;
|
|
|
|
case EditTool.Crop when _activePreview is Rectangle cr:
|
|
_activeCanvas?.ReleaseMouseCapture(); // MUST release before showing handles
|
|
if (cr.Width > 10 && cr.Height > 10)
|
|
{
|
|
// Commit the new box: drop the previous one and promote the drawn rect.
|
|
if (_cropPreviewRect is not null && !ReferenceEquals(_cropPreviewRect, cr))
|
|
(_cropPreviewRect.Parent as Panel)?.Children.Remove(_cropPreviewRect);
|
|
_cropPreviewRect = cr;
|
|
_cropCanvasRect = new Rect(Canvas.GetLeft(cr), Canvas.GetTop(cr), cr.Width, cr.Height);
|
|
_activePreview = null;
|
|
Panel.SetZIndex(_cropPreviewRect, 1); // below handles (ZIndex 10)
|
|
ShowCropConfirmBar();
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
// A click without a real drag: discard the tiny drawn rect, keep the existing box,
|
|
// and restore the fields to it.
|
|
_activeCanvas?.Children.Remove(cr);
|
|
_activePreview = null;
|
|
if (_cropPreviewRect is not null)
|
|
{
|
|
_cropCanvasRect = new Rect(Canvas.GetLeft(_cropPreviewRect), Canvas.GetTop(_cropPreviewRect),
|
|
_cropPreviewRect.Width, _cropPreviewRect.Height);
|
|
SyncCropBoxInputs();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
_activePreview = null;
|
|
}
|
|
|
|
private static List<Point> DensifyPolyline(List<Point> pts, double maxSpacing)
|
|
{
|
|
var dense = new List<Point>();
|
|
if (pts.Count == 0) return dense;
|
|
dense.Add(pts[0]);
|
|
for (int i = 1; i < pts.Count; i++)
|
|
{
|
|
Point a = pts[i - 1], b = pts[i];
|
|
double d = Math.Sqrt((b.X - a.X) * (b.X - a.X) + (b.Y - a.Y) * (b.Y - a.Y));
|
|
int steps = Math.Max(1, (int)Math.Ceiling(d / Math.Max(0.5, maxSpacing)));
|
|
for (int s = 1; s <= steps; s++)
|
|
dense.Add(new Point(a.X + (b.X - a.X) * s / steps, a.Y + (b.Y - a.Y) * s / steps));
|
|
}
|
|
return dense;
|
|
}
|
|
|
|
// The parts of rect a NOT covered by rect b (rectangle subtraction), as up to four strips. Returns
|
|
// [a] when they don't overlap, and an empty list when b fully covers a.
|
|
private static List<Rect> SubtractRect(Rect a, Rect b)
|
|
{
|
|
var res = new List<Rect>();
|
|
var inter = Rect.Intersect(a, b);
|
|
if (inter.IsEmpty) { res.Add(a); return res; }
|
|
if (inter.Top > a.Top) res.Add(new Rect(a.Left, a.Top, a.Width, inter.Top - a.Top));
|
|
if (inter.Bottom < a.Bottom) res.Add(new Rect(a.Left, inter.Bottom, a.Width, a.Bottom - inter.Bottom));
|
|
if (inter.Left > a.Left) res.Add(new Rect(a.Left, inter.Top, inter.Left - a.Left, inter.Height));
|
|
if (inter.Right < a.Right) res.Add(new Rect(inter.Right, inter.Top, a.Right - inter.Right, inter.Height));
|
|
return res;
|
|
}
|
|
|
|
// True partial eraser: remove only the parts of annotations under the eraser, not whole annotations.
|
|
// Ink/line strokes are split at the covered points; highlight rectangles are subtracted (rect eraser
|
|
// only - rectRegion). Text, images, signatures and covers can't be vector-erased, so they're left
|
|
// as-is. One undoable action. 'covered' tests a single canvas point; densifySpacing controls how
|
|
// finely strokes are resampled before testing.
|
|
private void ErasePartial(int pageIdx, Func<Point, bool> covered, Rect? rectRegion, double densifySpacing,
|
|
List<Point>? brushPath = null, double brushRadius = 0)
|
|
{
|
|
if (!_annotations.TryGetValue(pageIdx, out var list)) return;
|
|
|
|
// Both erasers partial-erase VECTOR annotations only: ink strokes (split at covered points) and
|
|
// highlights (the brush carves its circular footprint; the rect eraser subtracts its box). Text,
|
|
// images, and signatures aren't vector regions an eraser can carve, so they're left untouched
|
|
// (use the Select tool + Delete for those).
|
|
bool brush = rectRegion is null;
|
|
|
|
var result = new List<PageAnnotation>();
|
|
bool changed = false;
|
|
|
|
foreach (var a in list)
|
|
{
|
|
switch (a)
|
|
{
|
|
case CoverAnnotation:
|
|
result.Add(a); // paired opaque cover - leave intact
|
|
break;
|
|
|
|
case InkAnnotation ink when ink.Points.Count >= 1:
|
|
{
|
|
var dense = DensifyPolyline(ink.Points, densifySpacing);
|
|
if (!dense.Any(covered)) { result.Add(ink); break; }
|
|
changed = true;
|
|
var runs = new List<List<Point>>();
|
|
List<Point>? cur = null;
|
|
foreach (var p in dense)
|
|
{
|
|
if (covered(p)) { if (cur is { Count: >= 2 }) runs.Add(cur); cur = null; }
|
|
else (cur ??= []).Add(p);
|
|
}
|
|
if (cur is { Count: >= 2 }) runs.Add(cur);
|
|
foreach (var run in runs)
|
|
if (CloneAnnotation(ink) is InkAnnotation seg) { seg.Points = run; result.Add(seg); }
|
|
break;
|
|
}
|
|
|
|
case HighlightAnnotation h when rectRegion is Rect er:
|
|
{
|
|
var pieces = SubtractRect(h.Bounds, er);
|
|
if (pieces.Count == 1 && pieces[0] == h.Bounds) { result.Add(h); break; }
|
|
changed = true;
|
|
foreach (var pc in pieces)
|
|
if (pc.Width >= 1 && pc.Height >= 1 && CloneAnnotation(h) is HighlightAnnotation nh)
|
|
{ nh.Bounds = pc; result.Add(nh); }
|
|
break;
|
|
}
|
|
|
|
case HighlightAnnotation h when brush:
|
|
{
|
|
if (h.Style != HighlightStyle.Fill || brushPath is not { Count: > 0 }) { result.Add(h); break; }
|
|
// Record the eraser stroke on the highlight (skip ones the stroke doesn't reach). The
|
|
// stroke is widened to the brush radius and subtracted at render/export time, giving a
|
|
// smooth anti-aliased hole instead of a blocky grid.
|
|
var hit = h.Bounds; hit.Inflate(brushRadius, brushRadius);
|
|
if (!brushPath.Any(p => hit.Contains(p))) { result.Add(h); break; }
|
|
changed = true;
|
|
if (CloneAnnotation(h) is HighlightAnnotation carved)
|
|
{
|
|
carved.Erases ??= [];
|
|
carved.Erases.Add(new HighlightErase { Points = [..brushPath], Radius = brushRadius });
|
|
result.Add(carved);
|
|
}
|
|
break;
|
|
}
|
|
|
|
default:
|
|
result.Add(a); // text / image / signature: not vector-erasable, left for Select+Delete
|
|
break;
|
|
}
|
|
}
|
|
if (!changed) return;
|
|
PushPageSnapshotUndo(pageIdx);
|
|
_annotations[pageIdx] = result;
|
|
ClearSelection();
|
|
RenderAllAnnotations(pageIdx);
|
|
MarkDirty();
|
|
SetStatus(Loc("Str_St_Erased"));
|
|
}
|
|
|
|
// Ctrl+A: multi-select every annotation on the pages currently on screen (single selected page,
|
|
// or all continuous/grid tiles), so the user can see where everything is and grab stacked items.
|
|
// Returns false when there are no annotations to select (caller falls back to text select).
|
|
private bool SelectAllAnnotations()
|
|
{
|
|
List<int> pages = _pages.Count > 0
|
|
? [.. _pages.Keys]
|
|
: [_currentPage];
|
|
ClearSelection();
|
|
int n = 0;
|
|
foreach (int p in pages)
|
|
{
|
|
if (p < 0 || !_annotations.TryGetValue(p, out var list)) continue;
|
|
var cv = CanvasForPage(p);
|
|
foreach (var a in list)
|
|
{
|
|
if (!IsDraggable(a)) continue;
|
|
var b = AnnotBounds(a);
|
|
if (b.IsEmpty) continue;
|
|
ToggleMultiSelect(a, b, cv);
|
|
n++;
|
|
}
|
|
}
|
|
if (n > 0) SetStatus(n == 1 ? Loc("Str_St_SelectedOne") : string.Format(Loc("Str_St_SelectedMany"), n));
|
|
return n > 0;
|
|
}
|
|
|
|
// Select an annotation and, if it's grouped, every other member of its group - so the whole group
|
|
// shows selected and can move together. The lead is the primary selection; the rest get outlines.
|
|
private void SelectGroup(PageAnnotation lead)
|
|
{
|
|
int pg = lead.PageIndex;
|
|
ClearSelection();
|
|
RenderAllAnnotations(pg);
|
|
_activeCanvas = CanvasForPage(pg);
|
|
SelectAnnotation(lead, AnnotBounds(lead));
|
|
// Inside a group the paired cover is itself a selected member with its own outline, so the
|
|
// dashed "paired" hint SelectAnnotation may have drawn is redundant - and it wouldn't track the
|
|
// group during a drag, leaving a stray dashed box behind. Drop it.
|
|
if (_pairedCoverOutline is not null)
|
|
{
|
|
(_pairedCoverOutline.Parent as Canvas)?.Children.Remove(_pairedCoverOutline);
|
|
_pairedCoverOutline = null;
|
|
}
|
|
if (lead.GroupId.Length > 0 && _annotations.TryGetValue(pg, out var list))
|
|
foreach (var m in list)
|
|
if (!ReferenceEquals(m, lead) && m.GroupId == lead.GroupId)
|
|
{
|
|
_selectedSet.Add(m);
|
|
AddSelectionOutline(AnnotBounds(m), _activeCanvas);
|
|
}
|
|
}
|
|
|
|
// Rebuild the multi-selection outlines at the set members' current bounds. Used while dragging a
|
|
// group, since RenderAllAnnotations wipes the canvas (and the outlines) on every tick.
|
|
private void ReattachMultiOutlines()
|
|
{
|
|
foreach (var o in _selectionOutlines) (o.Parent as Canvas)?.Children.Remove(o);
|
|
_selectionOutlines.Clear();
|
|
foreach (var a in _selectedSet)
|
|
AddSelectionOutline(AnnotBounds(a), CanvasForPage(a.PageIndex));
|
|
}
|
|
|
|
// Group every currently-selected annotation under a fresh group id (they move together afterward).
|
|
// Grouping is a layer above pairing: a text/cover pair's partner is pulled in automatically so the
|
|
// pair always groups as a unit, and ungrouping later leaves the underlying pairing untouched.
|
|
private void GroupSelected()
|
|
{
|
|
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);
|
|
foreach (var a in sel.ToList())
|
|
if (PairPartner(a) is { } p && !sel.Contains(p)) sel.Add(p);
|
|
if (sel.Count < 2) return;
|
|
PushPagesSnapshotUndo(sel.Select(a => a.PageIndex));
|
|
string gid = Guid.NewGuid().ToString("N");
|
|
foreach (var a in sel) a.GroupId = gid;
|
|
MarkDirty();
|
|
SetStatus(string.Format(Loc("Str_St_Grouped"), sel.Count));
|
|
}
|
|
|
|
// Break a group: clear the shared group id on every member.
|
|
private void UngroupAnnotation(PageAnnotation a)
|
|
{
|
|
if (a.GroupId.Length == 0) return;
|
|
PushPageSnapshotUndo(a.PageIndex);
|
|
string gid = a.GroupId;
|
|
if (_annotations.TryGetValue(a.PageIndex, out var list))
|
|
foreach (var m in list) if (m.GroupId == gid) m.GroupId = "";
|
|
MarkDirty();
|
|
SetStatus(Loc("Str_St_Ungrouped"));
|
|
}
|
|
|
|
// Drop a single annotation out of its group, leaving the rest grouped. If that leaves only one
|
|
// member, the group is meaningless, so dissolve it too. The removed item is left selected alone.
|
|
private void RemoveFromGroup(PageAnnotation a)
|
|
{
|
|
if (a.GroupId.Length == 0) return;
|
|
PushPageSnapshotUndo(a.PageIndex);
|
|
string gid = a.GroupId;
|
|
a.GroupId = "";
|
|
if (_annotations.TryGetValue(a.PageIndex, out var list))
|
|
{
|
|
var remaining = list.Where(x => x.GroupId == gid).ToList();
|
|
if (remaining.Count == 1) remaining[0].GroupId = "";
|
|
}
|
|
MarkDirty();
|
|
ClearSelection();
|
|
RenderAllAnnotations(a.PageIndex);
|
|
_activeCanvas = CanvasForPage(a.PageIndex);
|
|
SelectAnnotation(a, AnnotBounds(a));
|
|
SetStatus(Loc("Str_St_RemovedFromGroup"));
|
|
}
|
|
|
|
// The selected annotation (primary or multi-select) that belongs to a text/cover pair, if any.
|
|
private PageAnnotation? SelectedPaired()
|
|
{
|
|
if (_selectedAnnotation is not null && _selectedAnnotation.PairId.Length > 0) return _selectedAnnotation;
|
|
foreach (var a in _selectedSet) if (a.PairId.Length > 0) return a;
|
|
return null;
|
|
}
|
|
|
|
// Break text/cover pairing for every pair represented in the current selection - so it works for a
|
|
// single paired item, both halves selected together, or a group containing one or more pairs. Each
|
|
// affected cover stops rendering dashed and no longer shows when its former partner is selected.
|
|
private void UnpairSelected()
|
|
{
|
|
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);
|
|
var pids = sel.Where(a => a.PairId.Length > 0).Select(a => a.PairId).Distinct().ToHashSet();
|
|
if (pids.Count == 0) return;
|
|
PushPagesSnapshotUndo(sel.Select(a => a.PageIndex));
|
|
|
|
var pages = new HashSet<int>();
|
|
foreach (var kv in _annotations)
|
|
foreach (var x in kv.Value)
|
|
if (pids.Contains(x.PairId)) { x.PairId = ""; pages.Add(kv.Key); }
|
|
|
|
if (_pairedCoverOutline is not null)
|
|
{
|
|
(_pairedCoverOutline.Parent as Canvas)?.Children.Remove(_pairedCoverOutline);
|
|
_pairedCoverOutline = null;
|
|
}
|
|
foreach (var p in pages) RenderAllAnnotations(p);
|
|
ReattachSelectionVisuals(); // keep the current selection chrome after the repaint
|
|
MarkDirty();
|
|
SetStatus(pids.Count == 1
|
|
? "Unpaired - the text and cover are now separate"
|
|
: $"Unpaired {pids.Count} text/cover pairs");
|
|
}
|
|
|
|
// RenderAllAnnotations rebuilds the page canvas from scratch, so the selection outline and any
|
|
// resize handles (which live on that same canvas) must be re-added after a repaint.
|
|
private void ReattachSelectionVisuals()
|
|
{
|
|
if (_selectionBorder is not null) _activeCanvas.Children.Add(_selectionBorder);
|
|
if (_pairedCoverOutline is not null) _activeCanvas.Children.Add(_pairedCoverOutline); // stays put during resize
|
|
foreach (var hd in _resizeHandles) _activeCanvas.Children.Add(hd);
|
|
}
|
|
|
|
internal static bool IsDescendantOf(DependencyObject child, DependencyObject parent)
|
|
{
|
|
var current = child;
|
|
while (current != null)
|
|
{
|
|
if (current == parent) return true;
|
|
current = GetAnyParent(current);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// VisualTreeHelper.GetParent throws on non-Visuals (e.g. a Run / Hyperlink inside a TextBlock,
|
|
// which is what the footer and About text are made of). Walk the logical tree for those and the
|
|
// visual tree otherwise, so a click on inline text never crashes the outside-click dismissers.
|
|
private static DependencyObject? GetAnyParent(DependencyObject d)
|
|
{
|
|
if (d is Visual || d is System.Windows.Media.Media3D.Visual3D)
|
|
return VisualTreeHelper.GetParent(d);
|
|
return LogicalTreeHelper.GetParent(d);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if <paramref name="element"/> is inside a form field overlay control
|
|
/// (tagged with <see cref="FormOverlayTag"/>). Used to let WPF handle mouse events
|
|
/// for TextBox, CheckBox, RadioButton, and ComboBox controls natively.
|
|
/// </summary>
|
|
private static bool IsFormFieldElement(DependencyObject element)
|
|
{
|
|
var current = element;
|
|
while (current != null)
|
|
{
|
|
if (current is FrameworkElement fe && fe.Tag as string == FormOverlayTag)
|
|
return true;
|
|
current = VisualTreeHelper.GetParent(current);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Abort an in-progress drag or resize and release the canvas's mouse capture. Used when the gesture
|
|
// can't be finished normally - the mouse-up was lost because the app was backgrounded mid-drag, or
|
|
// the window deactivated. Leaves the annotation selected at its last position so it stays usable.
|
|
private void FinishStuckGesture()
|
|
{
|
|
PageAnnotation? keep = _dragAnnot;
|
|
keep ??= _resizeTextAnnot;
|
|
keep ??= _resizeHlAnnot;
|
|
keep ??= _resizeInkAnnot;
|
|
keep ??= _resizeSigAnnot;
|
|
|
|
_isDraggingAnnot = false;
|
|
_isResizingSig = false;
|
|
_dragAnnot = null;
|
|
_resizeTextAnnot = null;
|
|
_resizeHlAnnot = null;
|
|
_resizeInkAnnot = null;
|
|
_resizeSigAnnot = null;
|
|
_resizeInkOrigPoints = null;
|
|
_dragGroupOrig.Clear();
|
|
|
|
// Release whatever element grabbed the mouse for this gesture (and the active canvas, in case).
|
|
if (Mouse.Captured is FrameworkElement cap) cap.ReleaseMouseCapture();
|
|
_activeCanvas?.ReleaseMouseCapture();
|
|
|
|
if (keep is not null)
|
|
{
|
|
RenderAllAnnotations(keep.PageIndex);
|
|
SelectAnnotation(keep, AnnotBounds(keep));
|
|
MarkDirty();
|
|
}
|
|
}
|
|
|
|
private void AddAnnotation(PageAnnotation annotation)
|
|
{
|
|
if (!_annotations.ContainsKey(annotation.PageIndex))
|
|
_annotations[annotation.PageIndex] = [];
|
|
_annotations[annotation.PageIndex].Add(annotation);
|
|
PushUndo(new UndoEntry(UndoKind.Annotation, annotation.PageIndex, WasDirty: _isDirty, Annot: annotation));
|
|
MarkDirty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves the current in-memory document bytes onto the undo stack so that
|
|
/// document-level operations (crop, delete page, merge, reorder) can be undone.
|
|
/// Must be called BEFORE modifying _doc.
|
|
/// </summary>
|
|
private void PushDocUndo()
|
|
{
|
|
if (_doc is null) return;
|
|
using var ms = new System.IO.MemoryStream();
|
|
_doc.Save(ms);
|
|
PushUndo(new UndoEntry(UndoKind.Document, DocBytes: ms.ToArray(), WasDirty: _isDirty));
|
|
}
|
|
|
|
// Snapshot one page's annotations (deep clones) onto the undo stack so an in-place edit - erase,
|
|
// group/ungroup, layer reorder, unpair, delete - reverts with a single Ctrl+Z. Call BEFORE mutating.
|
|
private void PushPageSnapshotUndo(int pageIdx) => PushPagesSnapshotUndo([pageIdx]);
|
|
|
|
// Multi-page variant (e.g. deleting a selection that spans pages).
|
|
private void PushPagesSnapshotUndo(IEnumerable<int> pages)
|
|
{
|
|
var snap = new Dictionary<int, List<PageAnnotation>>();
|
|
foreach (int p in pages.Distinct())
|
|
if (_annotations.TryGetValue(p, out var list))
|
|
snap[p] = [.. list.Select(CloneAnnotation).Where(c => c is not null).Cast<PageAnnotation>()];
|
|
if (snap.Count > 0)
|
|
PushUndo(new UndoEntry(UndoKind.PageSnapshot, WasDirty: _isDirty, AnnotSnapshot: snap));
|
|
}
|
|
|
|
private void Undo_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
// Commit any in-progress text box first so its annotation is on the undo stack and the
|
|
// order is deterministic. Otherwise the text box commits asynchronously (LostFocus fires
|
|
// when a re-render clears the canvas), which races the undo and makes a press appear to
|
|
// do nothing - the cause of "second Ctrl+Z did nothing" after placing several texts.
|
|
if (_activeTextBox is not null)
|
|
CommitActiveTextBox();
|
|
|
|
if (_undoStack.Count == 0)
|
|
{
|
|
SetStatus(Loc("Str_St_NothingToUndo"));
|
|
return;
|
|
}
|
|
|
|
var entry = _undoStack.Pop();
|
|
// Capture what this undo is about to overwrite, so Ctrl+Y can bring it back.
|
|
var inverse = CaptureInverse(entry);
|
|
if (ApplyHistoryEntry(entry) && inverse is { } inv)
|
|
_redoStack.Push(inv);
|
|
}
|
|
|
|
/// <summary>Redo (Ctrl+Y / Ctrl+Shift+Z): re-applies the last undone action. Any new edit
|
|
/// clears the redo stack (see PushUndo), so redo only chains directly after undos.</summary>
|
|
private void Redo_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_activeTextBox is not null)
|
|
CommitActiveTextBox();
|
|
if (_redoStack.Count == 0)
|
|
{
|
|
SetStatus(Loc("Str_St_NothingToRedo"));
|
|
return;
|
|
}
|
|
var entry = _redoStack.Pop();
|
|
var inverse = CaptureInverse(entry);
|
|
// Straight onto the undo stack - NOT PushUndo, which would clear the redo chain.
|
|
if (ApplyHistoryEntry(entry) && inverse is { } inv)
|
|
_undoStack.Push(inv);
|
|
SetStatus(Loc("Str_St_Redone"));
|
|
}
|
|
|
|
/// <summary>The state that applying <paramref name="entry"/> will overwrite, captured as a
|
|
/// history entry of its own: a document byte snapshot for document-level entries, a cloned
|
|
/// page-annotation snapshot for every annotation-level kind. Applying the returned entry
|
|
/// restores exactly what was there, which is what makes undo and redo symmetric.</summary>
|
|
private UndoEntry? CaptureInverse(UndoEntry entry)
|
|
{
|
|
try
|
|
{
|
|
if (entry.Kind == UndoKind.Document)
|
|
{
|
|
if (_doc is null) return null;
|
|
using var ms = new System.IO.MemoryStream();
|
|
_doc.Save(ms);
|
|
return new UndoEntry(UndoKind.Document, DocBytes: ms.ToArray(), WasDirty: _isDirty);
|
|
}
|
|
int[] pages = entry.Kind switch
|
|
{
|
|
UndoKind.Annotation or UndoKind.AnnotationGroup => [entry.PageIdx],
|
|
UndoKind.StampBatch => entry.Pages ?? [],
|
|
_ => entry.AnnotSnapshot is null ? [] : [.. entry.AnnotSnapshot.Keys],
|
|
};
|
|
var snap = new Dictionary<int, List<PageAnnotation>>();
|
|
foreach (int p in pages.Distinct())
|
|
snap[p] = _annotations.TryGetValue(p, out var list)
|
|
? [.. list.Select(CloneAnnotation).Where(c => c is not null).Cast<PageAnnotation>()]
|
|
: [];
|
|
return new UndoEntry(UndoKind.PageSnapshot, WasDirty: _isDirty, AnnotSnapshot: snap);
|
|
}
|
|
catch { return null; } // a failed snapshot must never block the undo itself
|
|
}
|
|
|
|
// Applies a history entry - the shared engine behind undo AND redo. Returns false when the
|
|
// entry cannot be applied (missing document bytes) so the caller skips pushing its inverse.
|
|
private bool ApplyHistoryEntry(UndoEntry entry)
|
|
{
|
|
if (entry.Kind == UndoKind.Annotation)
|
|
{
|
|
int pageIdx = entry.PageIdx;
|
|
if (_annotations.TryGetValue(pageIdx, out var pageList) && pageList.Count > 0)
|
|
{
|
|
// Remove the exact annotation this entry recorded (not just the last one in the
|
|
// list), so undo stays correct even when annotations were re-edited or reordered.
|
|
if (entry.Annot is not null)
|
|
pageList.Remove(entry.Annot);
|
|
else
|
|
pageList.RemoveAt(pageList.Count - 1);
|
|
}
|
|
ClearSelection();
|
|
RenderAllAnnotations(pageIdx);
|
|
MarkDirty(entry.WasDirty);
|
|
SetStatus(Loc("Str_St_UndidAnnotation"));
|
|
}
|
|
else if (entry.Kind == UndoKind.AnnotationGroup && entry.AnnotGroup is not null)
|
|
{
|
|
// A grouped edit (text cover + replacement text). Remove the exact annotations recorded,
|
|
// so one Ctrl+Z cancels the whole edit.
|
|
int pageIdx = entry.PageIdx;
|
|
if (_annotations.TryGetValue(pageIdx, out var pageList))
|
|
foreach (var a in entry.AnnotGroup) pageList.Remove(a);
|
|
ClearSelection();
|
|
RenderAllAnnotations(pageIdx);
|
|
MarkDirty(entry.WasDirty);
|
|
SetStatus(Loc("Str_St_UndidTextEdit"));
|
|
}
|
|
else if (entry.Kind == UndoKind.StampBatch && entry.Pages is not null)
|
|
{
|
|
// Page-number stamping adds one annotation per page as a single action; remove the
|
|
// last annotation from each stamped page in one undo.
|
|
foreach (int p in entry.Pages)
|
|
if (_annotations.TryGetValue(p, out var list) && list.Count > 0)
|
|
list.RemoveAt(list.Count - 1);
|
|
ClearSelection();
|
|
foreach (int p in entry.Pages)
|
|
if (_pages.ContainsKey(p)) // authoritative map: covers the primary tile in Grid/Two-Page too
|
|
RenderAllAnnotations(p);
|
|
MarkDirty(entry.WasDirty);
|
|
SetStatus(Loc("Str_St_RemovedStamps"));
|
|
}
|
|
else if (entry.Kind == UndoKind.ClearAnnotations && entry.AnnotSnapshot is not null)
|
|
{
|
|
// Restore every page's annotations that the clear removed, in one undo.
|
|
foreach (var kv in entry.AnnotSnapshot)
|
|
_annotations[kv.Key] = [.. kv.Value];
|
|
ClearSelection();
|
|
RenderAnnotationsOnAllVisiblePages();
|
|
MarkDirty(entry.WasDirty);
|
|
SetStatus(Loc("Str_St_RestoredAnnotations"));
|
|
}
|
|
else if (entry.Kind == UndoKind.PageSnapshot && entry.AnnotSnapshot is not null)
|
|
{
|
|
// Restore the affected page(s) to their pre-edit state - reverts erase, group/ungroup,
|
|
// layer reorder, unpair or delete in one Ctrl+Z. The snapshot holds deep clones.
|
|
foreach (var kv in entry.AnnotSnapshot)
|
|
_annotations[kv.Key] = [.. kv.Value];
|
|
ClearSelection();
|
|
RenderAnnotationsOnAllVisiblePages();
|
|
MarkDirty(entry.WasDirty);
|
|
SetStatus(Loc("Str_St_Undone"));
|
|
}
|
|
else // Document snapshot
|
|
{
|
|
if (entry.DocBytes is null) return false;
|
|
int selectedIdx = _currentPage;
|
|
var tempPath = App.MakeTempFile("undo");
|
|
System.IO.File.WriteAllBytes(tempPath, entry.DocBytes);
|
|
_doc?.Close();
|
|
// PdfSharpCore can write a snapshot whose xref offset points at the xref table,
|
|
// producing "Unexpected token 'xref'" on reopen. Repair via Import (preserves
|
|
// rotations) then PDFium, mirroring the save/reload path, instead of crashing.
|
|
try
|
|
{
|
|
_doc = PdfReader.Open(tempPath, PdfDocumentOpenMode.Modify);
|
|
}
|
|
catch (Exception undoOpenEx) when (PdfImport.IsXRefException(undoOpenEx))
|
|
{
|
|
var fixedPath = App.MakeTempFile("undofixed");
|
|
if (!PdfImport.TryImportRepairToPath(tempPath, fixedPath)
|
|
&& !PdfiumInterop.TryPdfiumSaveWithZeroRotations(tempPath, fixedPath))
|
|
throw;
|
|
tempPath = fixedPath;
|
|
_doc = PdfReader.Open(tempPath, PdfDocumentOpenMode.Modify);
|
|
}
|
|
_currentFile = tempPath;
|
|
_annotations.Clear();
|
|
_renderDims.Clear();
|
|
ClearSelection();
|
|
MarkDirty(entry.WasDirty);
|
|
RefreshPageList();
|
|
LoadOutlines(); // #133: bookmark edits ride this undo path, and page-level undos can change the outline too
|
|
if (selectedIdx >= 0 && selectedIdx < _doc!.PageCount)
|
|
_currentPage = selectedIdx;
|
|
else if (_doc.PageCount > 0)
|
|
_currentPage = 0;
|
|
// Re-render the current view so the main page(s) reflect the restored document.
|
|
// RefreshPageList only updates the sidebar, and re-selecting the same page does not
|
|
// fire SelectionChanged, so grid/two-page tiles would otherwise stay stale.
|
|
int reIdx = _currentPage;
|
|
if (_viewMode == ViewMode.Continuous)
|
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
|
|
(Action)(() => SetupContinuousView(reIdx)));
|
|
else
|
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() =>
|
|
{
|
|
RenderPage(_viewMode == ViewMode.Grid ? 0 : reIdx);
|
|
ReapplyGridOrFit();
|
|
}));
|
|
SetStatus(Loc("Str_St_UndidDocChange"));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Every NEW user action pushes history through here: fresh edits invalidate the redo chain
|
|
// (the branched future can no longer be replayed safely).
|
|
private void PushUndo(UndoEntry entry)
|
|
{
|
|
_undoStack.Push(entry);
|
|
_redoStack.Clear();
|
|
}
|
|
|
|
// Re-renders annotations on every page that currently has a visible surface: each overlay
|
|
// tracked by a multi-page view, plus the single-page canvas. RenderAllAnnotations re-adds
|
|
// form fields, so forms survive the refresh.
|
|
private void RenderAnnotationsOnAllVisiblePages()
|
|
{
|
|
if (_continuousCanvases.Count > 0)
|
|
foreach (var p in _continuousCanvases.Keys.ToList())
|
|
RenderAllAnnotations(p);
|
|
else if (_currentPage >= 0)
|
|
RenderAllAnnotations(_currentPage);
|
|
}
|
|
|
|
// Context-menu "Clear Page Annotations": removes annotations on the current page only.
|
|
private void ClearAnnotations_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_activeTextBox is not null) CommitActiveTextBox();
|
|
int pageIdx = _currentPage;
|
|
if (pageIdx < 0) return;
|
|
if (_annotations.TryGetValue(pageIdx, out var list) && list.Count > 0)
|
|
{
|
|
// Snapshot this page so the clear is a single undo, then drop the annotations.
|
|
var snap = new Dictionary<int, List<PageAnnotation>> { [pageIdx] = [.. list] };
|
|
PushUndo(new UndoEntry(UndoKind.ClearAnnotations, WasDirty: _isDirty, AnnotSnapshot: snap));
|
|
_annotations.Remove(pageIdx);
|
|
MarkDirty();
|
|
}
|
|
ClearSelection();
|
|
// Redraw the page on whichever surface it lives on (overlay in multi-page views, the
|
|
// single canvas otherwise) so the cleared page actually updates in continuous/grid mode.
|
|
RenderAllAnnotations(pageIdx);
|
|
SetStatus(Loc("Str_St_ClearedPageAnnotations"));
|
|
}
|
|
|
|
// Toolbar "Clear All Annotations": removes annotations across the whole document in one undo.
|
|
private void ClearAllAnnotations_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_doc is null) return;
|
|
if (_activeTextBox is not null) CommitActiveTextBox();
|
|
|
|
int total = _annotations.Values.Sum(l => l.Count);
|
|
if (total == 0) { SetStatus(Loc("Str_St_NoAnnotationsToClear")); return; }
|
|
|
|
// Snapshot every page so a single Ctrl+Z restores the whole document's annotations.
|
|
var snapshot = new Dictionary<int, List<PageAnnotation>>();
|
|
foreach (var kv in _annotations)
|
|
if (kv.Value.Count > 0) snapshot[kv.Key] = [.. kv.Value];
|
|
PushUndo(new UndoEntry(UndoKind.ClearAnnotations, WasDirty: _isDirty, AnnotSnapshot: snapshot));
|
|
|
|
_annotations.Clear();
|
|
ClearSelection();
|
|
RenderAnnotationsOnAllVisiblePages();
|
|
MarkDirty();
|
|
SetStatus(string.Format(Loc("Str_St_ClearedAll"), total));
|
|
}
|
|
|
|
// onlyPage: when set, burns just that one page's annotations (used by Transform, which rasterizes a
|
|
// single page and wants its annotations baked in). Default null burns every page, as before.
|
|
private void DrawAnnotationsOnDocument(int? onlyPage = null)
|
|
=> PdfBurn.DrawAnnotationsIntoDoc(_doc, _annotations, _renderDims, onlyPage, _pageRotations);
|
|
|
|
// The burn core (DrawAnnotationsIntoDoc) lives in Services/PdfBurn.cs.
|
|
}
|
|
}
|