Files
ElyOffice/apps/ely-pdf/src/marking.rs
T
ZacharyZhang-NY b7fca0146d Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
2026-10-08 21:20:08 -04:00

177 lines
8.6 KiB
Rust

//! Annotation and form menus, and the panel's changes (PDF-05, PDF-06).
use ely_gpui_component::primitives::Severity;
use ely_office::Tabs as _;
use ely_pdf_engine::{FieldKind, LinkChange, NewAnnot, NewField, Shape};
use gpui::{App, Context, Div, InteractiveElement as _, Menu, MenuItem, actions};
use crate::annotate::{Pick, around};
use crate::content::Tool;
use crate::viewer::Viewer;
actions!(
pdf,
[
Highlight,
Underline,
StrikeOut,
AddNote,
DrawRectangle,
DrawEllipse,
DrawLine,
DrawFreehand,
AddStamp,
AddTextField,
AddCheckbox,
AddRadioButton,
AddChoice
]
);
/// A stamp's size, in points.
const STAMP: (f32, f32) = (190.0, 50.0);
/// What the note panel asks for.
#[derive(Clone, Debug, PartialEq)]
pub enum Change {
Note(String),
Stamp(String),
Contents(String),
Color(u32),
Delete,
/// A field's name and options.
Design(String, Vec<String>),
Fill(String),
/// A link target as typed.
Target(String),
}
/// The Annotate and Form menus.
pub fn menus(cx: &App) -> [Menu; 2] {
let label = |key: &str| Viewer::text(cx, key, &[]);
[
Menu::new(label("menu.annotate")).items([
MenuItem::action(label("pdf.tool.highlight"), Highlight),
MenuItem::action(label("pdf.tool.underline"), Underline),
MenuItem::action(label("pdf.tool.strikeout"), StrikeOut),
MenuItem::separator(),
MenuItem::action(label("pdf.tool.note"), AddNote),
MenuItem::action(label("pdf.tool.stamp"), AddStamp),
MenuItem::separator(),
MenuItem::action(label("pdf.tool.square"), DrawRectangle),
MenuItem::action(label("pdf.tool.circle"), DrawEllipse),
MenuItem::action(label("pdf.tool.line"), DrawLine),
MenuItem::action(label("pdf.tool.ink"), DrawFreehand),
]),
Menu::new(label("menu.form")).items([
MenuItem::action(label("pdf.tool.field_text"), AddTextField),
MenuItem::action(label("pdf.tool.field_check"), AddCheckbox),
MenuItem::action(label("pdf.tool.field_radio"), AddRadioButton),
MenuItem::action(label("pdf.tool.field_choice"), AddChoice),
]),
]
}
impl Viewer {
/// Makes `change` if tab `tab` is still at `version`.
pub fn change_note(&mut self, (tab, version, pick): (u64, u64, Pick), change: Change, cx: &mut Context<Self>) {
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else {
let text = Self::text(cx, "pdf.object.changed", &[]);
return self.toast(text, Severity::Warning, cx);
};
self.on_same(&view, version, cx, move |view, cx| {
let note = |page, shape, contents| NewAnnot { page, shape, rgb: None, contents, author: String::new() };
match (pick, change) {
(Pick::NewNote { page, at }, Change::Note(text)) => view.annotate("adding a note", vec![note(page, Shape::Note(at), text)], cx),
(Pick::NewStamp { page, at }, Change::Stamp(icon)) => {
let rect = around(at, STAMP, view.sizes[page]);
view.annotate("adding a stamp", vec![note(page, Shape::Stamp(rect, icon), String::new())], cx)
}
(Pick::Annot { page, id }, Change::Contents(text)) => {
view.edit("changing an annotation", move |w, doc| w.change_annot(doc, page, id, None, Some(&text)), cx)
}
(Pick::Annot { page, id }, Change::Color(rgb)) => {
view.edit("colouring an annotation", move |w, doc| w.change_annot(doc, page, id, Some(rgb), None), cx)
}
(Pick::Annot { page, id }, Change::Delete) => view.edit("deleting an annotation", move |w, doc| w.delete_annot(doc, page, id), cx),
(Pick::Field { page, id }, Change::Delete) => view.edit("deleting a field", move |w, doc| w.delete_field(doc, page, id), cx),
(Pick::Field { page, id }, Change::Fill(value)) => view.edit("filling a field", move |w, doc| w.fill(doc, page, id, &value), cx),
(Pick::Field { page, id }, Change::Design(name, options)) => {
let Some(field) = view.notes.get(&page).and_then(|m| m.fields.iter().find(|f| f.id == id)) else {
return log::error!("pdf: a picked field is not read");
};
let renamed = (field.leaf() != name).then_some(name);
let choice = matches!(field.kind, FieldKind::Choice | FieldKind::List);
let listed = (choice && field.options != options).then_some(options);
if renamed.is_none() && listed.is_none() {
return log::info!("pdf: the field is as it was");
}
view.edit(
"changing a field",
move |w, doc| w.change_field(doc, page, id, renamed.as_deref(), listed.as_deref()),
cx,
)
}
(Pick::NewField { page, rect, kind }, Change::Design(name, options)) => {
let field = NewField { kind, name, rect, options };
view.edit("adding a field", move |w, doc| w.add_field(doc, page, &field), cx)
}
(Pick::NewLink { page, rect }, Change::Target(typed)) => {
let Some(to) = crate::links::parse(&typed, view.sizes.len()) else {
return bad_target(cx);
};
view.edit("adding a link", move |w, doc| w.change_link(doc, page, &LinkChange::Add { rect, to }), cx)
}
(Pick::Link { page, index }, change) => {
let Some(expect) = view.links.get(&page).and_then(|links| links.get(index)).map(|link| link.uri.clone()) else {
return log::error!("pdf: a picked link is not read");
};
let change = match change {
Change::Delete => LinkChange::Delete { index, expect },
Change::Target(typed) => match crate::links::parse(&typed, view.sizes.len()) {
Some(to) => LinkChange::Edit { index, expect, to },
None => return bad_target(cx),
},
other => return log::error!("pdf: {other:?} does not fit a link"),
};
view.edit("changing a link", move |w, doc| w.change_link(doc, page, &change), cx)
}
(pick, change) => log::error!("pdf: {change:?} does not fit {pick:?}"),
}
});
}
}
/// Says a typed link target is neither page nor address.
fn bad_target(cx: &mut Context<crate::view::PdfView>) {
let text = Viewer::text(cx, "pdf.link.bad", &[]);
cx.emit(crate::view::Failed(text.to_string()));
}
/// Wires the annotation and form actions onto the root.
pub fn on(root: Div, cx: &mut Context<Viewer>) -> Div {
let tool = |tool: Tool| move |viewer: &mut Viewer, cx: &mut Context<Viewer>| viewer.toggle_tool(tool, cx);
let (highlight, underline, strike, note, stamp) =
(tool(Tool::Highlight), tool(Tool::Underline), tool(Tool::StrikeOut), tool(Tool::Note), tool(Tool::Stamp));
let (square, circle, line, ink) = (tool(Tool::Square), tool(Tool::Circle), tool(Tool::Line), tool(Tool::Ink));
let (text, check, radio, choice) = (
tool(Tool::Field(FieldKind::Text)),
tool(Tool::Field(FieldKind::Checkbox)),
tool(Tool::Field(FieldKind::Radio)),
tool(Tool::Field(FieldKind::Choice)),
);
root.on_action(cx.listener(move |v, _: &Highlight, _, cx| highlight(v, cx)))
.on_action(cx.listener(move |v, _: &Underline, _, cx| underline(v, cx)))
.on_action(cx.listener(move |v, _: &StrikeOut, _, cx| strike(v, cx)))
.on_action(cx.listener(move |v, _: &AddNote, _, cx| note(v, cx)))
.on_action(cx.listener(move |v, _: &AddStamp, _, cx| stamp(v, cx)))
.on_action(cx.listener(move |v, _: &DrawRectangle, _, cx| square(v, cx)))
.on_action(cx.listener(move |v, _: &DrawEllipse, _, cx| circle(v, cx)))
.on_action(cx.listener(move |v, _: &DrawLine, _, cx| line(v, cx)))
.on_action(cx.listener(move |v, _: &DrawFreehand, _, cx| ink(v, cx)))
.on_action(cx.listener(move |v, _: &AddTextField, _, cx| text(v, cx)))
.on_action(cx.listener(move |v, _: &AddCheckbox, _, cx| check(v, cx)))
.on_action(cx.listener(move |v, _: &AddRadioButton, _, cx| radio(v, cx)))
.on_action(cx.listener(move |v, _: &AddChoice, _, cx| choice(v, cx)))
}