M5.3/M5.4: commenting UI: quick-bar Comment/Highlight/Draw flyout groups with pin, colour and thickness; sticky note and text box composer; highlight/underline/strikethrough from text selection; freehand, line, arrow, rectangle and oval gestures with live previews; Select tool selects (per-line frames for markup), moves, resizes with 8 handles, edits and deletes comments; canvas context menu (edit, reply, set status, colour, delete); Comments panel rewrite (add-a-comment box, search, selectable cards with reply box, ... menu, inline edit, status badges); comment edits re-render only their page and keep stale rasters on screen; control channel ui.drag, secondary click, pages_on_screen and comment state; view options quick/author/comment; 7 Lucide icons; parity entries; README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
15
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 |
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-circle-x"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m15 9-6 6" />
|
||||
<path d="m9 9 6 6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
|
|
@ -0,0 +1,17 @@
|
|||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-message-square-plus"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" />
|
||||
<path d="M12 8v6" />
|
||||
<path d="M9 11h6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
|
|
@ -0,0 +1,16 @@
|
|||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-move-right"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 8L22 12L18 16" />
|
||||
<path d="M2 12H22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
|
|
@ -0,0 +1,16 @@
|
|||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-pin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 524 B |
|
|
@ -0,0 +1,16 @@
|
|||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-reply"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
|
||||
<path d="m9 17-5-5 5-5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 354 B |
|
|
@ -0,0 +1,16 @@
|
|||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-thumbs-down"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
|
||||
<path d="M17 14V2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 481 B |
|
|
@ -0,0 +1,16 @@
|
|||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-thumbs-up"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
|
||||
<path d="M7 10v12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 480 B |
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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<DocId>)
|
|||
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()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<usize, f64>,
|
||||
thumbs: HashMap<usize, TextureHandle>,
|
||||
/// Thumbnails that are out of date (still shown until their replacement arrives).
|
||||
stale_thumbs: HashSet<usize>,
|
||||
/// 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<usize>,
|
||||
/// An edit requested by the view (organize toolbar, keys), applied by the app this frame.
|
||||
pub pending_edit: Option<Edit>,
|
||||
/// 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<ViewAction>,
|
||||
}
|
||||
|
|
@ -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<PageXform> {
|
||||
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<LinkTarget> = None;
|
||||
let mut canvas_action: Option<comments::CanvasAction> = 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<RenderRequest> = (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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
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<Markup> {
|
||||
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<Gesture>,
|
||||
pub composer: Option<Composer>,
|
||||
/// 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<String>,
|
||||
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<Item = &Annotation> {
|
||||
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<Rect> {
|
||||
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<Pos2>,
|
||||
origin: Option<Pos2>,
|
||||
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<Pos2> = 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<Shape> {
|
||||
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<Edit> {
|
||||
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<CanvasAction> {
|
||||
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<Rgb>) -> Option<Rgb> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Nav>,
|
||||
) -> Option<Edit> {
|
||||
let mut edit = None;
|
||||
// Search (the header's magnifier toggles it).
|
||||
if let Some(q) = view.comments.search.as_mut() {
|
||||
let r = ui.add(egui::TextEdit::singleline(q).hint_text("Search comments").desired_width(f32::INFINITY).id_salt("comment-search"));
|
||||
if view.comments.search_focus {
|
||||
r.request_focus();
|
||||
view.comments.search_focus = false;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
// "Add a comment": a sticky note on the current page, near its top-right corner.
|
||||
if allowed {
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut view.comments.add_box)
|
||||
.hint_text("Add a comment")
|
||||
.desired_width(f32::INFINITY)
|
||||
.margin(egui::Margin::symmetric(8, 6))
|
||||
.id_salt("comment-add-box"),
|
||||
);
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !view.comments.add_box.trim().is_empty() {
|
||||
let page = view.current.min(info.pages.len().saturating_sub(1));
|
||||
if let Some(p) = info.pages.get(page) {
|
||||
let at = p.view_to_user(p.width - 44.0, 24.0);
|
||||
let text = std::mem::take(&mut view.comments.add_box);
|
||||
edit = Some(Edit::AddAnnotation(printcraft_engine::NewAnnotation {
|
||||
page,
|
||||
shape: Shape::Note { at: [at[0] as f64, at[1] as f64], icon: NoteIcon::Comment },
|
||||
style: prefs.style(CommentTool::Note),
|
||||
contents: text.trim().to_string(),
|
||||
author: prefs.author.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if info.annotations.is_empty() {
|
||||
ui.add_space(30.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add(icons::image("message-square-text", 36.0, t.text_faint));
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("No comments yet.").color(t.text_muted));
|
||||
if allowed {
|
||||
ui.label(egui::RichText::new("Pick a tool in the quick bar, or type above.").color(t.text_faint).small());
|
||||
}
|
||||
});
|
||||
return edit;
|
||||
}
|
||||
let query = view.comments.search.clone().filter(|q| !q.trim().is_empty());
|
||||
let replies_of = |a: &Annotation| -> Vec<&Annotation> {
|
||||
match &a.name {
|
||||
Some(nm) => info.annotations.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
let roots: Vec<&Annotation> = info
|
||||
.annotations
|
||||
.iter()
|
||||
.filter(|a| a.in_reply_to.is_none())
|
||||
.filter(|a| query.as_deref().is_none_or(|q| matches(a, q) || replies_of(a).iter().any(|r| matches(r, q))))
|
||||
.collect();
|
||||
if roots.is_empty() {
|
||||
ui.label(egui::RichText::new("No comments match.").color(t.text_muted));
|
||||
}
|
||||
let mut page = usize::MAX;
|
||||
for a in roots {
|
||||
if a.page != page {
|
||||
page = a.page;
|
||||
let n = info.annotations.iter().filter(|x| x.page == page && x.in_reply_to.is_none()).count();
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(icons::image("chevron-down", 14.0, t.text_muted));
|
||||
ui.label(egui::RichText::new(format!("Page {}", info.pages[page].label)).font(theme::semibold(12.5)).color(t.text_muted));
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small()));
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
let thread = replies_of(a);
|
||||
if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) {
|
||||
edit = Some(e);
|
||||
}
|
||||
}
|
||||
edit
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn card(
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
a: &Annotation,
|
||||
thread: &[&Annotation],
|
||||
view: &mut DocView,
|
||||
prefs: &CommentPrefs,
|
||||
allowed: bool,
|
||||
nav: &mut Option<Nav>,
|
||||
) -> Option<Edit> {
|
||||
let mut edit = None;
|
||||
let key = (a.page, a.index);
|
||||
let selected = view.comments.selected == Some(key);
|
||||
let color = a.color.map(|c| color32(c.map(f64::from))).unwrap_or(t.accent);
|
||||
let status = thread.iter().rev().find_map(|r| r.state.as_deref());
|
||||
let replies: Vec<&&Annotation> = thread.iter().filter(|r| r.state.is_none()).collect();
|
||||
let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key);
|
||||
let fill = if selected { t.hover.gamma_multiply(0.7) } else { Color32::TRANSPARENT };
|
||||
let mut open_menu = false;
|
||||
let frame = egui::Frame::NONE
|
||||
.fill(fill)
|
||||
.corner_radius(CornerRadius::same(6))
|
||||
.inner_margin(egui::Margin { left: 10, right: 8, top: 10, bottom: 10 })
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
let head = ui.horizontal(|ui| {
|
||||
avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color);
|
||||
ui.label(egui::RichText::new(a.author.as_deref().unwrap_or("Unknown author")).font(theme::semibold(12.5)).color(t.text));
|
||||
if let Some(m) = &a.modified {
|
||||
ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate());
|
||||
}
|
||||
if selected && allowed {
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
open_menu = icons::button(ui, "ellipsis", 22.0, false, "More").clicked();
|
||||
});
|
||||
}
|
||||
});
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 4, bottom: 0 }).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
if editing {
|
||||
let (_, _, text) = view.comments.editing.as_mut().expect("checked");
|
||||
let r = ui.add(egui::TextEdit::multiline(text).desired_rows(2).desired_width(f32::INFINITY).id_salt(("comment-edit", key)));
|
||||
if !r.has_focus() && !ui.memory(|m| m.focused().is_some()) {
|
||||
r.request_focus();
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new("Save").color(Color32::WHITE)).fill(t.accent).corner_radius(12)).clicked() {
|
||||
let (page, index, text) = view.comments.editing.take().expect("checked");
|
||||
edit = Some(Edit::SetAnnotationContents { page, index, text });
|
||||
}
|
||||
if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
view.comments.editing = None;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let body = a.contents.as_deref().unwrap_or("");
|
||||
let text = if body.is_empty() {
|
||||
egui::RichText::new(subtype_label(&a.subtype)).italics().color(t.text_faint)
|
||||
} else {
|
||||
egui::RichText::new(body).color(t.text)
|
||||
};
|
||||
let mut label = egui::Label::new(text.font(theme::regular(13.0))).wrap();
|
||||
if !selected {
|
||||
label = label.truncate();
|
||||
}
|
||||
ui.add(label);
|
||||
}
|
||||
if let Some((icon, label, c)) = status.and_then(status_badge) {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(icons::image(icon, 13.0, c));
|
||||
ui.label(egui::RichText::new(label).font(theme::medium(11.5)).color(c));
|
||||
});
|
||||
}
|
||||
});
|
||||
// Replies, joined to the parent by a thread line.
|
||||
for r in &replies {
|
||||
ui.add_space(8.0);
|
||||
let resp = ui.horizontal(|ui| {
|
||||
avatar(ui, t, None, Some(&initials(r.author.as_deref().unwrap_or("?"))), t.text_faint);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(r.author.as_deref().unwrap_or("Reply")).font(theme::semibold(12.0)));
|
||||
if let Some(m) = &r.modified {
|
||||
ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate());
|
||||
}
|
||||
});
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(r.contents.as_deref().unwrap_or("")).font(theme::regular(12.5)).color(t.text))
|
||||
.wrap(),
|
||||
);
|
||||
});
|
||||
});
|
||||
let x = resp.response.rect.left() + 12.0;
|
||||
ui.painter().vline(x, (resp.response.rect.top() - 14.0)..=(resp.response.rect.top() - 2.0), Stroke::new(1.5, t.border));
|
||||
}
|
||||
if selected && allowed {
|
||||
ui.add_space(8.0);
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 0, bottom: 0 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut view.comments.reply)
|
||||
.hint_text("Add a reply")
|
||||
.desired_width(ui.available_width() - 56.0)
|
||||
.id_salt(("comment-reply", key)),
|
||||
);
|
||||
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let ok = !view.comments.reply.trim().is_empty();
|
||||
if (ui.add_enabled(ok, egui::Button::new("Post").corner_radius(12)).clicked() || enter) && ok {
|
||||
let text = std::mem::take(&mut view.comments.reply).trim().to_string();
|
||||
edit = Some(Edit::ReplyToAnnotation { page: a.page, index: a.index, text, author: prefs.author.clone() });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
head.response.rect
|
||||
});
|
||||
let rect = frame.response.rect;
|
||||
if selected {
|
||||
let bar = egui::Rect::from_min_size(rect.min, vec2(4.0, rect.height()));
|
||||
ui.painter().rect_filled(bar, CornerRadius { nw: 6, sw: 6, ne: 0, se: 0 }, CARD_ACCENT);
|
||||
if std::mem::take(&mut view.comments.reveal) {
|
||||
frame.response.scroll_to_me(Some(Align::Center));
|
||||
}
|
||||
}
|
||||
ui.painter().hline(rect.x_range().shrink(10.0), rect.bottom() + 2.0, Stroke::new(1.0, t.divider));
|
||||
ui.add_space(5.0);
|
||||
// The header row selects the card (and shows the comment on the page); unselected cards are
|
||||
// clickable everywhere.
|
||||
let hit = if selected { frame.inner } else { rect };
|
||||
let click = ui
|
||||
.interact(hit, ui.id().with(("card", key)), Sense::click())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(format!("{} — click to show it on the page", subtype_label(&a.subtype)));
|
||||
if click.clicked() {
|
||||
view.comments.selected = Some(key);
|
||||
view.comments.reply.clear();
|
||||
*nav = Some(Nav::Flash(a.page, a.rect));
|
||||
}
|
||||
if click.double_clicked() && allowed {
|
||||
view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default()));
|
||||
}
|
||||
let menu_id = ui.id().with(("card-menu", key));
|
||||
if open_menu {
|
||||
egui::Popup::open_id(ui.ctx(), menu_id);
|
||||
}
|
||||
egui::Popup::new(menu_id, ui.ctx().clone(), egui::PopupAnchor::Pointer, ui.layer_id())
|
||||
.open_memory(None)
|
||||
.kind(egui::PopupKind::Menu)
|
||||
.layout(Layout::top_down_justified(Align::Min))
|
||||
.show(|ui| {
|
||||
ui.set_min_width(160.0);
|
||||
if ui.button("Edit").clicked() {
|
||||
view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default()));
|
||||
ui.close();
|
||||
}
|
||||
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() {
|
||||
edit = Some(Edit::SetAnnotationStatus { page: a.page, index: a.index, state: s, author: prefs.author.clone() });
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if ui.button("Delete").clicked() {
|
||||
view.comments.selected = None;
|
||||
edit = Some(Edit::DeleteAnnotation { page: a.page, index: a.index });
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
edit
|
||||
}
|
||||
|
||||
/// A 26 pt round badge: a type glyph (comments) or initials (replies).
|
||||
fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, color: Color32) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::hover());
|
||||
let bg = if t.dark() { Color32::from_gray(0x55) } else { Color32::from_gray(0xD8) };
|
||||
ui.painter().circle_filled(r.center(), 12.0, bg);
|
||||
if let Some(i) = icon {
|
||||
icons::paint(ui, r, i, 13.0, legible(color, t));
|
||||
}
|
||||
if let Some(s) = text {
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, s, theme::semibold(10.5), t.text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Darken very light annotation colours (e.g. note yellow) so their glyph stays legible.
|
||||
fn legible(c: Color32, t: &Tokens) -> Color32 {
|
||||
let lum = 0.2126 * c.r() as f32 + 0.7152 * c.g() as f32 + 0.0722 * c.b() as f32;
|
||||
if !t.dark() && lum > 150.0 {
|
||||
Color32::from_rgb((c.r() as f32 * 0.55) as u8, (c.g() as f32 * 0.55) as u8, (c.b() as f32 * 0.55) as u8)
|
||||
} else {
|
||||
c
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,10 @@
|
|||
//! notice.
|
||||
//! - `ui.inspect {query?, role?, limit?}`: widgets in tree order with `id` (a string), `role`, `label`,
|
||||
//! `value`, `rect` (points), `enabled`, `toggled`, `selected`, `clickable`, `depth`.
|
||||
//! - `ui.click {id}` | `{label}` | `{x, y}`: click a widget (by its AccessKit action) or a point.
|
||||
//! - `ui.click {id}` | `{label}` | `{x, y, button?}`: click a widget (by its AccessKit action) or a
|
||||
//! point (`button`: primary or secondary, for context menus).
|
||||
//! - `ui.drag {from: [x, y], to: [x, y], steps?, modifiers?}`: press, move and release (drawing
|
||||
//! comments, selecting text, moving comments). `ui.state` reports `pages_on_screen` to aim at.
|
||||
//! - `ui.type {text}`, `ui.key {key, modifiers?}`: keyboard input to the focused widget / app.
|
||||
//! - `ui.command {id}`: run a registry command (as the menu would). `ui.commands` lists them.
|
||||
//! - `ui.set {key, value}`: the view options of the command line (`--page`, `--zoom`, …).
|
||||
|
|
@ -271,6 +274,7 @@ impl Control {
|
|||
"ui.open" => Ok(Handled::Now(host.open(str_param("path")?))),
|
||||
"ui.inspect" => Ok(Handled::Now(Ok(self.inspect(p)))),
|
||||
"ui.click" => self.click(p),
|
||||
"ui.drag" => self.drag(p),
|
||||
"ui.type" => {
|
||||
let text = str_param("text")?.to_string();
|
||||
Ok(Handled::AfterFrames(self.inject(vec![vec![egui::Event::Text(text)]]), json!({ "typed": true })))
|
||||
|
|
@ -327,11 +331,36 @@ impl Control {
|
|||
json!({ "widgets": out, "count": total, "truncated": total > out.len() })
|
||||
}
|
||||
|
||||
/// Press at `from`, move to `to` in `steps` frames, release (drawing, selecting text, moving).
|
||||
fn drag(&mut self, p: &Value) -> Result<Handled, String> {
|
||||
let point = |k: &str| -> Result<egui::Pos2, String> {
|
||||
match p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()).as_deref() {
|
||||
Some([x, y]) => Ok(egui::pos2(*x as f32, *y as f32)),
|
||||
_ => Err(format!("ui.drag: {k} must be [x, y] in points")),
|
||||
}
|
||||
};
|
||||
let (from, to) = (point("from")?, point("to")?);
|
||||
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200) as usize;
|
||||
let modifiers = modifiers(p.get("modifiers"))?;
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||||
let mut frames = vec![vec![egui::Event::PointerMoved(from)], vec![button(from, true)]];
|
||||
for k in 1..=steps {
|
||||
frames.push(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))]);
|
||||
}
|
||||
frames.push(vec![button(to, false)]);
|
||||
let n = self.inject(frames);
|
||||
Ok(Handled::AfterFrames(n, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] })))
|
||||
}
|
||||
|
||||
fn click(&mut self, p: &Value) -> Result<Handled, String> {
|
||||
if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
let pos = egui::pos2(x as f32, y as f32);
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
let which = match p.get("button").and_then(Value::as_str).unwrap_or("primary") {
|
||||
"primary" | "left" => egui::PointerButton::Primary,
|
||||
"secondary" | "right" => egui::PointerButton::Secondary,
|
||||
other => return Err(format!("ui.click: unknown button {other:?} (primary, secondary)")),
|
||||
};
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers: egui::Modifiers::NONE };
|
||||
let frames = self.inject(vec![vec![egui::Event::PointerMoved(pos)], vec![button(true)], vec![button(false)]]);
|
||||
return Ok(Handled::AfterFrames(frames, json!({ "clicked": [x, y] })));
|
||||
}
|
||||
|
|
@ -500,7 +529,16 @@ impl Host for crate::PrintCraftApp {
|
|||
"organize": v.organize,
|
||||
"find_open": v.find.is_some(),
|
||||
"page_errors": v.page_errors().iter().map(|(p, e)| json!({ "page": p + 1, "error": e })).collect::<Vec<_>>(),
|
||||
// Where pages are on screen (points), to aim ui.click / ui.drag at page content.
|
||||
"pages_on_screen": v.visible_page_rects().iter().map(|(p, r)| json!({ "page": p + 1, "rect": [r.min.x, r.min.y, r.max.x, r.max.y] })).collect::<Vec<_>>(),
|
||||
"selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })),
|
||||
"comment_composer_open": v.comments.composer.is_some(),
|
||||
})),
|
||||
"quick_tool": match self.quick_tool {
|
||||
crate::QuickTool::Select => "select".to_string(),
|
||||
crate::QuickTool::Hand => "hand".to_string(),
|
||||
crate::QuickTool::Comment(t) => t.command().trim_start_matches("comment.").to_string(),
|
||||
},
|
||||
"home": self.active.is_none(),
|
||||
"mode": format!("{:?}", self.mode),
|
||||
"left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null },
|
||||
|
|
|
|||
|
|
@ -32,7 +32,11 @@ impl PrintCraftApp {
|
|||
Ok(()) => {
|
||||
let info = &self.session.get(id).expect("exists").info;
|
||||
let view = &mut self.views[i];
|
||||
view.document_changed(info);
|
||||
match comment_page(&edit) {
|
||||
// Comment edits change one page: keep every other raster.
|
||||
Some(page) => view.page_changed(page),
|
||||
None => view.document_changed(info),
|
||||
}
|
||||
// Keep the pages the user acted on selected, where they now are.
|
||||
match edit {
|
||||
Edit::MovePages { pages, to } => {
|
||||
|
|
@ -42,8 +46,18 @@ impl PrintCraftApp {
|
|||
}
|
||||
Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]),
|
||||
Edit::DeletePages { .. } => view.select_pages(&[]),
|
||||
Edit::AddAnnotation(a) => {
|
||||
// Select the new comment (appended last among the page's comments).
|
||||
let newest = info.annotations.iter().filter(|x| x.page == a.page && x.in_reply_to.is_none()).map(|x| x.index).max();
|
||||
view.comments.selected = newest.map(|n| (a.page, n));
|
||||
view.comments.reveal = true;
|
||||
}
|
||||
Edit::DeleteAnnotation { .. } => view.comments.selected = None,
|
||||
_ => {}
|
||||
}
|
||||
if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned {
|
||||
self.quick_tool = crate::QuickTool::Select;
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -68,6 +82,7 @@ impl PrintCraftApp {
|
|||
Ok(label) => {
|
||||
let info = &self.session.get(id).expect("exists").info;
|
||||
self.views[i].document_changed(info);
|
||||
self.views[i].comments.selected = None;
|
||||
self.notify(format!("{} {label}", if undo { "Undid" } else { "Redid" }));
|
||||
}
|
||||
Err(e) => self.notify(e.to_string()),
|
||||
|
|
@ -230,6 +245,21 @@ impl PrintCraftApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// The page a comment edit changes (`None` for other edits).
|
||||
fn comment_page(edit: &Edit) -> Option<usize> {
|
||||
match edit {
|
||||
Edit::AddAnnotation(a) => Some(a.page),
|
||||
Edit::DeleteAnnotation { page, .. }
|
||||
| Edit::SetAnnotationContents { page, .. }
|
||||
| Edit::ReplyToAnnotation { page, .. }
|
||||
| Edit::SetAnnotationStatus { page, .. }
|
||||
| Edit::MoveAnnotation { page, .. }
|
||||
| Edit::ResizeAnnotation { page, .. }
|
||||
| Edit::StyleAnnotation { page, .. } => Some(*page),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via a temporary file in the same directory and rename over the target, so a crash or
|
||||
/// full disk never leaves a half-written PDF where the original was.
|
||||
pub fn write_atomically(path: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
|
|
|
|||
|
|
@ -17,40 +17,41 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("bookmark", include_bytes!("../../../assets/icons/bookmark.svg")),
|
||||
("bookmark-plus", include_bytes!("../../../assets/icons/bookmark-plus.svg")),
|
||||
("braces", include_bytes!("../../../assets/icons/braces.svg")),
|
||||
("brush-cleaning", include_bytes!("../../../assets/icons/brush-cleaning.svg")),
|
||||
("brush", include_bytes!("../../../assets/icons/brush.svg")),
|
||||
("brush-cleaning", include_bytes!("../../../assets/icons/brush-cleaning.svg")),
|
||||
("camera", include_bytes!("../../../assets/icons/camera.svg")),
|
||||
("check-circle-2", include_bytes!("../../../assets/icons/check-circle-2.svg")),
|
||||
("check", include_bytes!("../../../assets/icons/check.svg")),
|
||||
("check-circle-2", include_bytes!("../../../assets/icons/check-circle-2.svg")),
|
||||
("chevron-down", include_bytes!("../../../assets/icons/chevron-down.svg")),
|
||||
("chevron-left", include_bytes!("../../../assets/icons/chevron-left.svg")),
|
||||
("chevron-right", include_bytes!("../../../assets/icons/chevron-right.svg")),
|
||||
("chevron-up", include_bytes!("../../../assets/icons/chevron-up.svg")),
|
||||
("chevrons-left", include_bytes!("../../../assets/icons/chevrons-left.svg")),
|
||||
("chevrons-right", include_bytes!("../../../assets/icons/chevrons-right.svg")),
|
||||
("circle", include_bytes!("../../../assets/icons/circle.svg")),
|
||||
("circle-check", include_bytes!("../../../assets/icons/circle-check.svg")),
|
||||
("circle-dashed", include_bytes!("../../../assets/icons/circle-dashed.svg")),
|
||||
("circle-dot", include_bytes!("../../../assets/icons/circle-dot.svg")),
|
||||
("circle-help", include_bytes!("../../../assets/icons/circle-help.svg")),
|
||||
("circle", include_bytes!("../../../assets/icons/circle.svg")),
|
||||
("clock-3", include_bytes!("../../../assets/icons/clock-3.svg")),
|
||||
("circle-x", include_bytes!("../../../assets/icons/circle-x.svg")),
|
||||
("clock", include_bytes!("../../../assets/icons/clock.svg")),
|
||||
("cloud-upload", include_bytes!("../../../assets/icons/cloud-upload.svg")),
|
||||
("clock-3", include_bytes!("../../../assets/icons/clock-3.svg")),
|
||||
("cloud", include_bytes!("../../../assets/icons/cloud.svg")),
|
||||
("cloud-upload", include_bytes!("../../../assets/icons/cloud-upload.svg")),
|
||||
("code-xml", include_bytes!("../../../assets/icons/code-xml.svg")),
|
||||
("columns-2", include_bytes!("../../../assets/icons/columns-2.svg")),
|
||||
("compass", include_bytes!("../../../assets/icons/compass.svg")),
|
||||
("contrast", include_bytes!("../../../assets/icons/contrast.svg")),
|
||||
("copy-plus", include_bytes!("../../../assets/icons/copy-plus.svg")),
|
||||
("copy", include_bytes!("../../../assets/icons/copy.svg")),
|
||||
("copy-plus", include_bytes!("../../../assets/icons/copy-plus.svg")),
|
||||
("crop", include_bytes!("../../../assets/icons/crop.svg")),
|
||||
("droplet", include_bytes!("../../../assets/icons/droplet.svg")),
|
||||
("ellipsis-vertical", include_bytes!("../../../assets/icons/ellipsis-vertical.svg")),
|
||||
("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")),
|
||||
("ellipsis-vertical", include_bytes!("../../../assets/icons/ellipsis-vertical.svg")),
|
||||
("eraser", include_bytes!("../../../assets/icons/eraser.svg")),
|
||||
("external-link", include_bytes!("../../../assets/icons/external-link.svg")),
|
||||
("eye-off", include_bytes!("../../../assets/icons/eye-off.svg")),
|
||||
("eye", include_bytes!("../../../assets/icons/eye.svg")),
|
||||
("eye-off", include_bytes!("../../../assets/icons/eye-off.svg")),
|
||||
("file-check", include_bytes!("../../../assets/icons/file-check.svg")),
|
||||
("file-down", include_bytes!("../../../assets/icons/file-down.svg")),
|
||||
("file-input", include_bytes!("../../../assets/icons/file-input.svg")),
|
||||
|
|
@ -58,8 +59,8 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("file-minus", include_bytes!("../../../assets/icons/file-minus.svg")),
|
||||
("file-output", include_bytes!("../../../assets/icons/file-output.svg")),
|
||||
("file-pen-line", include_bytes!("../../../assets/icons/file-pen-line.svg")),
|
||||
("file-plus-2", include_bytes!("../../../assets/icons/file-plus-2.svg")),
|
||||
("file-plus", include_bytes!("../../../assets/icons/file-plus.svg")),
|
||||
("file-plus-2", include_bytes!("../../../assets/icons/file-plus-2.svg")),
|
||||
("file-scan", include_bytes!("../../../assets/icons/file-scan.svg")),
|
||||
("file-search", include_bytes!("../../../assets/icons/file-search.svg")),
|
||||
("file-symlink", include_bytes!("../../../assets/icons/file-symlink.svg")),
|
||||
|
|
@ -68,9 +69,9 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("files", include_bytes!("../../../assets/icons/files.svg")),
|
||||
("film", include_bytes!("../../../assets/icons/film.svg")),
|
||||
("filter", include_bytes!("../../../assets/icons/filter.svg")),
|
||||
("folder", include_bytes!("../../../assets/icons/folder.svg")),
|
||||
("folder-open", include_bytes!("../../../assets/icons/folder-open.svg")),
|
||||
("folder-plus", include_bytes!("../../../assets/icons/folder-plus.svg")),
|
||||
("folder", include_bytes!("../../../assets/icons/folder.svg")),
|
||||
("gauge", include_bytes!("../../../assets/icons/gauge.svg")),
|
||||
("git-compare", include_bytes!("../../../assets/icons/git-compare.svg")),
|
||||
("globe", include_bytes!("../../../assets/icons/globe.svg")),
|
||||
|
|
@ -80,26 +81,27 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("heading", include_bytes!("../../../assets/icons/heading.svg")),
|
||||
("highlighter", include_bytes!("../../../assets/icons/highlighter.svg")),
|
||||
("house", include_bytes!("../../../assets/icons/house.svg")),
|
||||
("image-plus", include_bytes!("../../../assets/icons/image-plus.svg")),
|
||||
("image", include_bytes!("../../../assets/icons/image.svg")),
|
||||
("image-plus", include_bytes!("../../../assets/icons/image-plus.svg")),
|
||||
("info", include_bytes!("../../../assets/icons/info.svg")),
|
||||
("italic", include_bytes!("../../../assets/icons/italic.svg")),
|
||||
("languages", include_bytes!("../../../assets/icons/languages.svg")),
|
||||
("lasso", include_bytes!("../../../assets/icons/lasso.svg")),
|
||||
("layers", include_bytes!("../../../assets/icons/layers.svg")),
|
||||
("layout-grid", include_bytes!("../../../assets/icons/layout-grid.svg")),
|
||||
("link-2", include_bytes!("../../../assets/icons/link-2.svg")),
|
||||
("link", include_bytes!("../../../assets/icons/link.svg")),
|
||||
("link-2", include_bytes!("../../../assets/icons/link-2.svg")),
|
||||
("list", include_bytes!("../../../assets/icons/list.svg")),
|
||||
("list-checks", include_bytes!("../../../assets/icons/list-checks.svg")),
|
||||
("list-ordered", include_bytes!("../../../assets/icons/list-ordered.svg")),
|
||||
("list", include_bytes!("../../../assets/icons/list.svg")),
|
||||
("lock-open", include_bytes!("../../../assets/icons/lock-open.svg")),
|
||||
("lock", include_bytes!("../../../assets/icons/lock.svg")),
|
||||
("maximize-2", include_bytes!("../../../assets/icons/maximize-2.svg")),
|
||||
("lock-open", include_bytes!("../../../assets/icons/lock-open.svg")),
|
||||
("maximize", include_bytes!("../../../assets/icons/maximize.svg")),
|
||||
("maximize-2", include_bytes!("../../../assets/icons/maximize-2.svg")),
|
||||
("message-circle-reply", include_bytes!("../../../assets/icons/message-circle-reply.svg")),
|
||||
("message-square-text", include_bytes!("../../../assets/icons/message-square-text.svg")),
|
||||
("message-square", include_bytes!("../../../assets/icons/message-square.svg")),
|
||||
("message-square-plus", include_bytes!("../../../assets/icons/message-square-plus.svg")),
|
||||
("message-square-text", include_bytes!("../../../assets/icons/message-square-text.svg")),
|
||||
("messages-square", include_bytes!("../../../assets/icons/messages-square.svg")),
|
||||
("mic", include_bytes!("../../../assets/icons/mic.svg")),
|
||||
("minimize", include_bytes!("../../../assets/icons/minimize.svg")),
|
||||
|
|
@ -107,6 +109,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("moon", include_bytes!("../../../assets/icons/moon.svg")),
|
||||
("mouse-pointer-2", include_bytes!("../../../assets/icons/mouse-pointer-2.svg")),
|
||||
("move", include_bytes!("../../../assets/icons/move.svg")),
|
||||
("move-right", include_bytes!("../../../assets/icons/move-right.svg")),
|
||||
("navigation", include_bytes!("../../../assets/icons/navigation.svg")),
|
||||
("paint-bucket", include_bytes!("../../../assets/icons/paint-bucket.svg")),
|
||||
("palette", include_bytes!("../../../assets/icons/palette.svg")),
|
||||
|
|
@ -117,6 +120,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("pen-line", include_bytes!("../../../assets/icons/pen-line.svg")),
|
||||
("pen-tool", include_bytes!("../../../assets/icons/pen-tool.svg")),
|
||||
("pencil", include_bytes!("../../../assets/icons/pencil.svg")),
|
||||
("pin", include_bytes!("../../../assets/icons/pin.svg")),
|
||||
("pipette", include_bytes!("../../../assets/icons/pipette.svg")),
|
||||
("plus", include_bytes!("../../../assets/icons/plus.svg")),
|
||||
("presentation", include_bytes!("../../../assets/icons/presentation.svg")),
|
||||
|
|
@ -124,26 +128,27 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("rectangle-horizontal", include_bytes!("../../../assets/icons/rectangle-horizontal.svg")),
|
||||
("redo-2", include_bytes!("../../../assets/icons/redo-2.svg")),
|
||||
("replace", include_bytes!("../../../assets/icons/replace.svg")),
|
||||
("reply", include_bytes!("../../../assets/icons/reply.svg")),
|
||||
("rotate-ccw", include_bytes!("../../../assets/icons/rotate-ccw.svg")),
|
||||
("rotate-cw", include_bytes!("../../../assets/icons/rotate-cw.svg")),
|
||||
("ruler", include_bytes!("../../../assets/icons/ruler.svg")),
|
||||
("save", include_bytes!("../../../assets/icons/save.svg")),
|
||||
("scan-text", include_bytes!("../../../assets/icons/scan-text.svg")),
|
||||
("scan", include_bytes!("../../../assets/icons/scan.svg")),
|
||||
("scan-text", include_bytes!("../../../assets/icons/scan-text.svg")),
|
||||
("scissors", include_bytes!("../../../assets/icons/scissors.svg")),
|
||||
("search", include_bytes!("../../../assets/icons/search.svg")),
|
||||
("send", include_bytes!("../../../assets/icons/send.svg")),
|
||||
("settings-2", include_bytes!("../../../assets/icons/settings-2.svg")),
|
||||
("settings", include_bytes!("../../../assets/icons/settings.svg")),
|
||||
("settings-2", include_bytes!("../../../assets/icons/settings-2.svg")),
|
||||
("share-2", include_bytes!("../../../assets/icons/share-2.svg")),
|
||||
("shield-check", include_bytes!("../../../assets/icons/shield-check.svg")),
|
||||
("signature", include_bytes!("../../../assets/icons/signature.svg")),
|
||||
("sliders-horizontal", include_bytes!("../../../assets/icons/sliders-horizontal.svg")),
|
||||
("sparkles", include_bytes!("../../../assets/icons/sparkles.svg")),
|
||||
("spline", include_bytes!("../../../assets/icons/spline.svg")),
|
||||
("square-dashed-mouse-pointer", include_bytes!("../../../assets/icons/square-dashed-mouse-pointer.svg")),
|
||||
("square-dashed", include_bytes!("../../../assets/icons/square-dashed.svg")),
|
||||
("square", include_bytes!("../../../assets/icons/square.svg")),
|
||||
("square-dashed", include_bytes!("../../../assets/icons/square-dashed.svg")),
|
||||
("square-dashed-mouse-pointer", include_bytes!("../../../assets/icons/square-dashed-mouse-pointer.svg")),
|
||||
("squares-subtract", include_bytes!("../../../assets/icons/squares-subtract.svg")),
|
||||
("stamp", include_bytes!("../../../assets/icons/stamp.svg")),
|
||||
("star", include_bytes!("../../../assets/icons/star.svg")),
|
||||
|
|
@ -154,8 +159,10 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("tag", include_bytes!("../../../assets/icons/tag.svg")),
|
||||
("text-cursor-input", include_bytes!("../../../assets/icons/text-cursor-input.svg")),
|
||||
("text-select", include_bytes!("../../../assets/icons/text-select.svg")),
|
||||
("trash-2", include_bytes!("../../../assets/icons/trash-2.svg")),
|
||||
("thumbs-down", include_bytes!("../../../assets/icons/thumbs-down.svg")),
|
||||
("thumbs-up", include_bytes!("../../../assets/icons/thumbs-up.svg")),
|
||||
("trash", include_bytes!("../../../assets/icons/trash.svg")),
|
||||
("trash-2", include_bytes!("../../../assets/icons/trash-2.svg")),
|
||||
("triangle-alert", include_bytes!("../../../assets/icons/triangle-alert.svg")),
|
||||
("type", include_bytes!("../../../assets/icons/type.svg")),
|
||||
("underline", include_bytes!("../../../assets/icons/underline.svg")),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
mod canvas;
|
||||
mod chrome;
|
||||
mod commands;
|
||||
pub mod comments;
|
||||
mod comments_panel;
|
||||
pub mod control;
|
||||
mod dialogs;
|
||||
mod editing;
|
||||
|
|
@ -62,6 +64,8 @@ pub enum RightPanel {
|
|||
pub enum QuickTool {
|
||||
Select,
|
||||
Hand,
|
||||
/// A commenting tool (Add comments).
|
||||
Comment(comments::CommentTool),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
|
@ -113,6 +117,8 @@ pub struct PrintCraftApp {
|
|||
pub left_open: bool,
|
||||
pub right: Option<RightPanel>,
|
||||
pub quick_tool: QuickTool,
|
||||
/// Comment author, per-tool colours and widths, pin.
|
||||
pub comment_prefs: comments::CommentPrefs,
|
||||
pub theme: ThemeKind,
|
||||
pub dialog: Option<Dialog>,
|
||||
pub palette_open: bool,
|
||||
|
|
@ -188,6 +194,7 @@ impl PrintCraftApp {
|
|||
left_open: true,
|
||||
right: None,
|
||||
quick_tool: QuickTool::Select,
|
||||
comment_prefs: Default::default(),
|
||||
theme: ThemeKind::Light,
|
||||
dialog: None,
|
||||
palette_open: false,
|
||||
|
|
@ -565,7 +572,28 @@ impl PrintCraftApp {
|
|||
v.select_pages(&pages.map_err(|_| "select: comma-separated page numbers")?);
|
||||
}
|
||||
("notice", Some(v)) => v.notice_dismissed = value == "off",
|
||||
(k, None) if ["page", "zoom", "layout", "organize", "fields", "find", "rotate", "select", "notice"].contains(&k) => {
|
||||
("quick", _) => {
|
||||
// `--quick select|hand|note|freetext|highlight|underline|strikeout|ink|line|arrow|square|circle`
|
||||
self.quick_tool = match value {
|
||||
"select" => QuickTool::Select,
|
||||
"hand" => QuickTool::Hand,
|
||||
other => {
|
||||
let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?;
|
||||
self.comment_prefs.group_tool[t.group()] = t;
|
||||
QuickTool::Comment(t)
|
||||
}
|
||||
};
|
||||
}
|
||||
("author", _) => self.comment_prefs.author = value.to_string(),
|
||||
("comment", Some(v)) => {
|
||||
// `--comment 2:4` selects the 4th annotation of page 2 (1-based, as comment_list reports).
|
||||
let (p, i) = value.split_once(':').ok_or("comment: PAGE:INDEX")?;
|
||||
let (p, i): (usize, usize) =
|
||||
(p.trim().parse().map_err(|_| "comment: PAGE:INDEX")?, i.trim().parse().map_err(|_| "comment: PAGE:INDEX")?);
|
||||
v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1)));
|
||||
v.comments.reveal = true;
|
||||
}
|
||||
(k, None) if ["page", "zoom", "layout", "organize", "fields", "find", "rotate", "select", "notice", "comment"].contains(&k) => {
|
||||
return Err(format!("`{k}` needs an open document"));
|
||||
}
|
||||
(other, _) => return Err(format!("unknown option {other}")),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, pos2, vec2};
|
||||
use printcraft_engine::catalog::{self, Availability, TOOL_GROUPS, ToolGroup};
|
||||
use printcraft_render::{Annotation, DocInfo, FieldKind, OutlineItem};
|
||||
use printcraft_render::{DocInfo, FieldKind, OutlineItem};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{LeftPanel, PrintCraftApp, RightPanel, icons, widgets};
|
||||
|
|
@ -168,7 +168,7 @@ fn tool_detail(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'stat
|
|||
|
||||
// ───────────────────────────────────────────────────────────────────────────── right panels
|
||||
|
||||
enum Nav {
|
||||
pub(crate) enum Nav {
|
||||
Page(usize),
|
||||
Flash(usize, [f32; 4]),
|
||||
}
|
||||
|
|
@ -182,12 +182,15 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
|
|||
let mut toggle_layer: Option<(usize, bool)> = None;
|
||||
let mut attachment_action: Option<(usize, bool)> = None; // (index, open instead of save)
|
||||
let mut bm_action: Option<BmAction> = None;
|
||||
let mut panel_edit: Option<printcraft_engine::Edit> = None;
|
||||
let mut bm_rename = app.bookmark_rename.clone();
|
||||
let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none());
|
||||
{
|
||||
let Some(doc) = app.session.get(id) else { return };
|
||||
let info = &doc.info;
|
||||
let view = &app.views[index];
|
||||
let view = &mut app.views[index];
|
||||
let prefs = &app.comment_prefs;
|
||||
let comment_allowed = doc.allows_annotation();
|
||||
egui::Panel::right("right_panel")
|
||||
.resizable(true)
|
||||
.default_size(330.0)
|
||||
|
|
@ -200,7 +203,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
|
|||
)
|
||||
.show(ui, |ui| {
|
||||
let (title, count) = match panel {
|
||||
RightPanel::Comments => ("Comments", Some(info.annotations.len())),
|
||||
RightPanel::Comments => ("Comments", Some(crate::comments_panel::count(info))),
|
||||
RightPanel::Bookmarks => ("Bookmarks", None),
|
||||
RightPanel::Pages => ("Pages", Some(info.pages.len())),
|
||||
RightPanel::Fields => ("Fields", Some(info.fields.len())),
|
||||
|
|
@ -216,6 +219,15 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
|
|||
if icons::button(ui, "x", 26.0, false, "Close").clicked() {
|
||||
close = true;
|
||||
}
|
||||
if panel == RightPanel::Comments
|
||||
&& icons::button(ui, "search", 26.0, view.comments.search.is_some(), "Search comments").clicked()
|
||||
{
|
||||
view.comments.search = match view.comments.search {
|
||||
Some(_) => None,
|
||||
None => Some(String::new()),
|
||||
};
|
||||
view.comments.search_focus = true;
|
||||
}
|
||||
if panel == RightPanel::Bookmarks
|
||||
&& bm_editable
|
||||
&& icons::button(ui, "bookmark-plus", 26.0, false, "New bookmark (⌘B)").clicked()
|
||||
|
|
@ -226,7 +238,11 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
|
|||
});
|
||||
ui.add_space(6.0);
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match panel {
|
||||
RightPanel::Comments => comments(ui, &t, info, &mut nav),
|
||||
RightPanel::Comments => {
|
||||
if let Some(e) = crate::comments_panel::show(ui, &t, info, view, prefs, comment_allowed, &mut nav) {
|
||||
panel_edit = Some(e);
|
||||
}
|
||||
}
|
||||
RightPanel::Bookmarks => {
|
||||
if info.outline.is_empty() {
|
||||
empty(ui, &t, "bookmark", "This document has no bookmarks.");
|
||||
|
|
@ -308,6 +324,9 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
|
|||
if close {
|
||||
app.right = None;
|
||||
}
|
||||
if let Some(e) = panel_edit {
|
||||
app.apply_edit(e);
|
||||
}
|
||||
app.bookmark_rename = bm_rename;
|
||||
if let Some(a) = bm_action {
|
||||
app.bookmark_action(a);
|
||||
|
|
@ -340,136 +359,6 @@ fn empty(ui: &mut egui::Ui, t: &Tokens, icon: &str, text: &str) {
|
|||
});
|
||||
}
|
||||
|
||||
fn subtype_icon(s: &str) -> &'static str {
|
||||
match s {
|
||||
"Text" => "sticky-note",
|
||||
"Highlight" => "highlighter",
|
||||
"Underline" => "underline",
|
||||
"StrikeOut" => "strikethrough",
|
||||
"Squiggly" => "spline",
|
||||
"FreeText" => "type",
|
||||
"Ink" => "pencil",
|
||||
"Square" => "square",
|
||||
"Circle" => "circle",
|
||||
"Line" => "arrow-up-right",
|
||||
"Polygon" | "PolyLine" => "pen-tool",
|
||||
"Stamp" => "stamp",
|
||||
"FileAttachment" => "paperclip",
|
||||
"Caret" => "text-select",
|
||||
"Redact" => "rectangle-horizontal",
|
||||
_ => "message-square-text",
|
||||
}
|
||||
}
|
||||
|
||||
fn comments(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, nav: &mut Option<Nav>) {
|
||||
if info.annotations.is_empty() {
|
||||
empty(ui, t, "message-square-text", "No comments yet.");
|
||||
return;
|
||||
}
|
||||
let mut page = usize::MAX;
|
||||
let roots: Vec<&Annotation> = info.annotations.iter().filter(|a| a.in_reply_to.is_none()).collect();
|
||||
for a in roots {
|
||||
if a.page != page {
|
||||
page = a.page;
|
||||
let n = info.annotations.iter().filter(|x| x.page == page && x.in_reply_to.is_none()).count();
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(format!("Page {}", info.pages[page].label)).font(theme::semibold(12.5)).color(t.text_muted));
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small()));
|
||||
});
|
||||
}
|
||||
let replies: Vec<&Annotation> = match &a.name {
|
||||
Some(nm) => info.annotations.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
if comment_card(ui, t, a, &replies).clicked() {
|
||||
*nav = Some(Nav::Flash(a.page, a.rect));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reader-friendly names for annotation subtypes (the PDF names are shown in tooltips).
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Darken very light annotation colours (e.g. note yellow) so their icon stays legible.
|
||||
fn legible(c: Color32, t: &Tokens) -> Color32 {
|
||||
let lum = 0.2126 * c.r() as f32 + 0.7152 * c.g() as f32 + 0.0722 * c.b() as f32;
|
||||
if !t.dark() && lum > 170.0 {
|
||||
Color32::from_rgb((c.r() as f32 * 0.62) as u8, (c.g() as f32 * 0.62) as u8, (c.b() as f32 * 0.62) as u8)
|
||||
} else {
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
fn comment_card(ui: &mut egui::Ui, t: &Tokens, a: &Annotation, replies: &[&Annotation]) -> egui::Response {
|
||||
let color = a.color.map(|c| Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)).unwrap_or(t.accent);
|
||||
let id = ui.id().with(("comment", a.page, a.rect[0].to_bits(), a.rect[1].to_bits()));
|
||||
let hovered = ui.data(|d| d.get_temp::<bool>(id)).unwrap_or(false);
|
||||
let resp = egui::Frame::NONE
|
||||
.fill(if hovered { t.hover } else { Color32::TRANSPARENT })
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin { left: 8, right: 8, top: 10, bottom: 10 })
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::hover());
|
||||
ui.painter().circle_filled(r.center(), 14.0, color.gamma_multiply(if t.dark() { 0.35 } else { 0.28 }));
|
||||
icons::paint(ui, r, subtype_icon(&a.subtype), 14.0, legible(color, t));
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(a.author.as_deref().unwrap_or("Unknown author")).font(theme::semibold(13.0))).truncate(),
|
||||
);
|
||||
let meta = format!("{}{}", subtype_label(&a.subtype), a.modified.as_deref().map(|m| format!(" · {m}")).unwrap_or_default());
|
||||
ui.add(egui::Label::new(egui::RichText::new(meta).color(t.text_faint).font(theme::regular(11.0))).truncate());
|
||||
});
|
||||
});
|
||||
// Body and replies are indented under the avatar and must wrap to the panel width.
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 36, right: 0, top: 4, bottom: 0 }).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
if let Some(c) = &a.contents {
|
||||
ui.add(egui::Label::new(egui::RichText::new(c).color(t.text)).wrap());
|
||||
}
|
||||
for r in replies {
|
||||
ui.add_space(6.0);
|
||||
let resp = egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 0, top: 2, bottom: 2 }).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
ui.add(egui::Label::new(egui::RichText::new(r.author.as_deref().unwrap_or("Reply")).font(theme::semibold(12.0))).truncate());
|
||||
if let Some(c) = &r.contents {
|
||||
ui.add(egui::Label::new(egui::RichText::new(c).color(t.text_muted).font(theme::regular(12.0))).wrap());
|
||||
}
|
||||
});
|
||||
let b = resp.response.rect;
|
||||
ui.painter().vline(b.left() + 1.0, b.y_range(), Stroke::new(2.0, t.border));
|
||||
}
|
||||
});
|
||||
})
|
||||
.response;
|
||||
let div_y = resp.rect.bottom() + 2.0;
|
||||
ui.painter().hline(resp.rect.x_range().shrink(8.0), div_y, Stroke::new(1.0, t.divider));
|
||||
ui.add_space(5.0);
|
||||
let click = ui
|
||||
.interact(resp.rect, id.with("click"), Sense::click())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(format!("/{} annotation — click to show it on the page", a.subtype));
|
||||
ui.data_mut(|d| d.insert_temp(id, click.hovered()));
|
||||
click
|
||||
}
|
||||
|
||||
/// What the user asked to do with a bookmark (applied after the panel is drawn).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum BmAction {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
//! Commenting in the real shell (egui_kittest): tools, gestures, selection, the composer and
|
||||
//! the Comments panel.
|
||||
|
||||
use egui::{Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use printcraft_render::Annotation;
|
||||
use printcraft_ui_egui::{PrintCraftApp, QuickTool};
|
||||
|
||||
/// Two 300×200 pages of Helvetica text.
|
||||
const TEXT_FIXTURE: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >> endobj
|
||||
5 0 obj << /Length 55 >> stream
|
||||
BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj ET
|
||||
endstream endobj
|
||||
6 0 obj << /Length 58 >> stream
|
||||
BT /F1 14 Tf 20 150 Td (A second brown animal) Tj ET
|
||||
endstream endobj
|
||||
7 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
fn harness(setup: impl FnOnce(&mut PrintCraftApp) + 'static) -> Harness<'static, PrintCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("author", "Tester").unwrap();
|
||||
setup(&mut app);
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
fn settle(h: &mut Harness<'static, PrintCraftApp>) {
|
||||
for _ in 0..200 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// A point on page 1 in PDF user space → screen (no rotation; MediaBox 300×200).
|
||||
fn at(h: &Harness<'static, PrintCraftApp>, x: f32, y: f32) -> Pos2 {
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
|
||||
pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height())
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'static, PrintCraftApp>, from: Pos2, to: Pos2) {
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(from + (to - from) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'static, PrintCraftApp>, p: Pos2) {
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.drag_at(p);
|
||||
h.run_steps(1);
|
||||
h.drop_at(p);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// Drag between two page points (user space).
|
||||
fn drag_pt(h: &mut Harness<'static, PrintCraftApp>, a: (f32, f32), b: (f32, f32)) {
|
||||
let (a, b) = (at(h, a.0, a.1), at(h, b.0, b.1));
|
||||
drag(h, a, b);
|
||||
}
|
||||
|
||||
/// Click a page point (user space).
|
||||
fn click_pt(h: &mut Harness<'static, PrintCraftApp>, p: (f32, f32)) {
|
||||
let p = at(h, p.0, p.1);
|
||||
click(h, p);
|
||||
}
|
||||
|
||||
/// A text field by its placeholder ("Add a comment", "Add a reply").
|
||||
fn field<'a>(h: &'a Harness<'static, PrintCraftApp>, placeholder: &'a str) -> egui_kittest::Node<'a> {
|
||||
h.query_all_by(|n| n.placeholder() == Some(placeholder)).next().unwrap_or_else(|| panic!("no field {placeholder:?}"))
|
||||
}
|
||||
|
||||
fn comments(h: &Harness<'static, PrintCraftApp>) -> Vec<Annotation> {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().info.annotations.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_over_text_with_the_highlighter_highlights_it() {
|
||||
let mut h = harness(|app| app.set_option("quick", "highlight").unwrap());
|
||||
let (a, b) = {
|
||||
let v = &h.state().views[0];
|
||||
(v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph"))
|
||||
};
|
||||
drag(&mut h, a, b);
|
||||
let c = comments(&h);
|
||||
assert_eq!(c.len(), 1, "{c:?}");
|
||||
assert_eq!((c[0].subtype.as_str(), c[0].author.as_deref(), c[0].quads.len()), ("Highlight", Some("Tester"), 1));
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the new comment is selected");
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Comment(printcraft_ui_egui::comments::CommentTool::Highlight), "the highlighter stays on");
|
||||
// The Comments panel opened with the tool and lists it.
|
||||
h.get_by_label_contains("Comments");
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Add highlight"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_made_first_is_marked_when_a_tool_is_picked() {
|
||||
let mut h = harness(|_| {});
|
||||
h.state_mut().views[0].select_text(0, 4, 8);
|
||||
assert!(h.state_mut().execute("comment.strikeout"));
|
||||
h.run_steps(2);
|
||||
let c = comments(&h);
|
||||
assert_eq!(c.len(), 1);
|
||||
assert_eq!(c[0].subtype, "StrikeOut");
|
||||
assert!(h.state().views[0].selected_text().is_none(), "the selection is consumed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_and_ink_are_drawn_by_dragging() {
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
|
||||
h.state_mut().set_option("quick", "ink").unwrap();
|
||||
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
|
||||
h.state_mut().set_option("quick", "arrow").unwrap();
|
||||
drag_pt(&mut h, (20.0, 20.0), (120.0, 30.0));
|
||||
// A click with a drawing tool draws nothing.
|
||||
h.state_mut().set_option("quick", "circle").unwrap();
|
||||
click_pt(&mut h, (200.0, 180.0));
|
||||
let c = comments(&h);
|
||||
let mut c = c;
|
||||
c.sort_by_key(|a| a.index);
|
||||
let kinds: Vec<&str> = c.iter().map(|a| a.subtype.as_str()).collect();
|
||||
assert_eq!(kinds, ["Square", "Ink", "Line"], "{c:?}");
|
||||
let r = c[0].rect;
|
||||
assert!((r[0] - 40.0).abs() < 3.0 && (r[1] - 40.0).abs() < 3.0 && (r[2] - 140.0).abs() < 3.0 && (r[3] - 100.0).abs() < 3.0, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sticky_note_composer_posts_and_returns_to_select() {
|
||||
let mut h = harness(|app| app.set_option("quick", "note").unwrap());
|
||||
click_pt(&mut h, (250.0, 180.0));
|
||||
assert!(h.state().views[0].comments.composer.is_some(), "the composer opened");
|
||||
field(&h, "Add a comment").type_text("Please check");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Post").click();
|
||||
h.run_steps(3);
|
||||
let c = comments(&h);
|
||||
assert_eq!(c.len(), 1);
|
||||
assert_eq!((c[0].subtype.as_str(), c[0].contents.as_deref()), ("Text", Some("Please check")));
|
||||
assert!(h.state().views[0].comments.composer.is_none());
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Select, "one-shot tools return to Select");
|
||||
// Cancelling another one creates nothing.
|
||||
h.state_mut().set_option("quick", "freetext").unwrap();
|
||||
click_pt(&mut h, (50.0, 60.0));
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(comments(&h).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_are_selected_moved_and_deleted_with_the_select_tool() {
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
|
||||
h.state_mut().set_option("quick", "select").unwrap();
|
||||
// Click away, then on the rectangle's border area.
|
||||
click_pt(&mut h, (250.0, 20.0));
|
||||
assert_eq!(h.state().views[0].comments.selected, None);
|
||||
click_pt(&mut h, (90.0, 70.0));
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
|
||||
drag_pt(&mut h, (90.0, 70.0), (140.0, 70.0));
|
||||
let r = comments(&h)[0].rect;
|
||||
assert!((r[0] - 90.0).abs() < 3.0 && (r[2] - 190.0).abs() < 3.0, "moved right by 50 pt: {r:?}");
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move comment"));
|
||||
h.key_press(egui::Key::Delete);
|
||||
h.run_steps(3);
|
||||
assert!(comments(&h).is_empty());
|
||||
h.state_mut().undo();
|
||||
h.run_steps(2);
|
||||
assert_eq!(comments(&h).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_posts_comments_and_replies() {
|
||||
let mut h = harness(|app| app.set_option("panel", "comments").unwrap());
|
||||
field(&h, "Add a comment").click();
|
||||
h.run_steps(2);
|
||||
field(&h, "Add a comment").type_text("General remark");
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
let c = comments(&h);
|
||||
assert_eq!((c.len(), c[0].subtype.as_str(), c[0].contents.as_deref()), (1, "Text", Some("General remark")));
|
||||
// The new comment is selected: reply to it.
|
||||
field(&h, "Add a reply").click();
|
||||
h.run_steps(2);
|
||||
field(&h, "Add a reply").type_text("Agreed");
|
||||
h.run_steps(1);
|
||||
h.get_by_label("Post").click();
|
||||
h.run_steps(3);
|
||||
let c = comments(&h);
|
||||
assert_eq!(c.len(), 2);
|
||||
let reply = c.iter().find(|a| a.in_reply_to.is_some()).expect("a reply");
|
||||
assert_eq!((reply.contents.as_deref(), reply.author.as_deref()), (Some("Agreed"), Some("Tester")));
|
||||
h.get_by_label_contains("Agreed");
|
||||
}
|
||||
|
|
@ -132,6 +132,27 @@ fn commands_keys_and_typing() {
|
|||
assert!(call(&mut h, &c, "ui.frobnicate", json!({})).unwrap_err().contains("unknown method"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_a_comment_by_drag_and_its_context_menu() {
|
||||
let (mut h, c) = harness();
|
||||
ok(&mut h, &c, "ui.command", json!({ "id": "comment.square" }));
|
||||
let st = ok(&mut h, &c, "ui.state", json!({}));
|
||||
assert_eq!(st["quick_tool"], "square");
|
||||
let r = &st["active"]["pages_on_screen"][0]["rect"];
|
||||
let (x0, y0, x1, y1) = (r[0].as_f64().unwrap(), r[1].as_f64().unwrap(), r[2].as_f64().unwrap(), r[3].as_f64().unwrap());
|
||||
let (a, b) = ([x0 + (x1 - x0) * 0.2, y0 + (y1 - y0) * 0.2], [x0 + (x1 - x0) * 0.5, y0 + (y1 - y0) * 0.4]);
|
||||
ok(&mut h, &c, "ui.drag", json!({ "from": a, "to": b }));
|
||||
h.run_steps(2);
|
||||
let st = ok(&mut h, &c, "ui.state", json!({}));
|
||||
assert_eq!(st["active"]["selected_comment"], json!({ "page": 1, "index": 1 }), "{st}");
|
||||
assert_eq!(st["documents"][0]["dirty"], true);
|
||||
// A right-click on it offers the comment menu.
|
||||
ok(&mut h, &c, "ui.click", json!({ "x": (a[0] + b[0]) / 2.0, "y": (a[1] + b[1]) / 2.0, "button": "secondary" }));
|
||||
let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "Set status" }));
|
||||
assert!(menu["count"].as_u64().unwrap() >= 1, "{menu}");
|
||||
assert!(call(&mut h, &c, "ui.drag", json!({ "from": [1, 2] })).unwrap_err().contains("to must be"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshots_of_window_and_region() {
|
||||
let (mut h, c) = harness();
|
||||
|
|
|
|||
|
|
@ -3433,9 +3433,9 @@ tier = "P0"
|
|||
milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = ["comment.list"]
|
||||
tools = ["doc_info"]
|
||||
evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks"]
|
||||
notes = "read-only; creating and managing comments is M5"
|
||||
tools = ["comment_list"]
|
||||
evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies"]
|
||||
notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.appearance-generation"
|
||||
|
|
@ -3443,11 +3443,11 @@ title = "Generate correct appearances for every markup type"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text"]
|
||||
notes = "appearances for every type PrintCraft creates (highlight multiplies); clouds, callouts, stamps and rich text are not drawn yet"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.sticky-note"
|
||||
|
|
@ -3455,11 +3455,11 @@ title = "Sticky note comments"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["comment.note"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
|
||||
notes = "our own Comment/Note icon drawings, with a pop-up"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.highlight"
|
||||
|
|
@ -3467,11 +3467,11 @@ title = "Highlight text"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["comment.highlight"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/engine/src/tests.rs::highlight_multiplies_over_text"]
|
||||
notes = "drag over text, or pick the tool with text selected; agents can highlight by text search"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.underline"
|
||||
|
|
@ -3479,10 +3479,10 @@ title = "Underline text"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
status = "shipped"
|
||||
commands = ["comment.underline"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/ui-egui/tests/comments.rs::a_selection_made_first_is_marked_when_a_tool_is_picked"]
|
||||
notes = ""
|
||||
|
||||
[[feature]]
|
||||
|
|
@ -3491,10 +3491,10 @@ title = "Strikethrough text"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
status = "shipped"
|
||||
commands = ["comment.strikeout"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::a_selection_made_first_is_marked_when_a_tool_is_picked", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
|
||||
notes = ""
|
||||
|
||||
[[feature]]
|
||||
|
|
@ -3503,11 +3503,11 @@ title = "Squiggly underline"
|
|||
area = "E"
|
||||
tier = "P2"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
|
||||
notes = "engine and agent tool; no toolbar entry yet"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.replace-text"
|
||||
|
|
@ -3551,11 +3551,11 @@ title = "Text box comment with rich text"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["comment.freetext"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn"]
|
||||
notes = "plain text in Helvetica; rich text (/RC, /DS) is not written yet"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.callout"
|
||||
|
|
@ -3575,11 +3575,11 @@ title = "Freehand drawing (ink)"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["comment.ink"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging"]
|
||||
notes = "one ink annotation per stroke"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.eraser"
|
||||
|
|
@ -3599,10 +3599,10 @@ title = "Line"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
status = "shipped"
|
||||
commands = ["comment.line"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
|
||||
notes = ""
|
||||
|
||||
[[feature]]
|
||||
|
|
@ -3611,10 +3611,10 @@ title = "Arrow"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
status = "shipped"
|
||||
commands = ["comment.arrow"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging"]
|
||||
notes = ""
|
||||
|
||||
[[feature]]
|
||||
|
|
@ -3623,11 +3623,11 @@ title = "Rectangle"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["comment.square"]
|
||||
tools = ["comment_add", "comment_edit"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance"]
|
||||
notes = "resize with the 8 handles"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.oval"
|
||||
|
|
@ -3635,10 +3635,10 @@ title = "Oval"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
status = "shipped"
|
||||
commands = ["comment.circle"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
|
||||
notes = ""
|
||||
|
||||
[[feature]]
|
||||
|
|
@ -3731,11 +3731,11 @@ title = "All ten line-ending styles and dashed/cloudy borders"
|
|||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
|
||||
notes = "None, OpenArrow and ClosedArrow are drawn; other endings are kept but not redrawn"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.properties-dialog"
|
||||
|
|
@ -3755,11 +3755,11 @@ title = "Comment properties bar"
|
|||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["comment.note"]
|
||||
tools = ["comment_edit"]
|
||||
evidence = ["crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance"]
|
||||
notes = "quick-bar colour swatches and line thickness per tool, colour from the context menu; no opacity control in the UI yet"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.lock-comment"
|
||||
|
|
@ -3791,11 +3791,11 @@ title = "Author name from identity preferences"
|
|||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it"]
|
||||
notes = "the login name, as Acrobat defaults to; no Identity preferences yet"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.reply"
|
||||
|
|
@ -3803,10 +3803,10 @@ title = "Reply to comments"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
status = "shipped"
|
||||
commands = ["comment.list"]
|
||||
tools = ["comment_reply"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent"]
|
||||
notes = ""
|
||||
|
||||
[[feature]]
|
||||
|
|
@ -3815,11 +3815,11 @@ title = "Set review status (accepted, rejected, cancelled, completed)"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["comment.list"]
|
||||
tools = ["comment_set_status"]
|
||||
evidence = ["crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/engine/src/tests.rs::comments_are_added_drawn_threaded_and_undone"]
|
||||
notes = "status replies (/State, /StateModel Review), shown as a badge"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.checkmark"
|
||||
|
|
@ -3839,11 +3839,11 @@ title = "Delete comments"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["comment.list"]
|
||||
tools = ["comment_delete"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"]
|
||||
notes = "deletes the pop-up and the reply thread"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.edit-comment-text"
|
||||
|
|
@ -3851,11 +3851,11 @@ title = "Edit comment text"
|
|||
area = "E"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["comment.list"]
|
||||
tools = ["comment_edit"]
|
||||
evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/automation/tests/automation.rs::comments_through_tools"]
|
||||
notes = "double-click a comment or card, or Edit in the menus"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.copy-comment-text"
|
||||
|
|
@ -3899,11 +3899,11 @@ title = "Search comments"
|
|||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["comment.list"]
|
||||
tools = ["comment_list"]
|
||||
evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies"]
|
||||
notes = "search by author, text and type in the panel; not yet in the Find bar"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.show-hide-comments"
|
||||
|
|
|
|||