diff --git a/ROADMAP.md b/ROADMAP.md index 165fccc5e8..7c52ddac17 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,6 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. A locked reply can't be deleted. Estimates unchanged. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4b4ac8873b..e2fdf9f783 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -886,7 +886,7 @@ pub fn tools() -> Vec { )), t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") .ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])), - t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), + t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), t( "doc_protect", "Protect with passwords", diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 23351db9aa..ce68cd25b4 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2134,6 +2134,35 @@ fn nested_replies_stay_in_the_listing() { assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}"); } +/// comment_delete on a reply deletes that reply and the replies to it, as the Comments panel's +/// reply menu does: the comment and its other replies stay, and one undo brings it back. +#[test] +fn deleting_a_reply_keeps_its_comment_and_the_other_replies() { + let dir = workdir("delete-reply"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let root = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [40, 40], "contents": "ROOT_NOTE", "author": "A" })); + let root_id = root["comment"]["id"].as_str().unwrap().to_string(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "WRONG_REPLY", "author": "B" })); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "RIGHT_REPLY", "author": "C" })); + let replies = |a: &mut Automation| -> Vec { + let list = ok(a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "{list}"); + list["comments"][0]["replies"].as_array().unwrap().clone() + }; + let wrong = replies(&mut a).into_iter().find(|r| r["contents"] == "WRONG_REPLY").unwrap(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": wrong["id"], "text": "ANSWER_TO_WRONG", "author": "D" })); + let texts = |a: &mut Automation| -> Vec { replies(a).iter().filter_map(|r| r["contents"].as_str().map(str::to_owned)).collect() }; + assert_eq!(texts(&mut a), ["WRONG_REPLY", "RIGHT_REPLY", "ANSWER_TO_WRONG"]); + + ok(&mut a, "comment_delete", json!({ "doc": doc, "page": wrong["page"], "index": wrong["index"] })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["comments"][0]["contents"], "ROOT_NOTE", "{list}"); + assert_eq!(texts(&mut a), ["RIGHT_REPLY"], "the reply took the answer to it along"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Delete comment"); + assert_eq!(texts(&mut a), ["WRONG_REPLY", "RIGHT_REPLY", "ANSWER_TO_WRONG"]); +} + /// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every /// page `fitz-A0`. A shared id is refused rather than taken to mean page 1's comment. #[test] diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index bf9b5fe72c..511bee805a 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -12,6 +12,8 @@ //! Everything is turned into `pdfcraft_engine::Edit`s, queued on the view as `pending_edit` and //! applied by the app, so each change is one undo step. +use std::collections::HashMap; + use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, pos2, vec2}; use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style}; use pdfcraft_render::{Annotation, DocInfo, PageInfo, PageText}; @@ -578,6 +580,61 @@ impl CommentView { self.editor_focus = None; } } + + /// The annotation at `gone` was just deleted; `before` and `after` are the document's + /// annotations until then and now. The selection and the inline editor stay on what they + /// were on, where it now is (deleting a reply keeps its comment selected), and close when it + /// went too. + pub(crate) fn annotation_deleted(&mut self, before: &[Annotation], after: &[Annotation], gone: (usize, usize)) { + self.selected = self.selected.and_then(|k| after_delete(before, after, gone, k)); + self.editing = self.editing.take().and_then(|(p, i, text)| after_delete(before, after, gone, (p, i)).map(|(p, i)| (p, i, text))); + if self.editing.is_none() { + self.editor_focus = None; + } + } +} + +/// Where the annotation at `keep` is once the one at `gone` was deleted, or `None` when it went +/// with it. Deleting takes the annotation's replies (at any depth) and pop-ups out of its page's +/// `/Annots` (`pdfcraft_annot::delete_annotation`), and everything after them moves up, so `keep` +/// is found by its place among the page's annotations that stay. When the page lost other +/// annotations than those, or the one in that place reads differently, it is not guessed at. +fn after_delete(before: &[Annotation], after: &[Annotation], gone: (usize, usize), keep: (usize, usize)) -> Option<(usize, usize)> { + if keep == gone { + return None; + } + if keep.0 != gone.0 { + return Some(keep); + } + let (old, new) = (on_page(before, gone.0), on_page(after, gone.0)); + // Only replies on the page go with it: `/IRT` is followed through the page's `/Annots`. + let parents: HashMap = old.iter().filter_map(|a| a.parent.filter(|p| p.0 == gone.0).map(|p| (a.index, p.1))).collect(); + let goes = |mut i: usize| { + // `link_replies` settles every chain within 64 steps. + for _ in 0..=64 { + if i == gone.1 { + return true; + } + let Some(&up) = parents.get(&i) else { return false }; + i = up; + } + false + }; + let stays: Vec<&Annotation> = old.iter().copied().filter(|a| !goes(a.index)).collect(); + if stays.len() != new.len() { + return None; + } + let rank = stays.iter().position(|a| a.index == keep.1)?; + let (was, now) = (stays.get(rank)?, new.get(rank)?); + let same = (&was.subtype, &was.name, &was.author, &was.contents, was.rect) == (&now.subtype, &now.name, &now.author, &now.contents, now.rect); + same.then_some((now.page, now.index)) +} + +/// The annotations listed on `page`, in `/Annots` order. +fn on_page(list: &[Annotation], page: usize) -> Vec<&Annotation> { + let mut v: Vec<&Annotation> = list.iter().filter(|a| a.page == page).collect(); + v.sort_by_key(|a| a.index); + v } /// Comments panel order (Acrobat: Sort by page, author, date, type, colour). @@ -2353,6 +2410,66 @@ mod tests { } } + /// A listed comment or reply at `index` on `page`, replying to `parent`. + fn listed(page: usize, index: usize, contents: &str, parent: Option<(usize, usize)>) -> Annotation { + Annotation { + page, + subtype: "Text".into(), + author: None, + contents: Some(contents.into()), + modified: None, + name: None, + in_reply_to: None, + parent, + grouped: false, + rect: [0.0, 0.0, 20.0, 20.0], + color: None, + index, + state: None, + quads: Vec::new(), + locked: false, + intent: None, + fill_sign: false, + no_rotate: false, + points: Vec::new(), + } + } + + /// What stays selected or open in the editor after a delete is found where it moved to: the + /// reply's own replies go with it (pop-ups, unlisted, are gone too), and a page that lost + /// something else, or whose annotation in that place reads differently, keeps nothing. + #[test] + fn a_kept_comment_is_found_where_a_delete_moved_it() { + // Page 0's `/Annots`: reply A, its pop-up (unlisted), the comment, reply B, a reply to A. + let before = vec![ + listed(0, 0, "A", Some((0, 2))), + listed(0, 2, "comment", None), + listed(0, 3, "B", Some((0, 2))), + listed(0, 4, "to A", Some((0, 0))), + listed(1, 0, "other page", None), + ]; + // Deleting A takes its pop-up and the reply to it: the comment and B move up two places. + let after = vec![listed(0, 0, "comment", None), listed(0, 1, "B", Some((0, 0))), listed(1, 0, "other page", None)]; + let gone = (0, 0); + assert_eq!(after_delete(&before, &after, gone, (0, 2)), Some((0, 0)), "the comment"); + assert_eq!(after_delete(&before, &after, gone, (0, 3)), Some((0, 1)), "the other reply"); + assert_eq!(after_delete(&before, &after, gone, (0, 4)), None, "the reply to A went with it"); + assert_eq!(after_delete(&before, &after, gone, gone), None, "the deleted one"); + assert_eq!(after_delete(&before, &after, gone, (1, 0)), Some((1, 0)), "another page keeps its places"); + // The page lost more than A and its replies: nothing is guessed. + assert_eq!(after_delete(&before, &after[..1], gone, (0, 2)), None); + // Another annotation in the comment's place. + let changed = vec![listed(0, 0, "something else", None), listed(0, 1, "B", Some((0, 0)))]; + assert_eq!(after_delete(&before, &changed, gone, (0, 2)), None); + + let mut view = CommentView { selected: Some((0, 2)), editing: Some((0, 3, "typed".into())), ..Default::default() }; + view.annotation_deleted(&before, &after, gone); + assert_eq!((view.selected, view.editing), (Some((0, 0)), Some((0, 1, "typed".into()))), "both follow"); + let mut view = CommentView { editing: Some((0, 4, "typed".into())), editor_focus: Some(EditorFocus::Take), ..Default::default() }; + view.annotation_deleted(&before, &after, gone); + assert_eq!((view.editing, view.editor_focus), (None, None), "the editor closes with the reply it was open on"); + } + use pdfcraft_render::TextGlyph; /// One glyph per character, `w` points wide, on a line of `em`-point text whose baseline is at `y`, drawn diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 54ca9e5152..0d917b5cae 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -3,7 +3,8 @@ //! Header "Comments N" (comments, as Acrobat counts them) with "N replies" under it and search; //! an "Add a comment" box that posts a sticky note on the current page; cards grouped by page. //! Selecting a card selects the comment on the page and opens its reply box and "…" menu (Edit, -//! Set status, Delete). Status replies show as a badge. +//! Set status, Delete), and gives each reply a "…" of its own (Edit, Copy text, Delete). Status +//! replies show as a badge. use std::collections::HashMap; @@ -358,27 +359,10 @@ fn card( }); egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 4, bottom: 0 }).show(ui, |ui| { ui.set_width(ui.available_width()); - if editing && let Some((_, _, text)) = view.comments.editing.as_mut() { - let r = ui.add(crate::widgets::block(text).desired_rows(2).desired_width(f32::INFINITY).id_salt(("comment-edit", key))); - match view.comments.editor_focus.take() { - Some(crate::comments::EditorFocus::Take) => r.request_focus(), - Some(crate::comments::EditorFocus::Release) => r.surrender_focus(), - None => {} + if editing { + if let Some(e) = inline_editor(ui, t, view, key, 2) { + edit = Some(e); } - let submit = crate::comments::submit_key(ui, &r, false); - ui.horizontal(|ui| { - if (ui - .add(egui::Button::new(egui::RichText::new(tl!("Save")).color(Color32::WHITE)).fill(t.accent).corner_radius(12)) - .clicked() - || submit) - && let Some((page, index, text)) = view.comments.editing.take() - { - edit = Some(Edit::SetAnnotationContents { page, index, text }); - } - if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - view.comments.editing = None; - } - }); } else { let body = a.contents.as_deref().unwrap_or(""); if body.is_empty() { @@ -405,6 +389,7 @@ fn card( for r in &replies { ui.add_space(8.0); let mut me = egui::Rect::NOTHING; + let reply_key = (r.page, r.index); ui.horizontal(|ui| { me = avatar(ui, t, None, Some(&initials(r.author.as_deref().unwrap_or("?"))), t.text_faint); ui.vertical(|ui| { @@ -415,8 +400,25 @@ fn card( if let Some(m) = &r.modified { ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate()); } + // Each reply has its own "…" (Edit, Copy text, Delete), as in Acrobat. + // A Replace Text caret is half of its comment, not a reply: it has none. + if selected && allowed && !r.grouped { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let more = icons::button(ui, "ellipsis", 20.0, false, tl!("Options for this reply")); + if let Some(e) = reply_menu(ui, view, r, &more) { + edit = Some(e); + } + }); + } }); - widgets::wrapped_text(ui, r.contents.as_deref().unwrap_or(""), theme::regular(12.5), t.text, false); + let open = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == reply_key); + if open { + if let Some(e) = inline_editor(ui, t, view, reply_key, 1) { + edit = Some(e); + } + } else { + widgets::wrapped_text(ui, r.contents.as_deref().unwrap_or(""), theme::regular(12.5), t.text, false); + } }); }); // The circles are 12 px in radius; leave a 1 px gap at each end. @@ -531,6 +533,65 @@ fn card( edit } +/// The inline editor open on comment or reply `key` in the panel, with Save and Cancel; returns +/// the edit Save makes. +fn inline_editor(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, key: (usize, usize), rows: usize) -> Option { + let mut edit = None; + let (_, _, text) = view.comments.editing.as_mut()?; + let r = ui.add(crate::widgets::block(text).desired_rows(rows).desired_width(f32::INFINITY).id_salt(("comment-edit", key))); + match view.comments.editor_focus.take() { + Some(crate::comments::EditorFocus::Take) => r.request_focus(), + Some(crate::comments::EditorFocus::Release) => r.surrender_focus(), + None => {} + } + let submit = crate::comments::submit_key(ui, &r, false); + ui.horizontal(|ui| { + if (ui.add(egui::Button::new(egui::RichText::new(tl!("Save")).color(Color32::WHITE)).fill(t.accent).corner_radius(12)).clicked() || submit) + && let Some((page, index, text)) = view.comments.editing.take() + { + edit = Some(Edit::SetAnnotationContents { page, index, text }); + } + if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + view.comments.editing = None; + } + }); + edit +} + +/// A reply's "…" menu, hung from its button `more`: Edit, Copy text and Delete. Delete takes +/// the reply alone, with what hangs off it (its own replies, status changes and pop-up), as +/// deleting a comment takes its thread; the comment and its other replies stay. Returns the +/// edit to apply. +fn reply_menu(ui: &egui::Ui, view: &mut DocView, r: &Annotation, more: &egui::Response) -> Option { + let mut edit = None; + let menu_id = ui.scope_id().with(("reply-menu", r.page, r.index)); + if more.clicked() { + egui::Popup::open_id(ui.ctx(), menu_id); + } + egui::Popup::new(menu_id, ui.ctx().clone(), egui::PopupAnchor::ParentRect(more.rect), ui.layer_id()) + .open_memory(None) + .kind(egui::PopupKind::Menu) + .layout(Layout::top_down_justified(Align::Min)) + .show(|ui| { + ui.set_min_width(140.0); + if ui.button(tl_ctx!("comment menu", "Edit")).clicked() { + view.comments.open_editor(r.page, r.index, r.contents.clone().unwrap_or_default()); + ui.close(); + } + if ui.button(tl!("Copy text")).clicked() { + ui.ctx().copy_text(r.contents.clone().unwrap_or_default()); + ui.close(); + } + ui.separator(); + // The card stays selected: the app finds its comment again after the delete. + if ui.add_enabled(!r.locked, egui::Button::new(tl!("Delete"))).clicked() { + edit = Some(Edit::DeleteAnnotation { page: r.page, index: r.index }); + ui.close(); + } + }); + edit +} + /// A 26 pt round badge: a type glyph (comments) or initials (replies). Returns where it was drawn. fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, color: Color32) -> egui::Rect { let (r, _) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::hover()); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 9a13d570d9..1ae66561b8 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -53,6 +53,12 @@ impl PdfCraftApp { } else { (None, 0, false) }; + // A deletion moves the comments after it on the page: these say where the selected one, + // and the one open in the panel's editor, went. + let annots_before = match (&edit, self.session.get(id)) { + (Edit::DeleteAnnotation { page, .. }, Some(d)) => d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect(), + _ => Vec::new(), + }; match self.session.apply(id, edit.clone()) { Ok(()) => { let Some(doc) = self.session.get(id) else { return true }; @@ -172,7 +178,7 @@ impl PdfCraftApp { view.comments.open_editor(a.page, n, String::new()); } } - Edit::DeleteAnnotation { .. } => view.comments.selected = None, + Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(&annots_before, &info.annotations, (page, index)), _ => {} } if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 3e6d735523..1ae1d5ca1d 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2833,3 +2833,6 @@ certificate Version الإصدار Rotate the selected pages clockwise تدوير الصفحات المحددة باتجاه عقارب الساعة Extract the selected pages… استخراج الصفحات المحددة… Delete the selected pages حذف الصفحات المحددة + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply خيارات هذا الرد diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ec0339f18a..30064446bc 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2520,3 +2520,6 @@ animation effects Off Изключени Rotate the selected pages clockwise Завъртане на избраните страници по часовниковата стрелка Extract the selected pages… Извличане на избраните страници… Delete the selected pages Изтриване на избраните страници + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Опции за този отговор diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0cacae3c2c..6917f86d45 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2818,3 +2818,6 @@ theme Appearance Aspecte Rotate the selected pages clockwise Gira les pàgines seleccionades en sentit horari Extract the selected pages… Extreu les pàgines seleccionades… Delete the selected pages Suprimeix les pàgines seleccionades + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opcions d'aquesta resposta diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 71c0c62869..457da979a4 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2749,3 +2749,6 @@ certificate Version وەشان Rotate the selected pages clockwise پەڕە هەڵبژێردراوەکان بە ئاڕاستەی میلی کاتژمێر بسوڕێنە Extract the selected pages… پەڕە هەڵبژێردراوەکان دەربهێنە… Delete the selected pages پەڕە هەڵبژێردراوەکان بسڕەوە + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply هەڵبژاردەکانی ئەم وەڵامە diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ed6d051b57..c8e2c7de10 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2864,3 +2864,6 @@ certificate Version Verze Rotate the selected pages clockwise Otočit vybrané stránky po směru hodinových ručiček Extract the selected pages… Extrahovat vybrané stránky… Delete the selected pages Odstranit vybrané stránky + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Možnosti této odpovědi diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8586a21d43..fa34681ad6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2558,3 +2558,6 @@ animation effects Off Aus Rotate the selected pages clockwise Ausgewählte Seiten im Uhrzeigersinn drehen Extract the selected pages… Ausgewählte Seiten extrahieren… Delete the selected pages Ausgewählte Seiten löschen + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Optionen für diese Antwort diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e3eac77caa..2d16480209 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2563,3 +2563,6 @@ animation effects Off Ανενεργά Rotate the selected pages clockwise Περιστροφή των επιλεγμένων σελίδων δεξιόστροφα Extract the selected pages… Εξαγωγή των επιλεγμένων σελίδων… Delete the selected pages Διαγραφή των επιλεγμένων σελίδων + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Επιλογές για αυτήν την απάντηση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0d4e505a70..51eb3f9483 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2880,3 +2880,6 @@ theme Appearance Aspecto Rotate the selected pages clockwise Girar las páginas seleccionadas en sentido horario Extract the selected pages… Extraer las páginas seleccionadas… Delete the selected pages Eliminar las páginas seleccionadas + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opciones de esta respuesta diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv index 5cfc217533..5c7279dfdc 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -2804,3 +2804,6 @@ theme Appearance ظاهر two cells would both be named {name} دو خانه هر دو با نام {name} نام‌گذاری می‌شوند a field named {name} already exists فیلدی با نام {name} از قبل وجود دارد Today امروز + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply گزینه‌های این پاسخ diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a542ee9b91..7742ef6aeb 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2827,3 +2827,6 @@ theme Appearance Teema Rotate the selected pages clockwise Kierrä valittuja sivuja myötäpäivään Extract the selected pages… Pura valitut sivut… Delete the selected pages Poista valitut sivut + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Tämän vastauksen asetukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 61c8b5171d..a8df3184fa 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2823,3 +2823,6 @@ certificate Version Version Rotate the selected pages clockwise Faire pivoter les pages sélectionnées dans le sens horaire Extract the selected pages… Extraire les pages sélectionnées… Delete the selected pages Supprimer les pages sélectionnées + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Options de cette réponse diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e9d627aca9..1e239dcd7a 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2794,3 +2794,6 @@ theme Appearance स्वरूप Rotate the selected pages clockwise चयनित पृष्ठों को दक्षिणावर्त घुमाएँ Extract the selected pages… चयनित पृष्ठ निकालें… Delete the selected pages चयनित पृष्ठ हटाएँ + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply इस जवाब के विकल्प diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6b37b9169b..14e2399a20 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2556,3 +2556,6 @@ animation effects Off Ki Rotate the selected pages clockwise A kijelölt oldalak forgatása az óramutató járásával megegyezően Extract the selected pages… A kijelölt oldalak kibontása… Delete the selected pages A kijelölt oldalak törlése + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply A válasz beállításai diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 292b336e10..e2eecad04e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2795,3 +2795,6 @@ theme Appearance Tampilan Rotate the selected pages clockwise Putar halaman yang dipilih searah jarum jam Extract the selected pages… Ekstrak halaman yang dipilih… Delete the selected pages Hapus halaman yang dipilih + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opsi untuk balasan ini diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 20729bae6a..e24c7239f5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2789,3 +2789,6 @@ certificate SHA-256 digest Impronta SHA-256 Rotate the selected pages clockwise Ruota le pagine selezionate in senso orario Extract the selected pages… Estrai le pagine selezionate… Delete the selected pages Elimina le pagine selezionate + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opzioni per questa risposta diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ed4d43b6c..62334da62c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2557,3 +2557,6 @@ animation effects Off オフ Rotate the selected pages clockwise 選択したページを時計回りに回転 Extract the selected pages… 選択したページを抽出… Delete the selected pages 選択したページを削除 + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply この返信のオプション diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 9022c3591c..f79ad88476 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2555,3 +2555,6 @@ animation effects Off Өшірулі Rotate the selected pages clockwise Таңдалған беттерді сағат тілімен бұру Extract the selected pages… Таңдалған беттерді шығарып алу… Delete the selected pages Таңдалған беттерді жою + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Осы жауаптың параметрлері diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 09b9cad9fa..3d543237b3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2794,3 +2794,6 @@ theme Appearance 모양 Rotate the selected pages clockwise 선택한 페이지를 시계 방향으로 회전 Extract the selected pages… 선택한 페이지 추출… Delete the selected pages 선택한 페이지 삭제 + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply 이 답글의 옵션 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 964467d823..ce4f67ebe2 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2465,3 +2465,6 @@ animation effects Off ປິດ Rotate the selected pages clockwise ໝູນໜ້າທີ່ເລືອກຕາມເຂັມໂມງ Extract the selected pages… ແຍກໜ້າທີ່ເລືອກອອກ… Delete the selected pages ລຶບໜ້າທີ່ເລືອກ + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply ຕົວເລືອກຂອງການຕອບກັບນີ້ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 43749f4ec8..da076f4559 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2818,3 +2818,6 @@ theme Appearance Išvaizda Rotate the selected pages clockwise Pasukti pasirinktus puslapius pagal laikrodžio rodyklę Extract the selected pages… Išskleisti pasirinktus puslapius… Delete the selected pages Ištrinti pasirinktus puslapius + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Šio atsakymo parinktys diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 6f1739974a..b7522c2084 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2733,3 +2733,6 @@ key Space Mellomrom Rotate the selected pages clockwise Roter de valgte sidene med klokken Extract the selected pages… Trekk ut de valgte sidene… Delete the selected pages Slett de valgte sidene + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Alternativer for dette svaret diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 963d717584..75756768d5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2653,3 +2653,6 @@ animation effects Off Wyłączone Rotate the selected pages clockwise Obróć zaznaczone strony w prawo Extract the selected pages… Wyodrębnij zaznaczone strony… Delete the selected pages Usuń zaznaczone strony + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opcje tej odpowiedzi diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c34ae83bc5..24dcb6247f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2842,3 +2842,6 @@ theme Appearance Aparência Rotate the selected pages clockwise Girar as páginas selecionadas no sentido horário Extract the selected pages… Extrair as páginas selecionadas… Delete the selected pages Excluir as páginas selecionadas + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opções desta resposta diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 78d2298b93..02e383e77c 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2818,3 +2818,6 @@ theme Appearance Aspect Rotate the selected pages clockwise Rotește paginile selectate în sens orar Extract the selected pages… Extrage paginile selectate… Delete the selected pages Șterge paginile selectate + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opțiuni pentru acest răspuns diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 274232d586..0429bd944b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2470,3 +2470,6 @@ animation effects Off Выкл Rotate the selected pages clockwise Повернуть выбранные страницы по часовой стрелке Extract the selected pages… Извлечь выбранные страницы… Delete the selected pages Удалить выбранные страницы + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Параметры этого ответа diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 420e6a5996..50d8fd345a 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2562,3 +2562,6 @@ animation effects Off Izklopljeni Rotate the selected pages clockwise Zavrti izbrane strani v smeri urnega kazalca Extract the selected pages… Izvleci izbrane strani… Delete the selected pages Izbriši izbrane strani + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Možnosti tega odgovora diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index ba7c9dc5b3..d0306f2430 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2544,3 +2544,6 @@ animation effects Off Joaktiv Rotate the selected pages clockwise Rrotullo faqet e përzgjedhura në drejtim të orës Extract the selected pages… Nxirr faqet e përzgjedhura… Delete the selected pages Fshi faqet e përzgjedhura + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opsionet për këtë përgjigje diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 7f339989ee..84ae40746b 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2466,3 +2466,6 @@ animation effects Off ఆఫ్ Rotate the selected pages clockwise ఎంచుకున్న పేజీలను సవ్య దిశలో తిప్పు Extract the selected pages… ఎంచుకున్న పేజీలను సంగ్రహించు… Delete the selected pages ఎంచుకున్న పేజీలను తొలగించు + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply ఈ ప్రత్యుత్తరం ఎంపికలు diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 2c9b892ed7..a13e6e3332 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2720,3 +2720,6 @@ theme Appearance ลักษณะที่ปรากฏ Rotate the selected pages clockwise หมุนหน้าที่เลือกตามเข็มนาฬิกา Extract the selected pages… แยกหน้าที่เลือกออก… Delete the selected pages ลบหน้าที่เลือก + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply ตัวเลือกของการตอบกลับนี้ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a2ecaea179..7695241b30 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2553,3 +2553,6 @@ animation effects Off Kapalı Rotate the selected pages clockwise Seçili sayfaları saat yönünde döndür Extract the selected pages… Seçili sayfaları ayıkla… Delete the selected pages Seçili sayfaları sil + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Bu yanıtın seçenekleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9c76868839..6f31d9d32a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2579,3 +2579,6 @@ animation effects Off Вимкнено Rotate the selected pages clockwise Повернути вибрані сторінки за годинниковою стрілкою Extract the selected pages… Витягнути вибрані сторінки… Delete the selected pages Видалити вибрані сторінки + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Параметри цієї відповіді diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 973ba870ea..e43fdd3404 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2824,3 +2824,6 @@ key Space Space Rotate the selected pages clockwise Xoay các trang đã chọn theo chiều kim đồng hồ Extract the selected pages… Trích xuất các trang đã chọn… Delete the selected pages Xóa các trang đã chọn + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Tùy chọn cho câu trả lời này diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d77c8f6425..7b63830365 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2521,3 +2521,6 @@ animation effects Off 关 Rotate the selected pages clockwise 顺时针旋转所选页面 Extract the selected pages… 提取所选页面… Delete the selected pages 删除所选页面 + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply 此回复的选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ce3b939f1d..8f0ea1bed3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2507,3 +2507,6 @@ animation effects Off 關 Rotate the selected pages clockwise 順時針旋轉所選頁面 Extract the selected pages… 擷取所選頁面… Delete the selected pages 刪除所選頁面 + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply 此回覆的選項 diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 3a3169576c..f8c26b59b8 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -4,7 +4,7 @@ use egui::accesskit::Role; use egui::{Pos2, pos2}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_render::Annotation; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; @@ -1327,6 +1327,98 @@ fn replies_follow_irt_references_when_names_repeat_on_every_page() { h.get_by_label("2 replies"); } +// Contributor-original PDF: a comment whose wrong reply (with a pop-up) comes before it in +// `/Annots` and has an answer of its own, a locked right reply and an Accepted status. `/F 128` +// is the Locked flag. +const REPLY_THREAD: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 9 0 R 4 0 R 6 0 R 7 0 R 8 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (note) /T (Ben) /Contents (The comment) >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (wrong) /IRT 4 0 R /T (Cat) /Contents (Wrong reply) /Popup 9 0 R >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (right) /IRT 4 0 R /T (Dan) /Contents (Right reply) /F 128 >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (answer) /IRT 5 0 R /T (Eve) /Contents (Answer to the wrong reply) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /IRT 4 0 R /StateModel /Review /State /Accepted /T (Ben) /Contents (Accepted set by Ben) >> endobj +9 0 obj << /Type /Annot /Subtype /Popup /Rect [60 100 160 160] /Parent 5 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// The "…" of the reply row that shows `text`: the last one above it (it sits on the row's +/// author line). +fn reply_options<'a>(h: &'a Harness<'static, PdfCraftApp>, text: &str) -> egui_kittest::Node<'a> { + let y = h.get_by_label(text).rect().top(); + h.query_all_by_label("Options for this reply") + .filter(|b| b.rect().top() <= y) + .max_by(|a, b| a.rect().top().total_cmp(&b.rect().top())) + .expect("a reply menu") +} + +/// One reply is deleted from its own "…" menu, with the answer to it and its pop-up: the comment, +/// its other reply and its status stay, and the card stays selected though the comment moved up +/// in `/Annots`. One undo brings the reply back. A locked reply can't be deleted, and Edit +/// changes the reply's text, not the comment's. +#[test] +fn one_reply_is_deleted_from_its_menu_and_the_thread_stays() { + let mut h = harness_with_bytes(REPLY_THREAD.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.selected = Some((0, 2)); + }); + let texts = |h: &Harness<'static, PdfCraftApp>| -> Vec { comments(h).iter().filter_map(|a| a.contents.clone()).collect() }; + let all = ["Wrong reply", "The comment", "Right reply", "Answer to the wrong reply", "Accepted set by Ben"]; + assert_eq!(texts(&h), all); + assert_eq!(h.query_all_by_label("Options for this reply").count(), 3, "each reply has one; the status is a badge"); + + reply_options(&h, "Wrong reply").click(); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(3); + assert_eq!(texts(&h), ["The comment", "Right reply", "Accepted set by Ben"], "the reply took its answer along"); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().info.annotations[0].index, 0, "its pop-up went too"); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the card stays selected where the comment now is"); + assert_eq!(h.query_all_by_label("Wrong reply").count(), 0); + assert_eq!(h.query_all_by_label("Answer to the wrong reply").count(), 0); + h.get_by_label("Right reply"); + h.get_by_label("Accepted"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Delete comment"), "one undo step"); + + // Copy text copies the reply's text. + reply_options(&h, "Right reply").click(); + h.run_steps(2); + h.get_by_label("Copy text").click(); + let mut copied = false; + for _ in 0..4 { + h.step(); + copied |= h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == "Right reply")); + } + assert!(copied, "the reply's text is copied"); + h.run_steps(2); + + // The locked reply's Delete is greyed out; its text can still be edited (ISO 32000-2 + // Table 167: Locked doesn't restrict the contents). + reply_options(&h, "Right reply").click(); + h.run_steps(2); + assert!(h.get_by_label("Delete").accesskit_node().is_disabled(), "a locked reply isn't deleted"); + h.query_all_by_label("Edit").last().expect("the reply menu's Edit, after the toolbar tab").click(); + h.run_steps(2); + let editor = h.query_all_by(|n| n.role() == Role::MultilineTextInput && n.placeholder() != Some("Add a reply")).next().expect("reply editor"); + editor.click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("Right reply, reworded".into())); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(3); + assert_eq!(texts(&h), ["The comment", "Right reply, reworded", "Accepted set by Ben"]); + + h.state_mut().undo(); + h.run_steps(2); + h.state_mut().undo(); + h.run_steps(3); + assert_eq!(texts(&h), all, "undo brings the reply and its answer back"); + h.get_by_label("Wrong reply"); +} + #[test] fn the_header_names_no_replies_when_there_are_none() { let h = harness_with_bytes(PER_PAGE_NAMES.to_vec(), |app| { diff --git a/docs/roadmap.md b/docs/roadmap.md index 40fa711c24..f1c3bfa106 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M5: one reply in the Comments panel is edited, copied or deleted from its own "…" menu; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -177,7 +178,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | -| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: replies thread by their `/IRT` reference rather than the parent's `/NM`, so files that repeat names per page (PyMuPDF) thread correctly, and the Comments header counts comments without their replies, as Acrobat does, with the reply count under it; the Comments panel groups replies and counts all page roots once per frame (#2669, item 5); stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: replies thread by their `/IRT` reference rather than the parent's `/NM`, so files that repeat names per page (PyMuPDF) thread correctly, and the Comments header counts comments without their replies, as Acrobat does, with the reply count under it; the Comments panel groups replies and counts all page roots once per frame (#2669, item 5); each reply in the Comments panel has its own "…" (Edit, Copy text, Delete): Delete takes that reply and the replies to it, the comment and its other replies stay, and the card stays selected; stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5e1c4a7e75..b846d36701 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3967,8 +3967,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_reply"] -evidence = ["crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent"] -notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted" +evidence = ["crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays"] +notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted. On the selected card each reply has its own \"…\" menu (Edit, Copy text, Delete); a Replace Text caret, half of its comment, has none. Missing: Set status on a reply from its menu, @mentions" [[feature]] id = "comment.set-status" @@ -4003,8 +4003,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_delete"] -evidence = ["crates/ui-egui/tests/it/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"] -notes = "deletes the pop-up and the reply thread" +evidence = ["crates/ui-egui/tests/it/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays", "crates/ui-egui/src/comments.rs::a_kept_comment_is_found_where_a_delete_moved_it", "crates/automation/tests/it/automation.rs::deleting_a_reply_keeps_its_comment_and_the_other_replies"] +notes = "deletes the pop-up and the reply thread. One reply is deleted from its own \"…\" in the Comments panel (comment_delete with the reply's id, or page and index), with its pop-up and the replies to it: the comment and its other replies stay, one undo step, and the card stays selected. A locked reply can't be deleted." [[feature]] id = "comment.keyboard-nudge" @@ -4039,8 +4039,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_edit"] -evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/it/automation.rs::comments_through_tools", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line"] -notes = "double-click a comment or card, or Edit in the menus; Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines)" +evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/it/automation.rs::comments_through_tools", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays"] +notes = "double-click a comment or card, or Edit in the menus (a reply from its own \"…\" menu); Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines)" [[feature]] id = "comment.copy-comment-text" @@ -4051,8 +4051,8 @@ milestone = "M5" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/comments.rs::comments_take_checkmarks_lock_hide_and_summarize"] -notes = "card and canvas context menus" +evidence = ["crates/ui-egui/tests/it/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays"] +notes = "card, reply and canvas context menus" [[feature]] id = "comment.filter-comments"