mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 08:52:40 +00:00
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:
co-authored by
Claude Fable 5.1
parent
418ac6a2af
commit
f1bb95b2bf
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, .. }
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user