mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Merge #2137: M5: comment pop-up windows on the page, with connector lines (comment.popups, comment.connector-lines)
# Conflicts: # ROADMAP.md # crates/annot/src/info.rs # crates/annot/src/lib.rs # crates/annot/src/modify.rs # crates/annot/src/tests.rs # crates/automation/src/comments.rs # crates/automation/tests/it/automation.rs # crates/engine/src/comments.rs # crates/render/src/inspect.rs # crates/render/src/inspect/annotations.rs # crates/render/src/inspect/model.rs # crates/render/src/inspect/tests.rs # crates/render/src/lib.rs # crates/ui-egui/src/tools/comments/input.rs # crates/ui-egui/src/tools/comments/paint.rs # crates/ui-egui/src/tools/comments/tests.rs # crates/ui-egui/tests/it/comments.rs
This commit is contained in:
commit
c638edf9c7
32 files changed
+1784
-26
No files matched your search
@@ -527,6 +527,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732.
|
||||
- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off.
|
||||
- **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it.
|
||||
- **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up.
|
||||
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
|
||||
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
|
||||
|
||||
@@ -27,7 +27,9 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
|
||||
comment another program drew with a font that has those characters, whose appearance is kept
|
||||
rather than redrawn with `?` (new text for it is taken).
|
||||
- **Edits:** reply (`/IRT`), set review status (a `/State` reply, as Acrobat's "Set status"
|
||||
does), change text, move, resize, restyle, delete (with pop-up and replies).
|
||||
does), change text, move, resize, restyle, delete (with pop-up and replies), and open, close
|
||||
or move the pop-up window (`set_popup`: `/Popup` `/Open` and `/Rect`, created beside a comment
|
||||
that has none; a note's own `/Open` follows). `summaries` report each comment's pop-up.
|
||||
|
||||
## API sketch
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ pub struct Summary {
|
||||
pub points: Vec<[f32; 2]>,
|
||||
/// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments.
|
||||
pub text_box: Option<[f32; 4]>,
|
||||
/// The comment's pop-up window (`/Popup`), when it has one.
|
||||
pub popup: Option<PopupInfo>,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
@@ -54,6 +56,49 @@ impl Summary {
|
||||
}
|
||||
}
|
||||
|
||||
/// A comment's pop-up window (ISO 32000-2 §12.5.6.14): where it sits on the page and whether it
|
||||
/// is shown open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PopupInfo {
|
||||
/// `/Rect` of the `/Popup` annotation, in user space.
|
||||
pub rect: [f32; 4],
|
||||
/// `/Open` of the `/Popup` annotation.
|
||||
pub open: bool,
|
||||
}
|
||||
|
||||
/// The pop-up the comment `annot` (the object `own`, when it is one of its own) names in
|
||||
/// `/Popup`, when it is the comment's: a pop-up annotation whose `/Parent`, if it has one, is a
|
||||
/// reference to the comment. A `/Popup` naming another annotation, or a pop-up of another
|
||||
/// comment, isn't. ISO 32000-2:2020 §12.5.6.14, Table 186: `/Parent` is "(Optional; shall be
|
||||
/// an indirect reference)".
|
||||
pub(crate) fn own_popup(doc: &Document, own: Option<ObjRef>, annot: &Dict) -> Option<ObjRef> {
|
||||
let pr = annot.reference(b"Popup")?;
|
||||
let is_own = doc.get(pr).as_dict().is_some_and(|pd| {
|
||||
pd.name(b"Subtype") == Some(b"Popup")
|
||||
&& pd
|
||||
.get(b"Parent")
|
||||
.is_none_or(|parent| parent.as_ref().is_some_and(|parent| own.is_some_and(|own| final_ref(doc, parent) == final_ref(doc, own))))
|
||||
});
|
||||
is_own.then_some(pr)
|
||||
}
|
||||
|
||||
/// The comment's own pop-up (see [`own_popup`]), as [`PopupInfo`]: `None` when it has none or it
|
||||
/// is unreadable.
|
||||
pub(crate) fn popup_info(doc: &Document, own: Option<ObjRef>, annot: &Dict) -> Option<PopupInfo> {
|
||||
// Another annotation's rectangle and open state aren't this comment's (review on #2137).
|
||||
let r = own_popup(doc, own, annot)?;
|
||||
let p = doc.get(r);
|
||||
let p = p.as_dict()?;
|
||||
let rect: Vec<f32> = doc.resolve(p.get(b"Rect")?).as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64().map(|x| x as f32)).collect();
|
||||
// Four numbers that are finite as `f32` (a huge one becomes infinite), corners in any order.
|
||||
let [x0, y0, x1, y1] = rect[..] else { return None };
|
||||
if !rect.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let open = p.get(b"Open").map(|o| doc.resolve(o)).is_some_and(|o| matches!(&*o, Object::Bool(true)));
|
||||
Some(PopupInfo { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], open })
|
||||
}
|
||||
|
||||
pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
let o = doc.resolve(d.get(key)?);
|
||||
let s = match &*o {
|
||||
@@ -205,6 +250,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
no_rotate: drawn_upright(doc, d),
|
||||
points,
|
||||
text_box,
|
||||
popup: popup_info(doc, entry.as_ref().map(|r| final_ref(doc, r)), d),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ mod types;
|
||||
use create::num_array;
|
||||
pub use create::{CALLOUT_KNEE_LINE, Side, add_annotation, callout_attach, callout_knee, callout_knee_on, callout_side, orient_image_stamp};
|
||||
pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached};
|
||||
pub use info::{Props, Summary, is_comment_subtype, is_group_subordinate, props, set_info, summaries};
|
||||
pub use info::{PopupInfo, Props, Summary, is_comment_subtype, is_group_subordinate, props, set_info, summaries};
|
||||
#[cfg(test)]
|
||||
use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref};
|
||||
use lookup::{annots, page_ref};
|
||||
@@ -42,8 +42,8 @@ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations};
|
||||
pub(crate) use modify::n;
|
||||
pub use modify::{
|
||||
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, release_members,
|
||||
set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect,
|
||||
set_review_state, set_style, with_replies_and_popups,
|
||||
set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_popup,
|
||||
set_rect, set_review_state, set_style, with_replies_and_popups,
|
||||
};
|
||||
pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model};
|
||||
pub use types::{
|
||||
|
||||
@@ -3,8 +3,8 @@ use super::create::{
|
||||
ink_bounds, normalize, num_array, on_side, rect_for, rgb,
|
||||
};
|
||||
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
|
||||
use super::info::{is_group_subordinate, line_endings_of, text_value};
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots};
|
||||
use super::info::{is_comment_subtype, is_group_subordinate, line_endings_of, own_popup, popup_info, text_value};
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, is_fill_sign, page_ref, set_annots};
|
||||
use super::text_style::{self, TextStyle};
|
||||
use super::types::{
|
||||
BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb,
|
||||
@@ -438,6 +438,83 @@ pub(crate) fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> {
|
||||
if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Open, close or move a comment's pop-up window (Acrobat: double-click a comment to open it,
|
||||
/// drag its title bar to move it). `rect` is the pop-up's new `/Rect` in user space; `open` its
|
||||
/// `/Open`. A comment without a `/Popup` gets one, placed to the right of the comment as new
|
||||
/// notes get theirs. A text box, a Fill & Sign mark or a reply has no pop-up of its own (a reply
|
||||
/// shows in its parent's). The lock does not apply: the window's place and state are not the
|
||||
/// comment. The modification date is left alone: nothing of the comment changed.
|
||||
pub fn set_popup(doc: &mut Document, page: usize, index: usize, rect: Option<[f64; 4]>, open: Option<bool>, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (p, r) = annot_ref(doc, page, index)?;
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
if !is_comment_subtype(&subtype) || subtype == "FreeText" || is_fill_sign(doc, &d) || d.contains(b"IRT") {
|
||||
return Err(AnnotError::Invalid(format!("{subtype} comments have no pop-up of their own")));
|
||||
}
|
||||
let rect = match rect {
|
||||
Some(rect) => {
|
||||
let rect = normalize(rect);
|
||||
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||||
return Err(AnnotError::Invalid("invalid pop-up rectangle".into()));
|
||||
}
|
||||
Some(rect)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let _ = meta;
|
||||
// Where a new pop-up goes: to the right of the comment, as new notes' do.
|
||||
let own: Vec<f64> = d
|
||||
.get(b"Rect")
|
||||
.map(|o| doc.resolve(o))
|
||||
.and_then(|o| o.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|v| doc.resolve(v).as_f64())
|
||||
.collect();
|
||||
let beside = match own[..] {
|
||||
[_, _, x1, y1] if finite(&own) => [x1 + 10.0, y1 - 120.0, x1 + 210.0, y1],
|
||||
_ => [0.0, 0.0, 200.0, 120.0],
|
||||
};
|
||||
// Only a pop-up that is this comment's: `/Popup` naming another annotation (or a pop-up
|
||||
// whose `/Parent` is another comment) would move or open that one, so it gets its own.
|
||||
match own_popup(doc, Some(r), &d) {
|
||||
Some(pr) => {
|
||||
// One without a `/Rect` of four numbers reads as no pop-up (`summaries`), so it gets
|
||||
// the new pop-up's place: opening it then shows it.
|
||||
let rect = rect.or_else(|| popup_info(doc, Some(r), &d).is_none().then_some(beside));
|
||||
doc.update_dict(pr, |p| {
|
||||
if let Some(rect) = rect {
|
||||
p.set(b"Rect".to_vec(), num_array(&rect));
|
||||
}
|
||||
if let Some(open) = open {
|
||||
p.set(b"Open".to_vec(), Object::Bool(open));
|
||||
}
|
||||
})?
|
||||
}
|
||||
None => {
|
||||
let mut pd = Dict::new();
|
||||
pd.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
pd.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||||
pd.set(b"Rect".to_vec(), num_array(&rect.unwrap_or(beside)));
|
||||
pd.set(b"Parent".to_vec(), Object::Ref(r));
|
||||
pd.set(b"Open".to_vec(), Object::Bool(open.unwrap_or(false)));
|
||||
pd.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
|
||||
let pref = doc.add(Object::Dict(pd));
|
||||
doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?;
|
||||
let mut list = annots(doc, p);
|
||||
list.push(Object::Ref(pref));
|
||||
set_annots(doc, p, list)?;
|
||||
}
|
||||
}
|
||||
// A note's own `/Open` says the same thing to viewers that read it there (§12.5.6.4).
|
||||
if let Some(open) = open
|
||||
&& (subtype == "Text" || d.contains(b"Open"))
|
||||
{
|
||||
doc.update_dict(r, |d| d.set(b"Open".to_vec(), Object::Bool(open)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized,
|
||||
/// restyled or deleted; its text and replies stay editable, as in Acrobat.
|
||||
pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> {
|
||||
|
||||
@@ -4562,6 +4562,163 @@ fn a_hostile_callout_line_is_never_rewritten_into_another_shape() {
|
||||
assert_eq!(assert_callout_line(&list(&doc, 0)[c], 6, "indirect"), [150.0, 400.0, 260.0, 620.0, 300.0, 620.0]);
|
||||
}
|
||||
|
||||
/// A pop-up is opened, closed and moved through `set_popup` (#comment.popups): a note's own
|
||||
/// pop-up follows, a comment without one gets one beside it, and the comment itself (its text,
|
||||
/// its date) is left alone. Summaries report the pop-up. Text boxes, Fill & Sign marks and
|
||||
/// replies have none of their own; a bad rectangle is refused.
|
||||
#[test]
|
||||
fn pop_ups_open_close_and_move_and_are_reported() {
|
||||
let mut doc = fixture();
|
||||
let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [20.0, 280.0], icon: NoteIcon::Comment }), &meta("n1")).unwrap();
|
||||
let before = summaries(&doc).into_iter().find(|s| s.index == note).unwrap();
|
||||
assert_eq!(before.popup.map(|p| p.open), Some(false), "new notes have a closed pop-up");
|
||||
set_popup(&mut doc, 0, note, None, Some(true), &meta("n1")).unwrap();
|
||||
let after = summaries(&doc).into_iter().find(|s| s.index == note).unwrap();
|
||||
assert_eq!(after.popup.map(|p| p.open), Some(true));
|
||||
assert_eq!(after.popup.map(|p| p.rect), before.popup.map(|p| p.rect), "opening does not move it");
|
||||
assert_eq!((after.modified, after.contents.as_deref()), (before.modified, Some("hello")), "the comment is untouched");
|
||||
let (_, r) = annot_ref(&mut doc, 0, note).unwrap();
|
||||
assert_eq!(annot_dict(&doc, r).get(b"Open"), Some(&Object::Bool(true)), "the note's own /Open follows");
|
||||
set_popup(&mut doc, 0, note, Some([150.0, 100.0, 290.0, 200.0]), Some(false), &meta("n1")).unwrap();
|
||||
let moved = summaries(&doc).into_iter().find(|s| s.index == note).unwrap();
|
||||
assert_eq!(moved.popup, Some(PopupInfo { rect: [150.0, 100.0, 290.0, 200.0], open: false }));
|
||||
assert_eq!(annot_dict(&doc, r).get(b"Open"), Some(&Object::Bool(false)));
|
||||
|
||||
// A highlight is created without a pop-up: the first open gives it one, beside it.
|
||||
let mark = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 200.0, 120.0, 200.0, 20.0, 190.0, 120.0, 190.0]] }),
|
||||
&meta("h1"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(summaries(&doc).into_iter().find(|s| s.index == mark).unwrap().popup, None);
|
||||
set_popup(&mut doc, 0, mark, None, Some(true), &meta("h1")).unwrap();
|
||||
let given = summaries(&doc).into_iter().find(|s| s.index == mark).unwrap().popup.expect("a pop-up now");
|
||||
assert!(given.open);
|
||||
assert!((given.rect[0] - 130.0).abs() < 0.01 && (given.rect[2] - 330.0).abs() < 0.01, "beside the mark: {:?}", given.rect);
|
||||
|
||||
// #2137 review: a /Popup naming another annotation (here the note) isn't this comment's: the
|
||||
// note is left alone and the mark gets a pop-up of its own.
|
||||
let (_, mr) = annot_ref(&mut doc, 0, mark).unwrap();
|
||||
doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(r))).unwrap();
|
||||
let note_before = annot_dict(&doc, r);
|
||||
set_popup(&mut doc, 0, mark, Some([10.0, 10.0, 110.0, 60.0]), Some(true), &meta("h1")).unwrap();
|
||||
assert_eq!(annot_dict(&doc, r), note_before, "the note isn't moved or opened");
|
||||
let own = annot_dict(&doc, mr).reference(b"Popup").expect("a pop-up of its own");
|
||||
assert_ne!(own, r);
|
||||
assert_eq!(doc.get(own).as_dict().and_then(|p| p.reference(b"Parent")), Some(mr));
|
||||
// Its own pop-up, with /Parent naming it, is the one moved next time.
|
||||
set_popup(&mut doc, 0, mark, Some([12.0, 10.0, 112.0, 60.0]), None, &meta("h1")).unwrap();
|
||||
assert_eq!(annot_dict(&doc, mr).reference(b"Popup"), Some(own), "the same pop-up");
|
||||
|
||||
// #2137 review: nor is it reported as the comment's (see the helper).
|
||||
foreign_pop_ups_are_not_the_marks(&mut doc, r, mark, mr);
|
||||
|
||||
// None of their own.
|
||||
let text_box = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [10.0, 10.0, 110.0, 40.0], font_size: 12.0 }), &meta("t1")).unwrap();
|
||||
assert!(matches!(set_popup(&mut doc, 0, text_box, None, Some(true), &meta("t1")), Err(AnnotError::Invalid(_))));
|
||||
let reply = add_reply(&mut doc, 0, note, "Agreed", "Ada", &meta("r1")).unwrap();
|
||||
assert!(matches!(set_popup(&mut doc, 0, reply, None, Some(true), &meta("r1")), Err(AnnotError::Invalid(_))));
|
||||
assert!(matches!(set_popup(&mut doc, 0, note, Some([0.0, 0.0, 0.5, 0.5]), None, &meta("n1")), Err(AnnotError::Invalid(_))));
|
||||
assert!(matches!(set_popup(&mut doc, 0, note, Some([f64::NAN, 0.0, 10.0, 10.0]), None, &meta("n1")), Err(AnnotError::Invalid(_))));
|
||||
assert!(matches!(set_popup(&mut doc, 0, 99, None, Some(true), &meta("n1")), Err(AnnotError::NoSuchAnnotation { .. })));
|
||||
|
||||
// #2137 review: /Annots may list the note through an object that is only a reference to it.
|
||||
// Its pop-up's /Parent names the note itself, so the pop-up is still the note's.
|
||||
let p = page_ref(&doc, 0).unwrap();
|
||||
let via = doc.add(Object::Ref(r));
|
||||
let list: Vec<Object> = annots(&doc, p).into_iter().map(|o| if o.as_ref() == Some(r) { Object::Ref(via) } else { o }).collect();
|
||||
set_annots(&mut doc, p, list).unwrap();
|
||||
let listed = summaries(&doc).into_iter().find(|s| s.index == note).unwrap();
|
||||
assert_eq!(listed.popup, Some(PopupInfo { rect: [150.0, 100.0, 290.0, 200.0], open: false }), "listed through {via:?}");
|
||||
}
|
||||
|
||||
/// #2137 review: a pop-up that is not the mark's own is neither reported as its pop-up nor
|
||||
/// changed through it (part of `pop_ups_open_close_and_move_and_are_reported`).
|
||||
fn foreign_pop_ups_are_not_the_marks(doc: &mut Document, note: ObjRef, mark: usize, mr: ObjRef) {
|
||||
// #2137 review: nor is it reported as the comment's. A /Popup naming the note, or the note's
|
||||
// own pop-up (its /Parent is the note), gives the mark no pop-up in the summaries.
|
||||
let popup_of_mark = |doc: &Document| summaries(doc).into_iter().find(|s| s.index == mark).unwrap().popup;
|
||||
let notes_popup = annot_dict(doc, note).reference(b"Popup").expect("the note's pop-up");
|
||||
for foreign in [note, notes_popup] {
|
||||
doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(foreign))).unwrap();
|
||||
assert_eq!(popup_of_mark(doc), None, "{foreign:?} isn't the mark's");
|
||||
}
|
||||
// A pop-up whose /Parent isn't a reference (it must be one) isn't the mark's either: it is
|
||||
// neither reported nor changed, and the mark gets one of its own.
|
||||
let mut bad = Dict::new();
|
||||
bad.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
bad.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||||
bad.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 50.0, 50.0].iter().map(|v| Object::Real(*v)).collect()));
|
||||
bad.set(b"Parent".to_vec(), Object::Dict(Dict::new()));
|
||||
let bad = doc.add(Object::Dict(bad));
|
||||
doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(bad))).unwrap();
|
||||
assert_eq!(popup_of_mark(doc), None, "a direct /Parent");
|
||||
let bad_before = doc.get(bad).as_dict().cloned();
|
||||
set_popup(doc, 0, mark, None, Some(true), &meta("h1")).unwrap();
|
||||
assert_eq!(doc.get(bad).as_dict().cloned(), bad_before, "left alone");
|
||||
assert_ne!(annot_dict(doc, mr).reference(b"Popup"), Some(bad));
|
||||
// A pop-up with no /Parent at all is taken as the comment's.
|
||||
let mut orphan = Dict::new();
|
||||
orphan.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
orphan.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||||
orphan.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 50.0, 50.0].iter().map(|v| Object::Real(*v)).collect()));
|
||||
let orphan = doc.add(Object::Dict(orphan));
|
||||
doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(orphan))).unwrap();
|
||||
assert_eq!(popup_of_mark(doc), Some(PopupInfo { rect: [0.0, 0.0, 50.0, 50.0], open: false }), "no /Parent");
|
||||
}
|
||||
|
||||
/// #2137 review: a comment's own pop-up without a `/Rect` of four numbers reads as none; opening
|
||||
/// it gives it the place a new pop-up gets, so the window shows again.
|
||||
#[test]
|
||||
fn opening_a_pop_up_without_a_rect_gives_it_one() {
|
||||
let mut doc = fixture();
|
||||
let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [20.0, 280.0], icon: NoteIcon::Comment }), &meta("n1")).unwrap();
|
||||
let (_, r) = annot_ref(&mut doc, 0, note).unwrap();
|
||||
let pr = annot_dict(&doc, r).reference(b"Popup").expect("new notes have a pop-up");
|
||||
doc.update_dict(pr, |p| {
|
||||
p.remove(b"Rect");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(summaries(&doc).into_iter().find(|s| s.index == note).unwrap().popup, None, "no /Rect reads as no pop-up");
|
||||
set_popup(&mut doc, 0, note, None, Some(true), &meta("n1")).unwrap();
|
||||
let popup = summaries(&doc).into_iter().find(|s| s.index == note).unwrap().popup.expect("the pop-up reads again");
|
||||
assert!(popup.open);
|
||||
assert!(popup.rect[2] - popup.rect[0] >= 100.0 && popup.rect[3] - popup.rect[1] >= 100.0, "a window's size: {:?}", popup.rect);
|
||||
assert_eq!(annot_dict(&doc, r).reference(b"Popup"), Some(pr), "the same pop-up, not a new one");
|
||||
}
|
||||
|
||||
/// #2137 review: a pop-up rectangle with its corners reversed is read, and saved, with them in
|
||||
/// order (ISO 32000-2 §7.9.5: a reader normalises a rectangle). One whose numbers aren't finite
|
||||
/// as `f32` (a file's huge number) reads as no pop-up, and opening it gives it a new place.
|
||||
#[test]
|
||||
fn a_pop_up_rect_with_reversed_corners_is_put_in_order_and_a_huge_one_is_none() {
|
||||
let mut doc = fixture();
|
||||
let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [20.0, 280.0], icon: NoteIcon::Comment }), &meta("n1")).unwrap();
|
||||
let (_, r) = annot_ref(&mut doc, 0, note).unwrap();
|
||||
let pr = annot_dict(&doc, r).reference(b"Popup").expect("new notes have a pop-up");
|
||||
let popup = |doc: &Document| summaries(doc).into_iter().find(|s| s.index == note).unwrap().popup;
|
||||
let set_rect = |doc: &mut Document, rect: [f64; 4]| doc.update_dict(pr, |p| p.set(b"Rect".to_vec(), num_array(&rect))).unwrap();
|
||||
|
||||
// Reversed in the file: read in order.
|
||||
set_rect(&mut doc, [290.0, 200.0, 150.0, 100.0]);
|
||||
assert_eq!(popup(&doc), Some(PopupInfo { rect: [150.0, 100.0, 290.0, 200.0], open: false }));
|
||||
// Reversed through `set_popup`: saved in order.
|
||||
set_popup(&mut doc, 0, note, Some([300.0, 220.0, 160.0, 120.0]), None, &meta("n1")).unwrap();
|
||||
let saved: Vec<f64> =
|
||||
doc.get(pr).as_dict().and_then(|p| p.get(b"Rect")).and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect();
|
||||
assert_eq!(saved, [160.0, 120.0, 300.0, 220.0]);
|
||||
|
||||
// A number too large for `f32` (or infinite): no pop-up, and opening it gives it a place.
|
||||
for huge in [1e300, f64::INFINITY] {
|
||||
set_rect(&mut doc, [0.0, 0.0, huge, 100.0]);
|
||||
assert_eq!(popup(&doc), None, "{huge}");
|
||||
}
|
||||
set_popup(&mut doc, 0, note, None, Some(true), &meta("n1")).unwrap();
|
||||
let given = popup(&doc).expect("the pop-up reads again");
|
||||
assert!(given.open && given.rect.iter().all(|v| v.is_finite()), "{given:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freehand_highlight_is_compatible_ink_with_marker_blending() {
|
||||
let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] };
|
||||
|
||||
@@ -206,6 +206,8 @@ impl Automation {
|
||||
"rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))),
|
||||
"color": c.color.map(hex),
|
||||
"group": group,
|
||||
// Unchanged on a page the viewer could not read, as `rect` is.
|
||||
"popup": c.popup.map(|w| json!({ "rect": crate::view_rect(&doc.info, c.page, w.rect), "open": w.open })),
|
||||
});
|
||||
// A text box's or typed text's oval or circle (a rectangle is not reported).
|
||||
if c.subtype == "FreeText"
|
||||
@@ -710,6 +712,17 @@ impl Automation {
|
||||
self.apply(a, Edit::MarkAnnotation { page, index, marked, author })
|
||||
}
|
||||
|
||||
pub(crate) fn comment_popup(&mut self, a: &Args) -> Result<Value> {
|
||||
let (page, index) = self.comment_target(a)?;
|
||||
let info = self.doc(a)?.info.pages[page].clone();
|
||||
let open = a.opt_bool("open")?;
|
||||
let rect = a.nums::<4>("rect")?.map(|r| rect_to_user(&info, r));
|
||||
if open.is_none() && rect.is_none() {
|
||||
return Err(ToolError::InvalidArgs("nothing to change: pass open and/or rect".into()));
|
||||
}
|
||||
self.apply(a, Edit::SetAnnotationPopup { page, index, rect, open })
|
||||
}
|
||||
|
||||
pub(crate) fn comment_lock(&mut self, a: &Args) -> Result<Value> {
|
||||
let (page, index) = self.comment_target(a)?;
|
||||
let locked = a.opt_bool("locked")?.unwrap_or(true);
|
||||
|
||||
@@ -873,6 +873,7 @@ impl Automation {
|
||||
"sign_trust" => self.sign_trust(&a)?,
|
||||
"comment_mark" => self.comment_mark(&a)?,
|
||||
"comment_lock" => self.comment_lock(&a)?,
|
||||
"comment_popup" => self.comment_popup(&a)?,
|
||||
"comments_hide" => self.comments_hide(&a)?,
|
||||
"layer_set" => self.layer_set(&a)?,
|
||||
"view_reading_colors" => self.view_reading_colors(&a)?,
|
||||
|
||||
@@ -1072,6 +1072,11 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
|
||||
.ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])),
|
||||
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
|
||||
t("comment_popup", "Open, close or move a comment's pop-up", "Open or close a comment's pop-up window (open), or move it (rect, in page view coordinates as comment_list reports). A comment without a pop-up gets one beside it. Text boxes, Fill & Sign marks and replies have none of their own. Undoable.")
|
||||
.with(schema(comment_ref(json!({
|
||||
"open": { "type": "boolean" },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
})), &["doc"])),
|
||||
t(
|
||||
"comments_copy",
|
||||
"Copy comments",
|
||||
|
||||
@@ -12472,6 +12472,32 @@ fn a_callouts_parts_are_listed_and_changed_through_the_tools() {
|
||||
assert!(near(&auto["points"][1], &[288.0, 220.0]), "{auto}");
|
||||
}
|
||||
|
||||
/// comment_popup opens, closes and moves a comment's pop-up, comment_list reports it, and each
|
||||
/// change is one undo step with its own label.
|
||||
#[test]
|
||||
fn comment_popups_are_opened_moved_and_listed() {
|
||||
let dir = workdir("popup");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [30.0, 40.0], "contents": "Look here" }));
|
||||
let listed = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
let note = &listed["comments"][0];
|
||||
assert_eq!(note["popup"]["open"], json!(false), "{listed}");
|
||||
let id = note["id"].as_str().unwrap().to_string();
|
||||
let opened = ok(&mut a, "comment_popup", json!({ "doc": doc, "id": id, "open": true }));
|
||||
assert_eq!(opened["undo"], json!("Open comment pop-up"), "{opened}");
|
||||
let listed = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
assert_eq!(listed["comments"][0]["popup"]["open"], json!(true));
|
||||
let moved = ok(&mut a, "comment_popup", json!({ "doc": doc, "id": id, "rect": [60.0, 60.0, 180.0, 140.0] }));
|
||||
assert_eq!(moved["undo"], json!("Move comment pop-up"));
|
||||
let listed = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
assert_eq!(listed["comments"][0]["popup"]["rect"], json!([60.0, 60.0, 180.0, 140.0]), "{listed}");
|
||||
let closed = ok(&mut a, "comment_popup", json!({ "doc": doc, "id": id, "open": false }));
|
||||
assert_eq!(closed["undo"], json!("Close comment pop-up"));
|
||||
assert!(matches!(err(&mut a, "comment_popup", json!({ "doc": doc, "id": id })), ToolError::InvalidArgs(_)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how
|
||||
/// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece
|
||||
/// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render:
|
||||
no_rotate: s.no_rotate,
|
||||
points: s.points,
|
||||
text_box: s.text_box,
|
||||
popup: s.popup.map(|p| pdfcraft_render::Popup { rect: p.rect, open: p.open }),
|
||||
})
|
||||
.collect();
|
||||
pdfcraft_render::link_replies(&mut list);
|
||||
|
||||
@@ -243,6 +243,14 @@ pub enum Edit {
|
||||
index: usize,
|
||||
points: Vec<[f64; 2]>,
|
||||
},
|
||||
/// Open, close or move a comment's pop-up window (`rect` in user space; a comment without a
|
||||
/// pop-up gets one).
|
||||
SetAnnotationPopup {
|
||||
page: usize,
|
||||
index: usize,
|
||||
rect: Option<[f64; 4]>,
|
||||
open: Option<bool>,
|
||||
},
|
||||
/// Resize a rectangle, oval or text box.
|
||||
ResizeAnnotation {
|
||||
page: usize,
|
||||
|
||||
@@ -87,6 +87,9 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Unlock comment",
|
||||
"Move comment",
|
||||
"Reshape comment",
|
||||
"Open comment pop-up",
|
||||
"Close comment pop-up",
|
||||
"Move comment pop-up",
|
||||
"Resize comment",
|
||||
"Change comment properties",
|
||||
"Fill in {name}",
|
||||
@@ -232,6 +235,9 @@ impl Edit {
|
||||
Edit::LockAnnotation { .. } => Label::of("Unlock comment"),
|
||||
Edit::MoveAnnotation { .. } => Label::of("Move comment"),
|
||||
Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"),
|
||||
Edit::SetAnnotationPopup { rect: Some(_), .. } => Label::of("Move comment pop-up"),
|
||||
Edit::SetAnnotationPopup { open: Some(true), .. } => Label::of("Open comment pop-up"),
|
||||
Edit::SetAnnotationPopup { .. } => Label::of("Close comment pop-up"),
|
||||
Edit::ResizeAnnotation { .. } => Label::of("Resize comment"),
|
||||
Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
|
||||
@@ -62,6 +62,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
| Edit::LockAnnotation { .. }
|
||||
| Edit::MoveAnnotation { .. }
|
||||
| Edit::ReshapeAnnotation { .. }
|
||||
| Edit::SetAnnotationPopup { .. }
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
@@ -634,6 +635,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?,
|
||||
Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?,
|
||||
Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?,
|
||||
Edit::SetAnnotationPopup { page, index, rect, open } => pdfcraft_annot::set_popup(doc, *page, *index, *rect, *open, &cx.meta())?,
|
||||
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
|
||||
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
|
||||
pdfcraft_annot::set_style(
|
||||
|
||||
@@ -66,6 +66,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
| Edit::EraseInk { .. }
|
||||
| Edit::MoveAnnotation { .. }
|
||||
| Edit::ReshapeAnnotation { .. }
|
||||
| Edit::SetAnnotationPopup { .. }
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
|
||||
@@ -37,7 +37,8 @@ pub use core::{check_pages, inspect, pretty_date};
|
||||
use core::{inspect_structure, spend};
|
||||
pub use model::{
|
||||
Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, MAX_GROUP_DEPTH, OutlineItem,
|
||||
PageInfo, PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, fill_group_text, group_primaries, link_replies, thread_tops,
|
||||
PageInfo, PageTransition, Popup, RemoteDestination, ReviewStates, TransitionStyle, Xfa, fill_group_text, group_primaries, link_replies,
|
||||
thread_tops,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::core::{limited_pdf_string, pretty_date, spend};
|
||||
use super::model::{
|
||||
Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, fill_group_text, link_replies,
|
||||
Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, Popup, RemoteDestination, fill_group_text, link_replies,
|
||||
state_or_default,
|
||||
};
|
||||
use super::navigation::text_string;
|
||||
@@ -209,6 +209,29 @@ impl<'a> Inspector<'a> {
|
||||
Some((lead, text_box)) if subtype == "FreeText" => (lead, Some(text_box)),
|
||||
_ => (points, None),
|
||||
};
|
||||
// Only the comment's own pop-up: a pop-up annotation whose /Parent, if any, is a
|
||||
// reference to this comment (keep in step with `pdfcraft_annot::summaries`).
|
||||
let own = self.resolved_id(a);
|
||||
let is_own = |p: &&Dictionary| {
|
||||
self.name(p, b"Subtype").as_deref() == Some("Popup")
|
||||
&& p.get(b"Parent")
|
||||
.map_or(true, |parent| parent.as_reference().is_ok() && self.resolved_id(parent).is_some_and(|id| Some(id) == own))
|
||||
};
|
||||
// A pop-up without a `/Rect` of four numbers finite as `f32` is none, as
|
||||
// `pdfcraft_annot::summaries` reads it: both lists of the comments agree. Its corners
|
||||
// may come in any order.
|
||||
let popup = d.get(b"Popup").ok().and_then(|o| self.dict(o)).filter(is_own).and_then(|p| {
|
||||
let rect = self.resolve(p.get(b"Rect").ok()?).as_array().ok()?;
|
||||
let nums: Vec<f32> = rect.iter().filter_map(|v| self.resolve(v).as_float().ok()).collect();
|
||||
let [x0, y0, x1, y1] = nums[..] else { return None };
|
||||
if !nums.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(Popup {
|
||||
rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)],
|
||||
open: p.get(b"Open").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false),
|
||||
})
|
||||
});
|
||||
let annotation = Annotation {
|
||||
page,
|
||||
subtype,
|
||||
@@ -232,6 +255,7 @@ impl<'a> Inspector<'a> {
|
||||
no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0),
|
||||
points,
|
||||
text_box,
|
||||
popup,
|
||||
};
|
||||
kept = kept.saturating_add(retained(&annotation));
|
||||
if kept > MAX_ANNOTATION_BYTES {
|
||||
|
||||
@@ -350,6 +350,8 @@ pub struct Annotation {
|
||||
pub points: Vec<[f32; 2]>,
|
||||
/// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments.
|
||||
pub text_box: Option<[f32; 4]>,
|
||||
/// The comment's pop-up window (`/Popup`): where it sits and whether it is open.
|
||||
pub popup: Option<Popup>,
|
||||
}
|
||||
|
||||
/// How far [`group_primaries`] follows `/IRT` from a group subordinate to its primary.
|
||||
@@ -412,6 +414,15 @@ pub fn fill_group_text(all: &mut [Annotation]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A comment's pop-up window (ISO 32000-2 §12.5.6.14).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Popup {
|
||||
/// `/Rect` of the `/Popup` annotation, in user space.
|
||||
pub rect: [f32; 4],
|
||||
/// `/Open`: shown open.
|
||||
pub open: bool,
|
||||
}
|
||||
|
||||
impl Annotation {
|
||||
/// A checkmark reply (`/StateModel /Marked`): "Marked" or "Unmarked" rather than a review status.
|
||||
pub fn is_mark(&self) -> bool {
|
||||
|
||||
@@ -305,6 +305,7 @@ fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() {
|
||||
no_rotate: true,
|
||||
points: Vec::new(),
|
||||
text_box: None,
|
||||
popup: None,
|
||||
};
|
||||
let rect = note.rect.map(f64::from);
|
||||
for rotation in [0u16, 90, 180, 270, 45] {
|
||||
@@ -2000,6 +2001,39 @@ fn carriage_returns_in_comment_text_become_line_breaks() {
|
||||
assert_eq!(info.annotations[0].contents.as_deref(), Some("one\n\ntwo\nthree"));
|
||||
}
|
||||
|
||||
/// A comment's pop-up (`/Popup`) is read with its place and state; one without reads none.
|
||||
#[test]
|
||||
fn a_comments_pop_up_is_read_with_its_place_and_state() {
|
||||
let bytes = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R 5 0 R 6 0 R] >> endobj\n4 0 obj << /Type /Annot /Subtype /Text /Rect [20 260 40 280] /Contents (Hi) /Popup 5 0 R /Open true >> endobj\n5 0 obj << /Type /Annot /Subtype /Popup /Rect [190 160 50 280] /Parent 4 0 R /Open true >> endobj\n6 0 obj << /Type /Annot /Subtype /Square /Rect [10 10 60 60] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n";
|
||||
let info = inspect(Arc::new(bytes.to_vec()), None).expect("opens");
|
||||
let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note");
|
||||
assert_eq!(note.popup, Some(Popup { rect: [50.0, 160.0, 190.0, 280.0], open: true }), "normalised, and open");
|
||||
let square = info.annotations.iter().find(|a| a.subtype == "Square").expect("the square");
|
||||
assert_eq!(square.popup, None);
|
||||
assert!(info.annotations.iter().all(|a| a.subtype != "Popup"), "the pop-up is not a comment of its own");
|
||||
|
||||
// #2137 review: a /Popup naming another annotation, a pop-up of another comment, or one
|
||||
// whose /Parent isn't a reference isn't this comment's pop-up; one without /Parent is.
|
||||
for (target, extra, own) in [
|
||||
("6 0 R", "", false),
|
||||
("7 0 R", "7 0 obj << /Type /Annot /Subtype /Popup /Rect [0 0 50 50] /Parent 6 0 R >> endobj\n", false),
|
||||
("7 0 R", "7 0 obj << /Type /Annot /Subtype /Popup /Rect [0 0 50 50] /Parent << >> >> endobj\n", false),
|
||||
("7 0 R", "7 0 obj << /Type /Annot /Subtype /Popup /Rect [0 0 50 50] >> endobj\n", true),
|
||||
] {
|
||||
let text = String::from_utf8_lossy(bytes).replace("/Popup 5 0 R", &format!("/Popup {target}")).replace("trailer", &format!("{extra}trailer"));
|
||||
let info = inspect(Arc::new(text.into_bytes()), None).expect("opens");
|
||||
let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note");
|
||||
assert_eq!(note.popup.is_some(), own, "/Popup {target} {extra}");
|
||||
}
|
||||
|
||||
// #2137 review: /Annots may list the note through an object that is only a reference to
|
||||
// it; its pop-up's /Parent names the note itself, and is still its own.
|
||||
let text = String::from_utf8_lossy(bytes).replace("/Annots [4 0 R", "/Annots [8 0 R").replace("trailer", "8 0 obj 4 0 R endobj\ntrailer");
|
||||
let info = inspect(Arc::new(text.into_bytes()), None).expect("opens");
|
||||
let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note");
|
||||
assert_eq!(note.popup, Some(Popup { rect: [50.0, 160.0, 190.0, 280.0], open: true }), "listed through 8 0 R");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_are_prettified() {
|
||||
assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45");
|
||||
@@ -2171,6 +2205,34 @@ mod group_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2137 review: a pop-up without a `/Rect` of four numbers reads as no pop-up, as
|
||||
/// `pdfcraft_annot::summaries` reads it, so the two lists of comments agree.
|
||||
#[test]
|
||||
fn a_pop_up_without_a_four_number_rect_is_none() {
|
||||
let bytes = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R 5 0 R] >> endobj\n4 0 obj << /Type /Annot /Subtype /Text /Rect [20 260 40 280] /Contents (Hi) /Popup 5 0 R >> endobj\n5 0 obj << /Type /Annot /Subtype /Popup RECT /Parent 4 0 R /Open true >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n";
|
||||
for (rect, read) in [("", false), ("/Rect [0 0 50]", false), ("/Rect (box)", false), ("/Rect [0 0 50 /x]", false), ("/Rect [0 0 50 60]", true)] {
|
||||
let info = inspect(Arc::new(bytes.replace("RECT", rect).into_bytes()), None).expect("opens");
|
||||
let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note");
|
||||
assert_eq!(note.popup.is_some(), read, "{rect:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2137 review: a pop-up `/Rect` with its corners reversed reads with them in order (ISO
|
||||
/// 32000-2 §7.9.5), and one with a number too large for `f32` reads as no pop-up, as
|
||||
/// `pdfcraft_annot::summaries` reads both.
|
||||
#[test]
|
||||
fn a_pop_up_rect_with_reversed_corners_is_put_in_order_and_a_huge_one_is_none() {
|
||||
let bytes = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R 5 0 R] >> endobj\n4 0 obj << /Type /Annot /Subtype /Text /Rect [20 260 40 280] /Contents (Hi) /Popup 5 0 R >> endobj\n5 0 obj << /Type /Annot /Subtype /Popup /Rect RECT /Parent 4 0 R /Open true >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n";
|
||||
let popup = |rect: &str| {
|
||||
let info = inspect(Arc::new(bytes.replace("RECT", rect).into_bytes()), None).expect("opens");
|
||||
info.annotations.iter().find(|a| a.subtype == "Text").expect("the note").popup.map(|p| p.rect)
|
||||
};
|
||||
assert_eq!(popup("[150 200 50 100]"), Some([50.0, 100.0, 150.0, 200.0]), "reversed");
|
||||
let huge = format!("1{}", "0".repeat(40));
|
||||
assert_eq!(popup(&format!("[0 0 {huge} 100]")), None, "a number past f32");
|
||||
assert_eq!(popup(&format!("[0 0 {huge}.0 100]")), None, "a real past f32");
|
||||
}
|
||||
|
||||
/// Inspection keeps every object it reads, converted, for as long as it runs, and nothing it
|
||||
/// reports comes from `/PieceInfo`. InDesign writes kilobytes of that private data into every
|
||||
/// page, and a 1,412-page book held over a gigabyte of it (#5873). It is left out of what
|
||||
|
||||
@@ -19,8 +19,8 @@ pub mod transition;
|
||||
|
||||
pub use inspect::{
|
||||
Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, MAX_GROUP_DEPTH,
|
||||
OutlineItem, PageInfo, PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, attachment_data, check_pages, fill_group_text,
|
||||
group_primaries, inspect, link_replies, pretty_date, thread_tops,
|
||||
OutlineItem, PageInfo, PageTransition, Popup, RemoteDestination, ReviewStates, TransitionStyle, Xfa, attachment_data, check_pages,
|
||||
fill_group_text, group_primaries, inspect, link_replies, pretty_date, thread_tops,
|
||||
};
|
||||
pub use pixels::Pixels;
|
||||
pub use raster::{
|
||||
|
||||
@@ -2716,7 +2716,8 @@ fn scroll_direction(dir: i8, dy: f32, resized: bool) -> i8 {
|
||||
/// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the
|
||||
/// text editor was finished.
|
||||
fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, primary_down: bool, hover_text: Option<(Pos2, String)>) -> bool {
|
||||
let PageEnv { added, author, avail, can_modify, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = *env;
|
||||
let PageEnv { added, allowed, author, avail, can_modify, comments_hidden, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } =
|
||||
*env;
|
||||
if let Some((pos, text)) = hover_text {
|
||||
// Click-through, as a tooltip is: the pages lose the pointer under anything that isn't.
|
||||
let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false);
|
||||
@@ -2735,6 +2736,14 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim
|
||||
if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) {
|
||||
view.pending_edit = Some(e);
|
||||
}
|
||||
if let Some(e) = comments::popups(ui.ctx(), view, info, prefs, comments_hidden, allowed) {
|
||||
// A click on the composer's Post also takes the focus from a pop-up's box, saving what
|
||||
// was typed there: both edits land in this frame, so both are kept, in one batch.
|
||||
view.pending_edit = Some(match view.pending_edit.take() {
|
||||
Some(first) => Edit::Batch { label: "Edit comment".into(), edits: vec![first, e] },
|
||||
None => e,
|
||||
});
|
||||
}
|
||||
if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) {
|
||||
view.pending_edit = Some(e);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ fn edges_are_primary_finite_in_order_and_capped() {
|
||||
}
|
||||
|
||||
/// The page modules: the per-page chain in `canvas::document_area` and what it calls.
|
||||
const PAGE_MODULES: [&str; 37] = [
|
||||
const PAGE_MODULES: [&str; 38] = [
|
||||
"canvas.rs",
|
||||
"canvas/page_input.rs",
|
||||
"canvas/area.rs",
|
||||
@@ -182,6 +182,7 @@ const PAGE_MODULES: [&str; 37] = [
|
||||
"tools/comments/input.rs",
|
||||
"tools/comments/menu.rs",
|
||||
"tools/comments/paint.rs",
|
||||
"tools/comments/popups.rs",
|
||||
"tools/comments/state.rs",
|
||||
"tools/forms_ui.rs",
|
||||
"tools/prepare.rs",
|
||||
@@ -241,13 +242,14 @@ const RAW_READS: [&str; 21] = [
|
||||
|
||||
/// Functions in the page modules that are not page input and read egui's pointer state
|
||||
/// themselves: (file, function, why).
|
||||
const NOT_PAGE_INPUT: [(&str, &str, &str); 10] = [
|
||||
const NOT_PAGE_INPUT: [(&str, &str, &str); 11] = [
|
||||
("canvas/area.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"),
|
||||
("canvas/area.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"),
|
||||
("canvas/area.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"),
|
||||
("canvas/area.rs", "presentation_click", "full screen's click to turn the page, read before the pages' widget exists"),
|
||||
("canvas/bars.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"),
|
||||
("canvas/organize.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"),
|
||||
("tools/comments/popups.rs", "popups", "the comment pop-up windows are Areas of their own over the page"),
|
||||
("tools/forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"),
|
||||
("tools/content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"),
|
||||
("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"),
|
||||
@@ -292,10 +294,15 @@ const NOT_PAGE_DRAGS: [(&str, &str, &str); 3] = [
|
||||
const CURSOR_WRITES: [&str; 4] = ["set_cursor_icon(", ".cursor_icon = ", "on_hover_cursor(", "on_hover_and_drag_cursor("];
|
||||
|
||||
/// Functions in the page modules that set a cursor other than the pages': (file, function, why).
|
||||
const NOT_PAGE_CURSOR: [(&str, &str, &str); 3] = [
|
||||
const NOT_PAGE_CURSOR: [(&str, &str, &str); 4] = [
|
||||
("tools/fill_sign/scan.rs", "scan_tab", "the signature dialog's Scan tab"),
|
||||
("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page, drawn after the pages' cursor is set"),
|
||||
("tools/calc_order_ui.rs", "panel", "the Calculation order panel, whose rows are dragged to reorder them"),
|
||||
(
|
||||
"tools/comments/popups.rs",
|
||||
"popups",
|
||||
"the comment pop-up windows are Areas of their own over the page; their title bars are dragged to move them",
|
||||
),
|
||||
];
|
||||
|
||||
/// Whether `line` uses `name`, and not a longer name that ends in it (a field, after its `.`, is
|
||||
|
||||
@@ -20,6 +20,11 @@ impl DocView {
|
||||
pub fn page_xform(&self, page: usize) -> Option<PageXform> {
|
||||
self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| *xf)
|
||||
}
|
||||
|
||||
/// Where the pages are on screen this frame, with their transforms.
|
||||
pub(crate) fn page_xforms(&self) -> Vec<(usize, PageXform)> {
|
||||
self.screen_xforms.iter().map(|(p, xf)| (*p, *xf)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` moved so its corner lies on a whole physical pixel, so that a raster drawn from there
|
||||
|
||||
@@ -168,6 +168,15 @@ impl PdfCraftApp {
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
// The comments the pop-ups hold unsaved text or a drag for, to see afterwards whether
|
||||
// their places still name them (pages may move, come or go).
|
||||
let popups_before: Vec<pdfcraft_render::Annotation> = match (self.views.get(i), self.session.get(id)) {
|
||||
(Some(v), Some(d)) => {
|
||||
let keys = v.comments.popup_keys();
|
||||
d.info.annotations.iter().filter(|a| keys.contains(&(a.page, a.index))).cloned().collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
match self.session.apply(id, edit.clone()) {
|
||||
Ok(()) => {
|
||||
let Some(doc) = self.session.get(id) else { return true };
|
||||
@@ -210,8 +219,7 @@ impl PdfCraftApp {
|
||||
view.forms.unbaked.extend(unbaked);
|
||||
}
|
||||
} else if let Some(page) = one_page {
|
||||
// Comment and content edits change one page: keep every other raster.
|
||||
view.page_changed(page);
|
||||
one_page_changed(view, &edit, page);
|
||||
} else if let Some((changed_pages, unbaked)) = form_edit {
|
||||
view.forms.unbaked.extend(unbaked);
|
||||
for page in changed_pages {
|
||||
@@ -223,6 +231,10 @@ impl PdfCraftApp {
|
||||
if matches!(edit, Edit::AddAnnotation(_)) {
|
||||
view.comments.comment_added();
|
||||
}
|
||||
// A deletion moves the pop-ups' state with its comments (`annotation_deleted`).
|
||||
if !matches!(edit, Edit::DeleteAnnotation { .. }) {
|
||||
view.comments.popups_after_edit(&popups_before, &info.annotations);
|
||||
}
|
||||
// Keep the pages the user acted on selected, where they now are.
|
||||
select_after_edit(view, &edit, info, &annots_before, self.quick_tool);
|
||||
if let (Some((target, ambiguous_before)), Edit::EditPageImage { page, .. }) = (target_arrange_image, &edit) {
|
||||
@@ -1008,6 +1020,15 @@ fn content_page(edit: &Edit) -> Option<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A comment or content edit changes one page, `page`: keep every other raster. Opening, closing
|
||||
/// or moving a pop-up changes none: the window is drawn over the page, and a /Popup annotation
|
||||
/// paints nothing in the raster (ISO 32000-2 §12.5.6.14).
|
||||
fn one_page_changed(view: &mut crate::DocView, edit: &Edit, page: usize) {
|
||||
if !matches!(edit, Edit::SetAnnotationPopup { .. }) {
|
||||
view.page_changed(page);
|
||||
}
|
||||
}
|
||||
|
||||
fn comment_page(edit: &Edit) -> Option<usize> {
|
||||
match edit {
|
||||
Edit::AddAnnotation(a) => Some(a.page),
|
||||
@@ -1017,6 +1038,7 @@ fn comment_page(edit: &Edit) -> Option<usize> {
|
||||
| Edit::SetAnnotationStatus { page, .. }
|
||||
| Edit::MoveAnnotation { page, .. }
|
||||
| Edit::ReshapeAnnotation { page, .. }
|
||||
| Edit::SetAnnotationPopup { page, .. }
|
||||
| Edit::ResizeAnnotation { page, .. }
|
||||
| Edit::StyleAnnotation { page, .. }
|
||||
| Edit::FillAnnotation { page, .. }
|
||||
|
||||
@@ -26,6 +26,7 @@ mod geometry;
|
||||
mod input;
|
||||
mod menu;
|
||||
mod paint;
|
||||
mod popups;
|
||||
mod state;
|
||||
|
||||
pub(crate) use composer::{composer, submit_key};
|
||||
@@ -45,6 +46,7 @@ use menu::{RECENT_CUSTOM, same_colour};
|
||||
pub(crate) use paint::paint_page;
|
||||
#[cfg(test)]
|
||||
use paint::{changing_points, drawn_shape, edited_in_view};
|
||||
pub(crate) use popups::popups;
|
||||
#[cfg(test)]
|
||||
use state::after_delete;
|
||||
use state::login_name;
|
||||
|
||||
@@ -3,6 +3,7 @@ use super::geometry::{
|
||||
reshapable, reshape_for, resizable, resized, snapped_caret,
|
||||
};
|
||||
use super::paint::{closes_at, drawn_shape, edit_anchor, erase, moves_box_only, new_comment};
|
||||
use super::popups::has_popup;
|
||||
use super::state::{Composer, ComposerKind, Gesture, PageCx};
|
||||
use super::{Annotation, CommentPrefs, CommentTool, CommentView, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
|
||||
use crate::page_cursor::{Layer, Subject};
|
||||
@@ -207,6 +208,8 @@ pub(crate) fn describe(cx: &PageCx<'_>, view: &mut DocView) {
|
||||
// tip is that of the one drawn on top, among those with something to say (#3620).
|
||||
let top = cx
|
||||
.comments()
|
||||
// An open pop-up already shows the comment: no tooltip over it.
|
||||
.filter(|a| !a.popup.is_some_and(|w| w.open))
|
||||
.filter(|a| cx.screen_rects(a).any(|r| r.contains(p)))
|
||||
.filter_map(|a| hover_tip(a, cx.prefs).map(|text| (a.index, text)))
|
||||
.max_by_key(|(index, _)| *index);
|
||||
@@ -333,12 +336,20 @@ fn select_input(
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if resp.double_clicked()
|
||||
if (resp.double_clicked() || resp.triple_clicked())
|
||||
&& on_page
|
||||
&& cx.allowed
|
||||
&& let Some((a, _)) = owner
|
||||
{
|
||||
cv.select_only((cx.page, a.index));
|
||||
// A double-click opens the comment's pop-up window, as in Acrobat; a text box, whose
|
||||
// text is the comment itself, is edited in place.
|
||||
if has_popup(a) {
|
||||
if !a.popup.is_some_and(|p| p.open) {
|
||||
view.pending_edit = Some(Edit::SetAnnotationPopup { page: cx.page, index: a.index, rect: None, open: Some(true) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
cv.composer = Some(Composer {
|
||||
page: cx.page,
|
||||
at: edit_anchor(cx.info, a),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::geometry::{HANDLES, handle_pos, held, is_callout, is_markup, reaches_points, reshapable, reshape_for, resizable};
|
||||
use super::popups::paint_connectors;
|
||||
use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS};
|
||||
use super::{
|
||||
Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape,
|
||||
@@ -58,6 +59,8 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
|
||||
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);
|
||||
}
|
||||
// Selected text markup is linked to its open pop-up too.
|
||||
paint_connectors(painter, cx, view, pointer);
|
||||
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))
|
||||
@@ -82,6 +85,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
|
||||
return paint_points(painter, cx, view, a, pointer, shift);
|
||||
}
|
||||
}
|
||||
paint_connectors(painter, cx, view, pointer);
|
||||
paint_gesture(painter, cx, view);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
use super::state::PageCx;
|
||||
use super::{Annotation, Color32, CommentPrefs, CornerRadius, DocInfo, DocView, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect, pos2, theme};
|
||||
|
||||
/// A comment with a pop-up window of its own: not a text box, a Fill & Sign mark, a reply or a
|
||||
/// status (replies are found by `/IRT` reference, [`Annotation::is_comment`]).
|
||||
pub(super) fn has_popup(a: &Annotation) -> bool {
|
||||
a.subtype != "FreeText" && !a.fill_sign && a.is_comment()
|
||||
}
|
||||
|
||||
/// The connector from a comment to its open pop-up, in the comment's colour, while the pointer is
|
||||
/// on either or the comment is selected (Acrobat's "lines connecting comment markups to their
|
||||
/// pop-ups on mouse rollover"). It is a faint wedge: a point on the comment where it is
|
||||
/// nearest the window, widening to a base on the window's side that faces
|
||||
/// the comment ([`wedge_base`]).
|
||||
pub(super) fn paint_connectors(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, pointer: Option<Pos2>) {
|
||||
let cv = &view.comments;
|
||||
for ((page, index), window) in &cv.popup_rects {
|
||||
if *page != cx.page {
|
||||
continue;
|
||||
}
|
||||
let Some(a) = cx.get(*index) else { continue };
|
||||
if !a.popup.is_some_and(|p| p.open) {
|
||||
continue;
|
||||
}
|
||||
let rects: Vec<Rect> = cx.screen_rects(a).collect();
|
||||
let on_mark = pointer.is_some_and(|p| rects.iter().any(|r| r.expand(3.0).contains(p)));
|
||||
if !(on_mark || cv.is_selected((*page, *index)) || cv.popup_hovered == Some((*page, *index))) {
|
||||
continue;
|
||||
}
|
||||
let Some(mark) = rects.first() else { continue };
|
||||
let from = nearest_on(*mark, window.center());
|
||||
let to = nearest_on(*window, from);
|
||||
if from.distance(to) < 4.0 {
|
||||
continue;
|
||||
}
|
||||
let color = a
|
||||
.color
|
||||
.map_or(Color32::from_rgb(0xDC, 0x26, 0x26), |c| Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8));
|
||||
let faded = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), WEDGE_ALPHA);
|
||||
let [b0, b1] = wedge_base(*window, from, to);
|
||||
let mut wedge = egui::Mesh::default();
|
||||
for p in [from, b0, b1] {
|
||||
wedge.colored_vertex(p, faded);
|
||||
}
|
||||
wedge.add_triangle(0, 1, 2);
|
||||
painter.add(egui::Shape::mesh(wedge));
|
||||
}
|
||||
}
|
||||
|
||||
/// How opaque a connector's wedge is (of 255).
|
||||
const WEDGE_ALPHA: u8 = 90;
|
||||
|
||||
/// The two ends of a connector's base on `window`: centred on `to` (the point of the window
|
||||
/// nearest the comment) along the side that faces `from`, as wide as a small part of the window
|
||||
/// and kept on that side. At a corner, the side is the one the comment lies further beyond.
|
||||
fn wedge_base(window: Rect, from: Pos2, to: Pos2) -> [Pos2; 2] {
|
||||
let half = (window.width().min(window.height()) * 0.08).clamp(5.0, 18.0);
|
||||
// `half` along [lo, hi] around `c`, moved in to stay on the side (a side shorter than the
|
||||
// base gives all of it).
|
||||
let spread = |c: f32, lo: f32, hi: f32| {
|
||||
let h = half.min((hi - lo).max(0.0) / 2.0);
|
||||
let c = c.clamp(lo + h, hi - h);
|
||||
(c - h, c + h)
|
||||
};
|
||||
let on_horizontal = (to.y - window.top()).abs() < 0.5 || (to.y - window.bottom()).abs() < 0.5;
|
||||
let on_vertical = (to.x - window.left()).abs() < 0.5 || (to.x - window.right()).abs() < 0.5;
|
||||
let beyond_x = (from.x - to.x).abs() / window.width().max(1.0);
|
||||
let beyond_y = (from.y - to.y).abs() / window.height().max(1.0);
|
||||
if on_horizontal && !(on_vertical && beyond_x > beyond_y) {
|
||||
let (a, b) = spread(to.x, window.left(), window.right());
|
||||
[pos2(a, to.y), pos2(b, to.y)]
|
||||
} else {
|
||||
let (a, b) = spread(to.y, window.top(), window.bottom());
|
||||
[pos2(to.x, a), pos2(to.x, b)]
|
||||
}
|
||||
}
|
||||
|
||||
/// The point of `r`'s boundary nearest `p` (`p` inside `r` is pushed to the nearer vertical edge).
|
||||
fn nearest_on(r: Rect, p: Pos2) -> Pos2 {
|
||||
let x = p.x.clamp(r.left(), r.right());
|
||||
let y = p.y.clamp(r.top(), r.bottom());
|
||||
if r.left() < x && x < r.right() && r.top() < y && y < r.bottom() {
|
||||
pos2(if (p.x - r.left()).abs() <= (p.x - r.right()).abs() { r.left() } else { r.right() }, y)
|
||||
} else {
|
||||
pos2(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
/// A pop-up window's size on screen, in points, whatever the zoom (the readers keep theirs the
|
||||
/// same as the page zooms), kept within these bounds whatever size the file gives it.
|
||||
const MIN_WIDTH: f32 = 200.0;
|
||||
const MAX_WIDTH: f32 = 800.0;
|
||||
const MIN_HEIGHT: f32 = 120.0;
|
||||
const MAX_HEIGHT: f32 = 800.0;
|
||||
/// The side of the grip at a window's lower right corner that resizes it.
|
||||
const RESIZE_GRIP: f32 = 10.0;
|
||||
/// How far a reply's panel sits in from the panel around it.
|
||||
const NEST: i8 = 6;
|
||||
/// Past this depth a reply's panel stops nesting and sits under the one before it.
|
||||
const MAX_NESTING: usize = 8;
|
||||
|
||||
/// `replies` (all filed under comment `top`) in thread order, each with its depth: a reply to
|
||||
/// `top` is 1, and each reply comes right after the one it answers, followed by its own replies.
|
||||
/// One whose parent isn't among them (a status reply, say) answers `top`. A cycle of `/IRT`
|
||||
/// references shows each reply once.
|
||||
fn thread<'a>(top: (usize, usize), replies: &[&'a Annotation]) -> Vec<(&'a Annotation, usize)> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
let known: HashSet<(usize, usize)> = replies.iter().map(|r| (r.page, r.index)).collect();
|
||||
let mut answered: HashMap<(usize, usize), Vec<&'a Annotation>> = HashMap::new();
|
||||
for r in replies {
|
||||
let parent = r.parent.filter(|p| known.contains(p) && *p != (r.page, r.index)).unwrap_or(top);
|
||||
answered.entry(parent).or_default().push(r);
|
||||
}
|
||||
let mut out = Vec::with_capacity(replies.len());
|
||||
let mut seen = HashSet::new();
|
||||
// Depth first, answers in document order: the stack holds them reversed.
|
||||
let below = |key: (usize, usize), depth: usize| answered.get(&key).into_iter().flatten().rev().map(move |r| (*r, depth));
|
||||
let mut stack: Vec<(&Annotation, usize)> = below(top, 1).collect();
|
||||
let mut next = 0;
|
||||
loop {
|
||||
while let Some((r, depth)) = stack.pop() {
|
||||
if !seen.insert((r.page, r.index)) {
|
||||
continue;
|
||||
}
|
||||
out.push((r, depth));
|
||||
stack.extend(below((r.page, r.index), depth.saturating_add(1)));
|
||||
}
|
||||
// Replies only a cycle reaches: shown from their first, as if they answered `top`. The
|
||||
// search resumes where it stopped, so the whole of it is one pass.
|
||||
while next < replies.len() && replies.get(next).is_some_and(|r| seen.contains(&(r.page, r.index))) {
|
||||
next += 1;
|
||||
}
|
||||
match replies.get(next) {
|
||||
Some(r) => stack.push((*r, 1)),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A thread as nested panels: each reply ([`thread`] order) and the positions of the replies
|
||||
/// drawn inside its panel; then the positions of the replies to the comment itself. Nesting stops
|
||||
/// at [`MAX_NESTING`], so drawing it recurses no deeper than that.
|
||||
fn nest<'a>(order: &[(&'a Annotation, usize)]) -> (Vec<(&'a Annotation, Vec<usize>)>, Vec<usize>) {
|
||||
let mut nodes: Vec<(&Annotation, Vec<usize>)> = Vec::with_capacity(order.len());
|
||||
let mut roots = Vec::new();
|
||||
// The panels open at each depth above the reply being placed.
|
||||
let mut open: Vec<usize> = Vec::new();
|
||||
for (i, (r, depth)) in order.iter().enumerate() {
|
||||
let depth = (*depth).clamp(1, MAX_NESTING);
|
||||
open.truncate(depth - 1);
|
||||
match open.last().and_then(|p| nodes.get_mut(*p)) {
|
||||
Some((_, kids)) => kids.push(i),
|
||||
None => roots.push(i),
|
||||
}
|
||||
nodes.push((r, Vec::new()));
|
||||
open.push(i);
|
||||
}
|
||||
(nodes, roots)
|
||||
}
|
||||
|
||||
/// A pop-up window's colours: the comment's colour, lightened for the body and the title bar,
|
||||
/// and ink that reads on them. Fixed, not from the app's theme: the window is the comment's, as
|
||||
/// it looks on paper.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Palette {
|
||||
edge: Color32,
|
||||
title: Color32,
|
||||
title_ink: Color32,
|
||||
body: Color32,
|
||||
panel_edge: Color32,
|
||||
ink: Color32,
|
||||
faint: Color32,
|
||||
}
|
||||
|
||||
fn palette(color: Color32) -> Palette {
|
||||
// `color` mixed into white, `f` of the way.
|
||||
let mix = |f: f32| {
|
||||
let c = |v: u8| (255.0 + (f32::from(v) - 255.0) * f).round().clamp(0.0, 255.0) as u8;
|
||||
Color32::from_rgb(c(color.r()), c(color.g()), c(color.b()))
|
||||
};
|
||||
let title = mix(0.8);
|
||||
let light = |c: Color32| 0.299 * f32::from(c.r()) + 0.587 * f32::from(c.g()) + 0.114 * f32::from(c.b()) > 150.0;
|
||||
Palette {
|
||||
edge: color,
|
||||
title,
|
||||
title_ink: if light(title) { Color32::from_gray(20) } else { Color32::WHITE },
|
||||
body: mix(0.35),
|
||||
panel_edge: mix(0.7),
|
||||
ink: Color32::from_gray(20),
|
||||
faint: Color32::from_gray(80),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a pop-up window's parts share as they draw.
|
||||
struct Parts<'v> {
|
||||
allowed: bool,
|
||||
author: &'v str,
|
||||
pal: Palette,
|
||||
cv: &'v mut super::state::CommentView,
|
||||
edits: &'v mut Vec<Edit>,
|
||||
}
|
||||
|
||||
/// One comment's or reply's text in its box, typed in place (saved when the box loses focus);
|
||||
/// read-only when comments can't be changed or it is locked.
|
||||
fn text_box(ui: &mut egui::Ui, parts: &mut Parts<'_>, a: &Annotation, name: &str) {
|
||||
let key = (a.page, a.index);
|
||||
if parts.allowed && !a.locked {
|
||||
let typed = parts.cv.popup_text.as_ref().filter(|(p, i, _)| (*p, *i) == key).map(|(_, _, s)| s.clone());
|
||||
let mut text = typed.unwrap_or_else(|| a.contents.clone().unwrap_or_default());
|
||||
let field = ui
|
||||
.add(
|
||||
egui::TextEdit::multiline(&mut text)
|
||||
.desired_rows(1)
|
||||
.desired_width(f32::INFINITY)
|
||||
.font(theme::regular(12.5))
|
||||
.text_color(parts.pal.ink)
|
||||
.background_color(Color32::WHITE)
|
||||
.margin(egui::Margin::symmetric(4, 2))
|
||||
.id_salt(("popup-text", a.page, a.index)),
|
||||
)
|
||||
.accessible_name(name);
|
||||
if field.changed() {
|
||||
parts.cv.popup_text = Some((a.page, a.index, text.clone()));
|
||||
}
|
||||
if field.lost_focus()
|
||||
&& let Some((_, _, s)) = parts.cv.popup_text.take_if(|(p, i, _)| (*p, *i) == key)
|
||||
&& s.trim_end() != a.contents.as_deref().unwrap_or_default()
|
||||
{
|
||||
parts.edits.push(Edit::SetAnnotationContents { page: a.page, index: a.index, text: s.trim_end().to_string() });
|
||||
}
|
||||
} else {
|
||||
egui::Frame::NONE.fill(Color32::WHITE).inner_margin(egui::Margin::symmetric(4, 2)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.label(egui::RichText::new(a.contents.as_deref().unwrap_or_default()).font(theme::regular(12.5)).color(parts.pal.ink));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The row under a comment's or reply's text: a Reply button at its end (the start is kept for
|
||||
/// more of a reply's controls), and the box to type that reply in once it is pressed (Post, or
|
||||
/// Ctrl/⌘+Enter, posts it; Enter starts a new line, as in the readers' pop-ups).
|
||||
fn footer(ui: &mut egui::Ui, parts: &mut Parts<'_>, a: &Annotation) {
|
||||
let key = (a.page, a.index);
|
||||
// Small buttons, as the readers' footers have.
|
||||
ui.spacing_mut().button_padding = egui::vec2(6.0, 1.0);
|
||||
let mut opened = false;
|
||||
if parts.allowed {
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let label = egui::RichText::new(tl!("Reply")).font(theme::regular(11.0)).color(parts.pal.ink);
|
||||
let button = egui::Button::new(label)
|
||||
.fill(Color32::WHITE.gamma_multiply(0.6))
|
||||
.stroke(egui::Stroke::new(1.0, parts.pal.panel_edge))
|
||||
.min_size(egui::vec2(0.0, 16.0));
|
||||
if ui.add(button).clicked() {
|
||||
parts.cv.popup_reply = Some((a.page, a.index, String::new()));
|
||||
opened = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
let Some(mut reply) = parts.cv.popup_reply.as_ref().filter(|(p, i, _)| (*p, *i) == key).map(|(_, _, s)| s.clone()) else { return };
|
||||
let field = ui.add(
|
||||
egui::TextEdit::multiline(&mut reply)
|
||||
.desired_rows(1)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text(tl!("Add a reply"))
|
||||
.font(theme::regular(12.5))
|
||||
.text_color(parts.pal.ink)
|
||||
.background_color(Color32::WHITE)
|
||||
.margin(egui::Margin::symmetric(4, 2))
|
||||
.id_salt(("popup-reply", a.page, a.index)),
|
||||
);
|
||||
if opened {
|
||||
field.request_focus();
|
||||
}
|
||||
if field.changed() {
|
||||
parts.cv.popup_reply = Some((a.page, a.index, reply.clone()));
|
||||
}
|
||||
let enter = super::submit_key(ui, &field, false);
|
||||
let ok = !reply.trim().is_empty();
|
||||
let (post, cancel) = ui
|
||||
.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let small = |s: &str| egui::RichText::new(s).font(theme::regular(11.0));
|
||||
let post = ui.add_enabled(ok, egui::Button::new(small(tl!("Post"))).min_size(egui::vec2(0.0, 16.0))).clicked();
|
||||
let cancel = ui.add(egui::Button::new(small(tl!("Cancel"))).min_size(egui::vec2(0.0, 16.0))).clicked();
|
||||
(post, cancel)
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
if (post || enter) && ok {
|
||||
parts.cv.popup_reply = None;
|
||||
parts.edits.push(Edit::ReplyToAnnotation { page: a.page, index: a.index, text: reply.trim().to_string(), author: parts.author.to_string() });
|
||||
} else if cancel {
|
||||
parts.cv.popup_reply = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// A reply's panel: who wrote it and when, its text and footer, and the panels of its own
|
||||
/// replies inside it.
|
||||
fn reply_panel(ui: &mut egui::Ui, parts: &mut Parts<'_>, nodes: &[(&Annotation, Vec<usize>)], at: usize) {
|
||||
let Some((r, kids)) = nodes.get(at) else { return };
|
||||
let pal = parts.pal;
|
||||
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, pal.panel_edge)).corner_radius(CornerRadius::same(3)).inner_margin(egui::Margin::same(4)).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(m) = &r.modified {
|
||||
ui.label(egui::RichText::new(m).font(theme::regular(10.5)).color(pal.faint));
|
||||
}
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
let who = r.author.as_deref().unwrap_or("?");
|
||||
ui.add(egui::Label::new(egui::RichText::new(who).font(theme::semibold(11.5)).color(pal.ink)).truncate());
|
||||
});
|
||||
});
|
||||
});
|
||||
text_box(ui, parts, r, r.author.as_deref().unwrap_or("?"));
|
||||
footer(ui, parts, r);
|
||||
for kid in kids {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: NEST, ..Default::default() }).show(ui, |ui| reply_panel(ui, parts, nodes, *kid));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The open pop-up windows of the comments on screen, laid out as the readers' are: a title bar
|
||||
/// with the comment's kind and author, its date and ×; the comment's text, editable in place; its
|
||||
/// replies as panels, each inside the one it answers; and a Reply button under each, which opens
|
||||
/// a box to answer it. The window keeps its size on screen as the page zooms, and its text and
|
||||
/// replies scroll inside it. The title bar drags the window and its lower right corner resizes
|
||||
/// it, both saved with the comment; × closes it. Returns the edits to apply (one, or a batch when
|
||||
/// a click both saves typed text and closes). Hidden comments show none.
|
||||
pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, hidden: bool, allowed: bool) -> Option<Edit> {
|
||||
let pointer = ctx.input(|i| i.pointer.hover_pos());
|
||||
let mut edits: Vec<Edit> = Vec::new();
|
||||
let mut rects = Vec::new();
|
||||
let mut hovered = None;
|
||||
// The comment each reply files under, as the Comments panel threads them (by reference).
|
||||
let tops = pdfcraft_render::thread_tops(&info.annotations);
|
||||
let xforms: Vec<(usize, PageXform)> = view.page_xforms();
|
||||
for (page, xf) in xforms {
|
||||
let shown: Vec<Annotation> =
|
||||
info.annotations.iter().filter(|a| !hidden && a.page == page && has_popup(a) && a.popup.is_some_and(|p| p.open)).cloned().collect();
|
||||
for a in shown {
|
||||
let Some(p) = a.popup else { continue };
|
||||
let key = (page, a.index);
|
||||
let placed = xf.user_rect(info, page, p.rect);
|
||||
// Its place follows the page; its size is the pop-up's `/Rect` in points on screen,
|
||||
// or the size its corner is being dragged to.
|
||||
// A `/Rect` with numbers that aren't finite gives the least size.
|
||||
let side = |a: f32, b: f32| Some((b - a).abs()).filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let saved = egui::vec2(side(p.rect[0], p.rect[2]), side(p.rect[1], p.rect[3]));
|
||||
let clamp = |s: egui::Vec2| egui::vec2(s.x.clamp(MIN_WIDTH, MAX_WIDTH), s.y.clamp(MIN_HEIGHT, MAX_HEIGHT));
|
||||
let resizing = view.comments.popup_resize.filter(|(rp, ri, _)| (*rp, *ri) == key).map(|(_, _, size)| size);
|
||||
let size = clamp(resizing.unwrap_or(saved));
|
||||
let dragging = view.comments.popup_drag.filter(|(dp, di, _)| (*dp, *di) == key).map(|(_, _, at)| at);
|
||||
let pos = dragging.unwrap_or(placed.left_top());
|
||||
let id = egui::Id::unique(("comment-popup", view.id.0, page, a.index));
|
||||
let color = a
|
||||
.color
|
||||
.map_or(Color32::from_rgb(0xFF, 0xD6, 0x5C), |c| Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8));
|
||||
let pal = palette(color);
|
||||
// A screen rectangle on this page in user space, to save as the pop-up's `/Rect`. Only
|
||||
// converts: the pointer and the redaction look are unused.
|
||||
let to_user = |r: Rect| {
|
||||
let cx = PageCx {
|
||||
page,
|
||||
xf: &xf,
|
||||
ptr: &PagePointer::default(),
|
||||
info,
|
||||
tool: QuickTool::Select,
|
||||
prefs: &CommentPrefs::default(),
|
||||
allowed,
|
||||
hidden,
|
||||
redact_mark: ([0.0; 3], 1.0),
|
||||
};
|
||||
cx.rect_to_user(r)
|
||||
};
|
||||
let area = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
|
||||
let frame = egui::Frame::popup(ui.style()).inner_margin(egui::Margin::ZERO).corner_radius(CornerRadius::same(3)).fill(pal.body);
|
||||
let window = frame.stroke(egui::Stroke::new(1.0, pal.edge)).show(ui, |ui| {
|
||||
ui.set_width(size.x);
|
||||
// The whole size, however short its thread.
|
||||
ui.set_min_height(size.y);
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
// Title bar: the comment's kind and author, its date and ×, on one line; it
|
||||
// drags the window.
|
||||
let bar = egui::Frame::NONE.fill(pal.title).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
|
||||
ui.set_width(size.x - 12.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Closing changes the comment's /Open, which needs comment permission.
|
||||
if ui.add_enabled_ui(allowed, |ui| crate::icons::button(ui, "x", 14.0, false, tl!("Close"))).inner.clicked() {
|
||||
edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: None, open: Some(false) });
|
||||
}
|
||||
if let Some(m) = &a.modified {
|
||||
ui.label(egui::RichText::new(m).font(theme::regular(10.5)).color(pal.title_ink));
|
||||
}
|
||||
// The kind and author take the rest of the line, cut short to fit.
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
let kind = tl!(crate::comments_panel::kind_label(&a));
|
||||
let title = match a.author.as_deref() {
|
||||
Some(who) => format!("{kind} – {who}"),
|
||||
None => kind.to_string(),
|
||||
};
|
||||
ui.add(egui::Label::new(egui::RichText::new(title).font(theme::semibold(12.0)).color(pal.title_ink)).truncate());
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
||||
// The bar drags the window, except where the × is: a region registered after
|
||||
// the button would take its clicks.
|
||||
let bar_rect = bar.response.rect;
|
||||
let grip = ui.interact(
|
||||
Rect::from_min_max(bar_rect.min, pos2(bar_rect.right() - 24.0, bar_rect.bottom())),
|
||||
id.with("grip"),
|
||||
egui::Sense::drag(),
|
||||
);
|
||||
if grip.hovered() || grip.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
if grip.drag_started_by(egui::PointerButton::Primary) {
|
||||
view.comments.popup_drag = Some((page, a.index, pos));
|
||||
}
|
||||
if grip.dragged()
|
||||
&& let Some((_, _, at)) = view.comments.popup_drag.as_mut()
|
||||
{
|
||||
*at += grip.drag_delta();
|
||||
}
|
||||
if grip.drag_stopped()
|
||||
&& let Some((_, _, at)) = view.comments.popup_drag.take()
|
||||
&& allowed
|
||||
&& at.distance(placed.left_top()) >= 2.0
|
||||
{
|
||||
// The same size on the page, at the new place. Only converts the
|
||||
// rectangle to user space: the pointer and the redaction look are unused.
|
||||
let rect = to_user(Rect::from_min_size(at, placed.size()));
|
||||
edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: Some(rect), open: None });
|
||||
}
|
||||
// Replies, as the thread reads: text replies only, filed under this
|
||||
// comment by `/IRT` reference as in the Comments panel, each under the
|
||||
// one it answers. A review status or a checkmark (`/State`) is a reply too,
|
||||
// but not one to read here, and a member of the comment's group (`/RT
|
||||
// /Group`, ISO 32000-2 §12.5.6.2) is part of the comment, not a reply.
|
||||
let replies: Vec<&Annotation> = info
|
||||
.annotations
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.state.is_none()
|
||||
&& !r.grouped
|
||||
&& r.parent.is_some_and(|parent| tops.get(&(r.page, r.index)).copied().unwrap_or(parent) == key)
|
||||
})
|
||||
.collect();
|
||||
// The comment's text. Replace Text as `main` writes it (#5688) keeps the
|
||||
// replacement on the caret grouped under an empty strikeout: the window shows
|
||||
// and edits the caret's text, the proposal's. Locking the comment locks that
|
||||
// text too.
|
||||
let mut holder = info
|
||||
.annotations
|
||||
.iter()
|
||||
.find(|r| {
|
||||
r.grouped
|
||||
&& r.parent == Some(key)
|
||||
&& a.contents.as_deref().unwrap_or("").is_empty()
|
||||
&& r.contents.as_deref().is_some_and(|c| !c.is_empty())
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_else(|| a.clone());
|
||||
holder.locked |= a.locked;
|
||||
let order = thread(key, &replies);
|
||||
let (nodes, roots) = nest(&order);
|
||||
let mut parts = Parts { allowed, author: &prefs.author, pal, cv: &mut view.comments, edits: &mut edits };
|
||||
// The body keeps the window's height: the text and replies scroll in what the
|
||||
// title bar leaves, with a bar that shows whenever there is more (egui's show
|
||||
// only near the pointer), in the ink's colour.
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.set_width(size.x - 8.0);
|
||||
ui.spacing_mut().scroll = egui::style::ScrollStyle {
|
||||
dormant_handle_opacity: 0.4,
|
||||
dormant_background_opacity: 0.1,
|
||||
foreground_color: true,
|
||||
..egui::style::ScrollStyle::solid()
|
||||
};
|
||||
ui.visuals_mut().override_text_color = Some(pal.ink);
|
||||
let body_height = (size.y - bar_rect.height() - 8.0).max(24.0);
|
||||
let scroll =
|
||||
egui::ScrollArea::vertical().id_salt(("popup-body", page, a.index)).max_height(body_height).auto_shrink([false, false]);
|
||||
scroll.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
text_box(ui, &mut parts, &holder, tl!("Add a comment"));
|
||||
footer(ui, &mut parts, &a);
|
||||
for root in &roots {
|
||||
reply_panel(ui, &mut parts, &nodes, *root);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// The lower right corner resizes the window; its new size is saved with the
|
||||
// comment, its upper left corner where it was, when the drag ends.
|
||||
let corner = window.response.rect.right_bottom();
|
||||
let grip =
|
||||
ui.interact(Rect::from_min_max(corner - egui::vec2(RESIZE_GRIP, RESIZE_GRIP), corner), id.with("resize"), egui::Sense::drag());
|
||||
if grip.hovered() || grip.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe);
|
||||
}
|
||||
// Two short diagonal strokes mark the grip.
|
||||
for inset in [3.0, 7.0] {
|
||||
let stroke = egui::Stroke::new(1.0, pal.faint);
|
||||
ui.painter().line_segment([corner - egui::vec2(3.0, inset), corner - egui::vec2(inset, 3.0)], stroke);
|
||||
}
|
||||
if grip.drag_started_by(egui::PointerButton::Primary) {
|
||||
view.comments.popup_resize = Some((page, a.index, size));
|
||||
}
|
||||
if grip.dragged()
|
||||
&& let Some((_, _, s)) = view.comments.popup_resize.as_mut()
|
||||
{
|
||||
*s += grip.drag_delta();
|
||||
}
|
||||
if grip.drag_stopped()
|
||||
&& let Some((_, _, s)) = view.comments.popup_resize.take()
|
||||
&& allowed
|
||||
{
|
||||
let s = clamp(s);
|
||||
// A press on the grip that ends where it began changes nothing.
|
||||
// The window's size in points is the `/Rect`'s, its upper left corner kept
|
||||
// where it is on screen: the rectangle of that size on the page there. A page
|
||||
// turned a quarter (its `/Rotate` and the view's) shows the `/Rect`'s width
|
||||
// down the screen.
|
||||
let zoom = xf.zoom();
|
||||
if (s - clamp(saved)).length() >= 2.0 && zoom.is_finite() && zoom > 0.0 {
|
||||
let turn = info.pages.get(page).map_or(0, |pg| u32::from(pg.rotation)) + u32::from(xf.rot);
|
||||
let on_page = if turn % 180 == 90 { egui::vec2(s.y, s.x) } else { s } * zoom;
|
||||
let rect = to_user(Rect::from_min_size(placed.left_top(), on_page));
|
||||
edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: Some(rect), open: None });
|
||||
}
|
||||
}
|
||||
});
|
||||
let shown_at = area.response.rect;
|
||||
if pointer.is_some_and(|p| shown_at.contains(p)) {
|
||||
hovered = Some(key);
|
||||
}
|
||||
rects.push((key, shown_at));
|
||||
}
|
||||
}
|
||||
view.comments.popup_rects = rects;
|
||||
view.comments.popup_hovered = hovered;
|
||||
match edits.len() {
|
||||
0 => None,
|
||||
1 => edits.pop(),
|
||||
_ => Some(Edit::Batch { label: "Edit comment".into(), edits }),
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,19 @@ pub struct CommentView {
|
||||
pub editing: Option<(usize, usize, String)>,
|
||||
/// The panel should scroll the selected card into view.
|
||||
pub reveal: bool,
|
||||
/// A pop-up window's title bar being dragged: the comment, and where the window's top-left
|
||||
/// corner is now on screen.
|
||||
pub popup_drag: Option<(usize, usize, Pos2)>,
|
||||
/// A pop-up window's corner being dragged: the comment, and the window's size on screen now.
|
||||
pub popup_resize: Option<(usize, usize, egui::Vec2)>,
|
||||
/// Text being typed in a pop-up window: (page, index, text).
|
||||
pub popup_text: Option<(usize, usize, String)>,
|
||||
/// A reply being typed in a pop-up window: the comment or reply it answers (page, index), and its text.
|
||||
pub popup_reply: Option<(usize, usize, String)>,
|
||||
/// Where each open pop-up was drawn last frame, for the connector lines and hit-testing.
|
||||
pub popup_rects: Vec<((usize, usize), Rect)>,
|
||||
/// The pop-up the pointer was over last frame.
|
||||
pub popup_hovered: Option<(usize, usize)>,
|
||||
/// 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).
|
||||
@@ -332,16 +345,59 @@ impl CommentView {
|
||||
if self.editing.is_none() {
|
||||
self.editor_focus = None;
|
||||
}
|
||||
// What is typed in a pop-up, and a window being dragged or resized, follow theirs too.
|
||||
let moved = |k: (usize, usize)| after_delete(before, after, gone, k);
|
||||
self.popup_text = self.popup_text.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text)));
|
||||
self.popup_reply = self.popup_reply.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text)));
|
||||
self.popup_drag = self.popup_drag.and_then(|(p, i, at)| moved((p, i)).map(|(p, i)| (p, i, at)));
|
||||
self.popup_resize = self.popup_resize.and_then(|(p, i, size)| moved((p, i)).map(|(p, i)| (p, i, size)));
|
||||
}
|
||||
|
||||
/// Undo or Redo just ran. Either may have put annotations back or taken them away, on any
|
||||
/// page, so a (page, index) held here may now name another one. The selection and the inline
|
||||
/// editor close, its unsaved text with it, rather than Save writing that text over whatever
|
||||
/// took its place.
|
||||
/// took its place; so do the pop-ups' unsaved text and drags.
|
||||
pub(crate) fn history_stepped(&mut self) {
|
||||
self.clear_selection();
|
||||
self.editing = None;
|
||||
self.editor_focus = None;
|
||||
self.popup_text = None;
|
||||
self.popup_reply = None;
|
||||
self.popup_drag = None;
|
||||
self.popup_resize = None;
|
||||
}
|
||||
|
||||
/// The comments the pop-ups hold unsaved text, a reply or a drag for.
|
||||
pub(crate) fn popup_keys(&self) -> Vec<(usize, usize)> {
|
||||
let text = self.popup_text.as_ref().map(|(p, i, _)| (*p, *i));
|
||||
let reply = self.popup_reply.as_ref().map(|(p, i, _)| (*p, *i));
|
||||
let drag = self.popup_drag.map(|(p, i, _)| (p, i));
|
||||
let resize = self.popup_resize.map(|(p, i, _)| (p, i));
|
||||
[text, reply, drag, resize].into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// An edit other than a deletion just ran: `before` are the comments at [`Self::popup_keys`]
|
||||
/// until then, `after` the document's annotations now. When pages moved, came or went, a pop-up's (page, index)
|
||||
/// may now name another comment: what was typed in that pop-up and not saved, and a window
|
||||
/// being dragged or resized, are dropped rather than written to whatever took its place.
|
||||
pub(crate) fn popups_after_edit(&mut self, before: &[Annotation], after: &[Annotation]) {
|
||||
// The same comment: its kind, author and `/NM`, at that place before and after.
|
||||
let who = |list: &[Annotation], k: (usize, usize)| {
|
||||
list.iter().find(|a| (a.page, a.index) == k).map(|a| (a.subtype.clone(), a.author.clone(), a.name.clone()))
|
||||
};
|
||||
let kept = |p: usize, i: usize| who(before, (p, i)).is_some_and(|w| who(after, (p, i)) == Some(w));
|
||||
if self.popup_text.as_ref().is_some_and(|(p, i, _)| !kept(*p, *i)) {
|
||||
self.popup_text = None;
|
||||
}
|
||||
if self.popup_reply.as_ref().is_some_and(|(p, i, _)| !kept(*p, *i)) {
|
||||
self.popup_reply = None;
|
||||
}
|
||||
if self.popup_drag.is_some_and(|(p, i, _)| !kept(p, i)) {
|
||||
self.popup_drag = None;
|
||||
}
|
||||
if self.popup_resize.is_some_and(|(p, i, _)| !kept(p, i)) {
|
||||
self.popup_resize = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ fn an_upright_note_on_a_turned_page_is_in_view_where_it_is_drawn() {
|
||||
no_rotate,
|
||||
points: Vec::new(),
|
||||
text_box: None,
|
||||
popup: None,
|
||||
};
|
||||
let page = PageInfo { width: 140.0, height: 200.0, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation: 90 };
|
||||
let xf = PageXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(140.0, 200.0)), rot: 0, pw: 140.0, ph: 200.0 };
|
||||
@@ -133,6 +134,7 @@ fn listed(page: usize, index: usize, contents: &str, parent: Option<(usize, usiz
|
||||
no_rotate: false,
|
||||
points: Vec::new(),
|
||||
text_box: None,
|
||||
popup: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +225,7 @@ fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotat
|
||||
no_rotate: false,
|
||||
points: Vec::new(),
|
||||
text_box: None,
|
||||
popup: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,6 +431,7 @@ fn context_menu_colour_submenu_keeps_its_picker_open_and_a_swatch_closes_the_men
|
||||
fill_sign: false,
|
||||
points: Vec::new(),
|
||||
text_box: None,
|
||||
popup: None,
|
||||
no_rotate: false,
|
||||
}],
|
||||
..Default::default()
|
||||
|
||||
@@ -3745,6 +3745,624 @@ fn a_callouts_arrow_knee_and_text_box_are_dragged_on_their_own() {
|
||||
assert!(near_pt(callout(&h).points[0], 40.0, 30.0));
|
||||
}
|
||||
|
||||
/// #2137 review: a pop-up is edited under comment permission, not change permission: in a
|
||||
/// document whose security allows comments but not changes, an open pop-up still takes typing
|
||||
/// and closes.
|
||||
#[test]
|
||||
fn a_pop_up_is_editable_where_comments_are_allowed_but_changes_are_not() {
|
||||
let plain = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /T (Ada) /Popup 5 0 R >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Popup /Parent 4 0 R /Rect [60 60 260 160] /Open true >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(plain.to_vec())).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "",
|
||||
owner_password: "owner",
|
||||
// Everything but bit 4, modifying the document.
|
||||
permissions: -1 ^ 8,
|
||||
encrypt_metadata: true,
|
||||
seed: [4; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
|
||||
let mut h = harness(move |app| {
|
||||
app.open_bytes("restricted.pdf", None, bytes).expect("opens");
|
||||
// Only the restricted document, so it is the first view.
|
||||
app.close_tab(0);
|
||||
});
|
||||
h.run_steps(6);
|
||||
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows");
|
||||
field(&h, "Add a comment");
|
||||
let close = h.query_all_by_label("Close").find(|n| window.contains(n.rect().center())).expect("the pop-up's ×");
|
||||
close.click();
|
||||
h.run_steps(4);
|
||||
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
|
||||
assert_eq!(note.popup.map(|p| p.open), Some(false), "closed");
|
||||
}
|
||||
|
||||
/// #2137 review: a pop-up lists the comment's text replies only, as the Comments panel does. A
|
||||
/// review status (`/StateModel /Review`) and a checkmark (`/StateModel /Marked`, the private
|
||||
/// reply Mark writes) are replies too, but not ones to read in the thread.
|
||||
#[test]
|
||||
fn a_pop_up_lists_text_replies_but_not_status_or_checkmark_replies() {
|
||||
let pdf = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /T (Ada) /NM (note-1) /Contents (Please check) /Popup 5 0 R >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Popup /Parent 4 0 R /Rect [60 40 260 160] /Open true >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /F 32 /T (Bob) /NM (reply-1) /IRT 4 0 R /Contents (Agreed with this) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /F 32 /T (Bob) /NM (reply-2) /IRT 4 0 R /State /Accepted /StateModel /Review /Contents (Accepted set by Bob) >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /F 32 /T (Ada) /NM (reply-3) /IRT 4 0 R /State /Marked /StateModel /Marked /Contents (Marked set by Ada) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let mut h = harness(move |app| {
|
||||
app.open_bytes("thread.pdf", None, pdf.to_vec()).expect("opens");
|
||||
app.close_tab(0);
|
||||
});
|
||||
h.run_steps(6);
|
||||
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows");
|
||||
let in_window = |text: &str| h.query_all_by(|n| shows_text(n, text)).any(|n| window.contains(n.rect().center()));
|
||||
assert!(in_window("Agreed with this"), "the text reply");
|
||||
assert!(!in_window("Accepted set by Bob"), "not the review status");
|
||||
assert!(!in_window("Marked set by Ada"), "not the checkmark");
|
||||
}
|
||||
|
||||
/// A Replace Text proposal: a strikeout and a caret grouped by `/IRT` and `/RT /Group`, with the
|
||||
/// pop-up `open`. `caret_first` writes the caret as the group's primary, holding the replacement,
|
||||
/// with the strikeout grouped under it; otherwise the strikeout is the primary and the
|
||||
/// replacement is on the caret grouped under it, as PdfCraft used to write it.
|
||||
fn replace_text_pdf(caret_first: bool, open: bool) -> Vec<u8> {
|
||||
let primary = if caret_first { 4 } else { 5 };
|
||||
let strike = format!(
|
||||
"<< /Type /Annot /Subtype /StrikeOut /Rect [40 100 140 120] /QuadPoints [40 120 140 120 40 100 140 100] /C [1 0 0] /T (Ada) /IT /StrikeOutTextEdit{} >>",
|
||||
if caret_first { " /IRT 4 0 R /RT /Group" } else { " /Popup 6 0 R" }
|
||||
);
|
||||
let caret = format!(
|
||||
"<< /Type /Annot /Subtype /Caret /Rect [135 95 145 105] /C [0 0 1] /T (Bob) /Contents (slow red){} >>",
|
||||
if caret_first { " /IT /Replace /Popup 6 0 R" } else { " /IRT 5 0 R /RT /Group" }
|
||||
);
|
||||
format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 4 0 R 6 0 R] >> endobj
|
||||
4 0 obj {caret} endobj
|
||||
5 0 obj {strike} endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Popup /Parent {primary} 0 R /Rect [160 20 290 150] /Open {open} >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// A Replace Text proposal's window shows the replacement as the comment's text, and the other
|
||||
/// member of the group is part of the comment, not a reply panel (ISO 32000-2 §12.5.6.2: a group's
|
||||
/// members are one unit). Both layouts: the caret as the primary, and the strikeout as the primary
|
||||
/// with the replacement on the grouped caret, as `main` writes it (#5688).
|
||||
#[test]
|
||||
fn a_replace_text_window_shows_the_replacement_as_its_text_not_as_a_reply() {
|
||||
for caret_first in [true, false] {
|
||||
let pdf = replace_text_pdf(caret_first, true);
|
||||
let mut h = harness(move |app| {
|
||||
app.open_bytes("replace.pdf", None, pdf).expect("opens");
|
||||
app.close_tab(0);
|
||||
});
|
||||
h.run_steps(6);
|
||||
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows");
|
||||
let boxes: Vec<String> = h
|
||||
.query_all_by(|n| n.role() == Role::MultilineTextInput)
|
||||
.filter(|n| window.contains(n.rect().center()))
|
||||
.map(|n| n.value().unwrap_or_default())
|
||||
.collect();
|
||||
assert_eq!(boxes, ["slow red"], "caret first: {caret_first}: one box, the replacement");
|
||||
}
|
||||
}
|
||||
|
||||
/// A locked Replace Text proposal, as `main` writes it (the strikeout primary and locked, the
|
||||
/// replacement on the grouped caret), shows the replacement in its window read-only: locking the
|
||||
/// comment locks the text the window shows for it.
|
||||
#[test]
|
||||
fn a_locked_replace_text_window_shows_the_replacement_read_only() {
|
||||
let pdf = String::from_utf8(replace_text_pdf(false, true))
|
||||
.unwrap()
|
||||
.replace("/IT /StrikeOutTextEdit /Popup", "/IT /StrikeOutTextEdit /F 128 /Popup")
|
||||
.into_bytes();
|
||||
let mut h = harness(move |app| {
|
||||
app.open_bytes("replace.pdf", None, pdf).expect("opens");
|
||||
app.close_tab(0);
|
||||
});
|
||||
h.run_steps(6);
|
||||
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows");
|
||||
let inside = |n: &egui_kittest::Node<'_>| window.contains(n.rect().center());
|
||||
let boxes = h.query_all_by(|n| n.role() == Role::MultilineTextInput).filter(inside).count();
|
||||
assert_eq!(boxes, 0, "no box to type in while the comment is locked");
|
||||
assert!(h.query_all_by_label("slow red").any(|n| inside(&n)), "the replacement is shown");
|
||||
}
|
||||
|
||||
/// A double-click on a Replace Text proposal opens the group's one window, the primary's, in
|
||||
/// either layout. (A double-click on the member grouped under it opens the same
|
||||
/// window once members are picked on the page; main hit-tests only the primary.)
|
||||
#[test]
|
||||
fn a_double_click_on_a_replace_text_opens_its_one_window() {
|
||||
for (caret_first, on_caret) in [(true, true), (false, false)] {
|
||||
let pdf = replace_text_pdf(caret_first, false);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("replace.pdf", None, pdf.clone()).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
crate::support::settle(&mut h);
|
||||
h.run_steps(45);
|
||||
let target = if on_caret { (140.0, 98.0) } else { (60.0, 110.0) };
|
||||
let p = at(&h, target.0, target.1);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
for _ in 0..2 {
|
||||
h.drag_at(p);
|
||||
h.run_steps(1);
|
||||
h.drop_at(p);
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(4);
|
||||
let primary_subtype = if caret_first { "Caret" } else { "StrikeOut" };
|
||||
let c = comments(&h);
|
||||
let primary = c.iter().find(|a| a.subtype == primary_subtype).expect("the primary");
|
||||
assert_eq!(primary.popup.map(|w| w.open), Some(true), "caret first: {caret_first}, on the caret: {on_caret}: the group's window opened");
|
||||
assert_eq!(h.state().views[0].comments.popup_rects.len(), 1, "one window");
|
||||
}
|
||||
}
|
||||
|
||||
/// A double-click on a sticky note opens its pop-up window on the page, where its text is read
|
||||
/// and edited; the title bar drags it (saved as the pop-up's place, one undo step); × closes it
|
||||
/// (comment.popups).
|
||||
#[test]
|
||||
fn a_double_click_opens_a_notes_pop_up_which_drags_and_closes() {
|
||||
// Frames a sixtieth apart, so two clicks land within egui's double-click time.
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("author", "Tester").unwrap();
|
||||
app.set_option("quick", "note").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
crate::support::settle(&mut h);
|
||||
click_pt(&mut h, (60.0, 160.0));
|
||||
field(&h, "Add a comment").type_text("Please check");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Post").click();
|
||||
h.run_steps(3);
|
||||
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
|
||||
assert_eq!(note.popup.map(|p| p.open), Some(false));
|
||||
assert!(h.state().views[0].comments.popup_rects.is_empty(), "closed pop-ups are not shown");
|
||||
// Well after the Post click, so egui does not count the next clicks with it.
|
||||
h.run_steps(45);
|
||||
|
||||
// Double-click the icon.
|
||||
let icon = at(&h, (note.rect[0] + note.rect[2]) / 2.0, (note.rect[1] + note.rect[3]) / 2.0);
|
||||
h.hover_at(icon);
|
||||
h.run_steps(1);
|
||||
for _ in 0..2 {
|
||||
h.drag_at(icon);
|
||||
h.run_steps(1);
|
||||
h.drop_at(icon);
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(4);
|
||||
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
|
||||
assert_eq!(note.popup.map(|p| p.open), Some(true), "the pop-up opened");
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Open comment pop-up"));
|
||||
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is on screen");
|
||||
// Its title names the kind of comment and its author.
|
||||
h.get_by_label("Note – Tester");
|
||||
|
||||
// Drag the title bar 80 px right and 40 px down: the pop-up's place on the page moves.
|
||||
let before = note.popup.unwrap().rect;
|
||||
let bar = pos2(window.left() + 30.0, window.top() + 12.0);
|
||||
drag(&mut h, bar, bar + egui::vec2(80.0, 40.0));
|
||||
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
|
||||
let after = note.popup.unwrap().rect;
|
||||
// 80 × 40 screen pixels is about 20 × 10 pt at this zoom.
|
||||
assert!(after[0] > before[0] + 10.0 && after[1] < before[1] - 5.0, "moved right and down on the page: {before:?} -> {after:?}");
|
||||
assert!(((after[2] - after[0]) - (before[2] - before[0])).abs() < 0.5, "the same width");
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move comment pop-up"));
|
||||
|
||||
// Close it.
|
||||
h.get_by_label("Close").click();
|
||||
h.run_steps(4);
|
||||
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
|
||||
assert_eq!(note.popup.map(|p| p.open), Some(false));
|
||||
assert!(h.state().views[0].comments.popup_rects.is_empty());
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Close comment pop-up"));
|
||||
}
|
||||
|
||||
// Contributor-original PDF: two notes sharing one /NM (as PyMuPDF names them), both with an open
|
||||
// pop-up; the second has a reply by reference and a reply to that reply, which has no /NM.
|
||||
const POPUPS_SHARING_A_NAME: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /NM (fitz-A0) /T (Alpha) /Contents (First note) /Popup 5 0 R /Open true >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 110 160 190] /Parent 4 0 R /Open true >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /NM (fitz-A0) /T (Beta) /Contents (Second note) /Popup 7 0 R /Open true >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Popup /Rect [180 -250 290 90] /Parent 6 0 R /Open true >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (Reply by reference) >> endobj
|
||||
9 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /IRT 8 0 R /T (Delta) /Contents (Reply to the reply) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// A pop-up reads its comment's thread by `/IRT` reference, as the Comments panel does: the
|
||||
/// replies show once, in the second note's window only, at any depth, though both notes share
|
||||
/// one `/NM`.
|
||||
#[test]
|
||||
fn a_pop_up_lists_its_replies_by_reference_when_names_repeat() {
|
||||
let h = harness_with_bytes(POPUPS_SHARING_A_NAME.to_vec(), |_| {});
|
||||
let windows = h.state().views[0].comments.popup_rects.clone();
|
||||
assert_eq!(windows.len(), 2, "both notes' pop-ups are open: {windows:?}");
|
||||
let (_, second) = windows.iter().find(|(key, _)| *key == (0, 2)).copied().expect("the second note's pop-up");
|
||||
for text in ["Reply by reference", "Reply to the reply"] {
|
||||
// The Comments panel lists the thread too; count only what the windows show.
|
||||
let shown: Vec<Pos2> =
|
||||
h.query_all_by(|n| shows_text(n, text)).map(|n| n.rect().center()).filter(|c| windows.iter().any(|(_, w)| w.contains(*c))).collect();
|
||||
assert_eq!(shown.len(), 1, "{text:?} shows in one window: {shown:?}");
|
||||
assert!(shown.iter().all(|c| second.contains(*c)), "{text:?} is in the second note's window");
|
||||
}
|
||||
}
|
||||
|
||||
// Contributor-original PDF: one blue note with its pop-up open beside it.
|
||||
const BLUE_NOTE_WITH_OPEN_POP_UP: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /C [0 0 1] /T (Alpha) /Contents (A note) /Popup 5 0 R /Open true >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Popup /Rect [120 20 260 120] /Parent 4 0 R /Open true >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// What the connectors of comments in colour `color` painted in the last frame: each wedge
|
||||
/// (its point, the two ends of its base, its colour: `color`, faded).
|
||||
fn connectors(h: &Harness<'static, PdfCraftApp>, color: egui::Color32) -> Vec<(Pos2, [Pos2; 2], egui::Color32)> {
|
||||
fn walk(shape: &egui::Shape, color: egui::Color32, out: &mut Vec<(Pos2, [Pos2; 2], egui::Color32)>) {
|
||||
match shape {
|
||||
egui::Shape::Mesh(m) => {
|
||||
if let [tip, b0, b1] = m.vertices.as_slice()
|
||||
&& tip.color.a() > 0
|
||||
&& tip.color.a() < 255
|
||||
&& tip.color.to_opaque() == color
|
||||
{
|
||||
out.push((tip.pos, [b0.pos, b1.pos], tip.color));
|
||||
}
|
||||
}
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, color, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for clipped in &h.output().shapes {
|
||||
walk(&clipped.shape, color, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A comment's connector (comment.connector-lines): a faint wedge in the comment's colour from
|
||||
/// the edge of the comment nearest its open pop-up to a base on the window's edge around the
|
||||
/// point nearest the comment; shown
|
||||
/// while the pointer is on the comment or on the window or the comment is selected, and not
|
||||
/// otherwise.
|
||||
#[test]
|
||||
fn a_connector_line_joins_a_comment_to_its_open_pop_up_while_the_pointer_is_on_either() {
|
||||
let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {});
|
||||
let blue = egui::Color32::from_rgb(0, 0, 255);
|
||||
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
|
||||
let mark = egui::Rect::from_two_pos(at(&h, 20.0, 180.0), at(&h, 40.0, 160.0));
|
||||
let on_edge = |r: egui::Rect, p: Pos2| {
|
||||
r.expand(1.0).contains(p) && [p.x - r.left(), r.right() - p.x, p.y - r.top(), r.bottom() - p.y].iter().any(|d| d.abs() < 1.0)
|
||||
};
|
||||
|
||||
// Away from both: no connector.
|
||||
h.hover_at(at(&h, 280.0, 190.0));
|
||||
h.run_steps(2);
|
||||
assert!(connectors(&h, blue).is_empty(), "no connector with the pointer elsewhere");
|
||||
|
||||
// On the note or on its window: one wedge, from the note's edge to the window's.
|
||||
for (what, pointer) in [("the note", mark.center()), ("the window", window.center())] {
|
||||
h.hover_at(pointer);
|
||||
h.run_steps(2);
|
||||
let found = connectors(&h, blue);
|
||||
assert_eq!(found.len(), 1, "one connector with the pointer on {what}: {found:?}");
|
||||
let (tip, [b0, b1], base) = found[0];
|
||||
assert!(on_edge(mark, tip), "its point is on the note's edge {mark:?}: {tip:?}");
|
||||
// The nearest point of the note: its lower right corner.
|
||||
assert!(tip.distance(mark.right_bottom()) < 1.0, "{tip:?}");
|
||||
assert!(on_edge(window, b0) && on_edge(window, b1), "its base is on the window's edge {window:?}: {b0:?} {b1:?}");
|
||||
// The base lies along one side, around the window's upper left corner, and is wider than a line.
|
||||
assert!((b0.x - b1.x).abs() < 0.5 || (b0.y - b1.y).abs() < 0.5, "along one side: {b0:?} {b1:?}");
|
||||
assert!(b0.distance(b1) >= 10.0, "a base, not a line: {b0:?} {b1:?}");
|
||||
assert!(b0.distance(window.left_top()).min(b1.distance(window.left_top())) < 1.0, "at the corner: {b0:?} {b1:?}");
|
||||
// The wedge is faint, in the comment's hue.
|
||||
assert!(base.a() < 255 && base.a() > 0 && base.to_opaque() == blue, "the wedge is faint blue: {base:?}");
|
||||
}
|
||||
|
||||
// Selected, the note keeps its connector with the pointer elsewhere.
|
||||
h.state_mut().views[0].comments.selected = Some((0, 0));
|
||||
h.hover_at(at(&h, 280.0, 190.0));
|
||||
h.run_steps(2);
|
||||
assert_eq!(connectors(&h, blue).len(), 1, "a selected comment shows its connector");
|
||||
}
|
||||
|
||||
/// A note with an open pop-up and a thread of replies: "Second" and "Third" answer the note,
|
||||
/// "Second, first answer" answers "Second" (`/IRT` to a reply).
|
||||
const NOTE_WITH_A_THREAD: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /C [1 0.85 0.2] /T (Alpha) /Contents (First) /Popup 5 0 R /Open true >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 -250 280 150] /Parent 4 0 R /Open true >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Second) /IRT 4 0 R /F 30 >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Second, first answer) /IRT 6 0 R /F 30 >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Third) /IRT 4 0 R /F 30 >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// A pop-up window (comment.popup-windows) is laid out as the readers' are: a title bar with the
|
||||
/// comment's kind and author; the comment's text; its replies as panels in thread order, an
|
||||
/// answer to a reply inside that reply's panel; and a Reply button under each, which opens a box
|
||||
/// that posts an answer to that comment or reply.
|
||||
#[test]
|
||||
fn a_pop_up_threads_its_replies_and_takes_a_new_one() {
|
||||
let mut h = harness_with_bytes(NOTE_WITH_A_THREAD.to_vec(), |_| {});
|
||||
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
|
||||
h.get_by_label("Note – Alpha");
|
||||
// Each reply's text box, inside the window.
|
||||
let rect = |h: &Harness<'static, PdfCraftApp>, text: &str| {
|
||||
let found: Vec<egui::Rect> = h.query_all_by(|n| shows_text(n, text)).map(|n| n.rect()).filter(|r| window.contains_rect(*r)).collect();
|
||||
assert_eq!(found.len(), 1, "{text:?} once in the window: {found:?}");
|
||||
found[0]
|
||||
};
|
||||
let (second, answer, third) = (rect(&h, "Second"), rect(&h, "Second, first answer"), rect(&h, "Third"));
|
||||
assert!(second.bottom() < answer.top() && answer.bottom() < third.top(), "thread order: {second:?} {answer:?} {third:?}");
|
||||
assert!(
|
||||
answer.left() > second.left() + 4.0 && answer.right() < second.right() - 4.0,
|
||||
"the answer sits inside its reply's panel: {second:?} {answer:?}"
|
||||
);
|
||||
assert!((third.left() - second.left()).abs() < 1.0, "replies to the note line up: {second:?} {third:?}");
|
||||
// The Reply buttons, top to bottom: the note's, then each reply's.
|
||||
let replies = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let mut b: Vec<egui::Rect> =
|
||||
h.query_all_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("Reply")).map(|n| n.rect()).collect();
|
||||
b.sort_by(|x, y| x.top().total_cmp(&y.top()));
|
||||
b
|
||||
};
|
||||
assert_eq!(replies(&h).len(), 4, "one under the note and each reply");
|
||||
|
||||
// The note's Reply: Enter starts a new line in the box, as in the readers' pop-ups; Post posts.
|
||||
let before = comments(&h).len();
|
||||
let at = replies(&h)[0].center();
|
||||
click(&mut h, at);
|
||||
field(&h, "Add a reply").type_text("Fourth");
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert_eq!(comments(&h).len(), before, "Enter does not post");
|
||||
h.event(egui::Event::Text("line two".into()));
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Post").click();
|
||||
h.run_steps(3);
|
||||
let all = comments(&h);
|
||||
let note = all.iter().find(|a| a.contents.as_deref() == Some("First")).expect("the note");
|
||||
let reply = all.iter().find(|a| a.contents.as_deref() == Some("Fourth\nline two")).expect("the reply was added, on two lines");
|
||||
assert_eq!(reply.parent, Some((note.page, note.index)));
|
||||
assert_eq!(reply.author.as_deref(), Some("Tester"));
|
||||
assert!(h.state().views[0].comments.popup_reply.is_none(), "the box closes");
|
||||
|
||||
// A reply's own Reply answers that reply; Ctrl+Enter posts too, as in the comment composer.
|
||||
let second_key = all.iter().find(|a| a.contents.as_deref() == Some("Second")).map(|a| (a.page, a.index)).expect("the reply");
|
||||
let at = replies(&h)[1].center();
|
||||
click(&mut h, at);
|
||||
field(&h, "Add a reply").type_text("Fifth");
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
let fifth = comments(&h).into_iter().find(|a| a.contents.as_deref() == Some("Fifth")).expect("Ctrl+Enter posts");
|
||||
assert_eq!(fifth.parent, Some(second_key), "it answers the reply");
|
||||
|
||||
// Cancel closes the box without posting.
|
||||
let before = comments(&h).len();
|
||||
let at = replies(&h)[0].center();
|
||||
click(&mut h, at);
|
||||
field(&h, "Add a reply").type_text("Never mind");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(comments(&h).len(), before);
|
||||
assert!(h.state().views[0].comments.popup_reply.is_none());
|
||||
}
|
||||
|
||||
/// A note with an open pop-up and `replies` replies to it.
|
||||
fn note_with_replies(replies: usize) -> Vec<u8> {
|
||||
let annots: String = (0..replies + 2).map(|i| format!("{} 0 R ", i + 4)).collect();
|
||||
let mut pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [{annots}] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (First) /Popup 5 0 R /Open true >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 10 280 150] /Parent 4 0 R /Open true >> endobj
|
||||
"
|
||||
);
|
||||
for i in 0..replies {
|
||||
pdf.push_str(&format!(
|
||||
"{} 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Reply {i}) /IRT 4 0 R /F 30 >> endobj\n",
|
||||
i + 6
|
||||
));
|
||||
}
|
||||
pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF");
|
||||
pdf.into_bytes()
|
||||
}
|
||||
|
||||
/// A pop-up window keeps its height however long its thread grows: the text and replies scroll
|
||||
/// inside it, as in the readers' pop-ups, rather than the window growing down the screen.
|
||||
#[test]
|
||||
fn a_long_thread_scrolls_inside_its_pop_up() {
|
||||
let height = |replies: usize| {
|
||||
let h = harness_with_bytes(note_with_replies(replies), |_| {});
|
||||
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
|
||||
window.height()
|
||||
};
|
||||
let (short, long) = (height(1), height(25));
|
||||
assert!((short - long).abs() < 2.0, "the same height with 1 reply and with 25: {short} {long}");
|
||||
assert!(long < 600.0, "within the screen: {long}");
|
||||
}
|
||||
|
||||
/// Dragging a pop-up window's lower right corner resizes it, and the new size is saved with the
|
||||
/// comment (the pop-up's `/Rect`) at the same place, in one undo step; the window shows it.
|
||||
#[test]
|
||||
fn a_pop_ups_corner_resizes_it() {
|
||||
let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {});
|
||||
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
|
||||
let before = comments(&h).into_iter().find(|a| a.subtype == "Text").and_then(|a| a.popup).expect("its pop-up").rect;
|
||||
let corner = window.right_bottom() - egui::vec2(3.0, 3.0);
|
||||
drag(&mut h, corner, corner + egui::vec2(80.0, 60.0));
|
||||
h.run_steps(3);
|
||||
let after = comments(&h).into_iter().find(|a| a.subtype == "Text").and_then(|a| a.popup).expect("its pop-up").rect;
|
||||
// Wider and taller on the page, its upper left corner where it was.
|
||||
assert!(after[2] - after[0] > before[2] - before[0] + 5.0, "wider: {before:?} -> {after:?}");
|
||||
assert!(after[3] - after[1] > before[3] - before[1] + 5.0, "taller: {before:?} -> {after:?}");
|
||||
assert!((after[0] - before[0]).abs() < 0.5 && (after[3] - before[3]).abs() < 0.5, "the same place: {before:?} -> {after:?}");
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move comment pop-up"));
|
||||
let (_, resized) = h.state().views[0].comments.popup_rects.first().copied().expect("still open");
|
||||
// The window shows the saved size, in points on screen whatever the zoom.
|
||||
assert!((resized.width() - (after[2] - after[0])).abs() < 3.0, "as wide as saved: {after:?} {resized:?}");
|
||||
assert!((resized.height() - (after[3] - after[1])).abs() < 3.0, "as tall as saved: {after:?} {resized:?}");
|
||||
assert!(resized.width() > window.width() + 60.0 && resized.height() > window.height() + 40.0, "bigger on screen: {window:?} -> {resized:?}");
|
||||
assert!((resized.left_top() - window.left_top()).length() < 1.0, "in the same place on screen");
|
||||
}
|
||||
|
||||
/// A pop-up window's box holding `text` (a comment's or a reply's).
|
||||
fn shows_text(n: &egui_kittest::kittest::AccessKitNode<'_>, text: &str) -> bool {
|
||||
n.role() == Role::MultilineTextInput && n.value().as_deref() == Some(text)
|
||||
}
|
||||
|
||||
/// On a page turned a quarter (View ▸ Rotate View), a resized pop-up window stays where it is on
|
||||
/// screen and shows the size it was dragged to.
|
||||
#[test]
|
||||
fn a_pop_up_resized_on_a_turned_page_stays_in_place() {
|
||||
let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |app| app.views[0].rotation = 90);
|
||||
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
|
||||
let corner = window.right_bottom() - egui::vec2(3.0, 3.0);
|
||||
drag(&mut h, corner, corner + egui::vec2(80.0, 60.0));
|
||||
h.run_steps(3);
|
||||
let (_, resized) = h.state().views[0].comments.popup_rects.first().copied().expect("still open");
|
||||
assert!((resized.left_top() - window.left_top()).length() < 1.0, "in the same place on screen: {window:?} -> {resized:?}");
|
||||
assert!((resized.width() - window.width() - 80.0).abs() < 3.0, "80 px wider: {window:?} -> {resized:?}");
|
||||
// Taller by the drag, less what its text had already stretched it.
|
||||
assert!(resized.height() > window.height() + 50.0, "taller: {window:?} -> {resized:?}");
|
||||
}
|
||||
|
||||
/// #2137 review: the composer's Post, clicked while a pop-up's box holds unsaved text, also
|
||||
/// saves that text as the box loses the focus. Both land in one frame and both are kept.
|
||||
#[test]
|
||||
fn posting_from_the_composer_keeps_a_pop_ups_unsaved_text() {
|
||||
let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |app| app.set_option("quick", "note").unwrap());
|
||||
// Low on the left, so the composer stays clear of the window.
|
||||
click_pt(&mut h, (30.0, 40.0));
|
||||
// The composer opens with its editor focused.
|
||||
h.run_steps(2);
|
||||
h.event(egui::Event::Text("New note".into()));
|
||||
h.run_steps(2);
|
||||
let box_at = h.query_all_by(|n| shows_text(n, "A note")).next().expect("the pop-up's box").rect().right_center() - egui::vec2(4.0, 0.0);
|
||||
click(&mut h, box_at);
|
||||
h.event(egui::Event::Text(" edited".into()));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].comments.composer.is_some(), "the composer is still open");
|
||||
let post = h.get_by_label("Post").rect().center();
|
||||
click(&mut h, post);
|
||||
h.run_steps(3);
|
||||
let all = comments(&h);
|
||||
assert!(
|
||||
all.iter().any(|a| a.contents.as_deref() == Some("New note")),
|
||||
"the composer's note: {:?}",
|
||||
all.iter().map(|a| &a.contents).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
all.iter().any(|a| a.contents.as_deref() == Some("A note edited")),
|
||||
"the pop-up's text: {:?}",
|
||||
all.iter().map(|a| &a.contents).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// #2137 review: what a pop-up holds for a comment (a reply being typed, unsaved text) follows
|
||||
/// that comment when a deletion moves it, and is dropped when an undo or a page edit leaves its
|
||||
/// (page, index) naming another; other edits keep it.
|
||||
#[test]
|
||||
fn a_pop_ups_unsaved_work_follows_its_comment_or_is_dropped() {
|
||||
use pdfcraft_engine::Edit;
|
||||
let mut h = harness_with_bytes(NOTE_WITH_A_THREAD.to_vec(), |_| {});
|
||||
let key = |h: &Harness<'static, PdfCraftApp>, text: &str| {
|
||||
comments(h).into_iter().find(|a| a.contents.as_deref() == Some(text)).map(|a| (a.page, a.index)).expect(text)
|
||||
};
|
||||
let (second, third) = (key(&h, "Second"), key(&h, "Third"));
|
||||
h.state_mut().views[0].comments.popup_reply = Some((third.0, third.1, "draft".into()));
|
||||
|
||||
// Another edit, on another comment: the draft stays.
|
||||
assert!(h.state_mut().apply_edit(Edit::SetAnnotationContents { page: second.0, index: second.1, text: "Second!".into() }));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].comments.popup_reply, Some((third.0, third.1, "draft".into())));
|
||||
|
||||
// Deleting "Second" (and its answer) moves "Third" up: the draft goes with it.
|
||||
assert!(h.state_mut().apply_edit(Edit::DeleteAnnotation { page: second.0, index: second.1 }));
|
||||
h.run_steps(2);
|
||||
let moved = key(&h, "Third");
|
||||
assert_ne!(moved, third, "Third moved up");
|
||||
assert_eq!(h.state().views[0].comments.popup_reply, Some((moved.0, moved.1, "draft".into())), "the draft is still Third's");
|
||||
|
||||
// Undo puts the thread back: the draft is dropped rather than left on another reply.
|
||||
h.state_mut().undo();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].comments.popup_reply, None);
|
||||
|
||||
// A page inserted before it: the comment's (page, index) now names nothing of it.
|
||||
let note = key(&h, "First");
|
||||
h.state_mut().views[0].comments.popup_text = Some((note.0, note.1, "unsaved".into()));
|
||||
assert!(h.state_mut().apply_edit(Edit::InsertBlankPage { at: 0, width: 300.0, height: 200.0 }));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].comments.popup_text, None);
|
||||
}
|
||||
|
||||
/// #2137 review: text typed into a pop-up and not yet saved is saved when × closes the window:
|
||||
/// the click takes the focus from the box and closes it in one frame, one undo step for both.
|
||||
#[test]
|
||||
fn closing_a_pop_up_saves_its_typed_text() {
|
||||
let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {});
|
||||
let box_at = h.query_all_by(|n| shows_text(n, "A note")).next().expect("the pop-up's box").rect().right_center() - egui::vec2(4.0, 0.0);
|
||||
click(&mut h, box_at);
|
||||
h.event(egui::Event::Text(" for you".into()));
|
||||
h.run_steps(2);
|
||||
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
|
||||
let close = h.query_all_by_label("Close").map(|n| n.rect().center()).find(|c| window.contains(*c)).expect("the window's ×");
|
||||
click(&mut h, close);
|
||||
h.run_steps(3);
|
||||
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
|
||||
assert_eq!(note.contents.as_deref(), Some("A note for you"), "the typed text is saved");
|
||||
assert_eq!(note.popup.map(|p| p.open), Some(false), "and the window closed");
|
||||
assert!(h.state().views[0].comments.popup_text.is_none());
|
||||
h.state_mut().undo();
|
||||
h.run_steps(2);
|
||||
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
|
||||
assert_eq!((note.contents.as_deref(), note.popup.map(|p| p.open)), (Some("A note"), Some(true)), "one undo step for both");
|
||||
}
|
||||
|
||||
/// `n` pages with one comment each, "Card 1" to "Card n".
|
||||
fn one_comment_per_page(n: usize) -> Vec<u8> {
|
||||
let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string()];
|
||||
@@ -4176,7 +4794,25 @@ fn edit_composer_preserves_only_typewriter_whitespace(kind: u8) {
|
||||
};
|
||||
support::input::double_click(&mut h, point);
|
||||
h.run_steps(3);
|
||||
let c = h.state().views[0].comments.composer.as_ref().expect("double-click opens the edit composer");
|
||||
if kind == 2 {
|
||||
// A double-click on a sticky note opens its pop-up window, as in Acrobat; its edit
|
||||
// composer is the page menu's "Edit text…".
|
||||
let popup_open = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
doc.info.annotations.iter().find(|a| a.page == 1 && a.index == 0).unwrap().popup.is_some_and(|p| p.open)
|
||||
};
|
||||
assert!(h.state().views[0].comments.composer.is_none(), "a note's double-click opens no composer");
|
||||
assert!(popup_open(&h), "a note's double-click opens its pop-up");
|
||||
h.hover_at(point);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(3);
|
||||
h.get_all_by_label("Edit text…").last().unwrap().click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
let c = h.state().views[0].comments.composer.as_ref().expect("the edit composer is open");
|
||||
assert_eq!(c.page, 1);
|
||||
assert_eq!(c.kind, pdfcraft_ui_egui::comments::ComposerKind::Edit(0));
|
||||
h.input_mut().events.push(egui::Event::Text(" edited\n\n".into()));
|
||||
|
||||
@@ -4235,11 +4235,11 @@ title = "Open and reposition comment pop-ups"
|
||||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
tools = ["comment_popup", "comment_list"]
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::a_double_click_opens_a_notes_pop_up_which_drags_and_closes", "crates/ui-egui/tests/it/comments.rs::a_pop_up_threads_its_replies_and_takes_a_new_one", "crates/ui-egui/tests/it/comments.rs::a_long_thread_scrolls_inside_its_pop_up", "crates/ui-egui/tests/it/comments.rs::a_pop_ups_corner_resizes_it", "crates/ui-egui/tests/it/comments.rs::a_pop_up_resized_on_a_turned_page_stays_in_place", "crates/annot/src/tests.rs::pop_ups_open_close_and_move_and_are_reported", "crates/automation/tests/it/automation.rs::comment_popups_are_opened_moved_and_listed", "crates/render/src/inspect/tests.rs::a_comments_pop_up_is_read_with_its_place_and_state"]
|
||||
notes = "double-click a comment to open its pop-up window on the page (its /Popup /Open and /Rect, so other viewers show it the same), at the size its /Rect gives in points whatever the zoom; a title bar with the comment's kind, author and date; its text typed in place; its replies as panels, each inside the one it answers (/IRT), their text typed in place too; a Reply button under the comment and under each reply opens a box that answers it (Enter starts a new line, Post or Ctrl+Enter posts); the text and replies scroll inside the window; the title bar drags it, its lower right corner resizes it, × closes it; each is an undo step. Not yet: the window's options menu (status, mark, lock, delete from the window), collapsing a reply's answers, resizing from the other corners and sides, Preferences ▸ Commenting pop-up behaviours (open on roll-over, hide when the Comments list opens)"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.connector-lines"
|
||||
@@ -4247,11 +4247,11 @@ title = "Connector lines between markups and pop-ups"
|
||||
area = "E"
|
||||
tier = "P2"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::a_connector_line_joins_a_comment_to_its_open_pop_up_while_the_pointer_is_on_either"]
|
||||
notes = "a faint wedge in the comment's colour from the comment's edge nearest its open pop-up, widening to the window's side that faces it, while the pointer is on either or the comment is selected (Acrobat's roll-over lines). Not yet: the preference to turn them off"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.review-history"
|
||||
|
||||
Reference in new issue
Block a user