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; } /// Generated film-grain tile, exposed so secondary windows (e.g. the /// print preview) can paint the same texture over their document area. 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 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 _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(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(); 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(); var pairMap = new Dictionary(); var groupMap = new Dictionary(); 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() .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; } } }