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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
This commit is contained in:
mtlcl410
2026-10-25 13:25:54 +08:00
co-authored by Claude Fable 5.1
parent 418ac6a2af
commit f1bb95b2bf
37 changed files with 587 additions and 10 deletions
+1
View File
@@ -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.
+3 -1
View File
@@ -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
+93
View File
@@ -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<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;
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<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],
};
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<PopupInfo>,
}
/// 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<PopupInfo> {
let r = annot.reference(b"Popup")?;
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();
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<String> {
@@ -2415,6 +2507,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
fill_sign: is_fill_sign(doc, d),
no_rotate: drawn_upright(doc, d),
points,
popup: popup_info(doc, d),
});
}
}
+45
View File
@@ -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 { .. })));
}
+13
View File
@@ -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<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);
+1
View File
@@ -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)?,
+5
View File
@@ -887,6 +887,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. 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",
+26
View File
@@ -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);
}
+18
View File
@@ -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<bool>,
},
/// 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<pdfcraft_render::Annotation
fill_sign: s.fill_sign,
no_rotate: s.no_rotate,
points: s.points,
popup: s.popup.map(|p| pdfcraft_render::Popup { rect: p.rect, open: p.open }),
})
.collect();
pdfcraft_render::link_replies(&mut list);
+31
View File
@@ -459,6 +459,17 @@ pub struct Annotation {
/// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a
/// cloud (their corners, `/Vertices`), in user space; empty for other comments.
pub points: Vec<[f32; 2]>,
/// The comment's pop-up window (`/Popup`): where it sits and whether it is open.
pub popup: Option<Popup>,
}
/// 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");
+1 -1
View File
@@ -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::{
+11
View File
@@ -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,
}
}
+203 -1
View File
@@ -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<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.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<Edit> {
let t = Tokens::get(ctx);
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;
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);
// 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) {
+1
View File
@@ -1058,6 +1058,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, .. }
+3
View File
@@ -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 إضافة حقل
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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 Премести страниците
Can't render this file because it contains an unexpected character in line 8 and column 16.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 51.
+3
View File
@@ -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 Προσθήκη πεδίου
Can't render this file because it contains an unexpected character in line 482 and column 9.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 60.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 39.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 44.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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 フィールドを追加
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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 Өріс қосу
Can't render this file because it contains an unexpected character in line 860 and column 9.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 406 and column 9.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -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 Добавить поле
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -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 ఫీల్డ్ జోడించు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 859 and column 9.
+3
View File
@@ -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 Додати поле
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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 移动页面
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -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 新增欄位
Can't render this file because it contains an unexpected character in line 9 and column 16.
+65
View File
@@ -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"));
}
+7 -7
View File
@@ -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"