mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 05:28:09 -06:00
2167 lines
94 KiB
Rust
2167 lines
94 KiB
Rust
//! Commenting in the document view (execution plan M5.3–M5.4; Acrobat's "Add comments").
|
||
//!
|
||
//! - **Tools** ([`CommentTool`]): sticky note, text box, highlight / underline / strikethrough
|
||
//! (driven by text selection), freehand, line, arrow, rectangle, oval. They live in the quick
|
||
//! bar in three flyout groups, as in Acrobat's comment toolbar (audit `a14-*`).
|
||
//! - **On the page:** drawing gestures with live previews; with the Select tool, comments can be
|
||
//! hovered, selected (blue frame with 8 round handles), moved, resized (rectangles, ovals and
|
||
//! text boxes), edited (double-click) and deleted (⌫ / Delete); a context menu offers Edit,
|
||
//! Reply, Set status, Colour and Delete.
|
||
//! - **Composer:** the floating "Add a comment" card used for new notes, text boxes and edits.
|
||
//!
|
||
//! Everything is turned into `pdfcraft_engine::Edit`s, queued on the view as `pending_edit` and
|
||
//! applied by the app, so each change is one undo step.
|
||
|
||
use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, pos2, vec2};
|
||
use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style};
|
||
use pdfcraft_render::{Annotation, DocInfo, PageInfo, PageText};
|
||
|
||
use crate::canvas::{DocView, PageXform};
|
||
use crate::theme::{self, Tokens};
|
||
use crate::{QuickTool, icons};
|
||
|
||
/// Acrobat's selection blue for annotation frames and handles (11-visual-spec §5).
|
||
const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
pub enum CommentTool {
|
||
Note,
|
||
TextBox,
|
||
Highlight,
|
||
Underline,
|
||
StrikeOut,
|
||
Squiggly,
|
||
Ink,
|
||
Line,
|
||
Arrow,
|
||
Rectangle,
|
||
Oval,
|
||
Polygon,
|
||
PolyLine,
|
||
Cloud,
|
||
Callout,
|
||
Caret,
|
||
ReplaceText,
|
||
Attach,
|
||
Eraser,
|
||
}
|
||
|
||
/// The quick-bar flyout groups, in Acrobat's order: Comment ▸, Highlight ▸, Draw ▸.
|
||
pub const GROUPS: [&[CommentTool]; 3] = [
|
||
&[CommentTool::Note, CommentTool::TextBox, CommentTool::Callout, CommentTool::Attach],
|
||
&[CommentTool::Highlight, CommentTool::Underline, CommentTool::StrikeOut, CommentTool::Squiggly, CommentTool::Caret, CommentTool::ReplaceText],
|
||
&[
|
||
CommentTool::Ink,
|
||
CommentTool::Line,
|
||
CommentTool::Arrow,
|
||
CommentTool::Rectangle,
|
||
CommentTool::Oval,
|
||
CommentTool::PolyLine,
|
||
CommentTool::Polygon,
|
||
CommentTool::Cloud,
|
||
CommentTool::Eraser,
|
||
],
|
||
];
|
||
|
||
pub const ALL: [CommentTool; 19] = [
|
||
CommentTool::Eraser,
|
||
CommentTool::ReplaceText,
|
||
CommentTool::Attach,
|
||
CommentTool::Polygon,
|
||
CommentTool::PolyLine,
|
||
CommentTool::Cloud,
|
||
CommentTool::Callout,
|
||
CommentTool::Caret,
|
||
CommentTool::Note,
|
||
CommentTool::TextBox,
|
||
CommentTool::Highlight,
|
||
CommentTool::Underline,
|
||
CommentTool::StrikeOut,
|
||
CommentTool::Squiggly,
|
||
CommentTool::Ink,
|
||
CommentTool::Line,
|
||
CommentTool::Arrow,
|
||
CommentTool::Rectangle,
|
||
CommentTool::Oval,
|
||
];
|
||
|
||
impl CommentTool {
|
||
pub fn command(self) -> &'static str {
|
||
match self {
|
||
Self::Note => "comment.note",
|
||
Self::TextBox => "comment.freetext",
|
||
Self::Highlight => "comment.highlight",
|
||
Self::Underline => "comment.underline",
|
||
Self::StrikeOut => "comment.strikeout",
|
||
Self::Squiggly => "comment.squiggly",
|
||
Self::Ink => "comment.ink",
|
||
Self::Line => "comment.line",
|
||
Self::Arrow => "comment.arrow",
|
||
Self::Rectangle => "comment.square",
|
||
Self::Oval => "comment.circle",
|
||
Self::Polygon => "comment.polygon",
|
||
Self::PolyLine => "comment.polyline",
|
||
Self::Cloud => "comment.cloud",
|
||
Self::Callout => "comment.callout",
|
||
Self::Caret => "comment.caret",
|
||
Self::ReplaceText => "comment.replace",
|
||
Self::Attach => "comment.attach",
|
||
Self::Eraser => "comment.eraser",
|
||
}
|
||
}
|
||
|
||
pub fn from_command(id: &str) -> Option<Self> {
|
||
ALL.into_iter().find(|t| t.command() == id)
|
||
}
|
||
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Note => "Add a sticky note",
|
||
Self::TextBox => "Add a text box",
|
||
Self::Highlight => "Highlight",
|
||
Self::Underline => "Underline",
|
||
Self::StrikeOut => "Strikethrough",
|
||
Self::Squiggly => "Squiggly underline",
|
||
Self::Ink => "Draw",
|
||
Self::Line => "Line",
|
||
Self::Arrow => "Arrow",
|
||
Self::Rectangle => "Rectangle",
|
||
Self::Oval => "Oval",
|
||
Self::Polygon => "Polygon",
|
||
Self::PolyLine => "Connected lines",
|
||
Self::Cloud => "Cloud",
|
||
Self::Callout => "Add a callout",
|
||
Self::Caret => "Insert text",
|
||
Self::ReplaceText => "Replace text",
|
||
Self::Attach => "Attach file",
|
||
Self::Eraser => "Eraser",
|
||
}
|
||
}
|
||
|
||
pub fn icon(self) -> &'static str {
|
||
match self {
|
||
Self::Note => "message-square-plus",
|
||
Self::TextBox => "type",
|
||
Self::Highlight => "highlighter",
|
||
Self::Underline => "underline",
|
||
Self::StrikeOut => "strikethrough",
|
||
Self::Squiggly => "spline",
|
||
Self::Ink => "pencil",
|
||
Self::Line => "minus",
|
||
Self::Arrow => "move-right",
|
||
Self::Rectangle => "square",
|
||
Self::Oval => "circle",
|
||
Self::Polygon => "pentagon",
|
||
Self::PolyLine => "spline",
|
||
Self::Cloud => "cloud",
|
||
Self::Callout => "message-square-quote",
|
||
Self::Caret => "text-cursor-input",
|
||
Self::ReplaceText => "replace",
|
||
Self::Attach => "paperclip",
|
||
Self::Eraser => "eraser",
|
||
}
|
||
}
|
||
|
||
pub fn group(self) -> usize {
|
||
GROUPS.iter().position(|g| g.contains(&self)).unwrap_or(0)
|
||
}
|
||
|
||
pub fn markup(self) -> Option<Markup> {
|
||
match self {
|
||
Self::Highlight => Some(Markup::Highlight),
|
||
Self::Underline => Some(Markup::Underline),
|
||
Self::StrikeOut => Some(Markup::StrikeOut),
|
||
Self::Squiggly => Some(Markup::Squiggly),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Tools that draw with a drag gesture.
|
||
pub fn draws(self) -> bool {
|
||
matches!(self, Self::Ink | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval | Self::Eraser)
|
||
}
|
||
|
||
/// Tools that place points one click at a time (double-click or Enter finishes).
|
||
pub fn clicks_points(self) -> bool {
|
||
matches!(self, Self::Polygon | Self::PolyLine | Self::Cloud)
|
||
}
|
||
|
||
/// Whether the line-thickness control applies.
|
||
pub fn has_width(self) -> bool {
|
||
self.draws() || self.clicks_points()
|
||
}
|
||
|
||
/// Whether the fill-colour control applies: shapes with an interior (#686).
|
||
pub fn has_fill(self) -> bool {
|
||
matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud)
|
||
}
|
||
|
||
/// A placeholder shape of this kind (for per-tool default styles).
|
||
fn sample(self) -> Shape {
|
||
match self {
|
||
Self::Note => Shape::Note { at: [0.0; 2], icon: NoteIcon::Comment },
|
||
Self::TextBox => Shape::TextBox { rect: [0.0; 4], font_size: 12.0 },
|
||
Self::Highlight | Self::Underline | Self::StrikeOut | Self::Squiggly => {
|
||
Shape::TextMarkup { kind: self.markup().unwrap_or(Markup::Highlight), quads: Vec::new() }
|
||
}
|
||
Self::Ink => Shape::Ink { strokes: Vec::new() },
|
||
Self::Line => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::None },
|
||
Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::OpenArrow },
|
||
Self::Rectangle => Shape::Rectangle { rect: [0.0; 4] },
|
||
Self::Oval => Shape::Oval { rect: [0.0; 4] },
|
||
Self::Polygon => Shape::Polygon { vertices: Vec::new(), cloud: false },
|
||
Self::Cloud => Shape::Polygon { vertices: Vec::new(), cloud: true },
|
||
Self::PolyLine => Shape::PolyLine { vertices: Vec::new(), start: LineEnding::None, end: LineEnding::None },
|
||
Self::Callout => Shape::Callout { rect: [0.0; 4], knee: [0.0; 2], point: [0.0; 2], font_size: 10.0, ending: LineEnding::OpenArrow },
|
||
Self::Caret => Shape::Caret { rect: [0.0; 4] },
|
||
Self::ReplaceText => Shape::TextMarkup { kind: Markup::StrikeOut, quads: Vec::new() },
|
||
Self::Eraser => Shape::Ink { strokes: Vec::new() },
|
||
Self::Attach => Shape::Attachment { at: [0.0; 2], icon: pdfcraft_engine::AttachIcon::PushPin, file: String::new(), data: Vec::new() },
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The comment swatches (our palette, close to Acrobat's quick colours).
|
||
pub const SWATCHES: [(&str, Rgb); 10] = [
|
||
("Yellow", [1.0, 0.94, 0.0]),
|
||
("Orange", [1.0, 0.54, 0.0]),
|
||
("Red", [0.89, 0.13, 0.13]),
|
||
("Pink", [1.0, 0.37, 0.64]),
|
||
("Purple", [0.54, 0.25, 0.82]),
|
||
("Blue", [0.0, 0.47, 0.84]),
|
||
("Light blue", [0.36, 0.75, 0.98]),
|
||
("Green", [0.18, 0.62, 0.36]),
|
||
("Gray", [0.5, 0.5, 0.5]),
|
||
("Black", [0.0, 0.0, 0.0]),
|
||
];
|
||
|
||
pub fn color32(c: Rgb) -> Color32 {
|
||
let b = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||
Color32::from_rgb(b(c[0]), b(c[1]), b(c[2]))
|
||
}
|
||
|
||
/// Commenting preferences shared by every document (author, per-tool styles, pin).
|
||
#[derive(Clone, Debug)]
|
||
pub struct CommentPrefs {
|
||
/// `/T` of new comments (Acrobat: Preferences ▸ Identity, default the login name).
|
||
pub author: String,
|
||
styles: Vec<(CommentTool, Style)>,
|
||
/// Keep the tool selected after use (the pin).
|
||
pub pinned: bool,
|
||
/// The tool each flyout group shows (the last one used).
|
||
pub group_tool: [CommentTool; 3],
|
||
/// Hide hover previews on highlight annotations when they contain no comment text beyond the author.
|
||
pub hide_empty_highlight_hover: bool,
|
||
}
|
||
|
||
impl Default for CommentPrefs {
|
||
fn default() -> Self {
|
||
Self {
|
||
author: login_name(),
|
||
styles: ALL.iter().map(|t| (*t, Style::default_for(&t.sample()))).collect(),
|
||
pinned: false,
|
||
group_tool: [CommentTool::Note, CommentTool::Highlight, CommentTool::Ink],
|
||
hide_empty_highlight_hover: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl CommentPrefs {
|
||
pub fn style(&self, tool: CommentTool) -> Style {
|
||
self.styles.iter().find(|(t, _)| *t == tool).map(|(_, s)| s.clone()).unwrap_or_default()
|
||
}
|
||
|
||
/// Every tool with its current default style (the persisted settings read this).
|
||
pub fn styles(&self) -> impl Iterator<Item = (CommentTool, &Style)> {
|
||
self.styles.iter().map(|(t, s)| (*t, s))
|
||
}
|
||
|
||
pub fn set_color(&mut self, tool: CommentTool, c: Rgb) {
|
||
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
|
||
s.color = c;
|
||
}
|
||
}
|
||
|
||
pub fn set_width(&mut self, tool: CommentTool, w: f64) {
|
||
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
|
||
s.width = w.clamp(0.5, 12.0);
|
||
}
|
||
}
|
||
|
||
/// The tool's opacity, 10–100 % (an invisible comment can't be found again).
|
||
pub fn set_opacity(&mut self, tool: CommentTool, o: f64) {
|
||
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
|
||
s.opacity = if o.is_finite() { o.clamp(0.1, 1.0) } else { 1.0 };
|
||
}
|
||
}
|
||
|
||
/// The tool's fill (`None`: no fill). Only shapes with an interior keep one.
|
||
pub fn set_fill(&mut self, tool: CommentTool, fill: Option<Rgb>) {
|
||
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
|
||
s.fill = if tool.has_fill() { fill.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0))) } else { None };
|
||
}
|
||
}
|
||
|
||
pub fn set_style(&mut self, tool: CommentTool, style: Style) {
|
||
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
|
||
*s = style;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Whether `tool` makes comments like `a`. Arrows are saved as plain Line annotations with an
|
||
/// ending, so the Line and Arrow tools both match a line.
|
||
pub(crate) fn made_by(a: &Annotation, tool: CommentTool) -> bool {
|
||
match tool_for(a) {
|
||
Some(CommentTool::Line) => matches!(tool, CommentTool::Line | CommentTool::Arrow),
|
||
made => made == Some(tool),
|
||
}
|
||
}
|
||
|
||
/// The tool whose defaults "Make Current Properties Default" should update.
|
||
///
|
||
/// Arrows are stored as Line annotations. An arrow ending belongs to the Arrow tool, so its
|
||
/// defaults stay separate from a plain line.
|
||
pub(crate) fn tool_for_default(a: &Annotation, endings: Option<&[LineEnding]>) -> Option<CommentTool> {
|
||
match tool_for(a) {
|
||
Some(CommentTool::Line) if endings.is_some_and(|ends| ends.iter().copied().any(arrow_ending)) => Some(CommentTool::Arrow),
|
||
other => other,
|
||
}
|
||
}
|
||
|
||
fn arrow_ending(ending: LineEnding) -> bool {
|
||
matches!(ending, LineEnding::OpenArrow | LineEnding::ClosedArrow | LineEnding::ROpenArrow | LineEnding::RClosedArrow)
|
||
}
|
||
|
||
/// The tool that makes comments like `a` (for Make Current Properties Default).
|
||
pub fn tool_for(a: &Annotation) -> Option<CommentTool> {
|
||
Some(match (a.subtype.as_str(), a.intent.as_deref()) {
|
||
("Text", _) => CommentTool::Note,
|
||
("FreeText", Some("FreeTextCallout")) => CommentTool::Callout,
|
||
("FreeText", Some("FreeTextTypeWriter")) => return None,
|
||
("FreeText", _) => CommentTool::TextBox,
|
||
("Highlight", _) => CommentTool::Highlight,
|
||
("Underline", _) => CommentTool::Underline,
|
||
("StrikeOut", _) => CommentTool::StrikeOut,
|
||
("Squiggly", _) => CommentTool::Squiggly,
|
||
("Ink", _) => CommentTool::Ink,
|
||
("Line", _) => CommentTool::Line,
|
||
("Square", _) => CommentTool::Rectangle,
|
||
("Circle", _) => CommentTool::Oval,
|
||
("Polygon", Some("PolygonCloud")) => CommentTool::Cloud,
|
||
("Polygon", _) => CommentTool::Polygon,
|
||
("PolyLine", _) => CommentTool::PolyLine,
|
||
("Caret", _) => CommentTool::Caret,
|
||
("FileAttachment", _) => CommentTool::Attach,
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// The user's login name, which Acrobat uses as the default comment author.
|
||
fn login_name() -> String {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
for var in ["USER", "USERNAME", "LOGNAME"] {
|
||
if let Ok(v) = std::env::var(var)
|
||
&& !v.trim().is_empty()
|
||
{
|
||
return v;
|
||
}
|
||
}
|
||
"Guest".into()
|
||
}
|
||
|
||
/// A drag in progress on a page.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum Gesture {
|
||
/// Drawing with a tool: points in user space (ink: the stroke; polygons: the vertices
|
||
/// clicked so far; others: start and end).
|
||
Draw { page: usize, tool: CommentTool, points: Vec<[f64; 2]> },
|
||
/// Moving a comment, from the press position on screen.
|
||
Move { page: usize, index: usize, from: Pos2 },
|
||
/// Resizing a comment by one of its handles: (dx, dy) ∈ {-1, 0, 1}² says which sides move.
|
||
Resize { page: usize, index: usize, handle: (i8, i8), from: Pos2, aspect_ratio: Option<f32> },
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub enum ComposerKind {
|
||
Note,
|
||
TextBox,
|
||
/// A callout whose arrow touches `point`; the composer's anchor is the text box's top-left.
|
||
Callout {
|
||
point: [f64; 2],
|
||
},
|
||
/// Insert text at the anchor (the caret's tip); `size` is the side of the caret's square box,
|
||
/// in user space units.
|
||
Caret {
|
||
size: f64,
|
||
},
|
||
/// Replace the selected text (`CommentView::replace` holds the quads).
|
||
Replace,
|
||
/// Edit the text of the comment at this index on the composer's page.
|
||
Edit(usize),
|
||
}
|
||
|
||
/// The floating "Add a comment" card.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Composer {
|
||
pub page: usize,
|
||
/// Anchor in user space (the note's top-left, the text box's top-left, the comment's corner).
|
||
pub at: [f64; 2],
|
||
pub kind: ComposerKind,
|
||
pub text: String,
|
||
pub focus: bool,
|
||
}
|
||
|
||
/// A released drawing gesture waiting for the page raster to show its annotation.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub(crate) struct Settling {
|
||
pub page: usize,
|
||
pub tool: CommentTool,
|
||
pub points: Vec<[f64; 2]>,
|
||
/// Frame time (seconds) at release; a raster that never arrives must not leave a ghost.
|
||
pub since: f64,
|
||
/// Its annotation is in the document, so a raster of the page from now on draws it. Until
|
||
/// then a raster is of the document without it (in full screen the edit is applied the frame
|
||
/// after the release, and a raster already on its way can arrive in between).
|
||
pub committed: bool,
|
||
}
|
||
|
||
/// Longest a released drawing stays painted without its page raster, and how many are kept.
|
||
const SETTLE_SECS: f64 = 20.0;
|
||
const SETTLE_MAX: usize = 16;
|
||
|
||
impl CommentView {
|
||
/// Remember a released drag drawing so it stays on screen until its page is re-rendered.
|
||
pub(crate) fn settle(&mut self, now: f64, page: usize, tool: CommentTool, points: &[[f64; 2]]) {
|
||
if !matches!(tool, CommentTool::Ink | CommentTool::Line | CommentTool::Arrow | CommentTool::Rectangle | CommentTool::Oval) {
|
||
return;
|
||
}
|
||
self.settling.retain(|s| now - s.since < SETTLE_SECS);
|
||
if self.settling.len() >= SETTLE_MAX {
|
||
self.settling.remove(0);
|
||
}
|
||
self.settling.push(Settling { page, tool, points: points.to_vec(), since: now, committed: false });
|
||
}
|
||
|
||
/// How many released drawings are still painted from their points (waiting for a raster).
|
||
pub fn settling_len(&self) -> usize {
|
||
self.settling.len()
|
||
}
|
||
|
||
/// A fresh raster of `page` is on screen: it draws the committed annotations itself. One
|
||
/// still waiting for its edit isn't in that raster, and stays drawn from its points.
|
||
pub(crate) fn page_rendered(&mut self, page: usize) {
|
||
self.settling.retain(|s| s.page != page || !s.committed);
|
||
}
|
||
|
||
/// The edit adding the released drawings was applied: rasters from now on draw them.
|
||
pub(crate) fn drawings_committed(&mut self) {
|
||
for s in &mut self.settling {
|
||
s.committed = true;
|
||
}
|
||
}
|
||
|
||
/// The edit adding the released drawings failed: nothing will draw them.
|
||
pub(crate) fn drawings_refused(&mut self) {
|
||
self.settling.retain(|s| s.committed);
|
||
}
|
||
}
|
||
|
||
/// Per-document commenting state.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct CommentView {
|
||
/// The selected comment: (page, index in `/Annots`).
|
||
pub selected: Option<(usize, usize)>,
|
||
pub gesture: Option<Gesture>,
|
||
/// Drawings just released: shown from their points until the page raster that includes the
|
||
/// committed annotation arrives, so the stroke never vanishes in between (#1858).
|
||
pub(crate) settling: Vec<Settling>,
|
||
/// Escape ends egui's drag too; ignore its synthetic release until the mouse is up.
|
||
cancelled_drag: bool,
|
||
pub composer: Option<Composer>,
|
||
/// Reply being typed under the selected card.
|
||
pub reply: String,
|
||
/// Inline edit of a card's text in the Comments panel: (page, index, text).
|
||
pub editing: Option<(usize, usize, String)>,
|
||
/// The panel should scroll the selected card into view.
|
||
pub reveal: bool,
|
||
/// The "Add a comment" box at the top of the Comments panel.
|
||
pub add_box: String,
|
||
/// The Comments panel's search query (`None`: search closed).
|
||
pub search: Option<String>,
|
||
/// Comment types, authors and statuses hidden by the panel's filter.
|
||
pub hidden_types: Vec<String>,
|
||
pub hidden_authors: Vec<String>,
|
||
pub hidden_statuses: Vec<String>,
|
||
/// Colours (`RRGGBB`, empty for none) and checkmark states ("Checked", "Unchecked") hidden.
|
||
pub hidden_colors: Vec<String>,
|
||
pub hidden_checks: Vec<String>,
|
||
/// How the panel orders comments.
|
||
pub sort: SortBy,
|
||
/// Pages whose group is folded in the panel (when sorted by page).
|
||
pub collapsed_pages: Vec<usize>,
|
||
/// Comment Properties was asked for from the panel: (page, index).
|
||
pub props_request: Option<(usize, usize)>,
|
||
/// Make Current Properties Default was asked for: (page, index).
|
||
pub default_request: Option<(usize, usize)>,
|
||
/// A file attachment comment's file was asked for: (page, index, open it in a new tab
|
||
/// rather than save it).
|
||
pub attachment_request: Option<(usize, usize, bool)>,
|
||
/// Replace Text: the struck-out text's quads while the replacement is typed.
|
||
pub replace: Option<Vec<[f64; 8]>>,
|
||
/// Attach file: where the icon goes (page, top-left in user space), for the app to pick a file.
|
||
pub attach_at: Option<(usize, [f64; 2])>,
|
||
pub search_focus: bool,
|
||
/// Where the canvas context menu was opened: (page, user-space point).
|
||
pub context_at: Option<(usize, [f64; 2])>,
|
||
/// A one-shot tool just finished; the app returns to the Select tool unless pinned.
|
||
pub tool_done: bool,
|
||
}
|
||
|
||
impl CommentView {
|
||
/// Takes back the last point of a polygon, cloud or connected lines being drawn; the first
|
||
/// point's removal cancels the drawing. Returns whether there was such a drawing.
|
||
pub fn undo_point(&mut self) -> bool {
|
||
let Some(Gesture::Draw { tool, points, .. }) = self.gesture.as_mut() else { return false };
|
||
if !tool.clicks_points() {
|
||
return false;
|
||
}
|
||
points.pop();
|
||
if points.is_empty() {
|
||
self.gesture = None;
|
||
}
|
||
true
|
||
}
|
||
}
|
||
|
||
/// Comments panel order (Acrobat: Sort by page, author, date, type, colour).
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub enum SortBy {
|
||
#[default]
|
||
Page,
|
||
Author,
|
||
Date,
|
||
Type,
|
||
Color,
|
||
}
|
||
|
||
/// What a page needs to know to handle comment input.
|
||
pub(crate) struct PageCx<'a> {
|
||
pub page: usize,
|
||
pub xf: &'a PageXform,
|
||
pub info: &'a DocInfo,
|
||
pub tool: QuickTool,
|
||
pub prefs: &'a CommentPrefs,
|
||
/// A redaction mark's fill while the pointer is over it, and its opacity (Redaction Tool
|
||
/// Properties).
|
||
pub redact_mark: ([f64; 3], f64),
|
||
/// The document allows commenting.
|
||
pub allowed: bool,
|
||
/// Comments are hidden (Hide all comments): nothing to select.
|
||
pub hidden: bool,
|
||
}
|
||
|
||
impl PageCx<'_> {
|
||
fn to_user(&self, p: Pos2) -> [f64; 2] {
|
||
let (vx, vy) = self.xf.screen_to_view(p);
|
||
let u = self.info.pages[self.page].view_to_user(vx, vy);
|
||
[u[0] as f64, u[1] as f64]
|
||
}
|
||
|
||
fn to_screen(&self, p: [f64; 2]) -> Pos2 {
|
||
let v = self.info.pages[self.page].user_to_view(p[0] as f32, p[1] as f32);
|
||
self.xf.norm_to_screen(v[0] / self.xf.pw, v[1] / self.xf.ph)
|
||
}
|
||
|
||
fn screen_rect(&self, a: &Annotation) -> Rect {
|
||
self.xf.user_rect(self.info, self.page, a.rect)
|
||
}
|
||
|
||
/// Top-level comments on this page, in paint order.
|
||
fn comments(&self) -> impl Iterator<Item = &Annotation> {
|
||
self.info.annotations.iter().filter(move |a| !self.hidden && a.page == self.page && a.in_reply_to.is_none() && a.subtype != "Popup")
|
||
}
|
||
|
||
/// Where a comment is on screen: one rectangle, or one per marked line for text markup.
|
||
fn screen_rects(&self, a: &Annotation) -> Vec<Rect> {
|
||
if is_markup(&a.subtype) && !a.quads.is_empty() {
|
||
a.quads
|
||
.iter()
|
||
.map(|q| {
|
||
let xs = [q[0], q[2], q[4], q[6]];
|
||
let ys = [q[1], q[3], q[5], q[7]];
|
||
let (x0, x1) = (xs.iter().copied().fold(f32::MAX, f32::min), xs.iter().copied().fold(f32::MIN, f32::max));
|
||
let (y0, y1) = (ys.iter().copied().fold(f32::MAX, f32::min), ys.iter().copied().fold(f32::MIN, f32::max));
|
||
self.xf.user_rect(self.info, self.page, [x0, y0, x1, y1])
|
||
})
|
||
.collect()
|
||
} else {
|
||
vec![self.screen_rect(a)]
|
||
}
|
||
}
|
||
|
||
/// A redaction mark's areas: one per marked quad, or its rectangle.
|
||
fn redact_rects(&self, a: &Annotation) -> Vec<Rect> {
|
||
if a.quads.is_empty() {
|
||
return vec![self.screen_rect(a)];
|
||
}
|
||
a.quads
|
||
.iter()
|
||
.map(|q| {
|
||
let xs = [q[0], q[2], q[4], q[6]];
|
||
let ys = [q[1], q[3], q[5], q[7]];
|
||
let (x0, x1) = (xs.iter().copied().fold(f32::MAX, f32::min), xs.iter().copied().fold(f32::MIN, f32::max));
|
||
let (y0, y1) = (ys.iter().copied().fold(f32::MAX, f32::min), ys.iter().copied().fold(f32::MIN, f32::max));
|
||
self.xf.user_rect(self.info, self.page, [x0, y0, x1, y1])
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The topmost comment under `p`.
|
||
fn hit(&self, p: Pos2) -> Option<&Annotation> {
|
||
self.comments().filter(|a| self.screen_rects(a).iter().any(|r| r.expand(3.0).contains(p))).last()
|
||
}
|
||
|
||
pub(crate) fn get(&self, index: usize) -> Option<&Annotation> {
|
||
self.comments().find(|a| a.index == index)
|
||
}
|
||
|
||
pub(crate) fn rect_to_user(&self, r: Rect) -> [f64; 4] {
|
||
let (a, b) = (self.to_user(r.left_top()), self.to_user(r.right_bottom()));
|
||
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
|
||
}
|
||
|
||
/// The same geometry drives the live image, its handles, and the edit on release.
|
||
/// `shift` is whether Shift is held now: it keeps the box's proportions from a corner.
|
||
pub(crate) fn adjusted_rect(
|
||
&self,
|
||
a: &Annotation,
|
||
gesture: Option<&Gesture>,
|
||
pointer: Option<Pos2>,
|
||
shift: bool,
|
||
pending: Option<&Edit>,
|
||
) -> Rect {
|
||
let r = self.screen_rect(a);
|
||
if let Some(Edit::ResizeAnnotation { page, index, rect }) = pending
|
||
&& (*page, *index) == (self.page, a.index)
|
||
{
|
||
return self.xf.user_rect(self.info, self.page, rect.map(|v| v as f32));
|
||
}
|
||
if let Some(Edit::MoveAnnotation { page, index, dx, dy }) = pending
|
||
&& (*page, *index) == (self.page, a.index)
|
||
{
|
||
return self.xf.user_rect(
|
||
self.info,
|
||
self.page,
|
||
[a.rect[0] + *dx as f32, a.rect[1] + *dy as f32, a.rect[2] + *dx as f32, a.rect[3] + *dy as f32],
|
||
);
|
||
}
|
||
match (gesture, pointer) {
|
||
(Some(Gesture::Move { page, index, from }), Some(p)) if (*page, *index) == (self.page, a.index) => r.translate(p - *from),
|
||
(Some(Gesture::Resize { page, index, handle, from, aspect_ratio }), Some(p)) if (*page, *index) == (self.page, a.index) => {
|
||
resized(r, *handle, p - *from, corner_ratio(r, *aspect_ratio, shift))
|
||
}
|
||
_ => r,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn is_markup(subtype: &str) -> bool {
|
||
matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly")
|
||
}
|
||
|
||
/// Rectangles, ovals, text boxes, stamps and drawings (drawn signatures among them). A callout's
|
||
/// `/Rect` also holds its leader line, so it only moves.
|
||
fn resizable(a: &Annotation) -> bool {
|
||
matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp" | "Ink") && a.intent.as_deref() != Some("FreeTextCallout")
|
||
}
|
||
|
||
const HANDLES: [(i8, i8); 8] = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)];
|
||
|
||
fn handle_pos(r: Rect, (hx, hy): (i8, i8)) -> Pos2 {
|
||
let x = match hx {
|
||
-1 => r.left(),
|
||
0 => r.center().x,
|
||
_ => r.right(),
|
||
};
|
||
let y = match hy {
|
||
-1 => r.top(),
|
||
0 => r.center().y,
|
||
_ => r.bottom(),
|
||
};
|
||
pos2(x, y)
|
||
}
|
||
|
||
/// The proportions a corner drag of `r` keeps: the box's own fixed ratio (an image signature's),
|
||
/// else `r`'s current one while Shift is held, else none.
|
||
pub(crate) fn corner_ratio(r: Rect, fixed: Option<f32>, shift: bool) -> Option<f32> {
|
||
fixed.or_else(|| (shift && r.width() > 0.0 && r.height() > 0.0).then(|| r.width() / r.height()))
|
||
}
|
||
|
||
pub(crate) fn resized(r: Rect, (hx, hy): (i8, i8), d: egui::Vec2, aspect_ratio: Option<f32>) -> Rect {
|
||
if hx != 0
|
||
&& hy != 0
|
||
&& let Some(ratio) = aspect_ratio.filter(|v| v.is_finite() && *v > 0.0)
|
||
{
|
||
// As in content_ui, the larger requested dimension drives proportional corner resizing.
|
||
// Keep the opposite corner fixed and prevent crossing it from flipping the image.
|
||
let width = (r.width() + f32::from(hx) * d.x).max((r.height() + f32::from(hy) * d.y) * ratio).max(4.0).max(4.0 * ratio);
|
||
let anchor = handle_pos(r, (-hx, -hy));
|
||
return Rect::from_two_pos(anchor, anchor + vec2(f32::from(hx) * width, f32::from(hy) * width / ratio));
|
||
}
|
||
let mut r = r;
|
||
match hx {
|
||
-1 => r.min.x += d.x,
|
||
1 => r.max.x += d.x,
|
||
_ => {}
|
||
}
|
||
match hy {
|
||
-1 => r.min.y += d.y,
|
||
1 => r.max.y += d.y,
|
||
_ => {}
|
||
}
|
||
Rect::from_two_pos(r.min, r.max)
|
||
}
|
||
|
||
/// Comment input on one page, before text selection runs. Returns `true` when the pointer
|
||
/// gesture belongs to commenting (text selection must ignore it).
|
||
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) -> bool {
|
||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||
let origin = ui.input(|i| i.pointer.press_origin());
|
||
let page_rect = cx.xf.rect;
|
||
let pressed_here = origin.is_some_and(|o| page_rect.contains(o));
|
||
let over_page = pointer.is_some_and(|p| page_rect.contains(p));
|
||
let fill_grab = fill_grabs(ui, cx, view);
|
||
let draw_grab = draw_grabs(ui, cx, view);
|
||
let caret_text = if cx.tool == QuickTool::Comment(CommentTool::Caret) { view.page_text(cx.page) } else { None };
|
||
let cv = &mut view.comments;
|
||
if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||
cv.gesture = None;
|
||
cv.cancelled_drag = true;
|
||
return true;
|
||
}
|
||
if cv.cancelled_drag {
|
||
return true;
|
||
}
|
||
if resp.secondary_clicked()
|
||
&& let Some(p) = pointer.filter(|p| page_rect.contains(*p))
|
||
{
|
||
cv.context_at = Some((cx.page, cx.to_user(p)));
|
||
cv.selected = cx.hit(p).map(|a| (cx.page, a.index));
|
||
}
|
||
match cx.tool {
|
||
// The shape just drawn is selected with its handles showing: they resize it without a
|
||
// trip to the Select tool (#2338). Anywhere else the tool keeps drawing.
|
||
QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here),
|
||
QuickTool::Comment(tool) if tool.draws() => {
|
||
if !cx.allowed {
|
||
return false;
|
||
}
|
||
if over_page {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||
}
|
||
if resp.drag_started()
|
||
&& pressed_here
|
||
&& let Some(o) = origin
|
||
{
|
||
cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] });
|
||
cv.selected = None;
|
||
}
|
||
if let Some(Gesture::Draw { page, tool, points }) = cv.gesture.as_mut()
|
||
&& *page == cx.page
|
||
&& let Some(p) = pointer
|
||
{
|
||
let p = clamp_to(page_rect, p);
|
||
let u = cx.to_user(p);
|
||
if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) {
|
||
let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p);
|
||
if last.distance(p) >= 1.5 {
|
||
points.push(u);
|
||
}
|
||
} else {
|
||
points.truncate(1);
|
||
points.push(u);
|
||
}
|
||
}
|
||
if resp.drag_stopped()
|
||
&& let Some(Gesture::Draw { page, tool, points }) = cv.gesture.clone()
|
||
&& page == cx.page
|
||
{
|
||
cv.gesture = None;
|
||
if tool == CommentTool::Eraser {
|
||
view.pending_edit = erase(cx, &points);
|
||
} else if let Some(shape) = drawn_shape(tool, &points) {
|
||
let now = ui.input(|i| i.time);
|
||
cv.settle(now, page, tool, &points);
|
||
view.pending_edit = Some(new_comment(cx, tool, shape, String::new()));
|
||
}
|
||
}
|
||
// A quick press/release does not start an egui drag, but Acrobat's Draw tool treats
|
||
// it as a one-point ink stroke (a dot). The appearance uses round line caps, so a
|
||
// single PDF point is visible and remains editable like any other drawing.
|
||
if tool == CommentTool::Ink
|
||
&& resp.clicked()
|
||
&& over_page
|
||
&& let Some(p) = pointer
|
||
&& let Some(shape) = drawn_shape(tool, &[cx.to_user(p)])
|
||
{
|
||
cv.selected = None;
|
||
let now = ui.input(|i| i.time);
|
||
cv.settle(now, cx.page, tool, &[cx.to_user(p)]);
|
||
view.pending_edit = Some(new_comment(cx, tool, shape, String::new()));
|
||
}
|
||
true
|
||
}
|
||
QuickTool::Comment(tool) if tool.clicks_points() => {
|
||
if !cx.allowed {
|
||
return false;
|
||
}
|
||
if over_page {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||
}
|
||
let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
// Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too.
|
||
// Only while a drawing is in progress: otherwise Delete belongs to the selected comment.
|
||
if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page)
|
||
&& !ui.ctx().egui_wants_keyboard_input()
|
||
&& ui.input_mut(|i| {
|
||
i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace) || i.consume_key(egui::Modifiers::NONE, egui::Key::Delete)
|
||
})
|
||
{
|
||
cv.undo_point();
|
||
return true;
|
||
}
|
||
if resp.clicked()
|
||
&& over_page
|
||
&& let Some(p) = pointer
|
||
{
|
||
let u = cx.to_user(p);
|
||
match cv.gesture.as_mut() {
|
||
Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => {
|
||
// Clicking the first point again closes a polygon.
|
||
let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0;
|
||
let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0);
|
||
if closes {
|
||
let pts = std::mem::take(points);
|
||
cv.gesture = None;
|
||
if let Some(shape) = drawn_shape(tool, &pts) {
|
||
view.pending_edit = Some(new_comment(cx, tool, shape, String::new()));
|
||
}
|
||
return true;
|
||
}
|
||
if !repeat {
|
||
points.push(u);
|
||
}
|
||
}
|
||
_ => {
|
||
cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![u] });
|
||
cv.selected = None;
|
||
}
|
||
}
|
||
}
|
||
if finish
|
||
&& let Some(Gesture::Draw { page, tool: t, points }) = cv.gesture.clone()
|
||
&& page == cx.page
|
||
&& t == tool
|
||
{
|
||
cv.gesture = None;
|
||
if let Some(shape) = drawn_shape(tool, &points) {
|
||
view.pending_edit = Some(new_comment(cx, tool, shape, String::new()));
|
||
}
|
||
}
|
||
true
|
||
}
|
||
QuickTool::Comment(CommentTool::Callout) => {
|
||
// Drag from what the callout points at to where its text box goes.
|
||
if !cx.allowed {
|
||
return false;
|
||
}
|
||
if over_page {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||
}
|
||
if resp.drag_started()
|
||
&& pressed_here
|
||
&& let Some(o) = origin
|
||
{
|
||
cv.gesture = Some(Gesture::Draw { page: cx.page, tool: CommentTool::Callout, points: vec![cx.to_user(o)] });
|
||
cv.selected = None;
|
||
}
|
||
if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut()
|
||
&& *page == cx.page
|
||
&& let Some(p) = pointer
|
||
{
|
||
points.truncate(1);
|
||
points.push(cx.to_user(clamp_to(page_rect, p)));
|
||
}
|
||
if resp.drag_stopped()
|
||
&& let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone()
|
||
&& page == cx.page
|
||
{
|
||
cv.gesture = None;
|
||
if let [point, at] = points[..]
|
||
&& (point[0] - at[0]).hypot(point[1] - at[1]) >= 8.0
|
||
{
|
||
cv.composer = Some(Composer { page, at, kind: ComposerKind::Callout { point }, text: String::new(), focus: true });
|
||
}
|
||
}
|
||
true
|
||
}
|
||
QuickTool::Comment(CommentTool::Attach) => {
|
||
if !cx.allowed {
|
||
return false;
|
||
}
|
||
if over_page {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||
}
|
||
if resp.clicked()
|
||
&& over_page
|
||
&& let Some(p) = pointer
|
||
{
|
||
cv.attach_at = Some((cx.page, cx.to_user(p)));
|
||
cv.selected = None;
|
||
}
|
||
true
|
||
}
|
||
QuickTool::Comment(CommentTool::Caret) => {
|
||
if !cx.allowed {
|
||
return false;
|
||
}
|
||
if over_page {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||
}
|
||
if resp.clicked()
|
||
&& over_page
|
||
&& let Some(p) = pointer
|
||
{
|
||
let (vx, vy) = cx.xf.screen_to_view(p);
|
||
let snapped = cx.info.pages.get(cx.page).zip(caret_text.as_deref()).and_then(|(page, t)| snapped_caret(page, t, vx, vy));
|
||
let (at, size) = snapped.unwrap_or_else(|| (cx.to_user(p), CARET_SIZE));
|
||
cv.composer = Some(Composer { page: cx.page, at, kind: ComposerKind::Caret { size }, text: String::new(), focus: true });
|
||
cv.selected = None;
|
||
}
|
||
true
|
||
}
|
||
QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => {
|
||
if !cx.allowed {
|
||
return false;
|
||
}
|
||
if over_page {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||
}
|
||
if resp.clicked()
|
||
&& over_page
|
||
&& let Some(p) = pointer
|
||
{
|
||
let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox };
|
||
cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true });
|
||
cv.selected = None;
|
||
}
|
||
true
|
||
}
|
||
QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin, pressed_here),
|
||
QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// With a Fill & Sign tool, the Fill & Sign marks already on the page are picked up as with the
|
||
/// Select tool, as in Acrobat: hovering one shows the move cursor, a click selects it, a drag
|
||
/// moves it and the selected one's handles resize it. Elsewhere the tool places its mark. While
|
||
/// the button is down the press decides; otherwise the pointer (egui clears the press origin on
|
||
/// release, so a click is located by the pointer).
|
||
pub(crate) fn fill_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool {
|
||
matches!(cx.tool, QuickTool::Fill(_)) && grabs(ui, cx, view, |a| a.fill_sign)
|
||
}
|
||
|
||
/// With the Select tool, a comment that would move (not text markup) is picked up before a form
|
||
/// field under it, so a signature placed over a field can still be dragged off it.
|
||
pub(crate) fn select_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool {
|
||
cx.tool == QuickTool::Select && grabs(ui, cx, view, |a| !is_markup(&a.subtype))
|
||
}
|
||
|
||
/// With a shape tool (rectangle, oval, line, polygon…), the selected comment's resize handles
|
||
/// are picked up as with the Select tool, so the shape just drawn can be resized straight away
|
||
/// (#2338). Only the handles: a press anywhere else, the shape itself included, still draws.
|
||
pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool {
|
||
let QuickTool::Comment(tool) = cx.tool else { return false };
|
||
if !(tool.draws() || tool.clicks_points()) || tool == CommentTool::Eraser || cx.hidden {
|
||
return false;
|
||
}
|
||
let cv = &view.comments;
|
||
if matches!(cv.gesture, Some(Gesture::Resize { page, .. }) if page == cx.page) {
|
||
return true;
|
||
}
|
||
// A polygon being clicked out keeps its clicks, even near an old shape's handle.
|
||
if cv.gesture.is_some() {
|
||
return false;
|
||
}
|
||
press_point(ui, cx).is_some_and(|p| on_handle(cx, view, p, |_| true))
|
||
}
|
||
|
||
/// Whether the press (or, with no button down, the pointer) is on a `pick` comment or on the
|
||
/// selected one's resize handles.
|
||
fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> bool {
|
||
if cx.hidden {
|
||
return false;
|
||
}
|
||
if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) {
|
||
return true;
|
||
}
|
||
let Some(p) = press_point(ui, cx) else { return false };
|
||
on_handle(cx, view, p, &pick) || cx.hit(p).is_some_and(pick)
|
||
}
|
||
|
||
/// Where a gesture on this page is decided: the press while the button is down, otherwise the
|
||
/// pointer (egui clears the press origin on release). `None` off the page.
|
||
fn press_point(ui: &egui::Ui, cx: &PageCx<'_>) -> Option<Pos2> {
|
||
let (pointer, origin, down) = ui.input(|i| (i.pointer.hover_pos(), i.pointer.press_origin(), i.pointer.any_down()));
|
||
(if down { origin } else { pointer }).filter(|p| cx.xf.rect.contains(*p))
|
||
}
|
||
|
||
/// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`.
|
||
fn on_handle(cx: &PageCx<'_>, view: &DocView, p: Pos2, pick: impl Fn(&Annotation) -> bool) -> bool {
|
||
view.comments
|
||
.selected
|
||
.filter(|(page, _)| *page == cx.page)
|
||
.and_then(|(_, i)| cx.get(i))
|
||
.filter(|a| pick(a) && cx.allowed && resizable(a))
|
||
.is_some_and(|a| HANDLES.into_iter().any(|h| handle_pos(cx.screen_rect(a), h).distance(p) <= 7.0))
|
||
}
|
||
|
||
fn clamp_to(r: Rect, p: Pos2) -> Pos2 {
|
||
pos2(p.x.clamp(r.left(), r.right()), p.y.clamp(r.top(), r.bottom()))
|
||
}
|
||
|
||
/// The Select tool: hover, select, move, resize, edit, delete.
|
||
fn select_input(
|
||
ui: &egui::Ui,
|
||
resp: &egui::Response,
|
||
cx: &PageCx<'_>,
|
||
view: &mut DocView,
|
||
pointer: Option<Pos2>,
|
||
origin: Option<Pos2>,
|
||
pressed_here: bool,
|
||
) -> bool {
|
||
let cv = &mut view.comments;
|
||
let selected = cv.selected.filter(|(p, _)| *p == cx.page).and_then(|(_, i)| cx.get(i));
|
||
// Handles of a selected, resizable comment.
|
||
let handle_at = |p: Pos2| -> Option<(i8, i8)> {
|
||
let a = selected.filter(|a| cx.allowed && resizable(a))?;
|
||
let r = cx.screen_rect(a);
|
||
HANDLES.into_iter().find(|h| handle_pos(r, *h).distance(p) <= 7.0)
|
||
};
|
||
let mut consumed = false;
|
||
if let Some(p) = pointer {
|
||
if let Some(h) = handle_at(p) {
|
||
ui.ctx().set_cursor_icon(match h {
|
||
(0, _) => egui::CursorIcon::ResizeVertical,
|
||
(_, 0) => egui::CursorIcon::ResizeHorizontal,
|
||
(-1, -1) | (1, 1) => egui::CursorIcon::ResizeNwSe,
|
||
_ => egui::CursorIcon::ResizeNeSw,
|
||
});
|
||
} else if let Some(a) = cx.hit(p) {
|
||
let movable = cx.allowed && !is_markup(&a.subtype);
|
||
ui.ctx().set_cursor_icon(if movable { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand });
|
||
}
|
||
}
|
||
if resp.drag_started()
|
||
&& pressed_here
|
||
&& let Some(o) = origin
|
||
{
|
||
if let (Some(h), Some(a)) = (handle_at(o), selected) {
|
||
// On screen, the image is also turned by the view's rotation.
|
||
let turned = !cx.xf.rot.is_multiple_of(180);
|
||
let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio });
|
||
// A drawn signature keeps its shape from the corners, as image signatures do.
|
||
let aspect_ratio = aspect_ratio.or_else(|| {
|
||
let r = cx.screen_rect(a);
|
||
(a.fill_sign && a.subtype == "Ink" && r.height() > 0.0).then(|| r.width() / r.height())
|
||
});
|
||
cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: h, from: o, aspect_ratio });
|
||
consumed = true;
|
||
} else if let Some(a) = cx.hit(o)
|
||
&& !is_markup(&a.subtype)
|
||
{
|
||
cv.selected = Some((cx.page, a.index));
|
||
cv.reveal = true;
|
||
if cx.allowed {
|
||
cv.gesture = Some(Gesture::Move { page: cx.page, index: a.index, from: o });
|
||
}
|
||
consumed = true;
|
||
}
|
||
}
|
||
if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) {
|
||
consumed = true;
|
||
}
|
||
if resp.drag_stopped()
|
||
&& let Some(p) = pointer
|
||
{
|
||
match cv.gesture.clone() {
|
||
Some(Gesture::Move { page, index, from }) if page == cx.page => {
|
||
cv.gesture = None;
|
||
let (a, b) = (cx.to_user(from), cx.to_user(p));
|
||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||
if from.distance(p) >= 2.0 {
|
||
view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy });
|
||
}
|
||
}
|
||
Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => {
|
||
cv.gesture = None;
|
||
if let Some(a) = cx.get(index) {
|
||
let from_rect = cx.screen_rect(a);
|
||
let shift = ui.input(|i| i.modifiers.shift);
|
||
let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift));
|
||
let rect = cx.rect_to_user(r);
|
||
if r.width() >= 4.0 && r.height() >= 4.0 {
|
||
view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect });
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
// (egui clears the press origin on release, so clicks are located by the pointer.)
|
||
let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p));
|
||
if resp.clicked() && on_page {
|
||
let follow_link = cx.tool == QuickTool::Select
|
||
&& !consumed
|
||
&& cv.gesture.is_none()
|
||
&& !resp.double_clicked()
|
||
&& pointer
|
||
.is_some_and(|p| cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p)));
|
||
match pointer.and_then(|p| cx.hit(p)) {
|
||
Some(a) if a.subtype == "Highlight" && follow_link => {
|
||
// A plain click follows the link beneath text highlighting. Keep other
|
||
// annotations and active gestures in control of their own input.
|
||
cv.selected = None;
|
||
}
|
||
Some(a) => {
|
||
cv.selected = Some((cx.page, a.index));
|
||
cv.reveal = true;
|
||
consumed = true;
|
||
}
|
||
None => cv.selected = None,
|
||
}
|
||
}
|
||
if resp.double_clicked()
|
||
&& on_page
|
||
&& cx.allowed
|
||
&& let Some(a) = pointer.and_then(|p| cx.hit(p))
|
||
{
|
||
cv.selected = Some((cx.page, a.index));
|
||
cv.composer = Some(Composer {
|
||
page: cx.page,
|
||
at: [a.rect[2] as f64, a.rect[3] as f64],
|
||
kind: ComposerKind::Edit(a.index),
|
||
text: a.contents.clone().unwrap_or_default(),
|
||
focus: true,
|
||
});
|
||
consumed = true;
|
||
}
|
||
consumed
|
||
}
|
||
|
||
/// After text selection ran: a markup tool turns a finished selection into a comment; Replace
|
||
/// Text asks for the replacement.
|
||
pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) {
|
||
let QuickTool::Comment(tool) = cx.tool else { return };
|
||
if tool == CommentTool::ReplaceText && cx.allowed && (resp.drag_stopped() || resp.double_clicked()) {
|
||
if let Some((page, quads)) = view.selection_quads(cx.info).filter(|(p, _)| *p == cx.page) {
|
||
view.clear_selection();
|
||
let last = quads.last().copied().unwrap_or_default();
|
||
let at = [last[2].max(last[6]), last[1].max(last[3])];
|
||
view.comments.replace = Some(quads);
|
||
view.comments.composer = Some(Composer { page, at, kind: ComposerKind::Replace, text: String::new(), focus: true });
|
||
}
|
||
return;
|
||
}
|
||
if tool.markup().is_none() || !cx.allowed || !(resp.drag_stopped() || resp.double_clicked()) {
|
||
return;
|
||
}
|
||
if let Some(kind) = tool.markup()
|
||
&& let Some((page, quads)) = view.selection_quads(cx.info).filter(|(p, _)| *p == cx.page)
|
||
{
|
||
view.clear_selection();
|
||
view.pending_edit = Some(new_comment(cx, tool, Shape::TextMarkup { kind, quads }, String::new()));
|
||
let _ = page;
|
||
}
|
||
}
|
||
|
||
/// Paint comment selection, hover and gesture previews on a page.
|
||
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
|
||
let cv = &view.comments;
|
||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||
// A redaction mark shows its fill while the pointer is over it, as Acrobat's does, so the
|
||
// marked area reads before anything is applied.
|
||
if cv.gesture.is_none()
|
||
&& let Some(a) = pointer.and_then(|p| cx.hit(p))
|
||
&& a.subtype == "Redact"
|
||
{
|
||
let ([r, g, b], opacity) = cx.redact_mark;
|
||
let fill = Color32::from_rgba_unmultiplied((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, (opacity.clamp(0.0, 1.0) * 255.0) as u8);
|
||
for rect in cx.redact_rects(a) {
|
||
painter.rect_filled(rect, CornerRadius::ZERO, fill);
|
||
}
|
||
}
|
||
if cv.gesture.is_none()
|
||
&& let Some(a) = pointer.and_then(|p| cx.hit(p))
|
||
&& (cx.tool == QuickTool::Select || matches!(cx.tool, QuickTool::Fill(_)) && a.fill_sign)
|
||
&& cv.selected != Some((cx.page, a.index))
|
||
{
|
||
for r in cx.screen_rects(a) {
|
||
painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside);
|
||
}
|
||
}
|
||
if let Some((page, index)) = cv.selected
|
||
&& page == cx.page
|
||
&& let Some(a) = cx.get(index)
|
||
{
|
||
if is_markup(&a.subtype) && !a.quads.is_empty() {
|
||
for r in cx.screen_rects(a) {
|
||
painter.rect_stroke(r.expand(2.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle);
|
||
}
|
||
return paint_gesture(painter, cx, view);
|
||
}
|
||
if view.signature_drag.contains(page, index) && matches!(cv.gesture, Some(Gesture::Move { page: p, index: i, .. }) if (p, i) == (page, index))
|
||
{
|
||
return paint_gesture(painter, cx, view);
|
||
}
|
||
let shift = ui.input(|i| i.modifiers.shift);
|
||
let r = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, view.pending_edit.as_ref()).expand(2.0);
|
||
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle);
|
||
if cx.allowed && resizable(a) {
|
||
for h in HANDLES {
|
||
let c = handle_pos(r, h);
|
||
painter.circle(c, 4.5, Color32::WHITE, Stroke::new(1.0, SELECT_BLUE));
|
||
}
|
||
}
|
||
}
|
||
paint_gesture(painter, cx, view);
|
||
}
|
||
|
||
fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
|
||
let now = painter.ctx().input(|i| i.time);
|
||
for s in view.comments.settling.iter().filter(|s| s.page == cx.page && now - s.since < SETTLE_SECS) {
|
||
paint_draw(painter, cx, &s.tool, &s.points);
|
||
}
|
||
if let Some(Gesture::Draw { page, tool, points }) = &view.comments.gesture
|
||
&& *page == cx.page
|
||
{
|
||
paint_draw(painter, cx, tool, points);
|
||
}
|
||
}
|
||
|
||
/// A drawing tool's shape from its points (a live drag, or a released one still settling).
|
||
fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, points: &[[f64; 2]]) {
|
||
{
|
||
let style = cx.prefs.style(*tool);
|
||
let zoom = cx.xf.rect.width() / cx.xf.pw.max(1.0);
|
||
let stroke = Stroke::new((style.width as f32 * zoom).max(1.0), color32(style.color));
|
||
let pts: Vec<Pos2> = points.iter().map(|p| cx.to_screen(*p)).collect();
|
||
match tool {
|
||
CommentTool::Ink => {
|
||
painter.add(egui::Shape::line(pts, stroke));
|
||
}
|
||
CommentTool::Eraser => {
|
||
painter.add(egui::Shape::line(pts, Stroke::new(12.0, Color32::from_gray(128).gamma_multiply(0.35))));
|
||
}
|
||
CommentTool::Line | CommentTool::Arrow if pts.len() == 2 => {
|
||
painter.line_segment([pts[0], pts[1]], stroke);
|
||
if *tool == CommentTool::Arrow {
|
||
let d = (pts[0] - pts[1]).normalized();
|
||
let s = (6.0 + 3.0 * style.width as f32) * zoom;
|
||
let rot = |v: egui::Vec2, a: f32| vec2(v.x * a.cos() - v.y * a.sin(), v.x * a.sin() + v.y * a.cos());
|
||
for a in [0.52f32, -0.52] {
|
||
painter.line_segment([pts[1], pts[1] + rot(d, a) * s], stroke);
|
||
}
|
||
}
|
||
}
|
||
CommentTool::Rectangle if pts.len() == 2 => {
|
||
painter.rect_stroke(Rect::from_two_pos(pts[0], pts[1]), CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
|
||
}
|
||
CommentTool::Oval if pts.len() == 2 => {
|
||
let r = Rect::from_two_pos(pts[0], pts[1]);
|
||
painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, stroke));
|
||
}
|
||
CommentTool::Polygon | CommentTool::PolyLine | CommentTool::Cloud => {
|
||
// The vertices so far, and a rubber band to the pointer.
|
||
let mut line = pts.clone();
|
||
if let Some(p) = painter.ctx().input(|i| i.pointer.hover_pos()) {
|
||
line.push(p);
|
||
}
|
||
painter.add(egui::Shape::line(line, stroke));
|
||
if let Some(first) = pts.first().filter(|_| *tool != CommentTool::PolyLine && pts.len() >= 3) {
|
||
painter.circle_stroke(*first, 5.0, Stroke::new(1.0, SELECT_BLUE));
|
||
}
|
||
}
|
||
CommentTool::Callout if pts.len() == 2 => {
|
||
painter.line_segment([pts[0], pts[1]], stroke);
|
||
painter.rect_stroke(Rect::from_min_size(pts[1], vec2(160.0, 40.0) * zoom), CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The shape a finished drawing gesture makes (`None` if it is too small to mean anything).
|
||
fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option<Shape> {
|
||
let (first, last) = (*points.first()?, *points.last()?);
|
||
let far = (first[0] - last[0]).hypot(first[1] - last[1]) >= 2.0;
|
||
let rect = [first[0].min(last[0]), first[1].min(last[1]), first[0].max(last[0]), first[1].max(last[1])];
|
||
let big = rect[2] - rect[0] >= 2.0 && rect[3] - rect[1] >= 2.0;
|
||
match tool {
|
||
CommentTool::Ink if !points.is_empty() => Some(Shape::Ink { strokes: vec![points.to_vec()] }),
|
||
CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line {
|
||
from: first,
|
||
to: last,
|
||
start: LineEnding::None,
|
||
end: if tool == CommentTool::Arrow { LineEnding::OpenArrow } else { LineEnding::None },
|
||
}),
|
||
CommentTool::Rectangle if big => Some(Shape::Rectangle { rect }),
|
||
CommentTool::Oval if big => Some(Shape::Oval { rect }),
|
||
CommentTool::Polygon | CommentTool::Cloud if points.len() >= 3 => {
|
||
Some(Shape::Polygon { vertices: points.to_vec(), cloud: tool == CommentTool::Cloud })
|
||
}
|
||
CommentTool::PolyLine if points.len() >= 2 => {
|
||
Some(Shape::PolyLine { vertices: points.to_vec(), start: LineEnding::None, end: LineEnding::None })
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Eraser: rub out drawings along `path` (user space); one undo step for all of them.
|
||
fn erase(cx: &PageCx<'_>, path: &[[f64; 2]]) -> Option<Edit> {
|
||
let zoom = (cx.xf.rect.width() / cx.xf.pw.max(1.0)) as f64;
|
||
let radius = (6.0 / zoom).max(1.0);
|
||
let b = path.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]);
|
||
let mut hits: Vec<usize> = cx
|
||
.comments()
|
||
.filter(|a| a.subtype == "Ink" && !a.locked)
|
||
.filter(|a| {
|
||
let r = a.rect;
|
||
(r[0] as f64) <= b[2] + radius && (r[2] as f64) >= b[0] - radius && (r[1] as f64) <= b[3] + radius && (r[3] as f64) >= b[1] - radius
|
||
})
|
||
.map(|a| a.index)
|
||
.collect();
|
||
if hits.is_empty() {
|
||
return None;
|
||
}
|
||
// Later indices first: deleting an emptied drawing shifts the ones after it.
|
||
hits.sort_unstable_by(|a, b| b.cmp(a));
|
||
let edits = hits.into_iter().map(|index| Edit::EraseInk { page: cx.page, index, path: path.to_vec(), radius }).collect();
|
||
Some(Edit::Batch { label: "Erase".into(), edits })
|
||
}
|
||
|
||
fn new_comment(cx: &PageCx<'_>, tool: CommentTool, shape: Shape, contents: String) -> Edit {
|
||
Edit::AddAnnotation(NewAnnotation { page: cx.page, shape, style: cx.prefs.style(tool), contents, author: cx.prefs.author.clone() })
|
||
}
|
||
|
||
/// The floating composer card (new note, new text box, edit). Returns the edit to apply.
|
||
pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs) -> Option<Edit> {
|
||
let c = view.comments.composer.as_ref()?;
|
||
let page = c.page;
|
||
let Some(xf) = view.page_xform(page) else {
|
||
// Scrolled away: keep the draft until the page is back.
|
||
return None;
|
||
};
|
||
let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true, hidden: false, redact_mark: ([0.89, 0.13, 0.13], 1.0) };
|
||
let anchor = cx.to_screen(c.at);
|
||
let t = Tokens::get(ctx);
|
||
let mut post = false;
|
||
let mut cancel = false;
|
||
let c = view.comments.composer.as_mut()?;
|
||
let title = match c.kind {
|
||
ComposerKind::Note => "Sticky note",
|
||
ComposerKind::TextBox => "Text box",
|
||
ComposerKind::Callout { .. } => "Callout",
|
||
ComposerKind::Caret { .. } => "Inserted text",
|
||
ComposerKind::Replace => "Replacement text",
|
||
ComposerKind::Edit(_) => "Edit comment",
|
||
};
|
||
let pos = pos2(anchor.x + 12.0, anchor.y);
|
||
egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
|
||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| {
|
||
ui.set_width(260.0);
|
||
ui.horizontal(|ui| {
|
||
ui.label(egui::RichText::new(crate::bidi::visual_auto(&prefs.author)).font(theme::semibold(13.0)));
|
||
ui.label(egui::RichText::new(tl!(title)).font(theme::regular(11.5)).color(t.text_faint));
|
||
});
|
||
ui.add_space(6.0);
|
||
let hint = match c.kind {
|
||
ComposerKind::TextBox | ComposerKind::Callout { .. } => tl!("Type text"),
|
||
ComposerKind::Caret { .. } => tl!("Text to insert"),
|
||
ComposerKind::Replace => tl!("Replacement text"),
|
||
_ => tl!("Add a comment"),
|
||
};
|
||
let edit =
|
||
ui.add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text"));
|
||
if c.focus {
|
||
edit.request_focus();
|
||
c.focus = false;
|
||
}
|
||
let enter = submit_key(ui, &edit, false);
|
||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
cancel = true;
|
||
}
|
||
ui.add_space(8.0);
|
||
ui.horizontal(|ui| {
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
let can_post = !c.text.trim().is_empty() || matches!(c.kind, ComposerKind::Edit(_));
|
||
if ui
|
||
.add_enabled(
|
||
can_post,
|
||
egui::Button::new(egui::RichText::new(tl!("Post")).color(Color32::WHITE)).fill(t.accent).corner_radius(14),
|
||
)
|
||
.clicked()
|
||
|| (enter && can_post)
|
||
{
|
||
post = true;
|
||
}
|
||
if ui.add(egui::Button::new(tl!("Cancel")).corner_radius(14)).clicked() {
|
||
cancel = true;
|
||
}
|
||
});
|
||
});
|
||
});
|
||
});
|
||
if cancel {
|
||
view.comments.composer = None;
|
||
return None;
|
||
}
|
||
if !post {
|
||
return None;
|
||
}
|
||
let c = view.comments.composer.take()?;
|
||
let text = c.text.trim_end().to_string();
|
||
match c.kind {
|
||
ComposerKind::Edit(index) => Some(Edit::SetAnnotationContents { page, index, text }),
|
||
ComposerKind::Note => {
|
||
view.comments.tool_done = true;
|
||
Some(new_comment(&cx, CommentTool::Note, Shape::Note { at: c.at, icon: NoteIcon::Comment }, text))
|
||
}
|
||
ComposerKind::TextBox => {
|
||
view.comments.tool_done = true;
|
||
Some(new_comment(
|
||
&cx,
|
||
CommentTool::TextBox,
|
||
Shape::TextBox { rect: text_box_rect(c.at, &text, 12.0, page_rotation(info, page)), font_size: 12.0 },
|
||
text,
|
||
))
|
||
}
|
||
ComposerKind::Callout { point } => {
|
||
view.comments.tool_done = true;
|
||
// A callout's leader and box stay in user-space axes (its appearance isn't turned).
|
||
let rect = text_box_rect(c.at, &text, 10.0, 0);
|
||
let side = if point[0] < rect[0] {
|
||
rect[0]
|
||
} else if point[0] > rect[2] {
|
||
rect[2]
|
||
} else {
|
||
(rect[0] + rect[2]) / 2.0
|
||
};
|
||
let knee = [(point[0] + side) / 2.0, (rect[1] + rect[3]) / 2.0];
|
||
Some(new_comment(&cx, CommentTool::Callout, Shape::Callout { rect, knee, point, font_size: 10.0, ending: LineEnding::OpenArrow }, text))
|
||
}
|
||
ComposerKind::Replace => {
|
||
view.comments.tool_done = true;
|
||
let quads = view.comments.replace.take()?;
|
||
Some(Edit::ReplaceText {
|
||
page,
|
||
quads,
|
||
text,
|
||
author: prefs.author.clone(),
|
||
strike: prefs.style(CommentTool::ReplaceText),
|
||
caret: prefs.style(CommentTool::Caret),
|
||
})
|
||
}
|
||
ComposerKind::Caret { size } => {
|
||
view.comments.tool_done = true;
|
||
let [x, y] = c.at;
|
||
let half = size / 2.0;
|
||
Some(new_comment(&cx, CommentTool::Caret, Shape::Caret { rect: [x - half, y - size, x + half, y] }, text))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Submit only from the focused comment editor. Match each key event's modifiers exactly,
|
||
/// so Shift+Enter stays a newline and another editor cannot submit this draft. Replies also
|
||
/// keep their existing unmodified Enter-to-post behavior.
|
||
pub(crate) fn submit_key(ui: &mut egui::Ui, field: &egui::Response, plain_enter: bool) -> bool {
|
||
field.has_focus()
|
||
&& ui.input_mut(|input| {
|
||
let mut submit = false;
|
||
input.events.retain(|event| {
|
||
let matched = matches!(event,
|
||
egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. }
|
||
if modifiers.matches_exact(egui::Modifiers::CTRL)
|
||
|| modifiers.matches_exact(egui::Modifiers::COMMAND)
|
||
|| plain_enter && modifiers.matches_exact(egui::Modifiers::NONE)
|
||
);
|
||
submit |= matched;
|
||
!matched
|
||
});
|
||
submit
|
||
})
|
||
}
|
||
|
||
/// The side of the caret's box, in user space units, away from text.
|
||
const CARET_SIZE: f64 = 8.0;
|
||
|
||
/// [`caret_snap`] in the page's user space: the caret's tip and the side of its box.
|
||
///
|
||
/// The caret is drawn upright in user space, so it only snaps on an unrotated page, where up on
|
||
/// the page is up on screen; its size goes through the same mapping as the tip, so a page with
|
||
/// `/UserUnit` gets a caret of the same size relative to its text.
|
||
pub(crate) fn snapped_caret(page: &PageInfo, text: &PageText, vx: f32, vy: f32) -> Option<([f64; 2], f64)> {
|
||
if !page.rotation.is_multiple_of(360) {
|
||
return None;
|
||
}
|
||
let ([x, y], size) = caret_snap(text, vx, vy)?;
|
||
let tip = page.view_to_user(x, y);
|
||
let foot = page.view_to_user(x, y + size);
|
||
let side = f64::from(tip[1] - foot[1]);
|
||
(side.is_finite() && side > 0.0).then(|| ([f64::from(tip[0]), f64::from(tip[1])], side))
|
||
}
|
||
|
||
/// Insert text: where a click puts the caret's tip (view space) and how tall the caret is.
|
||
///
|
||
/// On upright, horizontal text the tip snaps to the nearer edge of the character under (or
|
||
/// next to) the click, on the line's baseline, and the caret's side is half the text's em size
|
||
/// (clamped to 4–36 view-space points; [`snapped_caret`] maps it to user space): the
|
||
/// proposal marks an insertion point in the text, as a proofreader's caret does. Elsewhere
|
||
/// (away from text, or on turned, sloped or vertical text) it returns `None` and the caret stays
|
||
/// at the click. A ligature is one glyph, so the caret goes before or after it, never inside.
|
||
pub(crate) fn caret_snap(text: &PageText, vx: f32, vy: f32) -> Option<([f32; 2], f32)> {
|
||
let g = text.glyphs.get(text.nearest(vx, vy)?)?;
|
||
if !(g.direction[0] > 0.0 && g.direction[1].abs() <= 0.02) {
|
||
return None;
|
||
}
|
||
// Close enough to belong to this glyph: within half an em of its box.
|
||
let [x0, y0, x1, y1] = g.rect;
|
||
let slack = (y1 - y0) / 2.0;
|
||
if !(slack.is_finite() && slack > 0.0) || vx < x0 - slack || vx > x1 + slack || vy < y0 - slack || vy > y1 + slack {
|
||
return None;
|
||
}
|
||
// The em box runs from 0.2 em below the baseline to 0.8 em above it (`pdfcraft_render`'s
|
||
// text extraction); `quad` is its bottom edge in the writing direction, then its top edge
|
||
// backwards. Take the side of the box the click is nearer to: its foot and head.
|
||
let [q0, q1, q2, q3] = g.quad;
|
||
let (foot, head) = if vx < (q0[0] + q1[0]) / 2.0 { (q0, q3) } else { (q1, q2) };
|
||
let em = (head[0] - foot[0]).hypot(head[1] - foot[1]);
|
||
if !(em.is_finite() && em > 0.0) {
|
||
return None;
|
||
}
|
||
let baseline = [foot[0] + 0.2 * (head[0] - foot[0]), foot[1] + 0.2 * (head[1] - foot[1])];
|
||
Some((baseline, (em / 2.0).clamp(4.0, 36.0)))
|
||
}
|
||
|
||
/// A text box sized to its text (at most 300 pt wide), hanging from its top-left corner.
|
||
/// The box for `text` at `size` with its displayed top-left corner at `at`, on a page turned by
|
||
/// `rotation` (its `/Rotate`): on a quarter-turned page it is taller than wide in user space.
|
||
pub fn text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64; 4] {
|
||
use pdfcraft_engine::annot_text::{text_width, wrap};
|
||
let pad = 2.0;
|
||
let longest = text.lines().map(|l| text_width(l, size)).fold(0.0, f64::max);
|
||
let w = (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0);
|
||
let lines = wrap(text, size, w - 2.0 * pad).len().max(1);
|
||
let h = lines as f64 * size * 1.2 + 2.0 * pad + 2.0;
|
||
pdfcraft_engine::text_box_at(rotation, at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]])
|
||
}
|
||
|
||
/// The `/Rotate` of `page` (0 when it is unknown).
|
||
pub(crate) fn page_rotation(info: &DocInfo, page: usize) -> i64 {
|
||
info.pages.get(page).map_or(0, |p| i64::from(p.rotation))
|
||
}
|
||
|
||
/// Delete / Escape handling for comments (only while no text field has focus).
|
||
pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) {
|
||
if !ctx.input(|i| i.pointer.any_down()) {
|
||
view.comments.cancelled_drag = false;
|
||
}
|
||
if ctx.egui_wants_keyboard_input() {
|
||
return;
|
||
}
|
||
let cv = &mut view.comments;
|
||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
// One step back per press: cancel the gesture, then deselect, then drop the tool.
|
||
let cancelled = cv.gesture.take().is_some() || cv.selected.take().is_some();
|
||
if !cancelled && matches!(tool, QuickTool::Comment(_)) {
|
||
*tool = QuickTool::Select;
|
||
}
|
||
}
|
||
if allowed
|
||
&& let Some((page, index)) = cv.selected
|
||
&& ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
|
||
{
|
||
cv.selected = None;
|
||
view.pending_edit = Some(Edit::DeleteAnnotation { page, index });
|
||
}
|
||
}
|
||
|
||
/// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's
|
||
/// menus as in the Attachments panel: the comment is where the reader finds its file. Reading
|
||
/// the file changes nothing, so a read-only document offers them too.
|
||
pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, a: &Annotation) {
|
||
let source = pdfcraft_render::AttachmentSource::Annotation { page: a.page, index: a.index };
|
||
let Some(file) = info.attachments.iter().find(|f| f.source == source) else { return };
|
||
if ui.button(tl!("Save attachment…")).clicked() {
|
||
view.comments.attachment_request = Some((a.page, a.index, false));
|
||
ui.close();
|
||
}
|
||
if file.name.to_lowercase().ends_with(".pdf") && ui.button(tl!("Open in a new tab")).clicked() {
|
||
view.comments.attachment_request = Some((a.page, a.index, true));
|
||
ui.close();
|
||
}
|
||
ui.separator();
|
||
}
|
||
|
||
/// Items of the canvas context menu for the selected comment (or the page).
|
||
pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option<CanvasAction> {
|
||
let mut action = None;
|
||
let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && a.in_reply_to.is_none()));
|
||
match selected {
|
||
Some(a) => {
|
||
let (page, index) = (a.page, a.index);
|
||
attachment_items(ui, view, info, a);
|
||
if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() {
|
||
view.comments.composer = Some(Composer {
|
||
page,
|
||
at: [a.rect[2] as f64, a.rect[3] as f64],
|
||
kind: ComposerKind::Edit(index),
|
||
text: a.contents.clone().unwrap_or_default(),
|
||
focus: true,
|
||
});
|
||
ui.close();
|
||
}
|
||
if ui.add_enabled(allowed, egui::Button::new(tl!("Reply"))).clicked() {
|
||
view.comments.reveal = true;
|
||
action = Some(CanvasAction::OpenComments);
|
||
ui.close();
|
||
}
|
||
ui.add_enabled_ui(allowed, |ui| {
|
||
ui.menu_button(tl!("Set status"), |ui| {
|
||
for s in [ReviewState::None, ReviewState::Accepted, ReviewState::Cancelled, ReviewState::Completed, ReviewState::Rejected] {
|
||
if ui.button(tl!(s.name())).clicked() {
|
||
action =
|
||
Some(CanvasAction::Edit(Box::new(Edit::SetAnnotationStatus { page, index, state: s, author: prefs.author.clone() })));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
ui.menu_button(tl!("Colour"), |ui| {
|
||
if let Some(c) = swatch_grid(ui, a.color.map(|c| c.map(f64::from))) {
|
||
action = Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation {
|
||
page,
|
||
index,
|
||
color: Some(c),
|
||
opacity: None,
|
||
width: None,
|
||
endings: None,
|
||
})));
|
||
ui.close();
|
||
}
|
||
});
|
||
});
|
||
let thread: Vec<&pdfcraft_render::Annotation> = info.annotations.iter().filter(|r| a.name.is_some() && r.in_reply_to == a.name).collect();
|
||
let marked = crate::comments_panel::is_marked(&thread);
|
||
if ui.add_enabled(allowed, egui::Button::new(tl!(if marked { "Remove checkmark" } else { "Mark with checkmark" }))).clicked() {
|
||
action = Some(CanvasAction::Edit(Box::new(Edit::MarkAnnotation { page, index, marked: !marked, author: prefs.author.clone() })));
|
||
ui.close();
|
||
}
|
||
if ui.button(tl!("Copy text")).clicked() {
|
||
ui.ctx().copy_text(a.contents.clone().unwrap_or_default());
|
||
ui.close();
|
||
}
|
||
ui.separator();
|
||
if ui.add_enabled(allowed && !a.locked, egui::Button::new(tl!("Delete"))).clicked() {
|
||
view.comments.selected = None;
|
||
action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page, index })));
|
||
ui.close();
|
||
}
|
||
if ui.add_enabled(allowed, egui::Button::new(tl!("Properties…"))).clicked() {
|
||
action = Some(CanvasAction::Properties(page, index));
|
||
ui.close();
|
||
}
|
||
if ui.add_enabled(tool_for(a).is_some(), egui::Button::new(tl!("Make Current Properties Default"))).clicked() {
|
||
view.comments.default_request = Some((page, index));
|
||
ui.close();
|
||
}
|
||
}
|
||
None => {
|
||
if let Some((page, at)) = view.comments.context_at
|
||
&& ui.add_enabled(allowed, egui::Button::new(tl!("Add a sticky note here"))).clicked()
|
||
{
|
||
view.comments.composer = Some(Composer { page, at, kind: ComposerKind::Note, text: String::new(), focus: true });
|
||
ui.close();
|
||
}
|
||
if ui.button(tl!("Comments panel")).clicked() {
|
||
action = Some(CanvasAction::OpenComments);
|
||
ui.close();
|
||
}
|
||
}
|
||
}
|
||
action
|
||
}
|
||
|
||
/// What the canvas asks the app to do.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum CanvasAction {
|
||
Edit(Box<Edit>),
|
||
OpenComments,
|
||
/// Comment Properties for (page, index).
|
||
Properties(usize, usize),
|
||
/// Copy Image: image (page, index in `page_images`) to the clipboard.
|
||
CopyImage(usize, usize),
|
||
/// Copy Image in Original Format: the same image as a file (JPEG as stored, else PNG).
|
||
CopyImageFile(usize, usize),
|
||
}
|
||
|
||
/// How many custom colours the "Recent colours" row keeps.
|
||
const RECENT_CUSTOM: usize = 5;
|
||
|
||
fn recent_custom_id() -> egui::Id {
|
||
egui::Id::new("pdfcraft-recent-custom-colours")
|
||
}
|
||
|
||
/// Custom colours picked with "Other colour…" this session, newest first (#1193).
|
||
pub fn recent_custom_colours(ctx: &egui::Context) -> Vec<Rgb> {
|
||
ctx.data(|d| d.get_temp::<Vec<Rgb>>(recent_custom_id())).unwrap_or_default()
|
||
}
|
||
|
||
/// Put `c` first in the recent custom colours (once, at most [`RECENT_CUSTOM`]). Colours that are
|
||
/// already swatches are not repeated there.
|
||
pub fn remember_custom_colour(ctx: &egui::Context, c: Rgb) {
|
||
if !c.iter().all(|v| v.is_finite()) || SWATCHES.iter().any(|(_, s)| same_colour(s, &c)) {
|
||
return;
|
||
}
|
||
let c = c.map(|v| v.clamp(0.0, 1.0));
|
||
ctx.data_mut(|d| {
|
||
let recent = d.get_temp_mut_or_default::<Vec<Rgb>>(recent_custom_id());
|
||
recent.retain(|r| !same_colour(r, &c));
|
||
recent.insert(0, c);
|
||
recent.truncate(RECENT_CUSTOM);
|
||
});
|
||
}
|
||
|
||
fn same_colour(a: &Rgb, b: &Rgb) -> bool {
|
||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02)
|
||
}
|
||
|
||
fn rgb_of(c: Color32) -> Rgb {
|
||
[c.r(), c.g(), c.b()].map(|v| f64::from(v) / 255.0)
|
||
}
|
||
|
||
/// One round swatch; `on` rings it as the current colour.
|
||
fn swatch(ui: &mut egui::Ui, c: Rgb, on: bool, tooltip: &str) -> egui::Response {
|
||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tooltip));
|
||
ui.painter().circle_filled(r.center(), 9.0, color32(c));
|
||
ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(40)));
|
||
if on || resp.hovered() {
|
||
ui.painter().circle_stroke(r.center(), 11.0, Stroke::new(1.5, SELECT_BLUE));
|
||
}
|
||
resp.on_hover_text(tooltip)
|
||
}
|
||
|
||
/// The "+" swatch that opens "Other colour…": a ring with a plus, the size of a swatch.
|
||
fn other_colour_swatch(ui: &mut egui::Ui, open: bool) -> egui::Response {
|
||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||
let ink = ui.visuals().widgets.inactive.fg_stroke.color;
|
||
ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, ink.gamma_multiply(0.6)));
|
||
let c = r.center();
|
||
for d in [vec2(4.5, 0.0), vec2(0.0, 4.5)] {
|
||
ui.painter().line_segment([c - d, c + d], Stroke::new(1.5, ink));
|
||
}
|
||
if open || resp.hovered() {
|
||
ui.painter().circle_stroke(c, 11.0, Stroke::new(1.5, SELECT_BLUE));
|
||
}
|
||
let label = tl!("Other colour…");
|
||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, open, label));
|
||
resp.on_hover_text(label)
|
||
}
|
||
|
||
/// A grid of colour swatches with a "+" swatch for "Other colour…" (a full picker, as Acrobat's
|
||
/// quick colours offer) and, once used, the recent custom colours; returns the colour chosen.
|
||
/// The "+" sits in the first row, so the grid is no taller than before until the picker opens.
|
||
pub fn swatch_grid(ui: &mut egui::Ui, current: Option<Rgb>) -> Option<Rgb> {
|
||
let mut picked = None;
|
||
let on = |c: &Rgb| current.is_some_and(|cur| same_colour(&cur, c));
|
||
// Keyed on this grid's own place in the parent, so two grids in one dialog open their
|
||
// pickers separately.
|
||
let base = ui.next_auto_id();
|
||
ui.skip_ahead_auto_ids(1);
|
||
let open_id = base.with("other-colour");
|
||
let value_id = open_id.with("value");
|
||
let mut open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
|
||
egui::Grid::new(base.with("swatches")).spacing(vec2(6.0, 6.0)).show(ui, |ui| {
|
||
for (i, (name, c)) in SWATCHES.iter().enumerate() {
|
||
if swatch(ui, *c, on(c), tl!(name)).clicked() {
|
||
picked = Some(*c);
|
||
}
|
||
if i == 4 && other_colour_swatch(ui, open).clicked() {
|
||
open = !open;
|
||
// Start from the current colour each time the picker opens.
|
||
ui.data_mut(|d| d.insert_temp(value_id, current.map_or(Color32::BLACK, color32)));
|
||
}
|
||
if i % 5 == 4 {
|
||
ui.end_row();
|
||
}
|
||
}
|
||
});
|
||
let recent = recent_custom_colours(ui.ctx());
|
||
if !recent.is_empty() {
|
||
ui.label(egui::RichText::new(tl!("Recent colours")).small());
|
||
ui.horizontal(|ui| {
|
||
ui.spacing_mut().item_spacing.x = 6.0;
|
||
for c in &recent {
|
||
let [r, g, b, _] = color32(*c).to_array();
|
||
if swatch(ui, *c, on(c), &format!("#{r:02X}{g:02X}{b:02X}")).clicked() {
|
||
picked = Some(*c);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
if open && picked.is_none() {
|
||
let mut value = ui.data(|d| d.get_temp::<Color32>(value_id)).unwrap_or_else(|| current.map_or(Color32::BLACK, color32));
|
||
egui::widgets::color_picker::color_picker_color32(ui, &mut value, egui::widgets::color_picker::Alpha::Opaque);
|
||
ui.data_mut(|d| d.insert_temp(value_id, value));
|
||
// Dragging in the picker previews nothing; Apply commits once (one undo step).
|
||
if ui.button(tl!("Apply")).clicked() {
|
||
let c = rgb_of(value);
|
||
remember_custom_colour(ui.ctx(), c);
|
||
picked = Some(c);
|
||
}
|
||
}
|
||
if picked.is_some() {
|
||
open = false;
|
||
}
|
||
ui.data_mut(|d| d.insert_temp(open_id, open));
|
||
picked
|
||
}
|
||
|
||
/// "No fill" and the colour swatches; returns the choice clicked (`Some(None)`: no fill).
|
||
pub fn fill_picker(ui: &mut egui::Ui, current: Option<Rgb>) -> Option<Option<Rgb>> {
|
||
let mut picked = None;
|
||
ui.push_id("fill-picker", |ui| {
|
||
ui.vertical(|ui| {
|
||
if ui.selectable_label(current.is_none(), tl!("No fill")).clicked() {
|
||
picked = Some(None);
|
||
}
|
||
if let Some(c) = swatch_grid(ui, current) {
|
||
picked = Some(Some(c));
|
||
}
|
||
});
|
||
});
|
||
picked
|
||
}
|
||
|
||
/// The comment tools' extra quick-bar controls: pin, colour, fill, opacity and thickness.
|
||
pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) -> Option<Rgb> {
|
||
let style = prefs.style(tool);
|
||
let mut selected_color = None;
|
||
if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() {
|
||
prefs.pinned = !prefs.pinned;
|
||
}
|
||
let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click());
|
||
ui.painter().circle_filled(r.center(), 9.0, color32(style.color));
|
||
ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50)));
|
||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour")));
|
||
let resp = resp.on_hover_text(tl!("Colour"));
|
||
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).show(|ui| {
|
||
if let Some(c) = swatch_grid(ui, Some(style.color)) {
|
||
prefs.set_color(tool, c);
|
||
selected_color = Some(c);
|
||
ui.close();
|
||
}
|
||
});
|
||
if tool.has_fill() {
|
||
let resp = fill_button(ui, style.fill);
|
||
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).show(|ui| {
|
||
ui.label(egui::RichText::new(tl!("Fill colour")).font(theme::semibold(12.0)));
|
||
if let Some(f) = fill_picker(ui, style.fill) {
|
||
prefs.set_fill(tool, f);
|
||
ui.close();
|
||
}
|
||
});
|
||
}
|
||
let resp = icons::button(ui, "blend", 32.0, false, tl!("Opacity"));
|
||
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||
ui.set_min_width(180.0);
|
||
let mut percent = (style.opacity * 100.0).round();
|
||
ui.label(egui::RichText::new(tl!("Opacity")).font(theme::semibold(12.0)));
|
||
if ui.add(egui::Slider::new(&mut percent, 10.0..=100.0).step_by(5.0).suffix(" %")).changed() {
|
||
prefs.set_opacity(tool, percent / 100.0);
|
||
}
|
||
});
|
||
if tool.has_width() {
|
||
let resp = icons::button(ui, "sliders-horizontal", 32.0, false, tl!("Line thickness"));
|
||
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||
ui.set_min_width(180.0);
|
||
let mut w = style.width;
|
||
ui.label(egui::RichText::new(tl!("Line thickness")).font(theme::semibold(12.0)));
|
||
if ui.add(egui::Slider::new(&mut w, 0.5..=12.0).step_by(0.5).suffix(" pt")).changed() {
|
||
prefs.set_width(tool, w);
|
||
}
|
||
});
|
||
}
|
||
selected_color
|
||
}
|
||
|
||
/// The quick bar's fill button: a filled square in the fill colour, or a crossed-out outline
|
||
/// for no fill.
|
||
fn fill_button(ui: &mut egui::Ui, fill: Option<Rgb>) -> egui::Response {
|
||
let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click());
|
||
let sq = Rect::from_center_size(r.center(), vec2(16.0, 16.0));
|
||
let edge = Stroke::new(1.0, Color32::from_black_alpha(90));
|
||
match fill {
|
||
Some(c) => {
|
||
ui.painter().rect_filled(sq, 2.0, color32(c));
|
||
}
|
||
None => {
|
||
ui.painter().line_segment([sq.left_bottom(), sq.right_top()], Stroke::new(1.5, Color32::from_rgb(0xD3, 0x2F, 0x2F)));
|
||
}
|
||
}
|
||
ui.painter().rect_stroke(sq, 2.0, edge, egui::StrokeKind::Inside);
|
||
if resp.hovered() {
|
||
ui.painter().rect_stroke(r.shrink(2.0), 6.0, Stroke::new(1.0, Color32::from_black_alpha(40)), egui::StrokeKind::Inside);
|
||
}
|
||
let label = match fill {
|
||
Some(_) => tl!("Fill colour"),
|
||
None => tl!("Fill colour: none"),
|
||
};
|
||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||
resp.on_hover_text(label)
|
||
}
|
||
|
||
/// Status badge icon and label for a review state name.
|
||
pub fn status_badge(state: &str) -> Option<(&'static str, &'static str, Color32)> {
|
||
match state {
|
||
"Accepted" => Some(("thumbs-up", "Accepted", Color32::from_rgb(0x2D, 0x9D, 0x5B))),
|
||
"Rejected" => Some(("thumbs-down", "Rejected", Color32::from_rgb(0xD3, 0x2F, 0x2F))),
|
||
"Cancelled" => Some(("circle-x", "Cancelled", Color32::from_rgb(0x8E, 0x8E, 0x8E))),
|
||
"Completed" => Some(("check", "Completed", Color32::from_rgb(0x2D, 0x9D, 0x5B))),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use pdfcraft_render::TextGlyph;
|
||
|
||
/// One glyph per character, `w` points wide, on a line of `em`-point text whose baseline is at `y`, drawn
|
||
/// along `direction` (a unit vector in view space, y down).
|
||
fn line(chars: &str, x: f32, y: f32, w: f32, em: f32, direction: [f32; 2]) -> PageText {
|
||
let [dx, dy] = direction;
|
||
// "Up" for the glyph: its writing direction turned a quarter counterclockwise (y down).
|
||
let [ux, uy] = [dy, -dx];
|
||
let glyphs = chars
|
||
.chars()
|
||
.enumerate()
|
||
.map(|(i, c)| {
|
||
let start = [x + dx * w * i as f32, y + dy * w * i as f32];
|
||
let at = |along: f32, up: f32| [start[0] + dx * along + ux * up, start[1] + dy * along + uy * up];
|
||
let quad = [at(0.0, -0.2 * em), at(w, -0.2 * em), at(w, 0.8 * em), at(0.0, 0.8 * em)];
|
||
let xs = quad.map(|p| p[0]);
|
||
let ys = quad.map(|p| p[1]);
|
||
let rect = [
|
||
xs.into_iter().fold(f32::INFINITY, f32::min),
|
||
ys.into_iter().fold(f32::INFINITY, f32::min),
|
||
xs.into_iter().fold(f32::NEG_INFINITY, f32::max),
|
||
ys.into_iter().fold(f32::NEG_INFINITY, f32::max),
|
||
];
|
||
TextGlyph { text: c.to_string(), rect, quad, direction }
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let n = glyphs.len();
|
||
PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n] }
|
||
}
|
||
|
||
fn near(a: Option<([f32; 2], f32)>, b: ([f32; 2], f32)) -> bool {
|
||
a.is_some_and(|([x, y], s)| (x - b.0[0]).abs() < 1e-3 && (y - b.0[1]).abs() < 1e-3 && (s - b.1).abs() < 1e-3)
|
||
}
|
||
|
||
#[test]
|
||
fn the_caret_snaps_to_the_nearer_edge_on_the_baseline() {
|
||
let text = line("abcd", 20.0, 100.0, 10.0, 14.0, [1.0, 0.0]);
|
||
// "c" spans 40..50: its left half snaps before it, its right half after it.
|
||
assert!(near(caret_snap(&text, 43.0, 95.0), ([40.0, 100.0], 7.0)));
|
||
assert!(near(caret_snap(&text, 47.0, 92.0), ([50.0, 100.0], 7.0)));
|
||
// Just past the last character, on the line: after it.
|
||
assert!(near(caret_snap(&text, 63.0, 99.0), ([60.0, 100.0], 7.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn the_snapped_caret_follows_the_page_into_user_space() {
|
||
let text = line("abcd", 20.0, 100.0, 10.0, 14.0, [1.0, 0.0]);
|
||
let page = |rotation, user_unit: f32| PageInfo {
|
||
width: 300.0 * user_unit,
|
||
height: 200.0 * user_unit,
|
||
label: "1".into(),
|
||
crop: [0.0, 0.0, 300.0, 200.0],
|
||
rotation,
|
||
};
|
||
// An unrotated page: view y = 100 is user y = 100 (y up from the bottom of a 200 pt page).
|
||
let (tip, side) = snapped_caret(&page(0, 1.0), &text, 43.0, 95.0).expect("snaps");
|
||
assert!((tip[0] - 40.0).abs() < 1e-3 && (tip[1] - 100.0).abs() < 1e-3 && (side - 7.0).abs() < 1e-3);
|
||
// /UserUnit 2: the view is twice as large, so the same text is half as many user units.
|
||
let doubled = line("abcd", 40.0, 200.0, 20.0, 28.0, [1.0, 0.0]);
|
||
let (tip, side) = snapped_caret(&page(0, 2.0), &doubled, 86.0, 190.0).expect("snaps");
|
||
assert!((tip[0] - 40.0).abs() < 1e-3 && (tip[1] - 100.0).abs() < 1e-3 && (side - 7.0).abs() < 1e-3, "{tip:?} {side}");
|
||
// A rotated page: the caret would lie on its side, so it stays at the click.
|
||
for rotation in [90, 180, 270] {
|
||
assert_eq!(snapped_caret(&page(rotation, 1.0), &text, 43.0, 95.0), None, "/Rotate {rotation}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn away_from_upright_horizontal_text_the_caret_stays_at_the_click() {
|
||
let text = line("abcd", 20.0, 100.0, 10.0, 14.0, [1.0, 0.0]);
|
||
assert_eq!(caret_snap(&text, 200.0, 100.0), None, "far to the right");
|
||
assert_eq!(caret_snap(&text, 40.0, 40.0), None, "far above the line");
|
||
assert_eq!(caret_snap(&PageText::default(), 40.0, 100.0), None, "no text");
|
||
for (direction, what) in [([0.0, 1.0], "vertical"), ([-1.0, 0.0], "upside down"), ([0.995, 0.0998], "sloped")] {
|
||
let turned = line("abcd", 20.0, 100.0, 10.0, 14.0, direction);
|
||
let [x0, y0, x1, y1] = turned.glyphs[2].rect;
|
||
assert_eq!(caret_snap(&turned, (x0 + x1) / 2.0, (y0 + y1) / 2.0), None, "{what}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn recent_custom_colours_dedupe_cap_and_skip_swatches() {
|
||
// #1193: "Other colour…" remembers what it applied, newest first.
|
||
let ctx = egui::Context::default();
|
||
assert!(recent_custom_colours(&ctx).is_empty());
|
||
for i in 0..7 {
|
||
remember_custom_colour(&ctx, [0.1 * f64::from(i), 0.3, 0.7]);
|
||
}
|
||
let recent = recent_custom_colours(&ctx);
|
||
assert_eq!(recent.len(), RECENT_CUSTOM, "capped");
|
||
assert!(same_colour(&recent[0], &[0.6, 0.3, 0.7]), "newest first: {recent:?}");
|
||
remember_custom_colour(&ctx, [0.2, 0.3, 0.7]);
|
||
let again = recent_custom_colours(&ctx);
|
||
assert_eq!(again.len(), RECENT_CUSTOM);
|
||
assert!(
|
||
same_colour(&again[0], &[0.2, 0.3, 0.7]) && again.iter().filter(|c| same_colour(c, &[0.2, 0.3, 0.7])).count() == 1,
|
||
"moved, not repeated"
|
||
);
|
||
// Swatches and unusable values are not remembered; out-of-range ones are clamped.
|
||
remember_custom_colour(&ctx, SWATCHES[0].1);
|
||
remember_custom_colour(&ctx, [f64::NAN, 0.0, 0.0]);
|
||
assert!(same_colour(&recent_custom_colours(&ctx)[0], &[0.2, 0.3, 0.7]));
|
||
remember_custom_colour(&ctx, [1.7, -0.5, 0.25]);
|
||
assert!(same_colour(&recent_custom_colours(&ctx)[0], &[1.0, 0.0, 0.25]));
|
||
}
|
||
|
||
#[test]
|
||
fn other_colour_applies_the_picked_colour_once() {
|
||
// #1193: the swatch grid has a full picker; Apply returns its colour and remembers it.
|
||
use egui_kittest::kittest::Queryable;
|
||
let current = [0.2, 0.4, 0.6];
|
||
let mut h = egui_kittest::Harness::new_ui_state(
|
||
|ui, picked: &mut Vec<Rgb>| {
|
||
if let Some(c) = swatch_grid(ui, Some(current)) {
|
||
picked.push(c);
|
||
}
|
||
},
|
||
Vec::new(),
|
||
);
|
||
h.run();
|
||
assert!(h.query_by_label("Apply").is_none(), "the picker starts closed");
|
||
assert!(h.query_by_label("Recent colours").is_none());
|
||
h.get_by_label("Other colour…").click();
|
||
h.run();
|
||
h.get_by_label("Apply").click();
|
||
h.run();
|
||
let picked = h.state().clone();
|
||
assert_eq!(picked.len(), 1, "one choice: {picked:?}");
|
||
assert!(same_colour(&picked[0], ¤t), "the picker opens on the current colour: {picked:?}");
|
||
assert!(h.query_by_label("Apply").is_none(), "applying closes the picker");
|
||
assert!(h.query_by_label("Recent colours").is_some(), "the colour is offered again");
|
||
}
|
||
|
||
#[test]
|
||
fn two_grids_in_one_parent_open_their_pickers_separately() {
|
||
// Redaction properties and Prepare a form draw two grids in one parent ui.
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = egui_kittest::Harness::new_ui(|ui| {
|
||
let _ = swatch_grid(ui, Some([0.2, 0.4, 0.6]));
|
||
let _ = swatch_grid(ui, Some([0.6, 0.4, 0.2]));
|
||
});
|
||
h.run();
|
||
let plus = h.query_all_by_label("Other colour…").collect::<Vec<_>>();
|
||
assert_eq!(plus.len(), 2);
|
||
plus[0].click();
|
||
h.run();
|
||
assert_eq!(h.query_all_by_label("Apply").count(), 1, "only the first grid's picker opens");
|
||
}
|
||
|
||
#[test]
|
||
fn shift_keeps_the_current_proportions_unless_the_box_has_its_own() {
|
||
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(196.0, 132.0));
|
||
assert_eq!(corner_ratio(r, None, false), None);
|
||
assert_eq!(corner_ratio(r, None, true), Some(3.0));
|
||
assert_eq!(corner_ratio(r, Some(2.0), false), Some(2.0));
|
||
assert_eq!(corner_ratio(r, Some(2.0), true), Some(2.0));
|
||
// A degenerate box has no proportions to keep (no division by zero).
|
||
let flat = Rect::from_min_max(pos2(100.0, 100.0), pos2(196.0, 100.0));
|
||
assert_eq!(corner_ratio(flat, None, true), None);
|
||
// Dragging a corner with Shift held scales the box instead of stretching it.
|
||
let out = resized(r, (1, 1), vec2(48.0, 0.0), corner_ratio(r, None, true));
|
||
assert_eq!(out.min, r.min);
|
||
assert!((out.width() / out.height() - 3.0).abs() < 0.001);
|
||
}
|
||
|
||
#[test]
|
||
fn proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis() {
|
||
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(196.0, 132.0));
|
||
for (hx, hy) in [(-1, -1), (1, -1), (-1, 1), (1, 1)] {
|
||
for (dx, dy) in [(24.0, 0.0), (0.0, 16.0), (-48.0, -20.0), (-200.0, -200.0)] {
|
||
let out = resized(r, (hx, hy), vec2(f32::from(hx) * dx, f32::from(hy) * dy), Some(3.0));
|
||
assert_eq!(handle_pos(out, (-hx, -hy)), handle_pos(r, (-hx, -hy)));
|
||
assert!((out.width() / out.height() - 3.0).abs() < 0.001);
|
||
assert!(out.width() >= 4.0 && out.height() >= 4.0);
|
||
}
|
||
}
|
||
for handle in [(0, -1), (1, 0), (0, 1), (-1, 0)] {
|
||
let out = resized(r, handle, vec2(24.0, 16.0), Some(3.0));
|
||
assert_eq!(out, resized(r, handle, vec2(24.0, 16.0), None));
|
||
assert_eq!(if handle.0 == 0 { out.width() } else { out.height() }, if handle.0 == 0 { r.width() } else { r.height() });
|
||
}
|
||
// Ordinary comment corners keep their existing independent width/height behavior.
|
||
let free = resized(r, (1, 1), vec2(24.0, 0.0), None);
|
||
assert_eq!(free.size(), vec2(120.0, 32.0));
|
||
}
|
||
|
||
#[test]
|
||
fn tools_round_trip_through_their_commands() {
|
||
for t in ALL {
|
||
assert_eq!(CommentTool::from_command(t.command()), Some(t));
|
||
assert!(pdfcraft_engine::commands::command(t.command()).is_some(), "{} is registered", t.command());
|
||
assert!(icons::exists(t.icon()), "icon {}", t.icon());
|
||
assert!(GROUPS[t.group()].contains(&t));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn drawn_shapes_ignore_slips() {
|
||
assert_eq!(drawn_shape(CommentTool::Rectangle, &[[0.0, 0.0], [1.0, 1.0]]), None);
|
||
assert_eq!(drawn_shape(CommentTool::Rectangle, &[[10.0, 0.0], [0.0, 10.0]]), Some(Shape::Rectangle { rect: [0.0, 0.0, 10.0, 10.0] }));
|
||
assert_eq!(drawn_shape(CommentTool::Ink, &[[0.0, 0.0]]), Some(Shape::Ink { strokes: vec![vec![[0.0, 0.0]]] }));
|
||
assert!(matches!(
|
||
drawn_shape(CommentTool::Arrow, &[[0.0, 0.0], [5.0, 0.0]]),
|
||
Some(Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn text_boxes_fit_their_text() {
|
||
let r = text_box_rect([100.0, 500.0], "Hi", 12.0, 0);
|
||
assert_eq!((r[0], r[3]), (100.0, 500.0));
|
||
assert!(r[2] - r[0] < 60.0);
|
||
let long = text_box_rect([0.0, 500.0], &"word ".repeat(80), 12.0, 0);
|
||
assert_eq!(long[2], 300.0);
|
||
assert!(long[3] - long[1] > 50.0);
|
||
}
|
||
}
|