From f1bb95b2bf07daa7b0435935a231c43b7043c45d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:13:46 +0800 Subject: [PATCH] M5: comment pop-up windows on the page, with connector lines A double-click on a comment opens its pop-up window as in Acrobat: author, date, the text edited in place, the replies; the title bar drags it, x closes it, each an undo step saved in /Popup (/Open, /Rect) and a note's own /Open. A connector line in the comment's colour joins the comment to its open window on roll-over or selection. comment_popup does the same headlessly; comment_list reports the pop-up. Three undo labels in the 21 catalogues. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- ROADMAP.md | 1 + crates/annot/README.md | 4 +- crates/annot/src/lib.rs | 93 +++++++++++ crates/annot/src/tests.rs | 45 +++++ crates/automation/src/comments.rs | 13 ++ crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 5 + crates/automation/tests/it/automation.rs | 26 +++ crates/engine/src/lib.rs | 18 ++ crates/render/src/inspect.rs | 31 ++++ crates/render/src/lib.rs | 2 +- crates/ui-egui/src/canvas.rs | 11 ++ crates/ui-egui/src/comments.rs | 204 ++++++++++++++++++++++- crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 3 + crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/kk.tsv | 3 + crates/ui-egui/src/i18n/lt.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sl.tsv | 3 + crates/ui-egui/src/i18n/te.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/tests/it/comments.rs | 65 ++++++++ parity/acrobat-features.toml | 14 +- 37 files changed, 587 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f3515c2eb1..f2b0c90f0a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -386,6 +386,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 (M5, comment pop-ups):** A double-click on a comment opens its pop-up window on the page, as in Acrobat: author, date, the text edited in place, the replies; the title bar drags it and × closes it, each an undo step saved in the file's `/Popup` so other viewers agree. A connector line 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. diff --git a/crates/annot/README.md b/crates/annot/README.md index 8108201835..aa11b1f0f2 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -21,7 +21,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 diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 6c9fc5dce8..43f4a5dac3 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1846,6 +1846,75 @@ fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> { /// 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. +/// 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, 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; + match d.reference(b"Popup").filter(|pr| doc.get(*pr).as_dict().is_some()) { + Some(pr) => 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 own: Vec = 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], + }; + 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(()) +} + pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; let f = if locked { flags(doc, r) | FLAG_LOCKED } else { flags(doc, r) & !FLAG_LOCKED }; @@ -2308,6 +2377,29 @@ pub struct Summary { /// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud /// (`/Vertices`), in user space; empty for other comments. pub points: Vec<[f32; 2]>, + /// The comment's pop-up window (`/Popup`), when it has one. + pub popup: Option, +} + +/// 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 `annot` names, as [`PopupInfo`]: `None` when it has none or it is unreadable. +fn popup_info(doc: &Document, annot: &Dict) -> Option { + let r = annot.reference(b"Popup")?; + let p = doc.get(r); + let p = p.as_dict()?; + let rect: Vec = doc.resolve(p.get(b"Rect")?).as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64().map(|x| x as f32)).collect(); + let [x0, y0, x1, y1] = rect[..] else { 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 }) } fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { @@ -2415,6 +2507,7 @@ pub fn summaries(doc: &Document) -> Vec { fill_sign: is_fill_sign(doc, d), no_rotate: drawn_upright(doc, d), points, + popup: popup_info(doc, d), }); } } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index b9e47e5fb9..ce046216ec 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2135,3 +2135,48 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256"); assert!(!long.contains(&"w".repeat(257))); } + +/// 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); + + // 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 { .. }))); +} diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 0150001096..0f59ab8bbd 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -188,6 +188,8 @@ impl Automation { "modified": c.modified, "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), + // 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 line's ends, or the corners of connected lines, a polygon or a cloud, as // comment_edit's `points` takes them. @@ -604,6 +606,17 @@ impl Automation { self.apply(a, Edit::MarkAnnotation { page, index, marked, author }) } + pub(crate) fn comment_popup(&mut self, a: &Args) -> Result { + 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 { let (page, index) = self.comment_target(a)?; let locked = a.opt_bool("locked")?.unwrap_or(true); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index e94354021f..eb47e9b2e5 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -958,6 +958,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)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 01c57fdaaa..a43425b603 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -887,6 +887,11 @@ pub fn tools() -> Vec { 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. 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( "doc_protect", "Protect with passwords", diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 687dd37a4f..879455eeb5 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9065,3 +9065,29 @@ fn page_render_draws_stamps_without_an_appearance() { let grey = custom.iter().filter(|p| p[0].abs_diff(p[1]) < 24 && p[1].abs_diff(p[2]) < 24).count(); assert!(grey > 100 && grey == custom.len(), "an unknown name is a grey placeholder: {grey} of {}", custom.len()); } + +/// 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); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f11671bf85..db60eee99d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -297,6 +297,7 @@ fn scope_of(edit: &Edit) -> Scope { | Edit::EraseInk { .. } | Edit::MoveAnnotation { .. } | Edit::ReshapeAnnotation { .. } + | Edit::SetAnnotationPopup { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } @@ -1583,6 +1584,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, + }, /// Resize a rectangle, oval or text box. ResizeAnnotation { page: usize, @@ -2059,6 +2068,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}", @@ -2191,6 +2203,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 { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { Label::of("Change comment properties") @@ -2354,6 +2369,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::LockAnnotation { .. } | Edit::MoveAnnotation { .. } | Edit::ReshapeAnnotation { .. } + | Edit::SetAnnotationPopup { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } @@ -2645,6 +2661,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> 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(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; @@ -3027,6 +3044,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec, + /// The comment's pop-up window (`/Popup`): where it sits and whether it is open. + pub popup: Option, +} + +/// 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 { @@ -1501,6 +1512,13 @@ impl<'a> Inspector<'a> { } _ => Vec::new(), }; + let popup = d.get(b"Popup").ok().and_then(|o| self.dict(o)).map(|p| { + let [x0, y0, x1, y1] = rect4(self.resolve(p.get(b"Rect").unwrap_or(&Object::Null))); + 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, @@ -1523,6 +1541,7 @@ impl<'a> Inspector<'a> { fill_sign, no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0), points, + popup, }; kept = kept.saturating_add(retained(&annotation)); if kept > MAX_ANNOTATION_BYTES { @@ -4078,6 +4097,18 @@ trailer << /Root 1 0 R >> 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"); + } + #[test] fn dates_are_prettified() { assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45"); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 954823ef09..270f90affb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -18,7 +18,7 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops, + PageTransition, Popup, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops, }; pub use pixels::Pixels; pub use raster::{ diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c7015c4651..6a850c8e6c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -730,6 +730,11 @@ impl DocView { self.auto_scroll.panning() } + /// 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() + } + /// Pages that could not be rendered, with the reason (for automation; 0-based pages). pub fn page_errors(&self) -> Vec<(usize, &str)> { let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect(); @@ -3924,6 +3929,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { .filter(|a| { !(prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty)) }) + // An open pop-up already shows the comment: no tooltip over it. + .filter(|a| !a.popup.is_some_and(|w| w.open)) .filter(|a| pcx.screen_rects(a).any(|r| r.contains(p))) .filter_map(|a| annotation_hover_text(a).map(|text| (a.index, text))) .max_by_key(|(index, _)| *index) @@ -4311,6 +4318,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { 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, comments_hidden, can_modify) { + view.pending_edit = Some(e); + } if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { view.pending_edit = Some(e); } @@ -5903,6 +5913,7 @@ mod tests { fill_sign, no_rotate: false, points: Vec::new(), + popup: None, } } diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 928d5cbf6b..bced77fdc9 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -499,6 +499,15 @@ 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)>, + /// Text being typed in a pop-up window: (page, index, text). + pub popup_text: 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). @@ -1274,12 +1283,20 @@ fn select_input( None => cv.selected = None, } } - if resp.double_clicked() + if (resp.double_clicked() || resp.triple_clicked()) && on_page && cx.allowed && let Some(a) = pointer.and_then(|p| cx.hit(p)) { cv.selected = Some((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), @@ -1370,6 +1387,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> } } } + paint_connectors(painter, cx, view, pointer); paint_gesture(painter, cx, view); } @@ -1423,6 +1441,190 @@ fn paint_points(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, a: &An paint_gesture(painter, cx, view); } +/// A comment with a pop-up window of its own (not a text box, a Fill & Sign mark or a reply). +fn has_popup(a: &Annotation) -> bool { + a.subtype != "FreeText" && !a.fill_sign && a.in_reply_to.is_none() +} + +/// The line 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 runs from the point of the comment nearest the window to the +/// point of the window nearest the comment. +fn paint_connectors(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, pointer: Option) { + 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 = 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.selected == Some((*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)); + painter.line_segment([from, to], Stroke::new(1.5, color)); + } +} + +/// 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) + } +} + +/// The open pop-up windows of the comments on screen: author, date and the comment's text, +/// editable in place; the title bar drags the window (its new place is 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, hidden: bool, allowed: bool) -> Option { + let t = Tokens::get(ctx); + let pointer = ctx.input(|i| i.pointer.hover_pos()); + let mut edits: Vec = Vec::new(); + let mut rects = Vec::new(); + let mut hovered = None; + let xforms: Vec<(usize, PageXform)> = view.page_xforms(); + for (page, xf) in xforms { + let shown: Vec = + 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); + // The window keeps a readable size whatever the zoom: its place follows the page, + // its width stays between what a note needs and what fits. + let width = placed.width().clamp(220.0, 420.0); + 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::new(("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 area = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + egui::Frame::popup(ui.style()).inner_margin(egui::Margin::ZERO).corner_radius(CornerRadius::same(6)).show(ui, |ui| { + ui.set_width(width); + // Title bar: the comment's colour, author and date; drag to move, × to close. + let bar = egui::Frame::NONE.fill(color.gamma_multiply(0.35)).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { + ui.set_width(width - 20.0); + ui.horizontal(|ui| { + let who = a.author.clone().unwrap_or_else(|| crate::comments_panel::kind_label(&a).to_string()); + ui.label(egui::RichText::new(crate::bidi::visual_auto(&who)).font(theme::semibold(13.0)).color(t.text)); + if let Some(m) = &a.modified { + ui.label(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::icons::button(ui, "x", 18.0, false, tl!("Close")).clicked() { + edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: None, open: Some(false) }); + } + }); + }); + }); + // 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() - 30.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() { + 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 + { + // The same size on the page, at the new place. + let moved = Rect::from_min_size(at, placed.size()); + let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs: &CommentPrefs::default(), allowed, hidden }; + let rect = cx.rect_to_user(moved); + if at.distance(placed.left_top()) >= 2.0 { + edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: Some(rect), open: None }); + } + } + // Body: the text, typed in place; saved when the field loses focus. + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(10, 8)).show(ui, |ui| { + ui.set_width(width - 20.0); + if allowed && !a.locked { + let typed = view.comments.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) + .hint_text(tl!("Add a comment")) + .desired_rows(3) + .desired_width(f32::INFINITY) + .id_salt(("popup-text", page, a.index)), + ); + if field.changed() { + view.comments.popup_text = Some((page, a.index, text.clone())); + } + if field.lost_focus() + && let Some((_, _, s)) = view.comments.popup_text.take_if(|(p, i, _)| (*p, *i) == key) + && s.trim_end() != a.contents.as_deref().unwrap_or_default() + { + edits.push(Edit::SetAnnotationContents { page, index: a.index, text: s.trim_end().to_string() }); + } + } else { + ui.label(crate::bidi::visual_auto(a.contents.as_deref().unwrap_or_default())); + } + // Replies, as the thread reads. + let replies: Vec<&Annotation> = + info.annotations.iter().filter(|r| r.page == page && r.in_reply_to.is_some() && r.in_reply_to == a.name).collect(); + for r in replies { + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + ui.label( + egui::RichText::new(crate::bidi::visual_auto(r.author.as_deref().unwrap_or("?"))).font(theme::semibold(12.0)), + ); + ui.label( + egui::RichText::new(crate::bidi::visual_auto(r.contents.as_deref().unwrap_or_default())) + .font(theme::regular(12.0)), + ); + }); + } + }); + }); + }); + 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 }), + } +} + fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { let now = painter.ctx().input(|i| i.time); for s in view.comments.settling.iter().filter(|s| s.page == cx.page && now - s.since < SETTLE_SECS) { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 9a13d570d9..53d7ef244c 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1058,6 +1058,7 @@ fn comment_page(edit: &Edit) -> Option { | Edit::SetAnnotationStatus { page, .. } | Edit::MoveAnnotation { page, .. } | Edit::ReshapeAnnotation { page, .. } + | Edit::SetAnnotationPopup { page, .. } | Edit::ResizeAnnotation { page, .. } | Edit::StyleAnnotation { page, .. } | Edit::FillAnnotation { page, .. } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 25ddac1d5f..676a00d1d0 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1959,6 +1959,9 @@ Lock comment قفل التعليق Unlock comment إلغاء قفل التعليق Move comment نقل التعليق + Open comment pop-up فتح النافذة المنبثقة للتعليق + Close comment pop-up إغلاق النافذة المنبثقة للتعليق + Move comment pop-up نقل النافذة المنبثقة للتعليق Resize comment تغيير حجم التعليق Change comment properties تغيير خصائص التعليق Add field إضافة حقل diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index a8af8ed655..9fdaef697b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2048,6 +2048,9 @@ Microsoft Word (.docx) Microsoft Word (.docx) Move bookmark Премести отметката Move comment Премести коментара + Open comment pop-up Отваряне на изскачащия прозорец на коментара + Close comment pop-up Затваряне на изскачащия прозорец на коментара + Move comment pop-up Преместване на изскачащия прозорец на коментара Move image Премести изображението Move page Премести страницата Move pages Премести страниците diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3cadb98e3b..d163e04219 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2009,6 +2009,9 @@ key Space Mezerník Lock comment Zamknout komentář Unlock comment Odemknout komentář Move comment Přesunout komentář + Open comment pop-up Otevřít okno komentáře + Close comment pop-up Zavřít okno komentáře + Move comment pop-up Přesunout okno komentáře Resize comment Změnit velikost komentáře Change comment properties Změnit vlastnosti komentáře Add field Přidat pole diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7b9dcc06f0..ba297c6c1f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1895,6 +1895,9 @@ Lock comment Kommentar sperren Unlock comment Kommentar entsperren Move comment Kommentar verschieben + Open comment pop-up Kommentar-Pop-up öffnen + Close comment pop-up Kommentar-Pop-up schließen + Move comment pop-up Kommentar-Pop-up verschieben Resize comment Kommentargröße ändern Change comment properties Kommentareigenschaften ändern Add field Feld hinzufügen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index dbe7969173..5e116821ec 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1342,6 +1342,9 @@ Lock comment Κλείδωμα σχολίου Unlock comment Ξεκλείδωμα σχολίου Move comment Μετακίνηση σχολίου + Open comment pop-up Άνοιγμα αναδυόμενου παραθύρου σχολίου + Close comment pop-up Κλείσιμο αναδυόμενου παραθύρου σχολίου + Move comment pop-up Μετακίνηση αναδυόμενου παραθύρου σχολίου Resize comment Αλλαγή μεγέθους σχολίου Change comment properties Αλλαγή ιδιοτήτων σχολίου Add field Προσθήκη πεδίου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 951a3dd8c1..8b1797fb80 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1974,6 +1974,9 @@ action wizard Start Iniciar Lock comment Bloquear comentario Unlock comment Desbloquear comentario Move comment Mover comentario + Open comment pop-up Abrir la ventana emergente del comentario + Close comment pop-up Cerrar la ventana emergente del comentario + Move comment pop-up Mover la ventana emergente del comentario Resize comment Cambiar tamaño del comentario Change comment properties Cambiar propiedades del comentario Add field Añadir campo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ea6008039f..5c3549b622 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1890,6 +1890,9 @@ Lock comment Verrouiller le commentaire Unlock comment Déverrouiller le commentaire Move comment Déplacer le commentaire + Open comment pop-up Ouvrir la fenêtre du commentaire + Close comment pop-up Fermer la fenêtre du commentaire + Move comment pop-up Déplacer la fenêtre du commentaire Resize comment Redimensionner le commentaire Change comment properties Modifier les propriétés du commentaire Add field Ajouter un champ diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 33ced9925d..c27a322639 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1880,6 +1880,9 @@ Lock comment Megjegyzés zárolása Unlock comment Megjegyzés feloldása Move comment Megjegyzés áthelyezése + Open comment pop-up Megjegyzés előugró ablakának megnyitása + Close comment pop-up Megjegyzés előugró ablakának bezárása + Move comment pop-up Megjegyzés előugró ablakának áthelyezése Resize comment Megjegyzés méretezése Change comment properties Megjegyzés tulajdonságainak módosítása Add field Mező hozzáadása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 60ecf2de3c..809c3b2902 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1965,6 +1965,9 @@ Lock comment Blocca commento Unlock comment Sblocca commento Move comment Sposta commento + Open comment pop-up Apri il pop-up del commento + Close comment pop-up Chiudi il pop-up del commento + Move comment pop-up Sposta il pop-up del commento Resize comment Ridimensiona commento Change comment properties Modifica proprietà commento Add field Aggiungi campo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index db338ebb11..cc98c67080 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1972,6 +1972,9 @@ Lock comment 注釈をロック Unlock comment 注釈のロックを解除 Move comment 注釈を移動 + Open comment pop-up 注釈のポップアップを開く + Close comment pop-up 注釈のポップアップを閉じる + Move comment pop-up 注釈のポップアップを移動 Resize comment 注釈のサイズを変更 Change comment properties 注釈のプロパティを変更 Add field フィールドを追加 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index ea0d0d0fb4..feaeebf943 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1881,6 +1881,9 @@ Lock comment Түсініктемені құлыптау Unlock comment Түсініктеменің құлпын ашу Move comment Түсініктемені жылжыту + Open comment pop-up Пікірдің қалқымалы терезесін ашу + Close comment pop-up Пікірдің қалқымалы терезесін жабу + Move comment pop-up Пікірдің қалқымалы терезесін жылжыту Resize comment Түсініктеме өлшемін өзгерту Change comment properties Түсініктеме сипаттарын өзгерту Add field Өріс қосу diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 058bbe9052..8f1c7666bd 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2188,6 +2188,9 @@ comment menu Edit Redaguoti Mouse Up Atleidus pelės mygtuką Move bookmark Perkelti žymelę Move comment Perkelti komentarą + Open comment pop-up Atidaryti komentaro iškylantįjį langą + Close comment pop-up Uždaryti komentaro iškylantįjį langą + Move comment pop-up Perkelti komentaro iškylantįjį langą Move image Perkelti paveikslėlį Move page Perkelti puslapį Move pages Perkelti puslapius diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 056ca7d403..6537dcfd4a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1953,6 +1953,9 @@ Lock comment Zablokuj komentarz Unlock comment Odblokuj komentarz Move comment Przenieś komentarz + Open comment pop-up Otwórz okienko komentarza + Close comment pop-up Zamknij okienko komentarza + Move comment pop-up Przenieś okienko komentarza Resize comment Zmień rozmiar komentarza Change comment properties Zmień właściwości komentarza Add field Dodaj pole diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1434c7c6e2..82b6c60d1e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1921,6 +1921,9 @@ Microsoft Word (.docx) Microsoft Word (.docx) Move bookmark Mover marcador Move comment Mover comentário + Open comment pop-up Abrir o pop-up do comentário + Close comment pop-up Fechar o pop-up do comentário + Move comment pop-up Mover o pop-up do comentário Move image Mover imagem Move page Mover página Move pages Mover páginas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index bea98cd797..c7260ed8fb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1960,6 +1960,9 @@ Lock comment Заблокировать комментарий Unlock comment Разблокировать комментарий Move comment Переместить комментарий + Open comment pop-up Открыть всплывающее окно комментария + Close comment pop-up Закрыть всплывающее окно комментария + Move comment pop-up Переместить всплывающее окно комментария Resize comment Изменить размер комментария Change comment properties Изменить свойства комментария Add field Добавить поле diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index ccbb42d57b..b9766a62f9 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1147,6 +1147,9 @@ Mouse Up Miška navzgor Move bookmark Premakni zaznamek Move comment Premakni komentar + Open comment pop-up Odpri pojavno okno komentarja + Close comment pop-up Zapri pojavno okno komentarja + Move comment pop-up Premakni pojavno okno komentarja Move down Premakni navzdol Move earlier Premakni prej Move image Premakni sliko diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 317d47f9b9..91e955a18c 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1879,6 +1879,9 @@ action wizard Start ప్రారంభించు Lock comment వ్యాఖ్యను లాక్ చేయి Unlock comment వ్యాఖ్యను అన్‌లాక్ చేయి Move comment వ్యాఖ్యను తరలించు + Open comment pop-up వ్యాఖ్య పాప్-అప్ తెరవండి + Close comment pop-up వ్యాఖ్య పాప్-అప్ మూసివేయండి + Move comment pop-up వ్యాఖ్య పాప్-అప్ తరలించండి Resize comment వ్యాఖ్య పరిమాణం మార్చు Change comment properties వ్యాఖ్య లక్షణాలను మార్చు Add field ఫీల్డ్ జోడించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index bcdfe9a90e..7f6429098d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1853,6 +1853,9 @@ Lock comment Yorumu kilitle Unlock comment Yorumun kilidini aç Move comment Yorumu taşı + Open comment pop-up Yorum açılır penceresini aç + Close comment pop-up Yorum açılır penceresini kapat + Move comment pop-up Yorum açılır penceresini taşı Resize comment Yorumu yeniden boyutlandır Change comment properties Yorum özelliklerini değiştir Add field Alan ekle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 599b4b63af..b3be4618b1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1957,6 +1957,9 @@ Lock comment Заблокувати коментар Unlock comment Розблокувати коментар Move comment Перемістити коментар + Open comment pop-up Відкрити спливаюче вікно коментаря + Close comment pop-up Закрити спливаюче вікно коментаря + Move comment pop-up Перемістити спливаюче вікно коментаря Resize comment Змінити розмір коментаря Change comment properties Змінити властивості коментаря Add field Додати поле diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7ea3ca941c..0afde5bdba 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2058,6 +2058,9 @@ Microsoft Word (.docx) Microsoft Word(.docx) Move bookmark 移动书签 Move comment 移动注释 + Open comment pop-up 打开注释弹出窗口 + Close comment pop-up 关闭注释弹出窗口 + Move comment pop-up 移动注释弹出窗口 Move image 移动图像 Move page 移动页面 Move pages 移动页面 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b34cb787ca..6b668223af 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1959,6 +1959,9 @@ Lock comment 鎖定註解 Unlock comment 解鎖註解 Move comment 移動註解 + Open comment pop-up 開啟註解彈出視窗 + Close comment pop-up 關閉註解彈出視窗 + Move comment pop-up 移動註解彈出視窗 Resize comment 調整註解大小 Change comment properties 變更註解內容 Add field 新增欄位 diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index d516330dd7..3f96a0a3ac 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2656,3 +2656,68 @@ fn text_comments_can_save_a_zero_width_default() { assert_eq!(restored.comment_prefs.style(CommentTool::TextBox).width, 0.0); assert_eq!(restored.comment_prefs.style(CommentTool::Callout).width, 0.0); } + +/// 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, comment.connector-lines). +#[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); + 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"); + h.get_by_label("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")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 69c98d076a..e59ab4ee5a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4108,11 +4108,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/comments.rs::a_double_click_opens_a_notes_pop_up_which_drags_and_closes", "crates/annot/src/tests.rs::pop_ups_open_close_and_move_and_are_reported", "crates/automation/tests/automation.rs::comment_popups_are_opened_moved_and_listed", "crates/render/src/inspect.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); author, date, the text typed in place and the replies; the title bar drags it, × closes it; each is an undo step. Not yet: Acrobat's pop-up options menu (reply, status, delete from the window), replying from the window, resizing the window, Preferences ▸ Commenting pop-up behaviours (open on roll-over, hide when the Comments list opens)" [[feature]] id = "comment.connector-lines" @@ -4120,11 +4120,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/comments.rs::a_double_click_opens_a_notes_pop_up_which_drags_and_closes"] +notes = "a line in the comment's colour from the comment to its open pop-up 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"