M5: Attach a file as a comment (push pin, paperclip, graph or tag icon in PrintCraft's own drawings; the file is embedded and listed under Attachments); comment_add type attachment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bf58d1f6bf
commit
968d26a41e
|
|
@ -206,6 +206,12 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
|||
c.push_str(¬e_icon(&icon, col));
|
||||
return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res));
|
||||
}
|
||||
b"FileAttachment" => {
|
||||
let col = stroke.unwrap_or([0.0, 0.47, 0.84]);
|
||||
let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "PushPin".into());
|
||||
c.push_str(&attach_icon(&icon, col));
|
||||
return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res));
|
||||
}
|
||||
_ if markup => {
|
||||
let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?;
|
||||
let col = stroke?;
|
||||
|
|
@ -689,6 +695,21 @@ fn ellipse(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
|
|||
|
||||
/// PrintCraft's note icons, drawn in a 20 × 20 box: a speech bubble for `/Comment`, a page
|
||||
/// with a folded corner for everything else, both filled with the note colour.
|
||||
/// File attachment icons in a 20 × 20 box: PrintCraft's own drawings.
|
||||
fn attach_icon(name: &str, col: Rgb) -> String {
|
||||
let mut s = format!("{}{}1.2 w 1 j 1 J\n", rg(col), rg_stroke(col));
|
||||
match name {
|
||||
"Paperclip" => s.push_str("8 4 m 8 15 l 8 18 13 18 13 15 c 13 6 l 13 3 10 3 10 6 c 10 14 l S\n"),
|
||||
"Graph" => s.push_str("3 3 m 3 17 l 3 3 m 17 3 l S\n5 3 3 6 re 9 3 3 10 re 13 3 3 13 re f\n"),
|
||||
"Tag" => s.push_str("3 10 m 8 15 l 17 15 l 17 5 l 8 5 l h S\n7.3 10 m 7.3 10.7 6.7 11.3 6 11.3 c 5.3 11.3 4.7 10.7 4.7 10 c 4.7 9.3 5.3 8.7 6 8.7 c 6.7 8.7 7.3 9.3 7.3 10 c f\n"),
|
||||
_ => {
|
||||
// Push pin: a round head on a short needle.
|
||||
s.push_str("10 2 m 10 9 l S\n6 9 m 14 9 l 14 11 l 12 12 l 12 16 l 13.5 17 l 13.5 18.5 l 6.5 18.5 l 6.5 17 l 8 16 l 8 12 l 6 11 l h f\n");
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn note_icon(name: &str, col: Rgb) -> String {
|
||||
let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col));
|
||||
if name == "Comment" {
|
||||
|
|
|
|||
|
|
@ -356,6 +356,39 @@ pub enum Shape {
|
|||
Caret {
|
||||
rect: [f64; 4],
|
||||
},
|
||||
/// Attach a file as a comment: an icon whose top-left corner is at `at`, holding `data`
|
||||
/// (embedded) under the name `file`.
|
||||
Attachment {
|
||||
at: [f64; 2],
|
||||
icon: AttachIcon,
|
||||
file: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The standard file attachment icons (§12.5.6.15).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum AttachIcon {
|
||||
#[default]
|
||||
PushPin,
|
||||
Paperclip,
|
||||
Graph,
|
||||
Tag,
|
||||
}
|
||||
|
||||
impl AttachIcon {
|
||||
pub const ALL: [AttachIcon; 4] = [AttachIcon::PushPin, AttachIcon::Paperclip, AttachIcon::Graph, AttachIcon::Tag];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
AttachIcon::PushPin => "PushPin",
|
||||
AttachIcon::Paperclip => "Paperclip",
|
||||
AttachIcon::Graph => "Graph",
|
||||
AttachIcon::Tag => "Tag",
|
||||
}
|
||||
}
|
||||
pub fn from_name(n: &str) -> Option<AttachIcon> {
|
||||
Self::ALL.into_iter().find(|i| i.name().eq_ignore_ascii_case(n))
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle `[x0 y0 x1 y1]` as a quad in Acrobat's order (top-left, top-right, bottom-left,
|
||||
|
|
@ -380,6 +413,7 @@ impl Shape {
|
|||
Shape::Polygon { .. } => "Polygon",
|
||||
Shape::PolyLine { .. } => "PolyLine",
|
||||
Shape::Caret { .. } => "Caret",
|
||||
Shape::Attachment { .. } => "FileAttachment",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -473,7 +507,7 @@ impl Style {
|
|||
([0.89, 0.13, 0.13], 2.0)
|
||||
}
|
||||
Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0),
|
||||
Shape::Caret { .. } => ([0.0, 0.47, 0.84], 1.0),
|
||||
Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0),
|
||||
Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0),
|
||||
Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5),
|
||||
|
|
@ -653,7 +687,7 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
|
|||
let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}"));
|
||||
let half = style.width.max(0.0) / 2.0;
|
||||
let r = match shape {
|
||||
Shape::Note { at, .. } => {
|
||||
Shape::Note { at, .. } | Shape::Attachment { at, .. } => {
|
||||
if !finite(at) {
|
||||
return Err(bad("position"));
|
||||
}
|
||||
|
|
@ -770,6 +804,7 @@ fn subject(shape: &Shape) -> &'static str {
|
|||
Shape::PolyLine { .. } => "Polygonal Line",
|
||||
Shape::Callout { .. } => "Callout",
|
||||
Shape::Caret { .. } => "Inserted Text",
|
||||
Shape::Attachment { .. } => "File Attachment",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -899,6 +934,36 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
|||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Sy".to_vec(), Object::name("None"));
|
||||
}
|
||||
Shape::Attachment { icon, file, data, .. } => {
|
||||
if file.trim().is_empty() {
|
||||
return Err(AnnotError::Invalid("the attached file needs a name".into()));
|
||||
}
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Name".to_vec(), Object::name(icon.name()));
|
||||
d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
|
||||
// The file: an embedded file stream inside a file specification (§7.11.4).
|
||||
let mut params = Dict::new();
|
||||
params.set(b"Size".to_vec(), Object::Int(data.len() as i64));
|
||||
if let Some(date) = &meta.date {
|
||||
params.set(b"ModDate".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
|
||||
}
|
||||
let mut ef = Dict::new();
|
||||
ef.set(b"Type".to_vec(), Object::name("EmbeddedFile"));
|
||||
ef.set(b"Params".to_vec(), Object::Dict(params));
|
||||
let ef = doc.add(Object::Stream(printcraft_cos::Stream::flate(ef, data)));
|
||||
let mut efd = Dict::new();
|
||||
efd.set(b"F".to_vec(), Object::Ref(ef));
|
||||
efd.set(b"UF".to_vec(), Object::Ref(ef));
|
||||
let mut fs = Dict::new();
|
||||
fs.set(b"Type".to_vec(), Object::name("Filespec"));
|
||||
fs.set(b"F".to_vec(), PdfString::text(file.trim()));
|
||||
fs.set(b"UF".to_vec(), PdfString::text(file.trim()));
|
||||
fs.set(b"EF".to_vec(), Object::Dict(efd));
|
||||
d.set(b"FS".to_vec(), Object::Ref(doc.add(Object::Dict(fs))));
|
||||
if new.contents.is_empty() {
|
||||
d.set(b"Contents".to_vec(), PdfString::text(file.trim()));
|
||||
}
|
||||
}
|
||||
Shape::Callout { rect: tb, knee, point, .. } => {
|
||||
let tb = normalize(*tb);
|
||||
let attach = callout_attach(tb, *knee);
|
||||
|
|
|
|||
|
|
@ -527,3 +527,36 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
|
|||
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret")));
|
||||
assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_attach_as_comments() {
|
||||
let mut doc = fixture();
|
||||
let data = b"col1,col2\n1,2\n".to_vec();
|
||||
let shape = Shape::Attachment { at: [300.0, 500.0], icon: AttachIcon::Paperclip, file: "data.csv".into(), data: data.clone() };
|
||||
let i = add_annotation(
|
||||
&mut doc,
|
||||
&NewAnnotation { page: 0, shape, style: Style::default(), contents: String::new(), author: "Ada".into() },
|
||||
&meta("f"),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let a = &list(&doc, 0)[i];
|
||||
assert_eq!((a.name(b"Subtype"), a.name(b"Name")), (Some(&b"FileAttachment"[..]), Some(&b"Paperclip"[..])));
|
||||
assert_eq!(text(a, b"Contents"), "data.csv", "the file name describes it");
|
||||
let fs = doc.resolve(a.get(b"FS").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(text(&fs, b"UF"), "data.csv");
|
||||
let ef = fs.get(b"EF").and_then(|e| e.as_dict()).and_then(|e| e.reference(b"F")).unwrap();
|
||||
let Object::Stream(s) = &*doc.get(ef) else { panic!() };
|
||||
assert_eq!(s.decoded().unwrap(), data);
|
||||
assert!(ap_content(&doc, a).contains(" c\n") || ap_content(&doc, a).contains(" c "), "a drawn paperclip");
|
||||
let mut doc = doc;
|
||||
let bad = Shape::Attachment { at: [0.0, 0.0], icon: AttachIcon::Tag, file: " ".into(), data: Vec::new() };
|
||||
assert!(
|
||||
add_annotation(
|
||||
&mut doc,
|
||||
&NewAnnotation { page: 0, shape: bad, style: Style::default(), contents: String::new(), author: String::new() },
|
||||
&meta("x")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -271,6 +271,18 @@ impl Automation {
|
|||
};
|
||||
Shape::Callout { rect, knee, point, font_size: a.opt_num("font_size")?.unwrap_or(10.0) }
|
||||
}
|
||||
"attachment" => {
|
||||
let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let data = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let icon = match a.opt_str("icon")? {
|
||||
Some(n) => printcraft_engine::AttachIcon::from_name(n)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown icon {n:?} (PushPin, Paperclip, Graph, Tag)")))?,
|
||||
None => printcraft_engine::AttachIcon::PushPin,
|
||||
};
|
||||
Shape::Attachment { at: to_user(&info, x, y), icon, file, data }
|
||||
}
|
||||
"caret" => {
|
||||
let [x, y] = a.need::<2>("at", "a caret (the insertion point on the baseline)")?;
|
||||
Shape::Caret { rect: rect_to_user(&info, [x - 4.0, y, x + 4.0, y + 8.0]) }
|
||||
|
|
|
|||
|
|
@ -458,13 +458,14 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
"Add a comment as Acrobat's commenting tools do. Geometry is in points with the origin at the top-left of the displayed page, y down (as in page_render images at 72 dpi and text_find rects). \
|
||||
note: `at` [x, y] (icon top-left). stamp: `at` [x, y] (its centre) and `stamp`: approved, completed, confidential, draft, final, for comment, for public release, information only, not approved, not for public release, preliminary results, void, accepted, initial here, rejected, sign here, witness; dynamic: true for the dynamic approved/confidential/received/reviewed/revised stamps with a By … at … line. highlight/underline/strikeout/squiggly: `find` (text on the page to mark; every match with all: true) or `quads`. \
|
||||
rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. ink: `strokes` [[[x, y], …], …]. \
|
||||
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. Undoable.",
|
||||
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret"] },
|
||||
"type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] },
|
||||
"path": { "type": "string", "description": "attachment: the file to attach." },
|
||||
"points": { "type": "array", "items": point(), "minItems": 2 },
|
||||
"knee": point(),
|
||||
"contents": { "type": "string", "description": "The comment text (what a text box shows)." },
|
||||
|
|
@ -479,7 +480,7 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
"stamp": { "type": "string" },
|
||||
"dynamic": { "type": "boolean" },
|
||||
"strokes": { "type": "array", "items": { "type": "array", "items": point() } },
|
||||
"icon": { "type": "string", "enum": ["Comment", "Note", "Help", "Insert", "Key", "NewParagraph", "Paragraph"] },
|
||||
"icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph; attachment: PushPin, Paperclip, Graph, Tag." },
|
||||
"color": color(),
|
||||
"fill": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
|
|
|
|||
|
|
@ -957,6 +957,11 @@ fn drawing_comments_through_tools() {
|
|||
let strike = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "StrikeOut").unwrap().clone();
|
||||
assert_eq!(strike["replies"][0]["contents"], "second page", "the caret threads under the strikeout");
|
||||
assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2" })), Err(ToolError::InvalidArgs(_))));
|
||||
// A file attached as a comment.
|
||||
std::fs::write(dir.join("notes.txt"), b"remember").unwrap();
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "attachment", "path": "notes.txt", "at": [250, 20], "icon": "Paperclip" }));
|
||||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||||
assert!(info.to_string().contains("notes.txt"), "{info}");
|
||||
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
assert!(matches!(png[0], Content::Png { .. }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
|||
c("comment.callout", "Add a callout", None, None, Annotate, "message-square-quote"),
|
||||
c("comment.caret", "Insert text", None, None, Annotate, "text-cursor-input"),
|
||||
c("comment.replace", "Replace text", None, None, Annotate, "replace"),
|
||||
c("comment.attach", "Attach a file", None, None, Annotate, "paperclip"),
|
||||
c("form.fields", "Form fields panel", VIEW, None, Document, "list"),
|
||||
c("form.clear", "Clear form", EDIT, None, FillForms, "eraser"),
|
||||
c("comment.flatten", "Flatten comments", None, None, HasComments, "layers"),
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ pub use printcraft_forms::{
|
|||
pub use printcraft_annot::appearance as annot_text;
|
||||
pub use printcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
|
||||
pub use printcraft_annot::{
|
||||
FillMark, Markup, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind,
|
||||
Style, rect_quad,
|
||||
AttachIcon, FillMark, Markup, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup,
|
||||
StampKind, Style, rect_quad,
|
||||
};
|
||||
pub use printcraft_optimize as optimize;
|
||||
pub use printcraft_print as print;
|
||||
|
|
@ -81,6 +81,8 @@ enum Scope {
|
|||
|
||||
fn scope_of(edit: &Edit) -> Scope {
|
||||
match edit {
|
||||
// A file attachment also changes the Attachments list.
|
||||
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
|
||||
Edit::AddAnnotation(_)
|
||||
| Edit::DeleteAnnotation { .. }
|
||||
| Edit::SetAnnotationContents { .. }
|
||||
|
|
@ -830,6 +832,7 @@ fn annotation_noun(s: &Shape) -> &'static str {
|
|||
Shape::PolyLine { .. } => "connected lines",
|
||||
Shape::Callout { .. } => "callout",
|
||||
Shape::Caret { .. } => "inserted text",
|
||||
Shape::Attachment { .. } => "file attachment",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1450,6 +1450,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
|
|||
}
|
||||
}
|
||||
app.quick_tool = tool;
|
||||
if let Some((page, at)) = app.views[index].comments.attach_at.take() {
|
||||
app.attach_file_comment(page, at);
|
||||
}
|
||||
if let Some((p, i)) = open_props {
|
||||
app.open_comment_props(p, i);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,35 @@ const ICONS: [NoteIcon; 7] =
|
|||
[NoteIcon::Comment, NoteIcon::Note, NoteIcon::Help, NoteIcon::Insert, NoteIcon::Key, NoteIcon::NewParagraph, NoteIcon::Paragraph];
|
||||
|
||||
impl PrintCraftApp {
|
||||
/// Attach file: ask for a file (or take `attach_override`) and attach it at `at`.
|
||||
pub fn attach_file_comment(&mut self, page: usize, at: [f64; 2]) {
|
||||
let picked = match self.attach_override.take() {
|
||||
Some(f) => Some(f),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
None => rfd::FileDialog::new().set_title("Attach a file").pick_file().and_then(|p| {
|
||||
let data = std::fs::read(&p).ok()?;
|
||||
Some((p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), data))
|
||||
}),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None => None,
|
||||
};
|
||||
let Some((file, data)) = picked else { return };
|
||||
let tool = crate::comments::CommentTool::Attach;
|
||||
let shape = printcraft_engine::Shape::Attachment { at, icon: printcraft_engine::AttachIcon::PushPin, file, data };
|
||||
let edit = Edit::AddAnnotation(printcraft_engine::NewAnnotation {
|
||||
page,
|
||||
shape,
|
||||
style: self.comment_prefs.style(tool),
|
||||
contents: String::new(),
|
||||
author: self.comment_prefs.author.clone(),
|
||||
});
|
||||
// A one-shot tool: back to Select unless pinned (apply_edit acts on this).
|
||||
if let Some((i, _)) = self.active_ids() {
|
||||
self.views[i].comments.tool_done = true;
|
||||
}
|
||||
self.apply_edit(edit);
|
||||
}
|
||||
|
||||
/// Make Current Properties Default: new comments of this kind take this one's colour,
|
||||
/// opacity and line width.
|
||||
pub fn make_comment_default(&mut self, page: usize, index: usize) {
|
||||
|
|
|
|||
|
|
@ -41,11 +41,12 @@ pub enum CommentTool {
|
|||
Callout,
|
||||
Caret,
|
||||
ReplaceText,
|
||||
Attach,
|
||||
}
|
||||
|
||||
/// The quick-bar flyout groups, in Acrobat's order: Comment ▸, Highlight ▸, Draw ▸.
|
||||
pub const GROUPS: [&[CommentTool]; 3] = [
|
||||
&[CommentTool::Note, CommentTool::TextBox, CommentTool::Callout],
|
||||
&[CommentTool::Note, CommentTool::TextBox, CommentTool::Callout, CommentTool::Attach],
|
||||
&[CommentTool::Highlight, CommentTool::Underline, CommentTool::StrikeOut, CommentTool::Caret, CommentTool::ReplaceText],
|
||||
&[
|
||||
CommentTool::Ink,
|
||||
|
|
@ -59,8 +60,9 @@ pub const GROUPS: [&[CommentTool]; 3] = [
|
|||
],
|
||||
];
|
||||
|
||||
pub const ALL: [CommentTool; 16] = [
|
||||
pub const ALL: [CommentTool; 17] = [
|
||||
CommentTool::ReplaceText,
|
||||
CommentTool::Attach,
|
||||
CommentTool::Polygon,
|
||||
CommentTool::PolyLine,
|
||||
CommentTool::Cloud,
|
||||
|
|
@ -97,6 +99,7 @@ impl CommentTool {
|
|||
Self::Callout => "comment.callout",
|
||||
Self::Caret => "comment.caret",
|
||||
Self::ReplaceText => "comment.replace",
|
||||
Self::Attach => "comment.attach",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +125,7 @@ impl CommentTool {
|
|||
Self::Callout => "Add a callout",
|
||||
Self::Caret => "Insert text",
|
||||
Self::ReplaceText => "Replace text",
|
||||
Self::Attach => "Attach file",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +147,7 @@ impl CommentTool {
|
|||
Self::Callout => "message-square-quote",
|
||||
Self::Caret => "text-cursor-input",
|
||||
Self::ReplaceText => "replace",
|
||||
Self::Attach => "paperclip",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -193,6 +198,7 @@ impl CommentTool {
|
|||
Self::Callout => Shape::Callout { rect: [0.0; 4], knee: [0.0; 2], point: [0.0; 2], font_size: 10.0 },
|
||||
Self::Caret => Shape::Caret { rect: [0.0; 4] },
|
||||
Self::ReplaceText => Shape::TextMarkup { kind: Markup::StrikeOut, quads: Vec::new() },
|
||||
Self::Attach => Shape::Attachment { at: [0.0; 2], icon: printcraft_engine::AttachIcon::PushPin, file: String::new(), data: Vec::new() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -281,6 +287,7 @@ pub fn tool_for(a: &Annotation) -> Option<CommentTool> {
|
|||
("Polygon", _) => CommentTool::Polygon,
|
||||
("PolyLine", _) => CommentTool::PolyLine,
|
||||
("Caret", _) => CommentTool::Caret,
|
||||
("FileAttachment", _) => CommentTool::Attach,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -369,6 +376,8 @@ pub struct CommentView {
|
|||
pub default_request: Option<(usize, usize)>,
|
||||
/// Replace Text: the struck-out text's quads while the replacement is typed.
|
||||
pub replace: Option<Vec<[f64; 8]>>,
|
||||
/// Attach file: where the icon goes (page, top-left in user space), for the app to pick a file.
|
||||
pub attach_at: Option<(usize, [f64; 2])>,
|
||||
pub search_focus: bool,
|
||||
/// Where the canvas context menu was opened: (page, user-space point).
|
||||
pub context_at: Option<(usize, [f64; 2])>,
|
||||
|
|
@ -630,6 +639,22 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
|
|||
}
|
||||
true
|
||||
}
|
||||
QuickTool::Comment(CommentTool::Attach) => {
|
||||
if !cx.allowed {
|
||||
return false;
|
||||
}
|
||||
if over_page {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
if resp.clicked()
|
||||
&& over_page
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
cv.attach_at = Some((cx.page, cx.to_user(p)));
|
||||
cv.selected = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
QuickTool::Comment(CommentTool::Caret) => {
|
||||
if !cx.allowed {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -264,6 +264,8 @@ pub struct PrintCraftApp {
|
|||
/// system clipboard (tests turn that off).
|
||||
pub last_snapshot: Option<(u32, u32, Vec<u8>)>,
|
||||
pub system_clipboard: bool,
|
||||
/// Attach file: the file to attach instead of asking (tests, automation).
|
||||
pub attach_override: Option<(String, Vec<u8>)>,
|
||||
/// Where autosaves go (`None`: autosave off, e.g. on the web and in tests).
|
||||
pub recovery: Option<RecoveryStore>,
|
||||
/// Entries left by a previous session, offered in the Recovery dialog.
|
||||
|
|
@ -371,6 +373,7 @@ impl PrintCraftApp {
|
|||
page_clipboard: None,
|
||||
last_snapshot: None,
|
||||
system_clipboard: true,
|
||||
attach_override: None,
|
||||
recovery: None,
|
||||
recoverable: Vec::new(),
|
||||
recovery_keys: Default::default(),
|
||||
|
|
|
|||
|
|
@ -487,3 +487,17 @@ fn replacing_text_strikes_it_and_adds_a_caret() {
|
|||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Replace text"));
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_a_file_as_a_comment() {
|
||||
let mut h = harness(|app| app.set_option("quick", "attach").unwrap());
|
||||
h.state_mut().attach_override = Some(("notes.txt".into(), b"remember the milk".to_vec()));
|
||||
click_pt(&mut h, (250.0, 180.0));
|
||||
h.run_steps(3);
|
||||
let c = comments(&h);
|
||||
assert_eq!((c.len(), c[0].subtype.as_str(), c[0].contents.as_deref()), (1, "FileAttachment", Some("notes.txt")));
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
assert!(doc.info.attachments.iter().any(|a| a.name == "notes.txt"), "listed in the Attachments panel");
|
||||
assert_eq!(s.quick_tool, QuickTool::Select);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3695,11 +3695,11 @@ title = "Attach a file as a comment"
|
|||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["comment.attach"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
|
||||
notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.record-audio"
|
||||
|
|
|
|||
Loading…
Reference in New Issue