M5.4: Comment Properties (appearance: colour, opacity, thickness, note icon; general: author, subject; review history) from the canvas and card menus, one undo step; Comments panel filter (type, author, status) and sort (page, author, date, type, colour) with group headers; annot set_info/props
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
adb644c5e1
commit
245c468bc7
|
|
@ -906,3 +906,79 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
|||
out.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
|
||||
out
|
||||
}
|
||||
|
||||
/// Change a comment's author (`/T`), subject (`/Subj`) and, for notes, icon (`/Name`, redrawn).
|
||||
/// `None` leaves a value as it is.
|
||||
pub fn set_info(
|
||||
doc: &mut Document,
|
||||
page: usize,
|
||||
index: usize,
|
||||
author: Option<&str>,
|
||||
subject: Option<&str>,
|
||||
icon: Option<NoteIcon>,
|
||||
meta: &Meta,
|
||||
) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
let is_note = annot_dict(doc, r).name(b"Subtype") == Some(b"Text");
|
||||
if icon.is_some() && !is_note {
|
||||
return Err(AnnotError::Invalid("only sticky notes have an icon".into()));
|
||||
}
|
||||
doc.update_dict(r, |d| {
|
||||
if let Some(a) = author {
|
||||
d.set(b"T".to_vec(), PdfString::text(a));
|
||||
}
|
||||
if let Some(s) = subject {
|
||||
d.set(b"Subj".to_vec(), PdfString::text(s));
|
||||
}
|
||||
if let Some(i) = icon {
|
||||
d.set(b"Name".to_vec(), Object::name(i.name()));
|
||||
}
|
||||
touch(d, meta);
|
||||
})?;
|
||||
if icon.is_some() {
|
||||
set_appearance(doc, r)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What Comment properties shows for one comment.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Props {
|
||||
pub subtype: String,
|
||||
pub author: String,
|
||||
pub subject: String,
|
||||
pub color: Option<Rgb>,
|
||||
pub opacity: f64,
|
||||
/// Border / line width, for shapes, lines and drawings.
|
||||
pub width: Option<f64>,
|
||||
/// Notes only.
|
||||
pub icon: Option<NoteIcon>,
|
||||
/// `/M`, as written.
|
||||
pub modified: Option<String>,
|
||||
/// The appearance can be redrawn (so colour, opacity and width can change).
|
||||
pub restylable: bool,
|
||||
}
|
||||
|
||||
/// The current properties of the comment at `(page, index)`.
|
||||
pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
let p = page_ref(doc, page).ok()?;
|
||||
let entry = annots(doc, p).get(index).cloned()?;
|
||||
let obj = doc.resolve(&entry);
|
||||
let d = obj.as_dict()?;
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
|
||||
let c: Vec<f64> = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default();
|
||||
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) };
|
||||
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine")
|
||||
.then(|| d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0));
|
||||
Some(Props {
|
||||
author: text_value(doc, d, b"T").unwrap_or_default(),
|
||||
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
|
||||
color,
|
||||
opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0),
|
||||
width,
|
||||
icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)),
|
||||
modified: text_value(doc, d, b"M"),
|
||||
restylable: appearance::build(d).is_some(),
|
||||
subtype,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -332,3 +332,16 @@ fn fill_and_sign_items_are_drawn() {
|
|||
set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, &meta("")).unwrap();
|
||||
assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_subject_and_note_icon_change() {
|
||||
let mut doc = fixture();
|
||||
let n = add_annotation(&mut doc, &new(0, Shape::Note { at: [10.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap();
|
||||
let before = ap_content(&doc, &list(&doc, 0)[n]);
|
||||
set_info(&mut doc, 0, n, Some("Grace"), Some("Question"), Some(NoteIcon::Help), &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[n];
|
||||
assert_eq!((text(d, b"T").as_str(), text(d, b"Subj").as_str()), ("Grace", "Question"));
|
||||
assert_eq!(d.name(b"Name"), Some(&b"Help"[..]));
|
||||
assert_ne!(ap_content(&doc, d), before, "the icon was redrawn");
|
||||
assert!(matches!(set_info(&mut doc, 0, 0, None, None, Some(NoteIcon::Key), &meta("")), Err(AnnotError::Invalid(_))));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ pub use printcraft_forms::{Field as FormField, FieldKind as FormFieldKind, Field
|
|||
|
||||
/// Comment geometry helpers (text-box line breaking) for frontends.
|
||||
pub use printcraft_annot::appearance as annot_text;
|
||||
pub use printcraft_annot::{FillMark, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style};
|
||||
pub use printcraft_annot::{FillMark, Markup, NewAnnotation, NoteIcon, Props as CommentProps, ReviewState, Rgb, Shape, Style};
|
||||
|
||||
pub type SplitPart = (usize, usize, Arc<Vec<u8>>);
|
||||
|
||||
|
|
@ -71,7 +71,8 @@ fn scope_of(edit: &Edit) -> Scope {
|
|||
| Edit::SetAnnotationStatus { .. }
|
||||
| Edit::MoveAnnotation { .. }
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. } => Scope::Comments,
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
|
||||
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } => Scope::Form,
|
||||
Edit::Batch { edits, .. } => {
|
||||
let mut scopes = edits.iter().map(scope_of);
|
||||
|
|
@ -183,6 +184,11 @@ impl Document {
|
|||
Some(SecuritySummary { method: method.into(), owner: h.auth() == printcraft_cos::Auth::Owner && !pending, permissions, pending })
|
||||
}
|
||||
|
||||
/// Comment properties of the comment at `(page, index)` (Comment Properties dialog).
|
||||
pub fn comment_props(&self, page: usize, index: usize) -> Option<printcraft_annot::Props> {
|
||||
self.editor.as_ref().and_then(|e| printcraft_annot::props(&e.cos, page, index))
|
||||
}
|
||||
|
||||
/// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes.
|
||||
pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> {
|
||||
self.editor.as_ref().and_then(|e| printcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default()
|
||||
|
|
@ -467,6 +473,14 @@ pub enum Edit {
|
|||
opacity: Option<f64>,
|
||||
width: Option<f64>,
|
||||
},
|
||||
/// Comment properties ▸ General / note icon.
|
||||
SetAnnotationInfo {
|
||||
page: usize,
|
||||
index: usize,
|
||||
author: Option<String>,
|
||||
subject: Option<String>,
|
||||
icon: Option<NoteIcon>,
|
||||
},
|
||||
/// Fill in a form field.
|
||||
SetFieldValue {
|
||||
name: String,
|
||||
|
|
@ -533,7 +547,7 @@ impl Edit {
|
|||
Edit::SetAnnotationStatus { state, .. } => format!("Set status {}", state.name()),
|
||||
Edit::MoveAnnotation { .. } => "Move comment".into(),
|
||||
Edit::ResizeAnnotation { .. } => "Resize comment".into(),
|
||||
Edit::StyleAnnotation { .. } => "Change comment properties".into(),
|
||||
Edit::StyleAnnotation { .. } | Edit::SetAnnotationInfo { .. } => "Change comment properties".into(),
|
||||
Edit::SetFieldValue { name, .. } => format!("Fill in {name}"),
|
||||
Edit::ResetForm { .. } => "Clear form".into(),
|
||||
Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(),
|
||||
|
|
@ -612,7 +626,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(),
|
|||
| Edit::SetAnnotationStatus { .. }
|
||||
| Edit::MoveAnnotation { .. }
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. } => {
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. } => {
|
||||
if p.annotate() {
|
||||
Ok(())
|
||||
} else {
|
||||
|
|
@ -750,6 +765,9 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -
|
|||
Edit::StyleAnnotation { page, index, color, opacity, width } => {
|
||||
printcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, &cx.meta())?;
|
||||
}
|
||||
Edit::SetAnnotationInfo { page, index, author, subject, icon } => {
|
||||
printcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
|
||||
}
|
||||
Edit::SetFieldValue { name, value } => printcraft_forms::set_value(doc, name, value)?,
|
||||
Edit::ResetForm { names } => {
|
||||
printcraft_forms::reset(doc, names.as_deref())?;
|
||||
|
|
|
|||
|
|
@ -796,6 +796,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
|
|||
let mut hover_text: Option<(Pos2, String)> = None;
|
||||
let mut clicked_link: Option<LinkTarget> = None;
|
||||
let mut canvas_action: Option<comments::CanvasAction> = None;
|
||||
let mut open_props: Option<(usize, usize)> = None;
|
||||
|
||||
let out = scroll.show_viewport(ui, |ui, viewport| {
|
||||
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
|
||||
|
|
@ -1144,6 +1145,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
|
|||
match canvas_action {
|
||||
Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(*e),
|
||||
Some(comments::CanvasAction::OpenComments) => app.right = Some(RightPanel::Comments),
|
||||
Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)),
|
||||
None => {}
|
||||
}
|
||||
match clicked_link {
|
||||
|
|
@ -1156,6 +1158,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
|
|||
tool = QuickTool::Select;
|
||||
}
|
||||
app.quick_tool = tool;
|
||||
if let Some((p, i)) = open_props {
|
||||
app.open_comment_props(p, i);
|
||||
}
|
||||
if open_signature {
|
||||
app.signature_draft.clear();
|
||||
app.dialog = Some(crate::Dialog::Signature);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
//! Comment Properties (Acrobat: right-click a comment ▸ Properties…; audit "Comment
|
||||
//! properties"): Appearance (colour, opacity, line thickness, note icon), General (author,
|
||||
//! subject, modified) and Review History (status changes).
|
||||
|
||||
use egui::{Align, Layout};
|
||||
use printcraft_engine::{CommentProps, Edit, NoteIcon};
|
||||
|
||||
use crate::comments::swatch_grid;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PrintCraftApp, widgets};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PropsTab {
|
||||
Appearance,
|
||||
General,
|
||||
ReviewHistory,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PropsDraft {
|
||||
pub page: usize,
|
||||
pub index: usize,
|
||||
pub tab: PropsTab,
|
||||
pub original: CommentProps,
|
||||
pub edited: CommentProps,
|
||||
}
|
||||
|
||||
const ICONS: [NoteIcon; 7] =
|
||||
[NoteIcon::Comment, NoteIcon::Note, NoteIcon::Help, NoteIcon::Insert, NoteIcon::Key, NoteIcon::NewParagraph, NoteIcon::Paragraph];
|
||||
|
||||
impl PrintCraftApp {
|
||||
/// Open Comment Properties for the comment at `(page, index)` of the active document.
|
||||
pub fn open_comment_props(&mut self, page: usize, index: usize) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
let Some(p) = self.session.get(id).and_then(|d| d.comment_props(page, index)) else { return };
|
||||
self.comment_props = Some(PropsDraft { page, index, tab: PropsTab::Appearance, original: p.clone(), edited: p });
|
||||
self.dialog = Some(crate::Dialog::CommentProps);
|
||||
}
|
||||
}
|
||||
|
||||
/// The edits that make the comment match the draft (empty when nothing changed).
|
||||
pub fn edits(d: &PropsDraft) -> Vec<Edit> {
|
||||
let (o, e) = (&d.original, &d.edited);
|
||||
let mut out = Vec::new();
|
||||
let color = (e.color != o.color).then_some(e.color).flatten();
|
||||
let opacity = ((e.opacity - o.opacity).abs() > 1e-6).then_some(e.opacity);
|
||||
let width = (e.width != o.width).then_some(e.width).flatten();
|
||||
if e.restylable && (color.is_some() || opacity.is_some() || width.is_some()) {
|
||||
out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width });
|
||||
}
|
||||
let author = (e.author != o.author).then(|| e.author.clone());
|
||||
let subject = (e.subject != o.subject).then(|| e.subject.clone());
|
||||
let icon = (e.icon != o.icon).then_some(e.icon).flatten();
|
||||
if author.is_some() || subject.is_some() || icon.is_some() {
|
||||
out.push(Edit::SetAnnotationInfo { page: d.page, index: d.index, author, subject, icon });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Draw the dialog; returns (apply, cancel).
|
||||
pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> (bool, bool) {
|
||||
let history: Vec<(String, String, String)> = app
|
||||
.active_ids()
|
||||
.and_then(|(_, id)| app.session.get(id))
|
||||
.zip(app.comment_props.as_ref())
|
||||
.map(|(doc, d)| {
|
||||
let me = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && a.in_reply_to.is_none());
|
||||
let nm = me.and_then(|a| a.name.clone());
|
||||
doc.info
|
||||
.annotations
|
||||
.iter()
|
||||
.filter(|r| r.state.is_some() && nm.is_some() && r.in_reply_to == nm)
|
||||
.map(|r| (r.state.clone().unwrap_or_default(), r.author.clone().unwrap_or_default(), r.modified.clone().unwrap_or_default()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let Some(d) = app.comment_props.as_mut() else { return (false, true) };
|
||||
let kind = crate::comments_panel::subtype_label(&d.edited.subtype).to_string();
|
||||
ui.label(egui::RichText::new(format!("{kind} Properties")).font(theme::semibold(18.0)));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
for (tab, label) in [(PropsTab::Appearance, "Appearance"), (PropsTab::General, "General"), (PropsTab::ReviewHistory, "Review History")] {
|
||||
if widgets::mode_tab(ui, label, d.tab == tab).clicked() {
|
||||
d.tab = tab;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.add_space(6.0);
|
||||
let e = &mut d.edited;
|
||||
match d.tab {
|
||||
PropsTab::Appearance => {
|
||||
if !e.restylable {
|
||||
ui.label(egui::RichText::new("This comment's appearance can't be changed yet.").color(t.text_muted));
|
||||
}
|
||||
ui.add_enabled_ui(e.restylable, |ui| {
|
||||
egui::Grid::new("comment-props").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| {
|
||||
if let Some(icon) = e.icon.as_mut() {
|
||||
ui.label("Icon");
|
||||
egui::ComboBox::from_id_salt("note-icon").selected_text(icon.name()).show_ui(ui, |ui| {
|
||||
for i in ICONS {
|
||||
ui.selectable_value(icon, i, i.name());
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label("Colour");
|
||||
if let Some(c) = swatch_grid(ui, e.color) {
|
||||
e.color = Some(c);
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("Opacity");
|
||||
let mut pct = e.opacity * 100.0;
|
||||
if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() {
|
||||
e.opacity = pct / 100.0;
|
||||
}
|
||||
ui.end_row();
|
||||
if let Some(w) = e.width.as_mut() {
|
||||
ui.label("Thickness");
|
||||
ui.add(egui::Slider::new(w, 0.5..=12.0).step_by(0.5).suffix(" pt"));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
PropsTab::General => {
|
||||
egui::Grid::new("comment-general").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| {
|
||||
let l = ui.label("Author");
|
||||
ui.add(egui::TextEdit::singleline(&mut e.author).desired_width(280.0)).labelled_by(l.id);
|
||||
ui.end_row();
|
||||
let l = ui.label("Subject");
|
||||
ui.add(egui::TextEdit::singleline(&mut e.subject).desired_width(280.0)).labelled_by(l.id);
|
||||
ui.end_row();
|
||||
ui.label("Modified");
|
||||
ui.label(e.modified.as_deref().map(printcraft_render::pretty_date).unwrap_or_else(|| "—".into()));
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
PropsTab::ReviewHistory => {
|
||||
if history.is_empty() {
|
||||
ui.label(egui::RichText::new("No status has been set.").color(t.text_muted));
|
||||
}
|
||||
for (state, who, when) in &history {
|
||||
ui.label(format!("{state} — {who} {when}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
let (mut apply, mut cancel) = (false, false);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if widgets::pill_button(ui, "OK", true).clicked() {
|
||||
apply = true;
|
||||
}
|
||||
if widgets::pill_button(ui, "Cancel", false).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
(apply, cancel)
|
||||
}
|
||||
|
|
@ -267,6 +267,14 @@ pub struct CommentView {
|
|||
pub add_box: String,
|
||||
/// The Comments panel's search query (`None`: search closed).
|
||||
pub search: Option<String>,
|
||||
/// Comment types, authors and statuses hidden by the panel's filter.
|
||||
pub hidden_types: Vec<String>,
|
||||
pub hidden_authors: Vec<String>,
|
||||
pub hidden_statuses: Vec<String>,
|
||||
/// How the panel orders comments.
|
||||
pub sort: SortBy,
|
||||
/// Comment Properties was asked for from the panel: (page, index).
|
||||
pub props_request: Option<(usize, usize)>,
|
||||
pub search_focus: bool,
|
||||
/// Where the canvas context menu was opened: (page, user-space point).
|
||||
pub context_at: Option<(usize, [f64; 2])>,
|
||||
|
|
@ -274,6 +282,17 @@ pub struct CommentView {
|
|||
pub tool_done: bool,
|
||||
}
|
||||
|
||||
/// Comments panel order (Acrobat: Sort by page, author, date, type, colour).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SortBy {
|
||||
#[default]
|
||||
Page,
|
||||
Author,
|
||||
Date,
|
||||
Type,
|
||||
Color,
|
||||
}
|
||||
|
||||
/// What a page needs to know to handle comment input.
|
||||
pub(crate) struct PageCx<'a> {
|
||||
pub page: usize,
|
||||
|
|
@ -833,6 +852,10 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo
|
|||
action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page, index })));
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(allowed, egui::Button::new("Properties…")).clicked() {
|
||||
action = Some(CanvasAction::Properties(page, index));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some((page, at)) = view.comments.context_at
|
||||
|
|
@ -855,6 +878,8 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo
|
|||
pub enum CanvasAction {
|
||||
Edit(Box<Edit>),
|
||||
OpenComments,
|
||||
/// Comment Properties for (page, index).
|
||||
Properties(usize, usize),
|
||||
}
|
||||
|
||||
/// A grid of colour swatches; returns the one clicked.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use printcraft_engine::{Edit, NoteIcon, ReviewState, Shape};
|
|||
use printcraft_render::{Annotation, DocInfo};
|
||||
|
||||
use crate::canvas::DocView;
|
||||
use crate::comments::{CommentPrefs, CommentTool, color32, status_badge};
|
||||
use crate::comments::{CommentPrefs, CommentTool, SortBy, color32, status_badge};
|
||||
use crate::icons;
|
||||
use crate::panels::Nav;
|
||||
use crate::theme::{self, Tokens};
|
||||
|
|
@ -133,18 +133,52 @@ pub(crate) fn show(
|
|||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
let roots: Vec<&Annotation> = info
|
||||
let cv = &view.comments;
|
||||
let status_of = |a: &Annotation| replies_of(a).iter().rev().find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into());
|
||||
let mut roots: Vec<&Annotation> = info
|
||||
.annotations
|
||||
.iter()
|
||||
.filter(|a| a.in_reply_to.is_none())
|
||||
.filter(|a| query.as_deref().is_none_or(|q| matches(a, q) || replies_of(a).iter().any(|r| matches(r, q))))
|
||||
.filter(|a| !cv.hidden_types.iter().any(|t| t == subtype_label(&a.subtype)))
|
||||
.filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default()))
|
||||
.filter(|a| !cv.hidden_statuses.contains(&status_of(a)))
|
||||
.collect();
|
||||
let sort = cv.sort;
|
||||
let color_key = |a: &Annotation| {
|
||||
a.color.map(|c| format!("{:02X}{:02X}{:02X}", (c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)).unwrap_or_default()
|
||||
};
|
||||
match sort {
|
||||
SortBy::Page => {}
|
||||
SortBy::Author => roots.sort_by_key(|a| a.author.clone().unwrap_or_default().to_lowercase()),
|
||||
SortBy::Date => roots.sort_by(|a, b| b.modified.cmp(&a.modified)),
|
||||
SortBy::Type => roots.sort_by_key(|a| subtype_label(&a.subtype).to_string()),
|
||||
SortBy::Color => roots.sort_by_key(|a| color_key(a)),
|
||||
}
|
||||
if roots.is_empty() {
|
||||
ui.label(egui::RichText::new("No comments match.").color(t.text_muted));
|
||||
}
|
||||
// Group headers follow the sort key (no headers when sorted by date).
|
||||
let group_of = |a: &Annotation| -> Option<String> {
|
||||
match sort {
|
||||
SortBy::Page => Some(format!("Page {}", info.pages.get(a.page).map(|p| p.label.as_str()).unwrap_or("?"))),
|
||||
SortBy::Author => Some(a.author.clone().unwrap_or_else(|| "Unknown author".into())),
|
||||
SortBy::Type => Some(subtype_label(&a.subtype).to_string()),
|
||||
SortBy::Color => Some(if a.color.is_some() { format!("#{}", color_key(a)) } else { "No colour".into() }),
|
||||
SortBy::Date => None,
|
||||
}
|
||||
};
|
||||
let mut page = usize::MAX;
|
||||
let mut group: Option<String> = None;
|
||||
for a in roots {
|
||||
if a.page != page {
|
||||
if sort != SortBy::Page {
|
||||
let g = group_of(a);
|
||||
if g.is_some() && g != group {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(g.clone().unwrap_or_default()).font(theme::semibold(12.5)).color(t.text_muted));
|
||||
group = g;
|
||||
}
|
||||
} else if a.page != page {
|
||||
page = a.page;
|
||||
let n = info.annotations.iter().filter(|x| x.page == page && x.in_reply_to.is_none()).count();
|
||||
ui.add_space(6.0);
|
||||
|
|
@ -329,6 +363,10 @@ fn card(
|
|||
}
|
||||
}
|
||||
});
|
||||
if ui.button("Properties…").clicked() {
|
||||
view.comments.props_request = Some((a.page, a.index));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Delete").clicked() {
|
||||
view.comments.selected = None;
|
||||
|
|
@ -361,3 +399,61 @@ fn legible(c: Color32, t: &Tokens) -> Color32 {
|
|||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// The Comments panel header's filter and sort controls (Acrobat: filter funnel and "…").
|
||||
pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocView) {
|
||||
let cv = &mut view.comments;
|
||||
let mut types: Vec<String> = info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| subtype_label(&a.subtype).to_string()).collect();
|
||||
types.sort();
|
||||
types.dedup();
|
||||
let mut authors: Vec<String> =
|
||||
info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| a.author.clone().unwrap_or_default()).collect();
|
||||
authors.sort();
|
||||
authors.dedup();
|
||||
let active = !(cv.hidden_types.is_empty() && cv.hidden_authors.is_empty() && cv.hidden_statuses.is_empty());
|
||||
let more = icons::button(ui, "ellipsis", 26.0, false, "Sort comments");
|
||||
egui::Popup::menu(&more).show(|ui| {
|
||||
ui.label(egui::RichText::new("Sort by").small());
|
||||
for (s, label) in
|
||||
[(SortBy::Page, "Page"), (SortBy::Author, "Author"), (SortBy::Date, "Date"), (SortBy::Type, "Type"), (SortBy::Color, "Colour")]
|
||||
{
|
||||
if ui.radio(cv.sort == s, label).clicked() {
|
||||
cv.sort = s;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
let filter = icons::button(ui, "filter", 26.0, active, "Filter comments");
|
||||
egui::Popup::menu(&filter).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
let toggle = |ui: &mut egui::Ui, list: &mut Vec<String>, value: &str, label: &str| {
|
||||
let mut shown = !list.iter().any(|x| x == value);
|
||||
if ui.checkbox(&mut shown, label).changed() {
|
||||
if shown {
|
||||
list.retain(|x| x != value);
|
||||
} else {
|
||||
list.push(value.to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
ui.label(egui::RichText::new("Type").small());
|
||||
for ty in &types {
|
||||
toggle(ui, &mut cv.hidden_types, ty, ty);
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Author").small());
|
||||
for a in &authors {
|
||||
toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { "Unknown author" } else { a });
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Status").small());
|
||||
for s in ["None", "Accepted", "Rejected", "Cancelled", "Completed"] {
|
||||
toggle(ui, &mut cv.hidden_statuses, s, s);
|
||||
}
|
||||
if active && ui.button("Show all").clicked() {
|
||||
cv.hidden_types.clear();
|
||||
cv.hidden_authors.clear();
|
||||
cv.hidden_statuses.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
let mut boxes_now = false;
|
||||
let mut marks_now = false;
|
||||
let mut export_now = false;
|
||||
let mut props_now = false;
|
||||
let t = Tokens::get(ctx);
|
||||
let mut close = false;
|
||||
let mut next = dialog;
|
||||
|
|
@ -225,6 +226,12 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
split_ready = Some(by);
|
||||
}
|
||||
}
|
||||
Dialog::CommentProps => {
|
||||
let (apply, cancel) = crate::comment_props::body(ui, app, &t);
|
||||
props_now = apply;
|
||||
close = apply || cancel;
|
||||
return;
|
||||
}
|
||||
Dialog::Signature => {
|
||||
let (apply, cancel) = crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft);
|
||||
if apply {
|
||||
|
|
@ -458,6 +465,18 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
app.discard_recovered(&keys);
|
||||
}
|
||||
}
|
||||
if props_now && let Some(d) = app.comment_props.take() {
|
||||
let mut edits = crate::comment_props::edits(&d);
|
||||
match edits.len() {
|
||||
0 => {}
|
||||
1 => {
|
||||
app.apply_edit(edits.remove(0));
|
||||
}
|
||||
_ => {
|
||||
app.apply_edit(Edit::Batch { label: "Change comment properties".into(), edits });
|
||||
}
|
||||
}
|
||||
}
|
||||
if export_now && let Dialog::Export(kind) = dialog {
|
||||
app.start_export(kind);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,7 +255,8 @@ fn comment_page(edit: &Edit) -> Option<usize> {
|
|||
| Edit::SetAnnotationStatus { page, .. }
|
||||
| Edit::MoveAnnotation { page, .. }
|
||||
| Edit::ResizeAnnotation { page, .. }
|
||||
| Edit::StyleAnnotation { page, .. } => Some(*page),
|
||||
| Edit::StyleAnnotation { page, .. }
|
||||
| Edit::SetAnnotationInfo { page, .. } => Some(*page),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
mod canvas;
|
||||
mod chrome;
|
||||
mod commands;
|
||||
mod comment_props;
|
||||
pub mod comments;
|
||||
mod comments_panel;
|
||||
pub mod control;
|
||||
|
|
@ -102,6 +103,8 @@ pub enum Dialog {
|
|||
Export(export_ui::ExportKind),
|
||||
/// Fill & Sign ▸ Create signature (the drawing pad).
|
||||
Signature,
|
||||
/// Comment Properties.
|
||||
CommentProps,
|
||||
/// Documents from a session that ended unexpectedly.
|
||||
Recovery,
|
||||
}
|
||||
|
|
@ -203,6 +206,8 @@ pub struct PrintCraftApp {
|
|||
pub signature: Option<Vec<Vec<[f32; 2]>>>,
|
||||
/// Strokes being drawn in the signature pad.
|
||||
pub signature_draft: Vec<Vec<[f32; 2]>>,
|
||||
/// The Comment Properties dialog's state.
|
||||
pub comment_props: Option<comment_props::PropsDraft>,
|
||||
/// The last web link the app asked the system to open (tests and automation).
|
||||
pub last_opened_url: Option<String>,
|
||||
}
|
||||
|
|
@ -272,6 +277,7 @@ impl PrintCraftApp {
|
|||
export_status: None,
|
||||
signature: None,
|
||||
signature_draft: Vec::new(),
|
||||
comment_props: None,
|
||||
number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 },
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,6 +219,10 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
|
|||
if icons::button(ui, "x", 26.0, false, "Close").clicked() {
|
||||
close = true;
|
||||
}
|
||||
// Right to left: close, "…", filter, search (Acrobat's order left to right).
|
||||
if panel == RightPanel::Comments {
|
||||
crate::comments_panel::header_controls(ui, info, view);
|
||||
}
|
||||
if panel == RightPanel::Comments
|
||||
&& icons::button(ui, "search", 26.0, view.comments.search.is_some(), "Search comments").clicked()
|
||||
{
|
||||
|
|
@ -327,6 +331,9 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
|
|||
if let Some(e) = panel_edit {
|
||||
app.apply_edit(e);
|
||||
}
|
||||
if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.props_request.take()) {
|
||||
app.open_comment_props(p, i);
|
||||
}
|
||||
app.bookmark_rename = bm_rename;
|
||||
if let Some(a) = bm_action {
|
||||
app.bookmark_action(a);
|
||||
|
|
|
|||
|
|
@ -215,3 +215,53 @@ fn the_panel_posts_comments_and_replies() {
|
|||
assert_eq!((reply.contents.as_deref(), reply.author.as_deref()), (Some("Agreed"), Some("Tester")));
|
||||
h.get_by_label_contains("Agreed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_properties_change_appearance_and_author() {
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
|
||||
h.state_mut().open_comment_props(0, 0);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Rectangle Properties");
|
||||
{
|
||||
let d = h.state_mut().comment_props.as_mut().expect("open");
|
||||
assert_eq!(d.original.width, Some(2.0));
|
||||
d.edited.color = Some([0.0, 0.0, 1.0]);
|
||||
d.edited.width = Some(5.0);
|
||||
d.edited.author = "Grace".into();
|
||||
}
|
||||
h.get_by_label("General").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Author");
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
let p = doc.comment_props(0, 0).unwrap();
|
||||
assert_eq!((p.color, p.width, p.author.as_str()), (Some([0.0, 0.0, 1.0]), Some(5.0), "Grace"));
|
||||
assert_eq!(doc.can_undo(), Some("Change comment properties"), "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_filters_and_sorts() {
|
||||
let mut h = harness(|app| {
|
||||
app.set_option("panel", "comments").unwrap();
|
||||
app.set_option("quick", "square").unwrap();
|
||||
});
|
||||
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
|
||||
h.state_mut().set_option("quick", "circle").unwrap();
|
||||
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
|
||||
h.state_mut().set_option("quick", "select").unwrap();
|
||||
h.run_steps(2);
|
||||
let ovals = h.query_all_by_label("Oval").count();
|
||||
assert!(ovals >= 1);
|
||||
h.state_mut().views[0].comments.hidden_types.push("Oval".into());
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.query_all_by_label("Oval").count(), ovals - 1, "the oval's card is filtered out");
|
||||
assert!(h.query_all_by_label("Rectangle").count() >= 1);
|
||||
h.state_mut().views[0].comments.hidden_types.clear();
|
||||
h.state_mut().views[0].comments.sort = printcraft_ui_egui::comments::SortBy::Type;
|
||||
h.run_steps(2);
|
||||
// Grouped by type: a group header names each type.
|
||||
assert_eq!(h.query_all_by_label("Oval").count(), ovals + 1, "a group header was added");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3743,11 +3743,11 @@ title = "Comment properties (appearance, author, subject)"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["comment.list"]
|
||||
tools = ["comment_edit"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change"]
|
||||
notes = "Appearance (colour, opacity, thickness, note icon), General (author, subject), Review History"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.properties-bar"
|
||||
|
|
@ -3875,11 +3875,11 @@ title = "Filter comments (type, author, status, colour, checkmark)"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["comment.list"]
|
||||
tools = ["comment_list"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::the_panel_filters_and_sorts"]
|
||||
notes = "type, author and status; colour and checkmark filters not yet"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.sort-comments"
|
||||
|
|
@ -3887,11 +3887,11 @@ title = "Sort comments (page, author, date, type, colour)"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["comment.list"]
|
||||
tools = ["comment_list"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::the_panel_filters_and_sorts"]
|
||||
notes = "page, author, date, type, colour, with group headers"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.search-comments"
|
||||
|
|
@ -3947,11 +3947,11 @@ title = "Review history of state changes"
|
|||
area = "E"
|
||||
tier = "P2"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["comment.list"]
|
||||
tools = ["comment_list"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author"]
|
||||
notes = "status changes listed in Comment Properties"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.stamps-standard"
|
||||
|
|
|
|||
Loading…
Reference in New Issue