diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index d1f2515d..897cf13b 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -4,7 +4,7 @@
Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
-## In this repository (205)
+## In this repository (212)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -213,6 +213,13 @@ Every asset PrintCraft includes, bundles or uses to build its published material
| `docs/images/printcraft-tools.png` | PrintCraft screenshot: All tools | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: tool catalogue. Shows PrintCraft UI, Lucide icons, the showcase PDF built from the [[fetched]] OFL fonts, and the ArtCraft mark (Storyteller trademark from docs/brand/, not covered by this licence) |
| `docs/images/printcraft-twoup.png` | PrintCraft screenshot: Two-up reading | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: layouts. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
| `docs/images/printcraft-viewer.png` | PrintCraft screenshot: Viewer with comments | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README hero. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
+| `assets/icons/pin.svg` | Lucide icon "pin" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/move-right.svg` | Lucide icon "move-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/reply.svg` | Lucide icon "reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/thumbs-up.svg` | Lucide icon "thumbs-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
## Compiled in through dependencies (6)
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index d094a85e..ab350669 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -2596,3 +2596,80 @@ licence_file = "LICENSE-MIT"
kind = "image"
usage = "README hero. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
sha256 = "ecd261028d8837f7f7c013dd796d81ca7e89643bcfcbad3ab9fbfbe87ded04e3"
+
+[[asset]]
+path = "assets/icons/pin.svg"
+title = "Lucide icon \"pin\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "d28d42dd90e0ed601fb7120b6e0b98218289da8fc7bf41fac5ca83aeffd0ae32"
+
+[[asset]]
+path = "assets/icons/move-right.svg"
+title = "Lucide icon \"move-right\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "d4586a7c7aea911762604211e3624b3436b998d1343b0b29162b3eddf9e6ab81"
+
+[[asset]]
+path = "assets/icons/reply.svg"
+title = "Lucide icon \"reply\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "f6161386877fb29e058c85ac76f1793fb43f6fcf5144b21ef3f36a367cb41e94"
+
+[[asset]]
+path = "assets/icons/thumbs-up.svg"
+title = "Lucide icon \"thumbs-up\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "e30b1dc9b4f1d56fa935ee55a4a2c9dad0eaeaffa6603085a1d3f8d5c22f60a3"
+
+[[asset]]
+path = "assets/icons/thumbs-down.svg"
+title = "Lucide icon \"thumbs-down\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "283a01b7798806a6b9ffd19f4c449e72a08ef860281fa0d33b584b52590e7240"
+
+[[asset]]
+path = "assets/icons/message-square-plus.svg"
+title = "Lucide icon \"message-square-plus\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "5e9d80429d9b219c98402a0d4f3194d2a0cbfe0c41dd42f46771a4d7e1c563e8"
+
+[[asset]]
+path = "assets/icons/circle-x.svg"
+title = "Lucide icon \"circle-x\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "ef1fec17d88794ba6c34c5cf63bdadbd234b3d4dffbcc3d55468b47e3f398472"
diff --git a/README.md b/README.md
index 29d5a87b..072d045d 100644
--- a/README.md
+++ b/README.md
@@ -271,13 +271,14 @@ printcraft-cli edit in.pdf --rotate 1,2:90 --delete 5 --title "Q3" --out out.pd
## Built for agents, too
-Every engine feature is reachable without the GUI, through one table of JSON-Schema-described tools: open, inspect, render pages to PNG, extract and find text, rotate, delete, move and insert pages, set metadata, undo and redo, save, combine, extract and split. Three front doors share it:
+Every engine feature is reachable without the GUI, through one table of JSON-Schema-described tools: open, inspect, render pages to PNG, extract and find text, rotate, delete, move and insert pages, edit bookmarks and page labels, add, reply to, restyle and delete comments (highlight a phrase just by naming it), set metadata, undo and redo, save, combine, extract and split. Three front doors share it:
- **`printcraft-cli run`**, for one-off calls and JSON scripts:
```sh
printcraft-cli tools # every tool and its JSON Schema
printcraft-cli run text_find doc=1 query=invoice # key=value; values parse as JSON
+ printcraft-cli run --script review.json # e.g. comment_add {"type": "highlight", "find": "total due"}
printcraft-cli run --script steps.json --root ./work # several steps in one session
```
@@ -301,6 +302,8 @@ Start the desktop app with `printcraft --control /tmp/pc.json` and an agent can
printcraft-cli ui --control /tmp/pc.json inspect query=rotate # find widgets
printcraft-cli ui --control /tmp/pc.json click label="Organize pages"
printcraft-cli ui --control /tmp/pc.json key key=K modifiers='["command"]'
+printcraft-cli ui --control /tmp/pc.json command id=comment.square # pick a tool, then draw:
+printcraft-cli ui --control /tmp/pc.json drag from='[400,300]' to='[600,420]'
printcraft-cli ui --control /tmp/pc.json screenshot --out window.png
```
@@ -315,7 +318,8 @@ PrintCraft is a Cargo workspace of focused crates, layered so the core never dep
| `printcraft-filters` | Every PDF stream filter (Flate, LZW, ASCII85, RunLength, predictors), encode and decode, property-tested |
| `printcraft-crypt` | The standard security handler: RC4, AES-128/256, revisions 2–6, permissions |
| `printcraft-cos` | The PDF object layer: tolerant parsing, repair, copy-on-write edits, incremental and full writing |
-| `printcraft-organize` | Page operations, combine / extract / split, document information |
+| `printcraft-organize` | Page operations, combine / extract / split, bookmarks, page labels, document information |
+| `printcraft-annot` | Comments: builders and appearance streams for notes, text markup, shapes, ink and text boxes; replies, status, edits |
| `printcraft-render` | Rendering, inspection and text extraction with reading order |
| `printcraft-engine` | The façade every frontend uses: sessions, edits, undo, saving, the tool catalogue |
| `printcraft-automation` | Agent control: the headless tool table, `printcraft-cli run`, and the opt-in MCP server |
@@ -351,7 +355,8 @@ PrintCraft is young and moving fast. The aim is a workbench where you can view,
**Available today:**
- viewing, search and navigation;
-- organizing pages, combining, extracting and splitting;
+- organizing pages, combining, extracting and splitting; bookmarks and page labels;
+- commenting: sticky notes, highlight / underline / strikethrough, text boxes, freehand drawing, lines, arrows, rectangles and ovals, with replies, status, colours, moving, resizing and a searchable Comments panel;
- document information;
- opening encrypted documents, honouring their permissions, and saving them encrypted;
- undo and safe saving;
@@ -363,8 +368,8 @@ PrintCraft is young and moving fast. The aim is a workbench where you can view,
| Next up | Milestone |
|---|---|
-| Bookmark and page-label editing, page boxes | M4 |
-| Creating and editing comments, stamps, FDF/XFDF | M5 |
+| Page boxes | M4 |
+| Callouts, clouds, stamps, FDF/XFDF, comment summaries | M5 |
| Filling and authoring forms, JavaScript | M6 |
| Editing text and images in place, headers, watermarks | M7 |
| Adding passwords, redaction | M8 |
diff --git a/apps/printcraft-cli/src/main.rs b/apps/printcraft-cli/src/main.rs
index 45309fd5..81f7a8ca 100644
--- a/apps/printcraft-cli/src/main.rs
+++ b/apps/printcraft-cli/src/main.rs
@@ -472,7 +472,7 @@ fn ui(args: &[String]) -> Result<(), String> {
let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?;
let token = info["token"].as_str().ok_or(format!("{file}: no token"))?;
let pos = positional(args);
- let method = *pos.first().ok_or("ui: missing method (state, inspect, click, type, key, command, commands, set, open, screenshot)")?;
+ let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?;
let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") };
let mut params = serde_json::Map::new();
for kv in pos.iter().skip(1) {
diff --git a/assets/icons/circle-x.svg b/assets/icons/circle-x.svg
new file mode 100644
index 00000000..8872303b
--- /dev/null
+++ b/assets/icons/circle-x.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/assets/icons/message-square-plus.svg b/assets/icons/message-square-plus.svg
new file mode 100644
index 00000000..5aaa9ad5
--- /dev/null
+++ b/assets/icons/message-square-plus.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/assets/icons/move-right.svg b/assets/icons/move-right.svg
new file mode 100644
index 00000000..bf599d26
--- /dev/null
+++ b/assets/icons/move-right.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/assets/icons/pin.svg b/assets/icons/pin.svg
new file mode 100644
index 00000000..7c0602af
--- /dev/null
+++ b/assets/icons/pin.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/assets/icons/reply.svg b/assets/icons/reply.svg
new file mode 100644
index 00000000..cd4ffd18
--- /dev/null
+++ b/assets/icons/reply.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/assets/icons/thumbs-down.svg b/assets/icons/thumbs-down.svg
new file mode 100644
index 00000000..8b081734
--- /dev/null
+++ b/assets/icons/thumbs-down.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/assets/icons/thumbs-up.svg b/assets/icons/thumbs-up.svg
new file mode 100644
index 00000000..5007fae8
--- /dev/null
+++ b/assets/icons/thumbs-up.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/crates/automation/README.md b/crates/automation/README.md
index 22a445b7..3e84c5e7 100644
--- a/crates/automation/README.md
+++ b/crates/automation/README.md
@@ -3,7 +3,7 @@
Agent control for PrintCraft: a headless tool table over the engine, and an opt-in MCP server.
- **Layer:** L7 (architecture §13), but headless. It depends on `engine`, `render` and `organize`, never on a UI toolkit.
-- **Status:** 20 tools covering inspection, rendering, text, page edits, metadata, undo/redo, save, combine, extract and split. Planned next: the UI control channel (`ui.*`, M3.9), tools for comments, forms and bookmarks as those features land, and MCP resources for page images.
+- **Status:** 33 tools covering inspection, rendering, text, page edits, page labels, bookmarks, comments, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). Planned next: tools for forms and protection as those features land, and MCP resources for page images. The running app's UI is driven separately, through the `ui.*` control channel in `printcraft-ui-egui`.
## API
diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs
index b80f3118..bed9ac22 100644
--- a/crates/engine/src/catalog.rs
+++ b/crates/engine/src/catalog.rs
@@ -193,25 +193,26 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
icon: "message-square-text",
hue: ORANGE,
badge: None,
- availability: Planned("M5"),
+ availability: Ready,
sections: &[
ToolSection {
title: "Markup",
items: &[
- item("Sticky note", "sticky-note", "comment.note", Planned("M5")),
- item("Highlight text", "highlighter", "comment.highlight", Planned("M5")),
- item("Underline", "underline", "comment.underline", Planned("M5")),
- item("Strikethrough", "strikethrough", "comment.strikeout", Planned("M5")),
- item("Text box", "type", "comment.freetext", Planned("M5")),
+ item("Sticky note", "sticky-note", "comment.note", Ready),
+ item("Highlight text", "highlighter", "comment.highlight", Ready),
+ item("Underline", "underline", "comment.underline", Ready),
+ item("Strikethrough", "strikethrough", "comment.strikeout", Ready),
+ item("Text box", "type", "comment.freetext", Ready),
],
},
ToolSection {
title: "Drawing",
items: &[
- item("Draw freehand", "pencil", "comment.ink", Planned("M5")),
- item("Rectangle", "square", "comment.square", Planned("M5")),
- item("Oval", "circle", "comment.circle", Planned("M5")),
- item("Line / arrow", "arrow-up-right", "comment.line", Planned("M5")),
+ item("Draw freehand", "pencil", "comment.ink", Ready),
+ item("Rectangle", "square", "comment.square", Ready),
+ item("Oval", "circle", "comment.circle", Ready),
+ item("Line", "minus", "comment.line", Ready),
+ item("Arrow", "move-right", "comment.arrow", Ready),
item("Cloud", "cloud", "comment.cloud", Planned("M5")),
item("Stamp", "stamp", "comment.stamp", Planned("M5")),
],
diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
index 9c3821ff..e0f05c6c 100644
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -22,6 +22,8 @@ pub enum Needs {
Assembly,
/// The open document's security allows content and metadata changes.
Modification,
+ /// The open document's security allows adding and changing comments.
+ Annotate,
/// There is something to undo.
Undo,
/// There is something to redo.
@@ -143,6 +145,16 @@ pub const COMMANDS: &[CommandSpec] = &[
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
c("view.theme", "Switch light / dark theme", VIEW, None, Nothing, "moon"),
c("comment.list", "Comments panel", VIEW, None, Document, "message-square-text"),
+ c("comment.note", "Add a sticky note", None, None, Annotate, "sticky-note"),
+ c("comment.freetext", "Add a text box", None, None, Annotate, "type"),
+ c("comment.highlight", "Highlight text", None, None, Annotate, "highlighter"),
+ c("comment.underline", "Underline text", None, None, Annotate, "underline"),
+ c("comment.strikeout", "Strikethrough text", None, None, Annotate, "strikethrough"),
+ c("comment.ink", "Draw freehand", None, None, Annotate, "pencil"),
+ c("comment.line", "Draw a line", None, None, Annotate, "minus"),
+ c("comment.arrow", "Draw an arrow", None, None, Annotate, "move-right"),
+ c("comment.square", "Draw a rectangle", None, None, Annotate, "square"),
+ c("comment.circle", "Draw an oval", None, None, Annotate, "circle"),
c("form.fields", "Form fields panel", VIEW, None, Document, "list"),
c("protect.properties", "Security properties…", FILE, None, Document, "shield-check"),
c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"),
@@ -180,6 +192,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option)
Document => doc.is_some(),
Assembly => doc.is_some_and(|d| d.allows_assembly()),
Modification => doc.is_some_and(|d| d.allows_modification()),
+ Annotate => doc.is_some_and(|d| d.allows_annotation()),
Undo => doc.is_some_and(|d| d.can_undo().is_some()),
Redo => doc.is_some_and(|d| d.can_redo().is_some()),
}
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 3c52b2fe..0d09f6ce 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -13,7 +13,7 @@ use printcraft_engine::{DocId, Edit};
use printcraft_render::{DocInfo, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile};
use crate::theme::{self, Tokens};
-use crate::{PrintCraftApp, QuickTool, RightPanel, icons, widgets};
+use crate::{PrintCraftApp, QuickTool, RightPanel, comments, icons, widgets};
/// Logical pixels per PDF point at 100% (96 dpi, like browsers).
pub const PT: f32 = 96.0 / 72.0;
@@ -23,6 +23,8 @@ const MARGIN: f32 = 28.0;
const SIDE: f32 = 70.0;
const THUMB_TAG: u64 = 1 << 63;
const TEXT_TAG: u64 = 1 << 62;
+/// The tag of a raster that is out of date (shown until its replacement arrives).
+const STALE_TAG: u64 = u64::MAX;
/// Pages whose raster would exceed this many device pixels on a side are drawn in tiles.
const TILE_THRESHOLD: f32 = 4096.0;
const TILE: u32 = 1024;
@@ -96,6 +98,8 @@ pub struct DocView {
/// When each still-missing page was first shown, to flag unusually slow renders.
waiting_since: HashMap,
thumbs: HashMap,
+ /// Thumbnails that are out of date (still shown until their replacement arrives).
+ stale_thumbs: HashSet,
/// Sharp tiles of large pages: (page, tile x, tile y) → (scale tag, texture).
tiles: HashMap<(usize, u32, u32), (u64, TextureHandle)>,
/// Text layers, extracted in the background on demand (selection, find, copy).
@@ -122,6 +126,8 @@ pub struct DocView {
select_anchor: Option,
/// An edit requested by the view (organize toolbar, keys), applied by the app this frame.
pub pending_edit: Option,
+ /// Commenting state: selected comment, gestures, composer.
+ pub comments: crate::comments::CommentView,
/// A non-edit action requested by the organize toolbar, handled by the app.
pub pending_action: Option,
}
@@ -170,6 +176,7 @@ impl DocView {
errors: HashMap::new(),
waiting_since: HashMap::new(),
thumbs: HashMap::new(),
+ stale_thumbs: HashSet::new(),
tiles: HashMap::new(),
texts: HashMap::new(),
text_failed: HashSet::new(),
@@ -188,6 +195,7 @@ impl DocView {
select_anchor: None,
pending_edit: None,
pending_action: None,
+ comments: Default::default(),
}
}
@@ -226,11 +234,14 @@ impl DocView {
!self.last_queue.is_empty()
}
- /// Drop every cached raster and text layer (the document's appearance changed, e.g. a layer
- /// was toggled). Pages re-render on the next frame; the old textures are simply released.
+ /// Re-render every page and text layer (the document's appearance changed, e.g. a layer
+ /// was toggled). Out-of-date rasters stay on screen until their replacements arrive, so the
+ /// view never flashes blank.
pub fn invalidate_content(&mut self) {
- self.pages.clear();
- self.thumbs.clear();
+ for p in self.pages.values_mut() {
+ p.tag = STALE_TAG;
+ }
+ self.stale_thumbs.extend(self.thumbs.keys().copied());
self.tiles.clear();
self.texts.clear();
self.text_failed.clear();
@@ -244,6 +255,57 @@ impl DocView {
}
}
+ /// Only `page` changed (a comment was added, edited or removed): re-render that page and
+ /// re-read its text, keep everything else.
+ pub fn page_changed(&mut self, page: usize) {
+ if let Some(p) = self.pages.get_mut(&page) {
+ p.tag = STALE_TAG;
+ }
+ if self.thumbs.contains_key(&page) {
+ self.stale_thumbs.insert(page);
+ }
+ self.tiles.retain(|(p, _, _), _| *p != page);
+ self.texts.remove(&page);
+ self.text_failed.remove(&page);
+ self.errors.remove(&page);
+ self.last_queue.clear();
+ if self.selection.is_some_and(|s| s.page == page) {
+ self.selection = None;
+ }
+ if let Some(f) = self.find.as_mut() {
+ f.matches.retain(|(p, _)| *p != page);
+ f.current = None;
+ }
+ }
+
+ /// Pages drawn last frame and their screen rectangles.
+ pub fn visible_page_rects(&self) -> Vec<(usize, Rect)> {
+ self.screen_xforms.iter().map(|(p, xf)| (*p, xf.rect)).collect()
+ }
+
+ /// Where `page` is drawn this frame, with its coordinate mapping.
+ pub(crate) fn page_xform(&self, page: usize) -> Option {
+ self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| *xf)
+ }
+
+ /// The text selection as markup quadrilaterals in user space: (page, quads).
+ pub(crate) fn selection_quads(&self, info: &DocInfo) -> Option<(usize, Vec<[f64; 8]>)> {
+ let s = self.selection?;
+ let text = self.texts.get(&s.page)?;
+ let page = info.pages.get(s.page)?;
+ let quads: Vec<[f64; 8]> = text.line_rects(s.range()).into_iter().map(|r| page.view_rect_to_quad(r)).collect();
+ (!quads.is_empty()).then_some((s.page, quads))
+ }
+
+ pub(crate) fn clear_selection(&mut self) {
+ self.selection = None;
+ }
+
+ /// Select text on `page` from glyph `from` to glyph `to` (tests and automation).
+ pub fn select_text(&mut self, page: usize, from: usize, to: usize) {
+ self.selection = Some(Selection { page, anchor: from, head: to });
+ }
+
/// Open the find bar (or focus it if already open).
pub fn open_find(&mut self) {
let f = self.find.get_or_insert_with(Find::default);
@@ -419,6 +481,7 @@ impl DocView {
if r.request.tag & THUMB_TAG != 0 {
let tex = ctx.load_texture(format!("thumb-{:?}-{page}", self.id), img, TextureOptions::LINEAR);
self.thumbs.insert(page, tex);
+ self.stale_thumbs.remove(&page);
} else {
let tex = ctx.load_texture(format!("page-{:?}-{page}", self.id), img, TextureOptions::LINEAR);
self.pages.insert(page, PageTex { tag: r.request.tag, tex });
@@ -704,8 +767,18 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
let scale = view.render_scale(ppp);
let tag = (scale * 1000.0) as u64;
let hand = app.quick_tool == QuickTool::Hand;
+ let tool = app.quick_tool;
+ // Text selection runs for the Select tool and for the markup tools (highlight…).
+ let selects_text = match tool {
+ QuickTool::Select => true,
+ QuickTool::Comment(t) => t.markup().is_some(),
+ QuickTool::Hand => false,
+ };
+ let prefs = &app.comment_prefs;
+ let allowed = doc.allows_annotation();
let mut hover_text: Option<(Pos2, String)> = None;
let mut clicked_link: Option = None;
+ let mut canvas_action: Option = 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());
@@ -812,6 +885,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
}
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside);
+ // Comments: tools, selection, moving and resizing come before text selection.
+ let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed };
+ let consumed = comments::page_input(ui, &resp, &pcx, view);
+
// Text layer: find matches, selection, I-beam and drag-to-select.
let to_screen = |g: [f32; 4]| xf.view_rect(g);
if let Some(text) = view.texts.get(&i).cloned() {
@@ -836,7 +913,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70));
}
}
- if !hand && let Some(p) = pointer.filter(|p| r.contains(*p)) {
+ if selects_text
+ && !consumed
+ && let Some(p) = pointer.filter(|p| r.contains(*p))
+ {
let (vx, vy) = xf.screen_to_view(p);
let over_text = text.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]);
let over_link = info.links.iter().any(|l| l.page == i && xf.user_rect(info, i, l.rect).contains(p));
@@ -876,6 +956,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
+ comments::page_after_text(&resp, &pcx, view);
+ comments::paint_page(ui, painter, &pcx, view);
+
// Form-field highlight (Acrobat's "Highlight existing fields").
if view.highlight_fields {
for f in info.fields.iter().filter(|f| f.page == Some(i)) {
@@ -903,13 +986,14 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
LinkTarget::Other(s) => format!("{s} action"),
};
hover_text = Some((p, label));
- if resp.clicked() && !hand {
+ if resp.clicked() && tool == QuickTool::Select && !consumed {
clicked_link = Some(l.target.clone());
}
}
}
// Annotation hover shows the comment, as Acrobat's popups do.
- for a in info.annotations.iter().filter(|a| a.page == i) {
+ let gesturing = view.comments.gesture.is_some();
+ for a in info.annotations.iter().filter(|a| a.page == i && a.in_reply_to.is_none() && !gesturing) {
let sr = xf.user_rect(info, i, a.rect);
if sr.contains(p) && hover_text.is_none() {
let who = a.author.clone().unwrap_or_else(|| a.subtype.clone());
@@ -946,6 +1030,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
view.page_input = (current + 1).to_string();
}
}
+ resp.context_menu(|ui| {
+ canvas_action = comments::context_menu(ui, view, info, prefs, allowed);
+ });
(wanted, visible_now)
});
@@ -975,7 +1062,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
if want_thumbs {
let s = THUMB_W * ppp / info.pages.iter().map(|p| p.width).fold(1.0, f32::max);
for page in 0..info.pages.len() {
- if !view.thumbs.contains_key(&page) && !view.errors.contains_key(&page) {
+ if (!view.thumbs.contains_key(&page) || view.stale_thumbs.contains(&page)) && !view.errors.contains_key(&page) {
queue.push(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: s, tag: THUMB_TAG });
}
}
@@ -997,12 +1084,23 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
});
}
find_bar(view, info.pages.len(), avail, ui, &t);
+ if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) {
+ view.pending_edit = Some(e);
+ }
+ let mut tool = app.quick_tool;
+ comments::keys(ui.ctx(), view, &mut tool, allowed);
+ match canvas_action {
+ Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(e),
+ Some(comments::CanvasAction::OpenComments) => app.right = Some(RightPanel::Comments),
+ None => {}
+ }
match clicked_link {
Some(LinkTarget::Page(p)) => view.go_to_page(p),
Some(LinkTarget::Uri(u)) => ui.ctx().open_url(egui::OpenUrl::new_tab(u)),
Some(LinkTarget::Other(s)) => app.notify(format!("{s} actions run in the JavaScript engine (M6)")),
None => {}
}
+ app.quick_tool = tool;
quick_bar(app, avail, ui);
}
@@ -1135,15 +1233,64 @@ fn quick_bar(app: &mut PrintCraftApp, area: Rect, ui: &mut egui::Ui) {
if icons::button(ui, "hand", 32.0, app.quick_tool == QuickTool::Hand, "Hand (H)").clicked() {
app.quick_tool = QuickTool::Hand;
}
- for (icon, tip, cmd) in [
- ("message-square-text", "Add a comment (M5)", "comment.note"),
- ("highlighter", "Highlight text (M5)", "comment.highlight"),
- ("pencil", "Draw freehand (M5)", "comment.ink"),
- ("pen-line", "Fill & Sign (M5)", "sign.fill.text"),
- ] {
- if icons::button(ui, icon, 32.0, false, tip).clicked() {
- app.run_command(cmd);
+ // Comment ▸, Highlight ▸, Draw ▸ (Acrobat's comment toolbar groups). Clicking a
+ // group selects its last-used tool; clicking it again opens the flyout.
+ for g in 0..comments::GROUPS.len() {
+ let current = app.comment_prefs.group_tool[g];
+ let active = matches!(app.quick_tool, QuickTool::Comment(t) if t.group() == g);
+ let resp = icons::button(ui, current.icon(), 32.0, active, current.label());
+ // A small corner triangle marks the flyout.
+ let r = resp.rect;
+ let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)];
+ ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if active { Color32::WHITE } else { t.text_muted }, Stroke::NONE));
+ let open = (resp.clicked() && active) || resp.secondary_clicked();
+ if resp.clicked() && !active {
+ app.execute(current.command());
}
+ egui::Popup::menu(&resp)
+ .open_memory(open.then_some(egui::SetOpenCommand::Toggle))
+ .align(egui::RectAlign::RIGHT_START)
+ .gap(6.0)
+ .show(|ui| {
+ for tool in comments::GROUPS[g] {
+ let on = app.quick_tool == QuickTool::Comment(*tool);
+ let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click());
+ if click.hovered() {
+ ui.painter().rect_filled(row, CornerRadius::same(4), t.hover);
+ }
+ icons::paint(ui, Rect::from_min_size(row.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
+ ui.painter().text(
+ row.left_center() + vec2(34.0, 0.0),
+ Align2::LEFT_CENTER,
+ tool.label(),
+ theme::regular(13.0),
+ t.text,
+ );
+ if on {
+ icons::paint(
+ ui,
+ Rect::from_min_size(row.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)),
+ "check",
+ 14.0,
+ t.accent,
+ );
+ }
+ let click = click.on_hover_cursor(egui::CursorIcon::PointingHand);
+ click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tool.label()));
+ if click.clicked() {
+ app.execute(tool.command());
+ ui.close();
+ }
+ }
+ });
+ }
+ if icons::button(ui, "pen-line", 32.0, false, "Fill & Sign (M5)").clicked() {
+ app.run_command("sign.fill.text");
+ }
+ if let QuickTool::Comment(tool) = app.quick_tool {
+ let (r, _) = ui.allocate_exact_size(vec2(32.0, 9.0), Sense::hover());
+ ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider));
+ comments::quick_bar_controls(ui, tool, &mut app.comment_prefs);
}
});
});
@@ -1290,7 +1437,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable
});
let s = THUMB_W * ppp / info.pages.iter().map(|p| p.width).fold(1.0, f32::max);
let queue: Vec = (0..info.pages.len())
- .filter(|p| !view.thumbs.contains_key(p) && !view.errors.contains_key(p))
+ .filter(|p| (!view.thumbs.contains_key(p) || view.stale_thumbs.contains(p)) && !view.errors.contains_key(p))
.map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: s, tag: THUMB_TAG })
.collect();
if queue != view.last_queue {
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index 867c7da1..3a52ffa9 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -22,7 +22,7 @@ impl PrintCraftApp {
let why = match spec.needs {
commands::Needs::Undo => "Nothing to undo".to_string(),
commands::Needs::Redo => "Nothing to redo".to_string(),
- commands::Needs::Assembly | commands::Needs::Modification if self.active.is_some() => {
+ commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => {
"The document's security settings don't allow this change".to_string()
}
_ => "Open a document first".to_string(),
@@ -86,6 +86,32 @@ impl PrintCraftApp {
}
}
"comment.list" => self.right = Some(RightPanel::Comments),
+ tool if crate::comments::CommentTool::from_command(tool).is_some() => {
+ let tool = crate::comments::CommentTool::from_command(tool).expect("checked");
+ self.comment_prefs.group_tool[tool.group()] = tool;
+ self.quick_tool = crate::QuickTool::Comment(tool);
+ // Acrobat opens the Comments panel with the commenting tools.
+ if self.right.is_none() {
+ self.right = Some(RightPanel::Comments);
+ }
+ // A text selection made before picking a markup tool is marked right away.
+ if let (Some(kind), Some(i)) = (tool.markup(), active) {
+ let info = &self.session.get(self.views[i].id).expect("active").info;
+ if let Some((page, quads)) = self.views[i].selection_quads(info) {
+ self.views[i].clear_selection();
+ let style = self.comment_prefs.style(tool);
+ let author = self.comment_prefs.author.clone();
+ let shape = printcraft_engine::Shape::TextMarkup { kind, quads };
+ self.apply_edit(Edit::AddAnnotation(printcraft_engine::NewAnnotation {
+ page,
+ shape,
+ style,
+ contents: String::new(),
+ author,
+ }));
+ }
+ }
+ }
"form.fields" => self.right = Some(RightPanel::Fields),
"page.organize" => {
if let Some(i) = active {
diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs
new file mode 100644
index 00000000..81efe5db
--- /dev/null
+++ b/crates/ui-egui/src/comments.rs
@@ -0,0 +1,952 @@
+//! Commenting in the document view (execution plan M5.3–M5.4; Acrobat's "Add comments").
+//!
+//! - **Tools** ([`CommentTool`]): sticky note, text box, highlight / underline / strikethrough
+//! (driven by text selection), freehand, line, arrow, rectangle, oval. They live in the quick
+//! bar in three flyout groups, as in Acrobat's comment toolbar (audit `a14-*`).
+//! - **On the page:** drawing gestures with live previews; with the Select tool, comments can be
+//! hovered, selected (blue frame with 8 round handles), moved, resized (rectangles, ovals and
+//! text boxes), edited (double-click) and deleted (⌫ / Delete); a context menu offers Edit,
+//! Reply, Set status, Colour and Delete.
+//! - **Composer:** the floating "Add a comment" card used for new notes, text boxes and edits.
+//!
+//! Everything is turned into `printcraft_engine::Edit`s, queued on the view as `pending_edit` and
+//! applied by the app, so each change is one undo step.
+
+use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, pos2, vec2};
+use printcraft_engine::{Edit, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style};
+use printcraft_render::{Annotation, DocInfo};
+
+use crate::canvas::{DocView, PageXform};
+use crate::theme::{self, Tokens};
+use crate::{QuickTool, icons};
+
+/// Acrobat's selection blue for annotation frames and handles (11-visual-spec §5).
+const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum CommentTool {
+ Note,
+ TextBox,
+ Highlight,
+ Underline,
+ StrikeOut,
+ Ink,
+ Line,
+ Arrow,
+ Rectangle,
+ Oval,
+}
+
+/// The quick-bar flyout groups, in Acrobat's order: Comment ▸, Highlight ▸, Draw ▸.
+pub const GROUPS: [&[CommentTool]; 3] = [
+ &[CommentTool::Note, CommentTool::TextBox],
+ &[CommentTool::Highlight, CommentTool::Underline, CommentTool::StrikeOut],
+ &[CommentTool::Ink, CommentTool::Line, CommentTool::Arrow, CommentTool::Rectangle, CommentTool::Oval],
+];
+
+pub const ALL: [CommentTool; 10] = [
+ CommentTool::Note,
+ CommentTool::TextBox,
+ CommentTool::Highlight,
+ CommentTool::Underline,
+ CommentTool::StrikeOut,
+ CommentTool::Ink,
+ CommentTool::Line,
+ CommentTool::Arrow,
+ CommentTool::Rectangle,
+ CommentTool::Oval,
+];
+
+impl CommentTool {
+ pub fn command(self) -> &'static str {
+ match self {
+ Self::Note => "comment.note",
+ Self::TextBox => "comment.freetext",
+ Self::Highlight => "comment.highlight",
+ Self::Underline => "comment.underline",
+ Self::StrikeOut => "comment.strikeout",
+ Self::Ink => "comment.ink",
+ Self::Line => "comment.line",
+ Self::Arrow => "comment.arrow",
+ Self::Rectangle => "comment.square",
+ Self::Oval => "comment.circle",
+ }
+ }
+
+ pub fn from_command(id: &str) -> Option {
+ ALL.into_iter().find(|t| t.command() == id)
+ }
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::Note => "Add a sticky note",
+ Self::TextBox => "Add a text box",
+ Self::Highlight => "Highlight",
+ Self::Underline => "Underline",
+ Self::StrikeOut => "Strikethrough",
+ Self::Ink => "Draw",
+ Self::Line => "Line",
+ Self::Arrow => "Arrow",
+ Self::Rectangle => "Rectangle",
+ Self::Oval => "Oval",
+ }
+ }
+
+ pub fn icon(self) -> &'static str {
+ match self {
+ Self::Note => "message-square-plus",
+ Self::TextBox => "type",
+ Self::Highlight => "highlighter",
+ Self::Underline => "underline",
+ Self::StrikeOut => "strikethrough",
+ Self::Ink => "pencil",
+ Self::Line => "minus",
+ Self::Arrow => "move-right",
+ Self::Rectangle => "square",
+ Self::Oval => "circle",
+ }
+ }
+
+ pub fn group(self) -> usize {
+ GROUPS.iter().position(|g| g.contains(&self)).unwrap_or(0)
+ }
+
+ pub fn markup(self) -> Option {
+ match self {
+ Self::Highlight => Some(Markup::Highlight),
+ Self::Underline => Some(Markup::Underline),
+ Self::StrikeOut => Some(Markup::StrikeOut),
+ _ => None,
+ }
+ }
+
+ /// Tools that draw with a drag gesture.
+ pub fn draws(self) -> bool {
+ matches!(self, Self::Ink | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval)
+ }
+
+ /// Whether the line-thickness control applies.
+ pub fn has_width(self) -> bool {
+ self.draws()
+ }
+
+ /// A placeholder shape of this kind (for per-tool default styles).
+ fn sample(self) -> Shape {
+ match self {
+ Self::Note => Shape::Note { at: [0.0; 2], icon: NoteIcon::Comment },
+ Self::TextBox => Shape::TextBox { rect: [0.0; 4], font_size: 12.0 },
+ Self::Highlight | Self::Underline | Self::StrikeOut => {
+ Shape::TextMarkup { kind: self.markup().unwrap_or(Markup::Highlight), quads: Vec::new() }
+ }
+ Self::Ink => Shape::Ink { strokes: Vec::new() },
+ Self::Line => Shape::Line { from: [0.0; 2], to: [0.0; 2], arrow: false },
+ Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], arrow: true },
+ Self::Rectangle => Shape::Rectangle { rect: [0.0; 4] },
+ Self::Oval => Shape::Oval { rect: [0.0; 4] },
+ }
+ }
+}
+
+/// The comment swatches (our palette, close to Acrobat's quick colours).
+pub const SWATCHES: [(&str, Rgb); 10] = [
+ ("Yellow", [1.0, 0.94, 0.0]),
+ ("Orange", [1.0, 0.54, 0.0]),
+ ("Red", [0.89, 0.13, 0.13]),
+ ("Pink", [1.0, 0.37, 0.64]),
+ ("Purple", [0.54, 0.25, 0.82]),
+ ("Blue", [0.0, 0.47, 0.84]),
+ ("Light blue", [0.36, 0.75, 0.98]),
+ ("Green", [0.18, 0.62, 0.36]),
+ ("Gray", [0.5, 0.5, 0.5]),
+ ("Black", [0.0, 0.0, 0.0]),
+];
+
+pub fn color32(c: Rgb) -> Color32 {
+ let b = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
+ Color32::from_rgb(b(c[0]), b(c[1]), b(c[2]))
+}
+
+/// Commenting preferences shared by every document (author, per-tool styles, pin).
+#[derive(Clone, Debug)]
+pub struct CommentPrefs {
+ /// `/T` of new comments (Acrobat: Preferences ▸ Identity, default the login name).
+ pub author: String,
+ styles: Vec<(CommentTool, Style)>,
+ /// Keep the tool selected after use (the pin).
+ pub pinned: bool,
+ /// The tool each flyout group shows (the last one used).
+ pub group_tool: [CommentTool; 3],
+}
+
+impl Default for CommentPrefs {
+ fn default() -> Self {
+ Self {
+ author: login_name(),
+ styles: ALL.iter().map(|t| (*t, Style::default_for(&t.sample()))).collect(),
+ pinned: false,
+ group_tool: [CommentTool::Note, CommentTool::Highlight, CommentTool::Ink],
+ }
+ }
+}
+
+impl CommentPrefs {
+ pub fn style(&self, tool: CommentTool) -> Style {
+ self.styles.iter().find(|(t, _)| *t == tool).map(|(_, s)| s.clone()).unwrap_or_default()
+ }
+
+ pub fn set_color(&mut self, tool: CommentTool, c: Rgb) {
+ if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
+ s.color = c;
+ }
+ }
+
+ pub fn set_width(&mut self, tool: CommentTool, w: f64) {
+ if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
+ s.width = w.clamp(0.5, 12.0);
+ }
+ }
+}
+
+/// The user's login name, which Acrobat uses as the default comment author.
+fn login_name() -> String {
+ #[cfg(not(target_arch = "wasm32"))]
+ for var in ["USER", "USERNAME", "LOGNAME"] {
+ if let Ok(v) = std::env::var(var)
+ && !v.trim().is_empty()
+ {
+ return v;
+ }
+ }
+ "Guest".into()
+}
+
+/// A drag in progress on a page.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Gesture {
+ /// Drawing with a tool: points in user space (ink: the stroke; others: start and end).
+ Draw { page: usize, tool: CommentTool, points: Vec<[f64; 2]> },
+ /// Moving a comment, from the press position on screen.
+ Move { page: usize, index: usize, from: Pos2 },
+ /// Resizing a comment by one of its handles: (dx, dy) ∈ {-1, 0, 1}² says which sides move.
+ Resize { page: usize, index: usize, handle: (i8, i8), from: Pos2 },
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ComposerKind {
+ Note,
+ TextBox,
+ /// Edit the text of the comment at this index on the composer's page.
+ Edit(usize),
+}
+
+/// The floating "Add a comment" card.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Composer {
+ pub page: usize,
+ /// Anchor in user space (the note's top-left, the text box's top-left, the comment's corner).
+ pub at: [f64; 2],
+ pub kind: ComposerKind,
+ pub text: String,
+ pub focus: bool,
+}
+
+/// Per-document commenting state.
+#[derive(Clone, Debug, Default)]
+pub struct CommentView {
+ /// The selected comment: (page, index in `/Annots`).
+ pub selected: Option<(usize, usize)>,
+ pub gesture: Option,
+ pub composer: Option,
+ /// Reply being typed under the selected card.
+ pub reply: String,
+ /// Inline edit of a card's text in the Comments panel: (page, index, text).
+ pub editing: Option<(usize, usize, String)>,
+ /// The panel should scroll the selected card into view.
+ pub reveal: bool,
+ /// The "Add a comment" box at the top of the Comments panel.
+ pub add_box: String,
+ /// The Comments panel's search query (`None`: search closed).
+ pub search: Option,
+ pub search_focus: bool,
+ /// Where the canvas context menu was opened: (page, user-space point).
+ pub context_at: Option<(usize, [f64; 2])>,
+ /// A one-shot tool just finished; the app returns to the Select tool unless pinned.
+ pub tool_done: bool,
+}
+
+/// What a page needs to know to handle comment input.
+pub(crate) struct PageCx<'a> {
+ pub page: usize,
+ pub xf: &'a PageXform,
+ pub info: &'a DocInfo,
+ pub tool: QuickTool,
+ pub prefs: &'a CommentPrefs,
+ /// The document allows commenting.
+ pub allowed: bool,
+}
+
+impl PageCx<'_> {
+ fn to_user(&self, p: Pos2) -> [f64; 2] {
+ let (vx, vy) = self.xf.screen_to_view(p);
+ let u = self.info.pages[self.page].view_to_user(vx, vy);
+ [u[0] as f64, u[1] as f64]
+ }
+
+ fn to_screen(&self, p: [f64; 2]) -> Pos2 {
+ let v = self.info.pages[self.page].user_to_view(p[0] as f32, p[1] as f32);
+ self.xf.norm_to_screen(v[0] / self.xf.pw, v[1] / self.xf.ph)
+ }
+
+ fn screen_rect(&self, a: &Annotation) -> Rect {
+ self.xf.user_rect(self.info, self.page, a.rect)
+ }
+
+ /// Top-level comments on this page, in paint order.
+ fn comments(&self) -> impl Iterator- {
+ self.info.annotations.iter().filter(move |a| a.page == self.page && a.in_reply_to.is_none() && a.subtype != "Popup")
+ }
+
+ /// Where a comment is on screen: one rectangle, or one per marked line for text markup.
+ fn screen_rects(&self, a: &Annotation) -> Vec {
+ if is_markup(&a.subtype) && !a.quads.is_empty() {
+ a.quads
+ .iter()
+ .map(|q| {
+ let xs = [q[0], q[2], q[4], q[6]];
+ let ys = [q[1], q[3], q[5], q[7]];
+ let (x0, x1) = (xs.iter().copied().fold(f32::MAX, f32::min), xs.iter().copied().fold(f32::MIN, f32::max));
+ let (y0, y1) = (ys.iter().copied().fold(f32::MAX, f32::min), ys.iter().copied().fold(f32::MIN, f32::max));
+ self.xf.user_rect(self.info, self.page, [x0, y0, x1, y1])
+ })
+ .collect()
+ } else {
+ vec![self.screen_rect(a)]
+ }
+ }
+
+ /// The topmost comment under `p`.
+ fn hit(&self, p: Pos2) -> Option<&Annotation> {
+ self.comments().filter(|a| self.screen_rects(a).iter().any(|r| r.expand(3.0).contains(p))).last()
+ }
+
+ fn get(&self, index: usize) -> Option<&Annotation> {
+ self.comments().find(|a| a.index == index)
+ }
+}
+
+fn is_markup(subtype: &str) -> bool {
+ matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly")
+}
+
+fn resizable(subtype: &str) -> bool {
+ matches!(subtype, "Square" | "Circle" | "FreeText")
+}
+
+const HANDLES: [(i8, i8); 8] = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)];
+
+fn handle_pos(r: Rect, (hx, hy): (i8, i8)) -> Pos2 {
+ let x = match hx {
+ -1 => r.left(),
+ 0 => r.center().x,
+ _ => r.right(),
+ };
+ let y = match hy {
+ -1 => r.top(),
+ 0 => r.center().y,
+ _ => r.bottom(),
+ };
+ pos2(x, y)
+}
+
+fn resized(r: Rect, (hx, hy): (i8, i8), d: egui::Vec2) -> Rect {
+ let mut r = r;
+ match hx {
+ -1 => r.min.x += d.x,
+ 1 => r.max.x += d.x,
+ _ => {}
+ }
+ match hy {
+ -1 => r.min.y += d.y,
+ 1 => r.max.y += d.y,
+ _ => {}
+ }
+ Rect::from_two_pos(r.min, r.max)
+}
+
+/// Comment input on one page, before text selection runs. Returns `true` when the pointer
+/// gesture belongs to commenting (text selection must ignore it).
+pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) -> bool {
+ let pointer = ui.input(|i| i.pointer.hover_pos());
+ let origin = ui.input(|i| i.pointer.press_origin());
+ let page_rect = cx.xf.rect;
+ let pressed_here = origin.is_some_and(|o| page_rect.contains(o));
+ let over_page = pointer.is_some_and(|p| page_rect.contains(p));
+ let cv = &mut view.comments;
+ if resp.secondary_clicked()
+ && let Some(p) = pointer.filter(|p| page_rect.contains(*p))
+ {
+ cv.context_at = Some((cx.page, cx.to_user(p)));
+ cv.selected = cx.hit(p).map(|a| (cx.page, a.index));
+ }
+ match cx.tool {
+ QuickTool::Comment(tool) if tool.draws() => {
+ if !cx.allowed {
+ return false;
+ }
+ if over_page {
+ ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
+ }
+ if resp.drag_started()
+ && pressed_here
+ && let Some(o) = origin
+ {
+ cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] });
+ cv.selected = None;
+ }
+ if let Some(Gesture::Draw { page, tool, points }) = cv.gesture.as_mut()
+ && *page == cx.page
+ && let Some(p) = pointer
+ {
+ let p = clamp_to(page_rect, p);
+ let u = cx.to_user(p);
+ if *tool == CommentTool::Ink {
+ let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p);
+ if last.distance(p) >= 1.5 {
+ points.push(u);
+ }
+ } else {
+ points.truncate(1);
+ points.push(u);
+ }
+ }
+ if resp.drag_stopped()
+ && let Some(Gesture::Draw { page, tool, points }) = cv.gesture.clone()
+ && page == cx.page
+ {
+ cv.gesture = None;
+ if let Some(shape) = drawn_shape(tool, &points) {
+ view.pending_edit = Some(new_comment(cx, tool, shape, String::new()));
+ }
+ }
+ true
+ }
+ QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => {
+ 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
+ {
+ let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox };
+ cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true });
+ cv.selected = None;
+ }
+ true
+ }
+ QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin, pressed_here),
+ _ => false,
+ }
+}
+
+fn clamp_to(r: Rect, p: Pos2) -> Pos2 {
+ pos2(p.x.clamp(r.left(), r.right()), p.y.clamp(r.top(), r.bottom()))
+}
+
+/// The Select tool: hover, select, move, resize, edit, delete.
+fn select_input(
+ ui: &egui::Ui,
+ resp: &egui::Response,
+ cx: &PageCx<'_>,
+ view: &mut DocView,
+ pointer: Option,
+ origin: Option,
+ pressed_here: bool,
+) -> bool {
+ let cv = &mut view.comments;
+ let selected = cv.selected.filter(|(p, _)| *p == cx.page).and_then(|(_, i)| cx.get(i));
+ // Handles of a selected, resizable comment.
+ let handle_at = |p: Pos2| -> Option<(i8, i8)> {
+ let a = selected.filter(|a| cx.allowed && resizable(&a.subtype))?;
+ let r = cx.screen_rect(a);
+ HANDLES.into_iter().find(|h| handle_pos(r, *h).distance(p) <= 7.0)
+ };
+ let mut consumed = false;
+ if let Some(p) = pointer {
+ if let Some(h) = handle_at(p) {
+ ui.ctx().set_cursor_icon(match h {
+ (0, _) => egui::CursorIcon::ResizeVertical,
+ (_, 0) => egui::CursorIcon::ResizeHorizontal,
+ (-1, -1) | (1, 1) => egui::CursorIcon::ResizeNwSe,
+ _ => egui::CursorIcon::ResizeNeSw,
+ });
+ } else if let Some(a) = cx.hit(p) {
+ let movable = cx.allowed && !is_markup(&a.subtype);
+ ui.ctx().set_cursor_icon(if movable { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand });
+ }
+ }
+ if resp.drag_started()
+ && pressed_here
+ && let Some(o) = origin
+ {
+ if let (Some(h), Some(a)) = (handle_at(o), selected) {
+ cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: h, from: o });
+ consumed = true;
+ } else if let Some(a) = cx.hit(o)
+ && !is_markup(&a.subtype)
+ {
+ cv.selected = Some((cx.page, a.index));
+ cv.reveal = true;
+ if cx.allowed {
+ cv.gesture = Some(Gesture::Move { page: cx.page, index: a.index, from: o });
+ }
+ consumed = true;
+ }
+ }
+ if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) {
+ consumed = true;
+ }
+ if resp.drag_stopped()
+ && let Some(p) = pointer
+ {
+ match cv.gesture.clone() {
+ Some(Gesture::Move { page, index, from }) if page == cx.page => {
+ cv.gesture = None;
+ let (a, b) = (cx.to_user(from), cx.to_user(p));
+ let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
+ if from.distance(p) >= 2.0 {
+ view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy });
+ }
+ }
+ Some(Gesture::Resize { page, index, handle, from }) if page == cx.page => {
+ cv.gesture = None;
+ if let Some(a) = cx.get(index) {
+ let r = resized(cx.screen_rect(a), handle, p - from);
+ let (u0, u1) = (cx.to_user(r.left_top()), cx.to_user(r.right_bottom()));
+ let rect = [u0[0].min(u1[0]), u0[1].min(u1[1]), u0[0].max(u1[0]), u0[1].max(u1[1])];
+ if r.width() >= 4.0 && r.height() >= 4.0 {
+ view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect });
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ // (egui clears the press origin on release, so clicks are located by the pointer.)
+ let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p));
+ if resp.clicked() && on_page {
+ match pointer.and_then(|p| cx.hit(p)) {
+ Some(a) => {
+ cv.selected = Some((cx.page, a.index));
+ cv.reveal = true;
+ consumed = true;
+ }
+ None => cv.selected = None,
+ }
+ }
+ if resp.double_clicked()
+ && on_page
+ && cx.allowed
+ && let Some(a) = pointer.and_then(|p| cx.hit(p))
+ {
+ cv.selected = Some((cx.page, a.index));
+ cv.composer = Some(Composer {
+ page: cx.page,
+ at: [a.rect[2] as f64, a.rect[3] as f64],
+ kind: ComposerKind::Edit(a.index),
+ text: a.contents.clone().unwrap_or_default(),
+ focus: true,
+ });
+ consumed = true;
+ }
+ consumed
+}
+
+/// After text selection ran: a markup tool turns a finished selection into a comment.
+pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) {
+ let QuickTool::Comment(tool) = cx.tool else { return };
+ if tool.markup().is_none() || !cx.allowed || !(resp.drag_stopped() || resp.double_clicked()) {
+ return;
+ }
+ if let Some((page, quads)) = view.selection_quads(cx.info).filter(|(p, _)| *p == cx.page) {
+ view.clear_selection();
+ let kind = tool.markup().expect("checked");
+ view.pending_edit = Some(new_comment(cx, tool, Shape::TextMarkup { kind, quads }, String::new()));
+ let _ = page;
+ }
+}
+
+/// Paint comment selection, hover and gesture previews on a page.
+pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
+ let cv = &view.comments;
+ let pointer = ui.input(|i| i.pointer.hover_pos());
+ if cx.tool == QuickTool::Select
+ && cv.gesture.is_none()
+ && let Some(a) = pointer.and_then(|p| cx.hit(p))
+ && cv.selected != Some((cx.page, a.index))
+ {
+ for r in cx.screen_rects(a) {
+ painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside);
+ }
+ }
+ if let Some((page, index)) = cv.selected
+ && page == cx.page
+ && let Some(a) = cx.get(index)
+ {
+ if is_markup(&a.subtype) && !a.quads.is_empty() {
+ for r in cx.screen_rects(a) {
+ painter.rect_stroke(r.expand(2.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle);
+ }
+ return paint_gesture(painter, cx, view);
+ }
+ let mut r = cx.screen_rect(a).expand(2.0);
+ if let (Some(p), Some(g)) = (pointer, &cv.gesture) {
+ match g {
+ Gesture::Move { page, index: gi, from } if *page == cx.page && *gi == index => r = r.translate(p - *from),
+ Gesture::Resize { page, index: gi, handle, from } if *page == cx.page && *gi == index => r = resized(r, *handle, p - *from),
+ _ => {}
+ }
+ }
+ painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle);
+ if cx.allowed && resizable(&a.subtype) {
+ for h in HANDLES {
+ let c = handle_pos(r, h);
+ painter.circle(c, 4.5, Color32::WHITE, Stroke::new(1.0, SELECT_BLUE));
+ }
+ }
+ }
+ paint_gesture(painter, cx, view);
+}
+
+fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
+ if let Some(Gesture::Draw { page, tool, points }) = &view.comments.gesture
+ && *page == cx.page
+ {
+ let style = cx.prefs.style(*tool);
+ let zoom = cx.xf.rect.width() / cx.xf.pw.max(1.0);
+ let stroke = Stroke::new((style.width as f32 * zoom).max(1.0), color32(style.color));
+ let pts: Vec = points.iter().map(|p| cx.to_screen(*p)).collect();
+ match tool {
+ CommentTool::Ink => {
+ painter.add(egui::Shape::line(pts, stroke));
+ }
+ CommentTool::Line | CommentTool::Arrow if pts.len() == 2 => {
+ painter.line_segment([pts[0], pts[1]], stroke);
+ if *tool == CommentTool::Arrow {
+ let d = (pts[0] - pts[1]).normalized();
+ let s = (6.0 + 3.0 * style.width as f32) * zoom;
+ let rot = |v: egui::Vec2, a: f32| vec2(v.x * a.cos() - v.y * a.sin(), v.x * a.sin() + v.y * a.cos());
+ for a in [0.52f32, -0.52] {
+ painter.line_segment([pts[1], pts[1] + rot(d, a) * s], stroke);
+ }
+ }
+ }
+ CommentTool::Rectangle if pts.len() == 2 => {
+ painter.rect_stroke(Rect::from_two_pos(pts[0], pts[1]), CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
+ }
+ CommentTool::Oval if pts.len() == 2 => {
+ let r = Rect::from_two_pos(pts[0], pts[1]);
+ painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, stroke));
+ }
+ _ => {}
+ }
+ }
+}
+
+/// The shape a finished drawing gesture makes (`None` if it is too small to mean anything).
+fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option {
+ let (first, last) = (*points.first()?, *points.last()?);
+ let far = (first[0] - last[0]).hypot(first[1] - last[1]) >= 2.0;
+ let rect = [first[0].min(last[0]), first[1].min(last[1]), first[0].max(last[0]), first[1].max(last[1])];
+ let big = rect[2] - rect[0] >= 2.0 && rect[3] - rect[1] >= 2.0;
+ match tool {
+ CommentTool::Ink if points.len() >= 2 => Some(Shape::Ink { strokes: vec![points.to_vec()] }),
+ CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line { from: first, to: last, arrow: tool == CommentTool::Arrow }),
+ CommentTool::Rectangle if big => Some(Shape::Rectangle { rect }),
+ CommentTool::Oval if big => Some(Shape::Oval { rect }),
+ _ => None,
+ }
+}
+
+fn new_comment(cx: &PageCx<'_>, tool: CommentTool, shape: Shape, contents: String) -> Edit {
+ Edit::AddAnnotation(NewAnnotation { page: cx.page, shape, style: cx.prefs.style(tool), contents, author: cx.prefs.author.clone() })
+}
+
+/// The floating composer card (new note, new text box, edit). Returns the edit to apply.
+pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs) -> Option {
+ let c = view.comments.composer.as_ref()?;
+ let page = c.page;
+ let Some(xf) = view.page_xform(page) else {
+ // Scrolled away: keep the draft until the page is back.
+ return None;
+ };
+ let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true };
+ let anchor = cx.to_screen(c.at);
+ let t = Tokens::get(ctx);
+ let mut post = false;
+ let mut cancel = false;
+ let c = view.comments.composer.as_mut().expect("checked");
+ let title = match c.kind {
+ ComposerKind::Note => "Sticky note",
+ ComposerKind::TextBox => "Text box",
+ ComposerKind::Edit(_) => "Edit comment",
+ };
+ let pos = pos2(anchor.x + 12.0, anchor.y);
+ egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
+ egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| {
+ ui.set_width(260.0);
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(&prefs.author).font(theme::semibold(13.0)));
+ ui.label(egui::RichText::new(title).font(theme::regular(11.5)).color(t.text_faint));
+ });
+ ui.add_space(6.0);
+ let hint = if c.kind == ComposerKind::TextBox { "Type text" } else { "Add a comment" };
+ let edit =
+ ui.add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text"));
+ if c.focus {
+ edit.request_focus();
+ c.focus = false;
+ }
+ let enter = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command);
+ if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
+ cancel = true;
+ }
+ ui.add_space(8.0);
+ ui.horizontal(|ui| {
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ let can_post = !c.text.trim().is_empty() || matches!(c.kind, ComposerKind::Edit(_));
+ if ui
+ .add_enabled(can_post, egui::Button::new(egui::RichText::new("Post").color(Color32::WHITE)).fill(t.accent).corner_radius(14))
+ .clicked()
+ || (enter && can_post)
+ {
+ post = true;
+ }
+ if ui.add(egui::Button::new("Cancel").corner_radius(14)).clicked() {
+ cancel = true;
+ }
+ });
+ });
+ });
+ });
+ if cancel {
+ view.comments.composer = None;
+ return None;
+ }
+ if !post {
+ return None;
+ }
+ let c = view.comments.composer.take()?;
+ let text = c.text.trim_end().to_string();
+ match c.kind {
+ ComposerKind::Edit(index) => Some(Edit::SetAnnotationContents { page, index, text }),
+ ComposerKind::Note => {
+ view.comments.tool_done = true;
+ Some(new_comment(&cx, CommentTool::Note, Shape::Note { at: c.at, icon: NoteIcon::Comment }, text))
+ }
+ ComposerKind::TextBox => {
+ view.comments.tool_done = true;
+ Some(new_comment(&cx, CommentTool::TextBox, Shape::TextBox { rect: text_box_rect(c.at, &text, 12.0), font_size: 12.0 }, text))
+ }
+ }
+}
+
+/// A text box sized to its text (at most 300 pt wide), hanging from its top-left corner.
+pub fn text_box_rect(at: [f64; 2], text: &str, size: f64) -> [f64; 4] {
+ use printcraft_engine::annot_text::{text_width, wrap};
+ let pad = 2.0;
+ let longest = text.lines().map(|l| text_width(l, size)).fold(0.0, f64::max);
+ let w = (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0);
+ let lines = wrap(text, size, w - 2.0 * pad).len().max(1);
+ let h = lines as f64 * size * 1.2 + 2.0 * pad + 2.0;
+ [at[0], at[1] - h, at[0] + w, at[1]]
+}
+
+/// Delete / Escape handling for comments (only while no text field has focus).
+pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) {
+ if ctx.egui_wants_keyboard_input() {
+ return;
+ }
+ let cv = &mut view.comments;
+ if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
+ // One step back per press: cancel the gesture, then deselect, then drop the tool.
+ let cancelled = cv.gesture.take().is_some() || cv.selected.take().is_some();
+ if !cancelled && matches!(tool, QuickTool::Comment(_)) {
+ *tool = QuickTool::Select;
+ }
+ }
+ if allowed
+ && let Some((page, index)) = cv.selected
+ && ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
+ {
+ cv.selected = None;
+ view.pending_edit = Some(Edit::DeleteAnnotation { page, index });
+ }
+}
+
+/// Items of the canvas context menu for the selected comment (or the page).
+pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option {
+ let mut action = None;
+ let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && a.in_reply_to.is_none()));
+ match selected {
+ Some(a) => {
+ let (page, index) = (a.page, a.index);
+ if ui.add_enabled(allowed, egui::Button::new("Edit text…")).clicked() {
+ view.comments.composer = Some(Composer {
+ page,
+ at: [a.rect[2] as f64, a.rect[3] as f64],
+ kind: ComposerKind::Edit(index),
+ text: a.contents.clone().unwrap_or_default(),
+ focus: true,
+ });
+ ui.close();
+ }
+ if ui.add_enabled(allowed, egui::Button::new("Reply")).clicked() {
+ view.comments.reveal = true;
+ action = Some(CanvasAction::OpenComments);
+ ui.close();
+ }
+ ui.add_enabled_ui(allowed, |ui| {
+ ui.menu_button("Set status", |ui| {
+ for s in [ReviewState::None, ReviewState::Accepted, ReviewState::Cancelled, ReviewState::Completed, ReviewState::Rejected] {
+ if ui.button(s.name()).clicked() {
+ action = Some(CanvasAction::Edit(Edit::SetAnnotationStatus { page, index, state: s, author: prefs.author.clone() }));
+ ui.close();
+ }
+ }
+ });
+ ui.menu_button("Colour", |ui| {
+ if let Some(c) = swatch_grid(ui, a.color.map(|c| c.map(f64::from))) {
+ action = Some(CanvasAction::Edit(Edit::StyleAnnotation { page, index, color: Some(c), opacity: None, width: None }));
+ ui.close();
+ }
+ });
+ });
+ ui.separator();
+ if ui.add_enabled(allowed, egui::Button::new("Delete")).clicked() {
+ view.comments.selected = None;
+ action = Some(CanvasAction::Edit(Edit::DeleteAnnotation { page, index }));
+ ui.close();
+ }
+ }
+ None => {
+ if let Some((page, at)) = view.comments.context_at
+ && ui.add_enabled(allowed, egui::Button::new("Add a sticky note here")).clicked()
+ {
+ view.comments.composer = Some(Composer { page, at, kind: ComposerKind::Note, text: String::new(), focus: true });
+ ui.close();
+ }
+ if ui.button("Comments panel").clicked() {
+ action = Some(CanvasAction::OpenComments);
+ ui.close();
+ }
+ }
+ }
+ action
+}
+
+/// What the canvas asks the app to do.
+#[derive(Clone, Debug, PartialEq)]
+pub enum CanvasAction {
+ Edit(Edit),
+ OpenComments,
+}
+
+/// A grid of colour swatches; returns the one clicked.
+pub fn swatch_grid(ui: &mut egui::Ui, current: Option) -> Option {
+ let mut picked = None;
+ egui::Grid::new(ui.id().with("swatches")).spacing(vec2(6.0, 6.0)).show(ui, |ui| {
+ for (i, (name, c)) in SWATCHES.iter().enumerate() {
+ let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
+ let on = current.is_some_and(|cur| cur.iter().zip(c).all(|(a, b)| (a - b).abs() < 0.02));
+ ui.painter().circle_filled(r.center(), 9.0, color32(*c));
+ ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(40)));
+ if on || resp.hovered() {
+ ui.painter().circle_stroke(r.center(), 11.0, Stroke::new(1.5, SELECT_BLUE));
+ }
+ if resp.on_hover_text(*name).clicked() {
+ picked = Some(*c);
+ }
+ if i % 5 == 4 {
+ ui.end_row();
+ }
+ }
+ });
+ picked
+}
+
+/// The comment tools' extra quick-bar controls: pin, colour and thickness.
+pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) {
+ let style = prefs.style(tool);
+ if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { "Keep tool selected: on" } else { "Keep tool selected" }).clicked() {
+ prefs.pinned = !prefs.pinned;
+ }
+ let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click());
+ ui.painter().circle_filled(r.center(), 9.0, color32(style.color));
+ ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50)));
+ let resp = resp.on_hover_text("Colour");
+ egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).show(|ui| {
+ if let Some(c) = swatch_grid(ui, Some(style.color)) {
+ prefs.set_color(tool, c);
+ ui.close();
+ }
+ });
+ if tool.has_width() {
+ let resp = icons::button(ui, "sliders-horizontal", 32.0, false, "Line thickness");
+ egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
+ ui.set_min_width(180.0);
+ let mut w = style.width;
+ ui.label(egui::RichText::new("Line thickness").font(theme::semibold(12.0)));
+ if ui.add(egui::Slider::new(&mut w, 0.5..=12.0).step_by(0.5).suffix(" pt")).changed() {
+ prefs.set_width(tool, w);
+ }
+ });
+ }
+}
+
+/// Status badge icon and label for a review state name.
+pub fn status_badge(state: &str) -> Option<(&'static str, &'static str, Color32)> {
+ match state {
+ "Accepted" => Some(("thumbs-up", "Accepted", Color32::from_rgb(0x2D, 0x9D, 0x5B))),
+ "Rejected" => Some(("thumbs-down", "Rejected", Color32::from_rgb(0xD3, 0x2F, 0x2F))),
+ "Cancelled" => Some(("circle-x", "Cancelled", Color32::from_rgb(0x8E, 0x8E, 0x8E))),
+ "Completed" => Some(("check", "Completed", Color32::from_rgb(0x2D, 0x9D, 0x5B))),
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn tools_round_trip_through_their_commands() {
+ for t in ALL {
+ assert_eq!(CommentTool::from_command(t.command()), Some(t));
+ assert!(printcraft_engine::commands::command(t.command()).is_some(), "{} is registered", t.command());
+ assert!(icons::exists(t.icon()), "icon {}", t.icon());
+ assert!(GROUPS[t.group()].contains(&t));
+ }
+ }
+
+ #[test]
+ fn drawn_shapes_ignore_slips() {
+ assert_eq!(drawn_shape(CommentTool::Rectangle, &[[0.0, 0.0], [1.0, 1.0]]), None);
+ assert_eq!(drawn_shape(CommentTool::Rectangle, &[[10.0, 0.0], [0.0, 10.0]]), Some(Shape::Rectangle { rect: [0.0, 0.0, 10.0, 10.0] }));
+ assert_eq!(drawn_shape(CommentTool::Ink, &[[0.0, 0.0]]), None);
+ assert!(matches!(drawn_shape(CommentTool::Arrow, &[[0.0, 0.0], [5.0, 0.0]]), Some(Shape::Line { arrow: true, .. })));
+ }
+
+ #[test]
+ fn text_boxes_fit_their_text() {
+ let r = text_box_rect([100.0, 500.0], "Hi", 12.0);
+ assert_eq!((r[0], r[3]), (100.0, 500.0));
+ assert!(r[2] - r[0] < 60.0);
+ let long = text_box_rect([0.0, 500.0], &"word ".repeat(80), 12.0);
+ assert_eq!(long[2], 300.0);
+ assert!(long[3] - long[1] > 50.0);
+ }
+}
diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs
new file mode 100644
index 00000000..d80c0a88
--- /dev/null
+++ b/crates/ui-egui/src/comments_panel.rs
@@ -0,0 +1,363 @@
+//! The Comments panel (Acrobat's right-hand comment list; 11-visual-spec §3, audit `a15-*`).
+//!
+//! Header "Comments N" with 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.
+
+use egui::{Align, Color32, CornerRadius, Layout, Sense, Stroke, vec2};
+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::icons;
+use crate::panels::Nav;
+use crate::theme::{self, Tokens};
+
+/// Accent bar of the selected card (11-visual-spec §3: `#0165DD`).
+const CARD_ACCENT: Color32 = Color32::from_rgb(0x01, 0x65, 0xDD);
+
+/// Reader-friendly names for annotation subtypes (the PDF names are shown in tooltips).
+pub fn subtype_label(s: &str) -> &str {
+ match s {
+ "Text" => "Note",
+ "FreeText" => "Text box",
+ "StrikeOut" => "Strikethrough",
+ "Square" => "Rectangle",
+ "Circle" => "Oval",
+ "Ink" => "Drawing",
+ "PolyLine" => "Polyline",
+ "FileAttachment" => "Attachment",
+ "Caret" => "Insert text",
+ other => other,
+ }
+}
+
+pub fn subtype_icon(s: &str) -> &'static str {
+ match s {
+ "Text" => "message-square-text",
+ "Highlight" => "highlighter",
+ "Underline" => "underline",
+ "StrikeOut" => "strikethrough",
+ "Squiggly" => "spline",
+ "FreeText" => "type",
+ "Ink" => "pencil",
+ "Square" => "square",
+ "Circle" => "circle",
+ "Line" => "move-right",
+ "Polygon" | "PolyLine" => "pen-tool",
+ "Stamp" => "stamp",
+ "FileAttachment" => "paperclip",
+ "Caret" => "text-select",
+ "Redact" => "rectangle-horizontal",
+ _ => "message-square-text",
+ }
+}
+
+fn initials(name: &str) -> String {
+ let s: String = name.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect();
+ if s.is_empty() { "?".into() } else { s.to_uppercase() }
+}
+
+/// The number shown in the header: comments and their replies (not status changes).
+pub fn count(info: &DocInfo) -> usize {
+ info.annotations.iter().filter(|a| a.state.is_none()).count()
+}
+
+fn matches(a: &Annotation, q: &str) -> bool {
+ let q = q.to_lowercase();
+ [a.author.as_deref(), a.contents.as_deref(), Some(subtype_label(&a.subtype))].into_iter().flatten().any(|s| s.to_lowercase().contains(&q))
+}
+
+/// Draw the panel body. Returns an edit to apply (posting, replying, deleting…).
+pub(crate) fn show(
+ ui: &mut egui::Ui,
+ t: &Tokens,
+ info: &DocInfo,
+ view: &mut DocView,
+ prefs: &CommentPrefs,
+ allowed: bool,
+ nav: &mut Option