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.Controls
{
// Moved from Shell/Selection.cs; the namespace and class line are the only changes. Window
// members spelled bare here resolve through PdfViewer.Bridge.cs.
public partial class PdfViewer
{
// ============================================================
// Selection
// ============================================================
// Resolve the active theme's "SelectionAccent" color: a per-theme color picked to stay
// readable on the white PDF page (Accent is white in several themes, and AccentBorder is a
// pale cream that washes out on white). Falls back to brand green.
private Color AccentColor()
=> TryFindResource("SelectionAccent") is SolidColorBrush b ? b.Color : Color.FromRgb(30, 165, 76);
internal SolidColorBrush AccentBrush(byte alpha = 255)
{
var c = AccentColor();
return new SolidColorBrush(Color.FromArgb(alpha, c.R, c.G, c.B));
}
// A darker shade of the accent, used for a cover's selection chrome and its in-edit outline so a
// cover reads as distinct from the lighter accent on the text box stacked over it.
private SolidColorBrush DarkerAccentBrush(byte alpha = 255)
{
var c = AccentColor();
return new SolidColorBrush(Color.FromArgb(alpha, (byte)(c.R * 0.6), (byte)(c.G * 0.6), (byte)(c.B * 0.6)));
}
// ============================================================
// Flowing text selection (#127)
// ============================================================
// A drag that STARTS on a text character tracks the actual run of characters in reading
// order, browser-style, instead of the rectangle marquee. Geometry comes from
// TextRunService (PdfPig words, the same source as search), endpoints are caret positions
// (page, 0..N over the page's flattened chars), and the painted quads use the exact
// PDF-to-render math AddSearchHighlight uses, so everything lands where search lands.
// Drags that start on empty page keep the classic marquee (annotation box-select,
// region copy, OCR region) - see Canvas_MouseLeftButtonDown.
private readonly TextRunService _textRuns = new();
private bool _txtSelActive; // drag in progress
private bool _txtSelHasRange; // a committed selection is on screen
private (int Page, int Caret) _txtSelAnchor;
private (int Page, int Caret) _txtSelFocus;
private Point _txtSelDownPos; // press point (gesture canvas coords)
private bool _txtSelDragStarted; // true once movement exceeds the click threshold
private PageAnnotation? _txtSelClickAnnot; // annotation under the press; selected on plain click
private Rect _txtSelClickAnnotBounds;
private EditTool? _txtSelCommitTool; // #127 Phase 2: non-null while a Highlight/Strike/
// Underline tool owns the flowing drag - the release
// commits annotations instead of copying text
/// Canvas point to PDF space (points, bottom-left origin) - the inverse of the
/// mapping AddSearchHighlight paints with, same as ExtractTextFromRegion.
private static (double X, double Y) CanvasToPdf(Point pos, double renderW, double renderH, PageTextRuns runs)
=> (pos.X * runs.PdfWidth / renderW, runs.PdfHeight - pos.Y * runs.PdfHeight / renderH);
/// Called from the Select tool's mouse-down. Returns true (and arms the drag) only
/// when the press lands ON text; empty page falls through to the marquee.
private bool TryBeginTextSelection(int pageIdx, Point pos)
{
if (_currentFile is null) return false;
if (!_renderDims.TryGetValue(pageIdx, out var rd)) return false;
var runs = _textRuns.GetPage(_currentFile, pageIdx);
if (runs is null || runs.Chars.Count == 0) return false;
var (px, py) = CanvasToPdf(pos, rd.w, rd.h, runs);
if (!TextRunService.IsOverText(runs, px, py)) return false;
ClearTextSelection();
int caret = TextRunService.CaretFromPoint(runs, px, py);
_txtSelAnchor = _txtSelFocus = (pageIdx, caret);
_txtSelDownPos = pos;
_txtSelDragStarted = false;
_txtSelActive = true;
return true;
}
/// Mouse-move while a flowing selection drag is live. Resolves which page the
/// pointer is over (cross-page tracking in Continuous, where every overlay is a live tile),
/// moves the focus caret, and repaints.
private void UpdateTextSelectionDrag(MouseEventArgs e)
{
if (_currentFile is null) return;
// Click-vs-drag threshold: below ~4px of movement this is still a click (which selects
// the annotation under the press, if any, on mouse-up) - not a text drag.
if (!_txtSelDragStarted)
{
var tcv = _gestureCanvas ?? _activeCanvas;
if (tcv is null) return;
var tp = e.GetPosition(tcv);
if (Math.Abs(tp.X - _txtSelDownPos.X) < 4 && Math.Abs(tp.Y - _txtSelDownPos.Y) < 4) return;
_txtSelDragStarted = true;
}
int page = _gesturePage;
Canvas? cv = _gestureCanvas ?? _activeCanvas;
if (_viewMode == ViewMode.Continuous)
{
foreach (var kv in _pages)
{
var c = kv.Value;
double cw = double.IsNaN(c.Width) ? c.ActualWidth : c.Width;
double ch = double.IsNaN(c.Height) ? c.ActualHeight : c.Height;
var p = e.GetPosition(c);
if (p.X >= 0 && p.X <= cw && p.Y >= 0 && p.Y <= ch) { page = kv.Key; cv = c; break; }
}
}
if (cv is null) return;
// Clamp into the canvas so dragging past an edge clamps to the start/end of lines
// instead of losing the selection.
double cvW = double.IsNaN(cv.Width) ? cv.ActualWidth : cv.Width;
double cvH = double.IsNaN(cv.Height) ? cv.ActualHeight : cv.Height;
var pos = e.GetPosition(cv);
pos = new Point(Math.Max(0, Math.Min(cvW, pos.X)), Math.Max(0, Math.Min(cvH, pos.Y)));
if (!_renderDims.TryGetValue(page, out var rd)) return;
var runs = _textRuns.GetPage(_currentFile, page);
if (runs is null) return;
var (px, py) = CanvasToPdf(pos, rd.w, rd.h, runs);
var focus = (page, TextRunService.CaretFromPoint(runs, px, py));
if (focus == _txtSelFocus) return;
_txtSelFocus = focus;
RepaintTextSelection();
}
/// Mouse-up: commit the range, copy it (matching the app's existing
/// select-copies-immediately behavior), and leave the quads on screen.
private void FinishTextSelection()
{
_txtSelActive = false;
var clickAnnot = _txtSelClickAnnot;
var clickBounds = _txtSelClickAnnotBounds;
var commitTool = _txtSelCommitTool;
_txtSelClickAnnot = null;
_txtSelCommitTool = null;
if (!_txtSelDragStarted || _txtSelAnchor == _txtSelFocus)
{
// Plain click: the annotation under the press (e.g. a highlight box covering this
// paragraph) gets selected, exactly as it did before flowing selection existed.
// (Select tool only - a highlight-tool click just drops the empty gesture.)
ClearTextSelection();
if (commitTool is null && clickAnnot is not null) SelectAnnotation(clickAnnot, clickBounds);
return;
}
if (commitTool is EditTool hlTool)
{
CommitFlowingHighlight(hlTool);
return;
}
_txtSelHasRange = true;
int words;
_selectedText = BuildSelectedText(out words);
if (string.IsNullOrWhiteSpace(_selectedText))
{
SetStatus(Loc("Str_St_NoTextInSelection"));
ClearTextSelection();
return;
}
try { Clipboard.SetText(_selectedText); } catch { /* clipboard momentarily locked by another app */ }
SetStatus(string.Format(Loc("Str_St_CopiedWords"), words));
}
private ((int Page, int Caret) Start, (int Page, int Caret) End) OrderedSelection()
{
var a = _txtSelAnchor;
var f = _txtSelFocus;
bool aFirst = a.Page < f.Page || (a.Page == f.Page && a.Caret <= f.Caret);
return aFirst ? (a, f) : (f, a);
}
/// The caret slice of the selection that falls on one page, or (0,0) when none.
private (int Start, int End) SelectionSliceForPage(int page, int charCount)
{
var (s, e) = OrderedSelection();
if (page < s.Page || page > e.Page) return (0, 0);
int start = page == s.Page ? s.Caret : 0;
int end = page == e.Page ? e.Caret : charCount;
return (start, end);
}
private string BuildSelectedText(out int wordCount)
{
wordCount = 0;
if (_currentFile is null) return string.Empty;
var (s, e) = OrderedSelection();
var sb = new System.Text.StringBuilder();
for (int p = s.Page; p <= e.Page; p++)
{
var runs = _textRuns.GetPage(_currentFile, p);
if (runs is null || runs.Chars.Count == 0) continue;
var (start, end) = SelectionSliceForPage(p, runs.Chars.Count);
if (start >= end) continue;
string t = TextRunService.TextForRange(runs, start, end, out int w);
if (t.Length == 0) continue;
if (sb.Length > 0) sb.Append('\n');
sb.Append(t);
wordCount += w;
}
return sb.ToString();
}
/// Per-line rects (canvas render-dim space) of the current selection on one page -
/// one rect per line, first selected char to last, browser-style. Shared by the selection
/// quad painter and the flowing-highlight commit (#127 Phase 2) so a committed highlight
/// lands exactly where the drag preview showed it.
private List SelectionLineRectsForPage(int page)
{
var result = new List();
if (_currentFile is null) return result;
var (s, e) = OrderedSelection();
if (page < s.Page || page > e.Page) return result;
if (!_renderDims.TryGetValue(page, out var rd)) return result;
var runs = _textRuns.GetPage(_currentFile, page);
if (runs is null || runs.Chars.Count == 0) return result;
var (start, end) = SelectionSliceForPage(page, runs.Chars.Count);
if (start >= end) return result;
double sx = rd.w / runs.PdfWidth;
double sy = rd.h / runs.PdfHeight;
int i = start;
while (i < end)
{
var line = runs.Lines[runs.Chars[i].Line];
int segEnd = Math.Min(end, line.End);
// A selected caret slice runs left-to-right for LTR and right-to-left for RTL.
// Use its physical extremes rather than assuming the first glyph is on the left.
double left = runs.Chars.Skip(i).Take(segEnd - i).Min(c => c.Left);
double right = runs.Chars.Skip(i).Take(segEnd - i).Max(c => c.Right);
double h = (line.Top - line.Bottom) * sy;
double pad = h * 0.12; // a touch of breathing room; tighter than search's 0.30
result.Add(new Rect(left * sx, rd.h - line.Top * sy - pad,
Math.Max((right - left) * sx, 2), Math.Max(h + pad * 2, 2)));
i = segEnd;
}
return result;
}
/// Paints one page's selection quads onto its overlay. Called while dragging and
/// from the tail of RenderAllAnnotations so the quads survive re-renders, exactly like
/// search highlights do.
private void ApplyTextSelectionQuads(int page, Canvas canvas)
{
if (!_txtSelActive && !_txtSelHasRange) return;
foreach (var r in SelectionLineRectsForPage(page))
{
var rect = new Rectangle
{
Opacity = 60.0 / 255.0,
Width = r.Width,
Height = r.Height,
IsHitTestVisible = false,
Tag = "TextSelQuad"
};
// Live theme binding (net48 rule: a plain brush snapshot won't follow a theme
// switch) - the quads recolor the moment the theme or accent changes.
rect.SetResourceReference(Shape.FillProperty, "SelectionAccent");
Canvas.SetLeft(rect, r.X);
Canvas.SetTop(rect, r.Y);
canvas.Children.Add(rect);
}
}
/// #127 Phase 2: turns the flowing selection into Highlight / Strikethrough /
/// Underline annotations - one per selected line, grouped per page so one gesture behaves
/// as one annotation (select, move, delete together), and one page-snapshot undo entry so
/// Ctrl+Z reverts the whole gesture in a single step.
private void CommitFlowingHighlight(EditTool tool)
{
var (s, e) = OrderedSelection();
var perPage = new List<(int Page, List Rects)>();
for (int p = s.Page; p <= e.Page; p++)
{
var rects = SelectionLineRectsForPage(p);
if (rects.Count > 0) perPage.Add((p, rects));
}
if (perPage.Count == 0) { ClearTextSelection(); return; }
PushPagesSnapshotUndo(perPage.Select(pp => pp.Page));
var style = tool == EditTool.Strikethrough ? HighlightStyle.Strikethrough
: tool == EditTool.Underline ? HighlightStyle.Underline
: HighlightStyle.Fill;
int total = 0;
foreach (var (page, rects) in perPage)
{
// One group per page; a single-line highlight stays ungrouped.
string gid = rects.Count > 1 ? Guid.NewGuid().ToString("N") : "";
if (!_annotations.ContainsKey(page)) _annotations[page] = [];
foreach (var r in rects)
{
var ha = new HighlightAnnotation { PageIndex = page, Bounds = r, Style = style, GroupId = gid };
ha.SetColor(tool == EditTool.Highlight ? _highlightColor : _lineAnnotColor);
_annotations[page].Add(ha);
total++;
}
}
MarkDirty();
ClearTextSelection();
foreach (var (page, _) in perPage) RenderAllAnnotations(page);
SetStatus(string.Format(Loc(style == HighlightStyle.Fill ? "Str_St_HighlightedLines" : style == HighlightStyle.Strikethrough ? "Str_St_StruckLines" : "Str_St_UnderlinedLines"), total));
}
/// Drops and repaints the quads on every page the selection touches.
private void RepaintTextSelection()
{
RemoveTextSelQuads();
var (s, e) = OrderedSelection();
for (int p = s.Page; p <= e.Page; p++)
{
var canvas = VisibleCanvasForPage(p);
if (canvas is not null) ApplyTextSelectionQuads(p, canvas);
}
}
private void RemoveTextSelQuads()
{
foreach (var canvas in AllPageCanvases())
{
var toRemove = canvas.Children.OfType()
.Where(r => r.Tag is string s && s == "TextSelQuad").ToList();
foreach (var r in toRemove)
canvas.Children.Remove(r);
}
}
/// Ctrl+A: flowing select-all on the current page (quads over every line) and copy.
private void SelectAllText()
{
if (_currentFile is null) return;
int pageIdx = _currentPage;
if (pageIdx < 0) return;
try
{
var runs = _textRuns.GetPage(_currentFile, pageIdx);
if (runs is null || runs.Chars.Count == 0)
{
SetStatus(Loc("Str_St_NoTextOnPage"));
return;
}
ClearTextSelection();
_txtSelAnchor = (pageIdx, 0);
_txtSelFocus = (pageIdx, runs.Chars.Count);
_txtSelHasRange = true;
RepaintTextSelection();
_selectedText = TextRunService.TextForRange(runs, 0, runs.Chars.Count, out _);
if (string.IsNullOrWhiteSpace(_selectedText))
{
SetStatus(Loc("Str_St_NoTextOnPage"));
ClearTextSelection();
return;
}
Clipboard.SetText(_selectedText);
SetStatus(Loc("Str_St_SelectAllCopied"));
}
catch (Exception ex)
{
SetStatus(string.Format(Loc("Str_St_SelectAllError"), ex.Message));
}
}
private void CopySelectedText()
{
if (!string.IsNullOrEmpty(_selectedText))
{
Clipboard.SetText(_selectedText);
SetStatus(Loc("Str_St_Copied"));
}
else
{
SetStatus(Loc("Str_St_NoTextSelected"));
}
}
internal void ClearTextSelection()
{
if (_selectRect is not null)
{
// Remove from the rect's ACTUAL parent. Since the cross-page marquee rework the
// selection box lives on the window-level MarqueeLayer, not the page canvas, so
// removing from _activeCanvas was a silent no-op that orphaned the box on the
// layer until app restart (#121).
(_selectRect.Parent as Canvas)?.Children.Remove(_selectRect);
_selectRect = null;
}
_selectedText = null;
_txtSelActive = false;
_txtSelHasRange = false;
_txtSelDragStarted = false;
_txtSelCommitTool = null;
RemoveTextSelQuads();
}
/// Marquee fallback: rectangle region copy, used when a drag starts on EMPTY page
/// (scans, margins). Kept word-box based on purpose - on pages with no text layer there is
/// nothing to flow along, and this is also what the annotation box-select falls back to.
private void ExtractTextFromRegion(int pageIdx, Rect canvasBounds)
{
if (_currentFile is null || pageIdx < 0) return;
if (!_renderDims.ContainsKey(pageIdx)) return;
try
{
var (renderW, renderH) = _renderDims[pageIdx];
using var pigDoc = PdfPigDoc.Open(_currentFile);
if (pageIdx >= pigDoc.NumberOfPages) return;
var page = pigDoc.GetPage(pageIdx + 1); // PdfPig is 1-based
double pdfW = page.Width;
double pdfH = page.Height;
double sx = pdfW / renderW;
double sy = pdfH / renderH;
// Convert canvas rect to PDF coordinates (flip Y - PDF origin is bottom-left)
double pdfLeft = canvasBounds.Left * sx;
double pdfRight = canvasBounds.Right * sx;
double pdfTop = pdfH - (canvasBounds.Top * sy);
double pdfBottom = pdfH - (canvasBounds.Bottom * sy);
// pdfTop > pdfBottom because of Y flip
double pdfMinY = Math.Min(pdfTop, pdfBottom);
double pdfMaxY = Math.Max(pdfTop, pdfBottom);
var words = page.GetWords()
.Where(w =>
{
var bb = w.BoundingBox;
double cx = (bb.Left + bb.Right) / 2;
double cy = (bb.Bottom + bb.Top) / 2;
return cx >= pdfLeft && cx <= pdfRight && cy >= pdfMinY && cy <= pdfMaxY;
})
.ToList();
if (words.Count == 0)
{
SetStatus(Loc("Str_St_NoTextInSelection"));
ClearTextSelection();
return;
}
_selectedText = WordsToText(words);
Clipboard.SetText(_selectedText);
int wordCount = words.Count;
SetStatus(string.Format(Loc("Str_St_CopiedWords"), wordCount));
}
catch (Exception ex)
{
SetStatus(string.Format(Loc("Str_St_ExtractError"), ex.Message));
ClearTextSelection();
}
}
}
}