vendor: import KillerPDF 1.7.5 source (GPL-3.0) as the base for MMD PDF
This commit is contained in:
@@ -0,0 +1,714 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using Docnet.Core;
|
||||
using Docnet.Core.Models;
|
||||
using Microsoft.Win32;
|
||||
using PdfSharpCore.Drawing;
|
||||
using PdfSharpCore.Pdf;
|
||||
using PdfSharpCore.Pdf.IO;
|
||||
using KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF
|
||||
{
|
||||
public partial class MainWindow
|
||||
{
|
||||
// ============================================================
|
||||
// Context menu
|
||||
// ============================================================
|
||||
|
||||
private void ApplyGrainTexture()
|
||||
{
|
||||
// Film grain with a mix of bright AND dark specks so the texture reads
|
||||
// on any background - bright specks show on dark themes, dark specks
|
||||
// show on light themes. Denser and a touch stronger than the first pass.
|
||||
const int size = 256;
|
||||
var bmp = new WriteableBitmap(size, size, 96, 96, PixelFormats.Bgra32, null);
|
||||
var pixels = new byte[size * size * 4]; // start fully transparent
|
||||
var rng = new Random(1337);
|
||||
for (int i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
if (rng.Next(3) != 0) continue; // ~33% pixel density
|
||||
bool bright = rng.Next(2) == 0; // half bright, half dark
|
||||
byte v = bright ? (byte)rng.Next(190, 255) : (byte)rng.Next(0, 50);
|
||||
byte a = (byte)rng.Next(35, 95); // alpha for subtlety
|
||||
pixels[i] = v;
|
||||
pixels[i + 1] = v;
|
||||
pixels[i + 2] = v;
|
||||
pixels[i + 3] = a;
|
||||
}
|
||||
bmp.WritePixels(new System.Windows.Int32Rect(0, 0, size, size), pixels, size * 4, 0);
|
||||
// BOTH panes by name, not through GrainBrush - that member resolves to ActiveViewer, so
|
||||
// only the focused pane ever got the texture and pane B rendered flat.
|
||||
Viewer.Grain.ImageSource = bmp;
|
||||
ViewerB.Grain.ImageSource = bmp;
|
||||
// TryFindResource, NOT the Resources indexer: the indexer looks ONLY in this window's
|
||||
// own dictionary and returns null without complaint if the key lives higher up.
|
||||
// A key defined above this window (GrainBrushShared can be) is invisible to the
|
||||
// indexer, and the failure is silent - the grain simply never gets its texture.
|
||||
// TryFindResource walks window -> app, so it works wherever the key is defined.
|
||||
if (TryFindResource("GrainBrushShared") is ImageBrush sharedGrain) sharedGrain.ImageSource = bmp;
|
||||
StatusGrainBrush.ImageSource = bmp;
|
||||
// The family flyout standard's grain tile (FlyoutGrain style) shares the same texture.
|
||||
// GrainTileBrush lives at APPLICATION scope so standalone windows (the file picker)
|
||||
// resolve it too - and app-level Freezables are frozen, so it cannot be mutated in
|
||||
// place like the window-scoped brushes above. Build a finished frozen brush and
|
||||
// REPLACE the dictionary entry; the swap retriggers every DynamicResource reference.
|
||||
var tileSource = System.Windows.Media.Imaging.BitmapFrame.Create(bmp);
|
||||
tileSource.Freeze(); // a WriteableBitmap itself cannot freeze; its frame can
|
||||
var tile = new ImageBrush(tileSource)
|
||||
{
|
||||
TileMode = TileMode.Tile,
|
||||
ViewportUnits = BrushMappingMode.Absolute,
|
||||
Viewport = new Rect(0, 0, size, size),
|
||||
Stretch = Stretch.None,
|
||||
};
|
||||
tile.Freeze();
|
||||
Application.Current.Resources["GrainTileBrush"] = tile;
|
||||
}
|
||||
|
||||
/// <summary>Generated film-grain tile, exposed so secondary windows (e.g. the
|
||||
/// print preview) can paint the same texture over their document area.</summary>
|
||||
public ImageSource? GrainTexture => GrainBrush?.ImageSource;
|
||||
|
||||
// One shared ContextMenu instance; its items are rebuilt on every open to match whatever is
|
||||
// under the cursor (a specific annotation -> per-type actions; empty page -> page-level actions).
|
||||
private ContextMenu _ctxMenu = null!;
|
||||
|
||||
private void BuildContextMenu()
|
||||
{
|
||||
_ctxMenu = MakeThemedMenu();
|
||||
// BOTH panes' primary canvases (2026-08-15): `_annotationCanvas` here bridged to the
|
||||
// ACTIVE viewer, which is pane A at startup - pane B's canvas never got the menu and
|
||||
// right-click did nothing there. One shared menu instance is fine: WPF opens it
|
||||
// against whichever element raised it.
|
||||
Viewer.AttachContextMenuExt(_ctxMenu);
|
||||
ViewerB.AttachContextMenuExt(_ctxMenu);
|
||||
}
|
||||
|
||||
// Rebuild the shared context menu for a right-click at canvas point pt on the given page. If an
|
||||
// annotation sits under the cursor it is selected and gets a menu tailored to its type; otherwise
|
||||
// the page-level menu (tools, rotate, stamp, undo, clear) is shown.
|
||||
internal void PopulateContextMenu(Point pt, int pageIdx)
|
||||
{
|
||||
_ctxMenu.Items.Clear();
|
||||
var hit = AnnotationAt(pt, pageIdx);
|
||||
if (hit is not null)
|
||||
{
|
||||
// Target this annotation: select it unless it's already part of the current selection
|
||||
// (so right-clicking one of several selected items doesn't collapse the multi-selection).
|
||||
// A grouped annotation selects its whole group so the menu acts on the group.
|
||||
if (!ReferenceEquals(hit, _selectedAnnotation) && !_selectedSet.Contains(hit))
|
||||
{
|
||||
if (hit.GroupId.Length > 0)
|
||||
SelectGroup(hit);
|
||||
else
|
||||
{
|
||||
ClearSelection();
|
||||
RenderAllAnnotations(pageIdx);
|
||||
SelectAnnotation(hit, AnnotBounds(hit));
|
||||
}
|
||||
}
|
||||
AddAnnotationMenuItems(hit);
|
||||
}
|
||||
// Tiled views (continuous/grid/two-page) have no clickable link overlay, so resolve a link
|
||||
// under the cursor by bounds-check and offer the same menu the single-page overlay carries.
|
||||
else if (LinkAt(pt, pageIdx) is { } lk)
|
||||
AddLinkMenuItems(_ctxMenu, lk.target, lk.annotIndex, pageIdx);
|
||||
else AddPageMenuItems(pt, pageIdx);
|
||||
}
|
||||
|
||||
// Topmost draggable annotation under pt on the given page, or null. Mirrors the click-select loop
|
||||
// (reverse order = topmost first) so the menu targets exactly what a left-click would select.
|
||||
private PageAnnotation? AnnotationAt(Point pt, int pageIdx)
|
||||
{
|
||||
if (pageIdx < 0 || !_annotations.TryGetValue(pageIdx, out var list)) return null;
|
||||
for (int i = list.Count - 1; i >= 0; i--)
|
||||
if (IsDraggable(list[i]) && HitTestAnnotation(list[i], pt, out _)) return list[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
// Link under pt on the given page, resolved by the same padded bounds-check the click/hover use.
|
||||
// Tiled views (continuous/grid/two-page) have no clickable link overlay, so _continuousLinks is the
|
||||
// source of truth there; single-page links carry their own overlay ContextMenu and never reach here.
|
||||
private (object target, int annotIndex)? LinkAt(Point pt, int pageIdx)
|
||||
{
|
||||
if (!_continuousLinks.TryGetValue(pageIdx, out var links)) return null;
|
||||
const double pad = LinkHitPad; // matches the click/hover pad so the menu targets the same links
|
||||
foreach (var l in links)
|
||||
if (pt.X >= l.Cx - pad && pt.X <= l.Cx + l.Cw + pad &&
|
||||
pt.Y >= l.Cy - pad && pt.Y <= l.Cy + l.Ch + pad)
|
||||
return (l.Tag, l.AnnotIndex);
|
||||
return null;
|
||||
}
|
||||
|
||||
// A short human label for the kind of annotation right-clicked, shown as a disabled menu header.
|
||||
private static string AnnotationKindLabel(PageAnnotation a) => a switch
|
||||
{
|
||||
CoverAnnotation => "Cover",
|
||||
TextAnnotation => "Text box",
|
||||
InkAnnotation => "Drawing",
|
||||
SignatureAnnotation => "Signature",
|
||||
ImageAnnotation => "Image",
|
||||
HighlightAnnotation h => h.Style switch
|
||||
{
|
||||
HighlightStyle.Strikethrough => "Strikethrough",
|
||||
HighlightStyle.Underline => "Underline",
|
||||
_ => "Highlight"
|
||||
},
|
||||
_ => "Annotation"
|
||||
};
|
||||
|
||||
// Per-type menu for a clicked annotation. Page-level items (tools, rotate, stamp) are omitted.
|
||||
private void AddAnnotationMenuItems(PageAnnotation hit)
|
||||
{
|
||||
bool multi = SelectionCount() > 1;
|
||||
|
||||
// Edit sits at the top of every single-annotation menu (completes the menu). For a text box it
|
||||
// lifts the text into an editable box; for other types it just ensures the editing bar is open.
|
||||
if (!multi)
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Edit"), (s, e) => EditAnnotation(hit), glyph: ""));
|
||||
|
||||
// Raise / Lower one visual layer. Enabled only when something actually overlaps in that
|
||||
// direction (so they're grayed out when nothing is stacked above / below at this spot).
|
||||
int idx = -1;
|
||||
_annotations.TryGetValue(hit.PageIndex, out var pageList);
|
||||
if (pageList is not null) idx = pageList.IndexOf(hit);
|
||||
var raise = MakeMenuItem(Loc("Str_Ctx_Raise"), (s, e) => MoveAnnotationLayer(hit, +1), glyph: "");
|
||||
raise.IsEnabled = pageList is not null && idx >= 0 && OverlapNeighbor(pageList, idx, hit, +1) >= 0;
|
||||
_ctxMenu.Items.Add(raise);
|
||||
var lower = MakeMenuItem(Loc("Str_Ctx_Lower"), (s, e) => MoveAnnotationLayer(hit, -1), glyph: "");
|
||||
lower.IsEnabled = pageList is not null && idx >= 0 && OverlapNeighbor(pageList, idx, hit, -1) >= 0;
|
||||
_ctxMenu.Items.Add(lower);
|
||||
|
||||
// Pairing: on a single paired item, offer to jump to its other half. Unpair shows whenever the
|
||||
// selection contains a pair - including when both halves are selected together (or as a group).
|
||||
if (hit.PairId.Length > 0 && !multi)
|
||||
{
|
||||
var partner = PairPartner(hit);
|
||||
if (partner is not null)
|
||||
_ctxMenu.Items.Add(MakeMenuItem(
|
||||
Loc(partner is CoverAnnotation ? "Str_Ctx_SelectCover" : "Str_Ctx_SelectText"),
|
||||
(s, e) => SelectPartner(hit), glyph: ""));
|
||||
}
|
||||
if (SelectedPaired() is not null)
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Unpair"), (s, e) => UnpairSelected(), glyph: ""));
|
||||
|
||||
// Grouping: group an ad-hoc multi-selection, or - on a grouped item - drop just this one
|
||||
// (Remove from group) or dissolve the whole group (Ungroup).
|
||||
if (hit.GroupId.Length > 0)
|
||||
{
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RemoveFromGroup"), (s, e) => RemoveFromGroup(hit), glyph: ""));
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Ungroup"), (s, e) => UngroupAnnotation(hit), glyph: ""));
|
||||
}
|
||||
else if (multi)
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Group"), (s, e) => GroupSelected(), glyph: ""));
|
||||
|
||||
_ctxMenu.Items.Add(new Separator());
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Copy"), (s, e) => CopySelectedAnnotations(), "Ctrl+C", ""));
|
||||
if (_annotationClipboard.Count > 0)
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Paste"), (s, e) => PasteAnnotations(hit.PageIndex), "Ctrl+V", ""));
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_DeleteSel"), (s, e) => DeleteSelected(), "Delete", ""));
|
||||
}
|
||||
|
||||
// Page-level menu shown when the right-click lands on empty page space (no annotation under it).
|
||||
// Tailored to the click: Copy Text only over a text selection, the placement actions drop their
|
||||
// item where the user right-clicked, and delete is "Delete Selected" when something is selected
|
||||
// (otherwise "Delete Page").
|
||||
private void AddPageMenuItems(Point pt, int pageIdx)
|
||||
{
|
||||
bool hasSelection = SelectionCount() > 0;
|
||||
bool hasTextSel = !string.IsNullOrEmpty(_selectedText);
|
||||
|
||||
// Copy Text only when there is actually selected text to copy.
|
||||
if (hasTextSel)
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CopyText"), (s, e) => CopySelectedText(), "Ctrl+C", ""));
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Print"), (s, e) => Print_Click(s!, e), "Ctrl+P", ""));
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_OcrPage"), (s, e) => OcrPageToClipboard(pageIdx), "Ctrl+Shift+O", ""));
|
||||
_ctxMenu.Items.Add(new Separator());
|
||||
|
||||
// Placement actions - drop the item exactly where the user right-clicked (Select / Highlight /
|
||||
// Draw were removed: they only make sense as toolbar modes, not as one-shot right-click actions).
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Lbl_Text"), (s, e) =>
|
||||
{
|
||||
SetTool(EditTool.Text);
|
||||
_activeCanvas = CanvasForPage(pageIdx);
|
||||
PlaceTextBox(pt, pageIdx);
|
||||
}, "T", ""));
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Lbl_Image"), (s, e) =>
|
||||
{
|
||||
_activeCanvas = CanvasForPage(pageIdx);
|
||||
PlaceImageFromDialog(pt, pageIdx);
|
||||
}, "I", ""));
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Lbl_Signature"), (s, e) =>
|
||||
{
|
||||
_activeCanvas = CanvasForPage(pageIdx);
|
||||
if (_pendingSignature is not null) PlaceSignature(pt, pageIdx);
|
||||
else { SetTool(EditTool.Signature); ShowSignaturePopup(); }
|
||||
}, "G", ""));
|
||||
_ctxMenu.Items.Add(new Separator());
|
||||
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Lbl_Rotate"), (s, e) => OpenTransformWindow(), "R", ""));
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RotateCW"), (s, e) => RotatePages_Click(90), glyph: ""));
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RotateCCW"), (s, e) => RotatePages_Click(-90), glyph: ""));
|
||||
_ctxMenu.Items.Add(new Separator());
|
||||
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_DuplicatePage"), (s, e) => DuplicatePage(pageIdx), glyph: ""));
|
||||
// Delete Selected when something is selected; otherwise Delete Page (the page under the cursor,
|
||||
// which the right-click already made the selected page).
|
||||
if (hasSelection)
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_DeleteSel"), (s, e) => DeleteSelected(), "Delete", ""));
|
||||
else
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_DeletePage"), (s, e) => Delete_Click(s!, e), glyph: ""));
|
||||
_ctxMenu.Items.Add(new Separator());
|
||||
|
||||
if (_annotationClipboard.Count > 0)
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Paste"), (s, e) => PasteAnnotations(pageIdx), "Ctrl+V", ""));
|
||||
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_StampPages"), (s, e) => OpenStampTool(), "S", ""));
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_UndoLast"), (s, e) => Undo_Click(s!, e), "Ctrl+Z", ""));
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Redo"), (s, e) => Redo_Click(s!, e), "Ctrl+Y", ""));
|
||||
_ctxMenu.Items.Add(MakeMenuItem(Loc("Str_Ctx_ClearPage"), (s, e) => ClearAnnotations_Click(s!, e), glyph: ""));
|
||||
AppendBookLayoutItem(_ctxMenu);
|
||||
}
|
||||
|
||||
// Book layout changes the whole two-page viewport, so it belongs on both the page menu and
|
||||
// the surrounding-canvas menu. Keeping the item construction here prevents the two menus
|
||||
// from drifting apart (including the live check state and keyboard hint).
|
||||
private void AppendBookLayoutItem(ContextMenu menu)
|
||||
{
|
||||
if (_viewMode != ViewMode.TwoPage) return;
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
var book = MakeMenuItem(Loc("Str_View_BookMode"), (s, e) => ToggleBookMode(), "B", "");
|
||||
book.IsCheckable = true;
|
||||
book.IsChecked = Controls.PdfViewer.BookMode;
|
||||
menu.Items.Add(book);
|
||||
}
|
||||
|
||||
// Deep-copies page pageIdx and inserts the copy right after it. AddPage on a same-document page
|
||||
// would share the reference rather than duplicate, so the page is re-imported from an in-memory
|
||||
// copy of the document (the same round-trip the undo snapshot uses).
|
||||
private void DuplicatePage(int pageIdx)
|
||||
{
|
||||
if (_doc is null || pageIdx < 0 || pageIdx >= _doc.PageCount) return;
|
||||
var doc = _doc;
|
||||
try
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
doc.Save(ms, false);
|
||||
ms.Position = 0;
|
||||
using var src = PdfReader.Open(ms, PdfDocumentOpenMode.Import);
|
||||
var copy = doc.AddPage(src.Pages[pageIdx]); // imported copy, appended at the end
|
||||
doc.Pages.RemoveAt(doc.PageCount - 1);
|
||||
doc.Pages.Insert(pageIdx + 1, copy);
|
||||
SaveTempAndReload();
|
||||
PageList.SelectedIndex = pageIdx + 1;
|
||||
SetStatus(string.Format(Loc("Str_St_DuplicatedPage"), pageIdx + 1));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
KillerDialog.Show(this, Loc("Str_Err_DuplicateFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
// Nearest annotation that visually overlaps `a`, searching up (dir>0) or down (dir<0) the page's
|
||||
// z-order list from a's index. Returns its list index, or -1 if nothing overlaps in that direction.
|
||||
// "Layer" order is judged by what actually sits on top of / under a at its location, so Raise/Lower
|
||||
// step past only the things stacked with it - not unrelated annotations elsewhere on the page.
|
||||
private int OverlapNeighbor(List<PageAnnotation> list, int i, PageAnnotation a, int dir)
|
||||
{
|
||||
var ab = AnnotBounds(a);
|
||||
if (dir > 0)
|
||||
{
|
||||
for (int k = i + 1; k < list.Count; k++)
|
||||
if (AnnotBounds(list[k]).IntersectsWith(ab)) return k;
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int k = i - 1; k >= 0; k--)
|
||||
if (AnnotBounds(list[k]).IntersectsWith(ab)) return k;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Raise (+1) or Lower (-1) an annotation one visual layer: move it just past the nearest annotation
|
||||
// that overlaps it in that direction. No-op when nothing overlaps above / below. Later in the list
|
||||
// = drawn on top.
|
||||
private void MoveAnnotationLayer(PageAnnotation a, int dir)
|
||||
{
|
||||
if (!_annotations.TryGetValue(a.PageIndex, out var list)) return;
|
||||
int i = list.IndexOf(a);
|
||||
if (i < 0) return;
|
||||
int target = OverlapNeighbor(list, i, a, dir);
|
||||
if (target < 0) return;
|
||||
PushPageSnapshotUndo(a.PageIndex);
|
||||
list.RemoveAt(i);
|
||||
list.Insert(target, a); // after removal, inserting at the neighbor's old index lands a past it
|
||||
RenderAllAnnotations(a.PageIndex);
|
||||
ReattachSelectionVisuals();
|
||||
MarkDirty();
|
||||
}
|
||||
|
||||
// Annotations copied via the context menu, held as page-independent deep clones until pasted.
|
||||
private readonly List<PageAnnotation> _annotationClipboard = [];
|
||||
|
||||
// Deep-copy an annotation so the clipboard (and paste) is fully independent of the live object.
|
||||
// CoverAnnotation must be matched before HighlightAnnotation since it derives from it.
|
||||
private static PageAnnotation? CloneAnnotation(PageAnnotation a) => a switch
|
||||
{
|
||||
CoverAnnotation c => new CoverAnnotation
|
||||
{
|
||||
PageIndex = c.PageIndex,
|
||||
PairId = c.PairId,
|
||||
GroupId = c.GroupId,
|
||||
Bounds = c.Bounds,
|
||||
Style = c.Style,
|
||||
ColorR = c.ColorR,
|
||||
ColorG = c.ColorG,
|
||||
ColorB = c.ColorB,
|
||||
ColorA = c.ColorA
|
||||
},
|
||||
HighlightAnnotation h => new HighlightAnnotation
|
||||
{
|
||||
PageIndex = h.PageIndex,
|
||||
PairId = h.PairId,
|
||||
GroupId = h.GroupId,
|
||||
Bounds = h.Bounds,
|
||||
Style = h.Style,
|
||||
ColorR = h.ColorR,
|
||||
ColorG = h.ColorG,
|
||||
ColorB = h.ColorB,
|
||||
ColorA = h.ColorA,
|
||||
Erases = h.Erases?.Select(e => new HighlightErase { Points = [.. e.Points], Radius = e.Radius }).ToList()
|
||||
},
|
||||
TextAnnotation t => new TextAnnotation
|
||||
{
|
||||
PageIndex = t.PageIndex,
|
||||
PairId = t.PairId,
|
||||
GroupId = t.GroupId,
|
||||
Position = t.Position,
|
||||
Content = t.Content,
|
||||
FontSize = t.FontSize,
|
||||
FontName = t.FontName,
|
||||
Bold = t.Bold,
|
||||
Italic = t.Italic,
|
||||
Strike = t.Strike,
|
||||
Underline = t.Underline,
|
||||
Width = t.Width,
|
||||
Height = t.Height,
|
||||
ColorR = t.ColorR,
|
||||
ColorG = t.ColorG,
|
||||
ColorB = t.ColorB,
|
||||
ColorA = t.ColorA,
|
||||
BgR = t.BgR,
|
||||
BgG = t.BgG,
|
||||
BgB = t.BgB,
|
||||
BgA = t.BgA
|
||||
},
|
||||
InkAnnotation ink => new InkAnnotation
|
||||
{
|
||||
PageIndex = ink.PageIndex,
|
||||
PairId = ink.PairId,
|
||||
GroupId = ink.GroupId,
|
||||
Points = [.. ink.Points],
|
||||
StrokeWidth = ink.StrokeWidth,
|
||||
ColorR = ink.ColorR,
|
||||
ColorG = ink.ColorG,
|
||||
ColorB = ink.ColorB,
|
||||
ColorA = ink.ColorA
|
||||
},
|
||||
SignatureAnnotation s => new SignatureAnnotation
|
||||
{
|
||||
PageIndex = s.PageIndex,
|
||||
PairId = s.PairId,
|
||||
GroupId = s.GroupId,
|
||||
Position = s.Position,
|
||||
Scale = s.Scale,
|
||||
SourceWidth = s.SourceWidth,
|
||||
SourceHeight = s.SourceHeight,
|
||||
Strokes = [.. s.Strokes.Select(st => new List<Point>(st))],
|
||||
StrokeWidth = s.StrokeWidth,
|
||||
ImageData = s.ImageData
|
||||
},
|
||||
ImageAnnotation img => new ImageAnnotation
|
||||
{
|
||||
PageIndex = img.PageIndex,
|
||||
PairId = img.PairId,
|
||||
GroupId = img.GroupId,
|
||||
Position = img.Position,
|
||||
Scale = img.Scale,
|
||||
SourceWidth = img.SourceWidth,
|
||||
SourceHeight = img.SourceHeight,
|
||||
ImageData = img.ImageData
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
|
||||
// Copy the current selection (primary or multi-select) into the annotation clipboard.
|
||||
private void CopySelectedAnnotations()
|
||||
{
|
||||
var sel = new List<PageAnnotation>();
|
||||
if (_selectedAnnotation is not null) sel.Add(_selectedAnnotation);
|
||||
foreach (var a in _selectedSet) if (!sel.Contains(a)) sel.Add(a);
|
||||
if (sel.Count == 0) return;
|
||||
|
||||
_annotationClipboard.Clear();
|
||||
foreach (var a in sel)
|
||||
if (CloneAnnotation(a) is { } c) _annotationClipboard.Add(c);
|
||||
|
||||
SetStatus(string.Format(Loc(_annotationClipboard.Count == 1
|
||||
? "Str_St_CopiedAnnotationOne" : "Str_St_CopiedAnnotationMany"), _annotationClipboard.Count));
|
||||
}
|
||||
|
||||
// Paste the clipboard onto pageIdx: fresh clones nudged down-right so they don't sit exactly on
|
||||
// the originals, clamped on-page, added (each its own undo step) and left selected. A pasted
|
||||
// text/cover pair keeps its pairing internally but is regenerated so it's independent of the source.
|
||||
private void PasteAnnotations(int pageIdx)
|
||||
{
|
||||
if (_annotationClipboard.Count == 0 || pageIdx < 0) return;
|
||||
const double off = 14;
|
||||
|
||||
var pasted = new List<PageAnnotation>();
|
||||
var pairMap = new Dictionary<string, string>();
|
||||
var groupMap = new Dictionary<string, string>();
|
||||
foreach (var src in _annotationClipboard)
|
||||
{
|
||||
if (CloneAnnotation(src) is not { } c) continue;
|
||||
c.PageIndex = pageIdx;
|
||||
// Remap pairing and grouping so the pasted set stays internally linked but independent
|
||||
// of the originals (a copied group/pair pastes as its own new group/pair).
|
||||
if (c.PairId.Length > 0)
|
||||
{
|
||||
if (!pairMap.TryGetValue(c.PairId, out var np))
|
||||
{
|
||||
np = Guid.NewGuid().ToString("N");
|
||||
pairMap[c.PairId] = np;
|
||||
}
|
||||
c.PairId = np;
|
||||
}
|
||||
if (c.GroupId.Length > 0)
|
||||
{
|
||||
if (!groupMap.TryGetValue(c.GroupId, out var ng))
|
||||
{
|
||||
ng = Guid.NewGuid().ToString("N");
|
||||
groupMap[c.GroupId] = ng;
|
||||
}
|
||||
c.GroupId = ng;
|
||||
}
|
||||
AnnotSetPos(c, new Point(AnnotGetPos(c).X + off, AnnotGetPos(c).Y + off));
|
||||
AnnotSetPos(c, ClampAnnotPos(c));
|
||||
pasted.Add(c);
|
||||
}
|
||||
if (pasted.Count == 0) return;
|
||||
|
||||
ClearSelection();
|
||||
foreach (var c in pasted) AddAnnotation(c);
|
||||
RenderAllAnnotations(pageIdx);
|
||||
|
||||
var canvas = CanvasForPage(pageIdx);
|
||||
_activeCanvas = canvas;
|
||||
if (pasted.Count == 1)
|
||||
SelectAnnotation(pasted[0], AnnotBounds(pasted[0]));
|
||||
else
|
||||
foreach (var c in pasted) ToggleMultiSelect(c, AnnotBounds(c), canvas);
|
||||
|
||||
SetStatus(string.Format(Loc(pasted.Count == 1
|
||||
? "Str_St_PastedAnnotationOne" : "Str_St_PastedAnnotationMany"), pasted.Count));
|
||||
}
|
||||
|
||||
// --- Edit / pairing / grouping menu actions ------------------------------------------------
|
||||
|
||||
// Other members of a group are moved alongside the primary during a drag; this holds each one
|
||||
// with the position it had when the drag began so the whole group translates rigidly.
|
||||
private List<(PageAnnotation a, Point orig)> _dragGroupOrig => ActiveViewer.DragGroupOrigRef;
|
||||
|
||||
// "Edit" menu action: inline-edit a text box. For any other annotation, selecting it (which the
|
||||
// right-click already did) opened its color/size bar, so there's nothing more to do here.
|
||||
private void EditAnnotation(PageAnnotation hit)
|
||||
{
|
||||
if (hit is TextAnnotation ta)
|
||||
{
|
||||
var p = new Point(ta.Position.X + Math.Min(Math.Max(ta.Width, 8) / 2, 10),
|
||||
ta.Position.Y + Math.Min(Math.Max(ta.Height, 8) / 2, 8));
|
||||
EditTextAtPosition(p, ta.PageIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of a text/cover pair, if any.
|
||||
private PageAnnotation? PairPartner(PageAnnotation a)
|
||||
{
|
||||
if (a.PairId.Length == 0 || !_annotations.TryGetValue(a.PageIndex, out var list)) return null;
|
||||
return list.FirstOrDefault(x => !ReferenceEquals(x, a) && x.PairId == a.PairId);
|
||||
}
|
||||
|
||||
// Switch the selection from one half of a pair to the other.
|
||||
private void SelectPartner(PageAnnotation a)
|
||||
{
|
||||
var p = PairPartner(a);
|
||||
if (p is null) return;
|
||||
ClearSelection();
|
||||
RenderAllAnnotations(p.PageIndex);
|
||||
_activeCanvas = CanvasForPage(p.PageIndex);
|
||||
SelectAnnotation(p, AnnotBounds(p));
|
||||
}
|
||||
|
||||
private void PageList_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// Did the click land on a thumbnail, or the empty area below the list? Page-specific actions
|
||||
// (rotate, move, delete) only make sense on a thumbnail; the empty area gets the page-agnostic
|
||||
// menu (same one the gray area around the page uses). With no document open the menu still
|
||||
// opens, carrying only the sidebar-side section appended at the bottom.
|
||||
bool onThumb = false;
|
||||
if (_doc is not null)
|
||||
for (var d = e.OriginalSource as DependencyObject; d != null; d = VisualTreeHelper.GetParent(d))
|
||||
if (d is ListBoxItem) { onThumb = true; break; }
|
||||
|
||||
var menu = MakeThemedMenu();
|
||||
if (onThumb)
|
||||
{
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_InsertBlank"), (s, ev) => InsertBlankPage_Click(s!, ev), glyph: ""));
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_DuplicatePage"), (s, ev) => DuplicatePage(PageList.SelectedIndex), glyph: ""));
|
||||
menu.Items.Add(new Separator());
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RotateCWShort"), (s, ev) => RotatePages_Click(90), glyph: ""));
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RotateCCWShort"), (s, ev) => RotatePages_Click(-90), glyph: ""));
|
||||
menu.Items.Add(new Separator());
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Lbl_MoveUp"), (s, ev) => MoveUp_Click(s!, ev), glyph: ""));
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Lbl_MoveDown"), (s, ev) => MoveDown_Click(s!, ev), glyph: ""));
|
||||
menu.Items.Add(new Separator());
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_ExtractPages"), (s, ev) => Split_Click(s!, ev), glyph: ""));
|
||||
// #207: the shared export dialog, scoped to the clicked selection (still editable).
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_ExportPageImage"), (s, ev) =>
|
||||
{
|
||||
var nums = PageList.SelectedItems.Cast<PageThumbnailVm>()
|
||||
.Select(vm => vm.PageIndex + 1).OrderBy(n => n);
|
||||
_ = ExportImagesFlow(string.Join(",", nums));
|
||||
}, glyph: null));
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_DeletePages"), (s, ev) => Delete_Click(s!, ev), glyph: ""));
|
||||
menu.Items.Add(new Separator());
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_StampPages"), (s, ev) => OpenStampTool(), glyph: ""));
|
||||
}
|
||||
else if (_doc is not null)
|
||||
{
|
||||
FillPageAgnosticMenu(menu);
|
||||
}
|
||||
AppendSidebarSideSection(menu);
|
||||
menu.PlacementTarget = PageList;
|
||||
menu.IsOpen = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
// Right-click on any other part of the sidebar - the PAGES/OUTLINES header, the page
|
||||
// controls row, the outline panel's empty space, the toggle strip - reaches this handler
|
||||
// on the two container Borders (SidebarBorder, SidebarToggleStrip). The page list's own
|
||||
// handler above marks its clicks handled, so they never double up; the outline TREE is
|
||||
// ceded entirely, because its bookmark menu opens on Preview mouse DOWN and does not mark
|
||||
// the Up handled - without the guard this would open a second menu over it.
|
||||
private void SidebarArea_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.OriginalSource is DependencyObject d)
|
||||
for (var n = d; n != null; n = VisualTreeHelper.GetParent(n))
|
||||
if (ReferenceEquals(n, OutlineTree)) return;
|
||||
var menu = MakeThemedMenu();
|
||||
if (_doc is not null) FillPageAgnosticMenu(menu);
|
||||
AppendSidebarSideSection(menu);
|
||||
menu.PlacementTarget = (UIElement)sender;
|
||||
menu.IsOpen = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
// The sidebar-side picker rides the bottom of the sidebar's own right-click menu (it moved
|
||||
// out of the Settings panel, like the toolbar picker moved onto the toolbar). The keyboard
|
||||
// gesture is shown on the side Ctrl+Shift+B would switch TO - it is a toggle, so putting it
|
||||
// on the checked row would advertise a press that does nothing.
|
||||
private void AppendSidebarSideSection(ContextMenu menu)
|
||||
{
|
||||
if (menu.Items.Count > 0) menu.Items.Add(new Separator());
|
||||
menu.Items.Add(new MenuItem { Header = Loc("Str_Sidebar"), IsEnabled = false });
|
||||
var left = new MenuItem { Header = Loc("Str_Sidebar_Left"), IsCheckable = true, IsChecked = !_sidebarRight };
|
||||
var right = new MenuItem { Header = Loc("Str_Sidebar_Right"), IsCheckable = true, IsChecked = _sidebarRight };
|
||||
(_sidebarRight ? left : right).InputGestureText = "Shift+F9";
|
||||
left.Click += (_, _2) => SelectSidebarSide(false);
|
||||
right.Click += (_, _2) => SelectSidebarSide(true);
|
||||
menu.Items.Add(left);
|
||||
menu.Items.Add(right);
|
||||
}
|
||||
|
||||
private ContextMenu MakeThemedMenu()
|
||||
{
|
||||
var menu = new ContextMenu();
|
||||
// Code-created ContextMenus are popup roots and can miss the owning Window's implicit
|
||||
// style during construction. Attach it explicitly so every generated menu receives
|
||||
// the square, two-stage 98SE frame instead of WPF's rounded fallback chrome.
|
||||
if (TryFindResource(typeof(ContextMenu)) is Style style) menu.Style = style;
|
||||
TextOptions.SetTextFormattingMode(menu, TextFormattingMode.Display);
|
||||
TextOptions.SetTextRenderingMode(menu, TextRenderingMode.Grayscale);
|
||||
return menu;
|
||||
}
|
||||
|
||||
// Document-wide actions for a right-click with no specific page under the cursor: the empty sidebar
|
||||
// area below the thumbnails, or the gray area around the page in the document pane.
|
||||
private void FillPageAgnosticMenu(ContextMenu menu)
|
||||
{
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_AddBlankPage"), (s, e) => AddBlankPageAtEnd(), glyph: ""));
|
||||
menu.Items.Add(new Separator());
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Print"), (s, e) => Print_Click(s!, e), "Ctrl+P", ""));
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Lbl_ZoomIn"), (s, e) => ZoomIn_Click(s!, e), "Ctrl+=", ""));
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Lbl_ZoomOut"), (s, e) => ZoomOut_Click(s!, e), "Ctrl+-", ""));
|
||||
menu.Items.Add(new Separator());
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_UndoLast"), (s, e) => Undo_Click(s!, e), "Ctrl+Z", ""));
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_Redo"), (s, e) => Redo_Click(s!, e), "Ctrl+Y", ""));
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Lbl_Clear"), (s, e) => ClearAllAnnotations_Click(s!, e), glyph: ""));
|
||||
AppendBookLayoutItem(menu);
|
||||
}
|
||||
|
||||
// Opens the page-agnostic menu for a right-click on the gray area around the page (the document
|
||||
// pane background). Wired in code so right-clicking outside the page is no longer a dead spot.
|
||||
// internal: PdfViewer's XAML binds this and forwards to it.
|
||||
internal void DocPaneBackground_RightClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (_doc is null) return;
|
||||
// Only when the click really hit the background, not a page tile (those have their own menu).
|
||||
if (e.OriginalSource is DependencyObject d)
|
||||
for (var n = d; n != null; n = VisualTreeHelper.GetParent(n))
|
||||
if (n is Canvas) return;
|
||||
var menu = MakeThemedMenu();
|
||||
FillPageAgnosticMenu(menu);
|
||||
menu.PlacementTarget = (UIElement)sender;
|
||||
menu.IsOpen = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
// glyph: optional Segoe MDL2 codepoint rendered in the menu's left gutter (the 16px
|
||||
// check column doubles as an icon slot - see the MenuItem template in MainWindow.xaml).
|
||||
private static MenuItem MakeMenuItem(string header, RoutedEventHandler click, string? gesture = null, string? glyph = null)
|
||||
{
|
||||
var item = new MenuItem { Header = header };
|
||||
item.Click += click;
|
||||
if (gesture != null)
|
||||
item.InputGestureText = gesture;
|
||||
if (glyph != null)
|
||||
{
|
||||
var icon = new TextBlock
|
||||
{
|
||||
Text = glyph,
|
||||
FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"),
|
||||
FontSize = 12,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
item.Icon = icon;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user