Files
mmd-pdf/Shell/ContextMenu.cs
kua-agent 6610acfa44 feat: rebrand to MMD PDF, single corporate theme, and remove original text under an edit
- Services/PdfRedactText.cs: strip text whose origin falls inside a CoverAnnotation
  from the page content stream at save time, hooked into PdfBurn.DrawAnnotationsIntoDoc.
  Fixes edited values staying recoverable by text extraction.
- Themes/MMD.xaml replaces all thirteen themes; picker and accent strip removed; no dark mode.
- Rename KillerPDF -> MMD PDF across code, resources, packaging and locale strings; new icon.
- Remove the upstream author credit and the in-app install button.
2026-08-27 07:37:09 +02:00

715 lines
37 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 MmdPdf.Services;
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
namespace MmdPdf
{
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, "MmdPdf", 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;
}
}
}