Files
ElyOffice/apps/ely-pdf/src/marks.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

203 lines
7.9 KiB
Rust

//! Bookmarks: jump, add, rename, delete (PDF-01, PDF-07).
use ely_gpui_component::{
feedback::EmptyState,
forms::TextInput,
lists::Outline,
menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem},
overlays::PromptDialog,
primitives::IconName,
};
use ely_office::{Closing as _, Tabs as _};
use ely_pdf_engine::{Bookmark, BookmarkChange};
use gpui::{Action, AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Styled as _, Window, div};
use crate::document::{DeleteBookmark, RenameBookmark};
use crate::view::PdfView;
use crate::viewer::Viewer;
/// One bookmark: title, level, target, and where it sits.
#[derive(Clone, Debug, PartialEq)]
pub struct Flat {
pub title: String,
pub level: usize,
pub to: Option<(usize, f32)>,
/// Child indices from the top.
pub path: Vec<usize>,
}
/// Bookmarks depth first.
pub fn flat(marks: &[Bookmark], above: &[usize], out: &mut Vec<Flat>) {
for (at, mark) in marks.iter().enumerate() {
let path: Vec<usize> = above.iter().copied().chain([at]).collect();
out.push(Flat { title: mark.title.clone(), level: path.len(), to: mark.page.map(|page| (page, mark.y)), path: path.clone() });
flat(&mark.down, &path, out);
}
}
/// A new bookmark's place: after the pick, else last.
pub fn place_for(marks: &[Bookmark], picked: Option<&Flat>) -> Vec<usize> {
match picked {
Some(flat) => {
let mut path = flat.path.clone();
*path.last_mut().expect("a path has a step") += 1;
path
}
None => vec![marks.len()],
}
}
/// A bookmark title being typed.
pub struct Naming {
pub tab: u64,
pub version: u64,
pub field: Entity<TextInput>,
/// None adds at `path`; Some renames the bookmark titled so.
pub expect: Option<String>,
pub path: Vec<usize>,
pub page: usize,
}
impl PdfView {
/// The picked bookmark, if it is still there.
pub fn picked_mark(&self) -> Option<Flat> {
let mut all = Vec::new();
flat(&self.marks, &[], &mut all);
self.mark_pick.and_then(|at| all.get(at).cloned())
}
}
/// The bookmarks of `view`; a press picks and jumps.
pub fn outline(view: &Entity<PdfView>, cx: &App) -> AnyElement {
let mut items = Vec::new();
flat(&view.read(cx).marks, &[], &mut items);
if items.is_empty() {
return EmptyState::new("pdf-marks-none", IconName::File, Viewer::text(cx, "pdf.marks.none", &[])).into_any_element();
}
let targets: Vec<Option<(usize, f32)>> = items.iter().map(|flat| flat.to).collect();
let picked = view.read(cx).mark_pick;
let view = view.clone();
let mut outline = (items.into_iter().enumerate())
.fold(Outline::new("pdf-marks"), |outline, (at, flat)| outline.item(at.to_string(), flat.title, flat.level))
.on_select(move |key, _, cx| {
let Ok(at) = key.parse::<usize>() else {
return;
};
view.update(cx, |view, cx| {
view.mark_pick = Some(at);
match targets.get(at).copied().flatten() {
Some((page, y)) => view.show(page, y, cx),
None => cx.notify(),
}
});
});
if let Some(at) = picked {
outline = outline.current(at.to_string());
}
let item = |key: &str, action: Box<dyn Action>| {
PopupItem::new(Viewer::text(cx, key, &[])).on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx))
};
let popup = Popup::new().item(item("pdf.mark.rename", Box::new(RenameBookmark))).item(item("pdf.mark.delete", Box::new(DeleteBookmark)));
ContextMenu::new("pdf-marks-menu", popup).size_full().child(div().p_2().child(outline)).into_any_element()
}
impl Viewer {
/// Asks a title for a new or picked bookmark.
pub fn ask_bookmark(&mut self, add: bool, window: &mut Window, cx: &mut Context<Self>) {
let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else {
return self.no_document(cx);
};
let read = view.read(cx);
let picked = read.picked_mark();
let (title, expect, path, page) = match (add, picked) {
(true, picked) => {
let n = (read.current + 1).to_string();
(Self::text(cx, "pdf.page.label", &[("n", &n)]).to_string(), None, place_for(&read.marks, picked.as_ref()), read.current)
}
(false, Some(flat)) => (flat.title.clone(), Some(flat.title), flat.path, read.current),
(false, None) => return,
};
let version = read.version;
let field = cx.new(|cx| {
let mut field = TextInput::new(window, cx);
field.set_text(title, cx);
field
});
self.naming = Some(Naming { tab, version, field, expect, path, page });
cx.notify();
}
pub(crate) fn name_bookmark(&mut self, title: String, cx: &mut Context<Self>) {
let Some(Naming { tab, version, expect, path, page, .. }) = self.naming.take() else {
return;
};
cx.notify();
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else {
return;
};
let change = match expect {
None => BookmarkChange::Add { path, title, page, y: 0.0 },
Some(expect) => BookmarkChange::Edit { path, expect, title: Some(title), to: None },
};
self.on_same(&view, version, cx, |view, cx| {
view.edit("changing bookmarks", move |worker, doc| worker.change_bookmark(doc, &change), cx)
});
}
pub fn delete_bookmark(&mut self, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let (picked, version) = (view.read(cx).picked_mark(), view.read(cx).version);
let Some(flat) = picked else {
return;
};
let change = BookmarkChange::Delete { path: flat.path, expect: flat.title };
self.on_same(&view, version, cx, |view, cx| {
view.mark_pick = None;
view.edit("deleting a bookmark", move |worker, doc| worker.change_bookmark(doc, &change), cx)
});
}
/// The bookmark title prompt, while open.
pub fn naming_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let naming = self.naming.as_ref()?;
let (closed, sent) = (cx.entity(), cx.entity());
let key = if naming.expect.is_some() { "pdf.mark.rename" } else { "pdf.mark.add" };
let prompt = PromptDialog::new("pdf-mark-name", Self::text(cx, key, &[]), &naming.field, move |_, cx| {
closed.update(cx, |viewer, cx| {
viewer.naming = None;
cx.notify();
})
})
.label(Self::text(cx, "pdf.mark.title", &[]))
.submit(Self::text(cx, "pdf.text.apply", &[]))
.check({
let empty = Self::text(cx, "pdf.mark.empty", &[]);
move |title| if title.trim().is_empty() { Err(empty.clone()) } else { Ok(()) }
})
.on_submit(move |title, _, cx| sent.update(cx, |viewer, cx| viewer.name_bookmark(title.trim().to_string(), cx)));
Some(prompt.into_any_element())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mark(title: &str, page: Option<usize>, down: Vec<Bookmark>) -> Bookmark {
Bookmark { title: title.into(), page, y: 0.0, down }
}
#[test]
fn bookmarks_flatten_with_levels_and_paths() {
let marks = [mark("A", Some(0), vec![mark("A1", Some(1), vec![])]), mark("Web", None, vec![])];
let mut out = Vec::new();
flat(&marks, &[], &mut out);
let short: Vec<(&str, usize, Vec<usize>)> = out.iter().map(|f| (f.title.as_str(), f.level, f.path.clone())).collect();
assert_eq!(short, [("A", 1, vec![0]), ("A1", 2, vec![0, 0]), ("Web", 1, vec![1])]);
assert_eq!(place_for(&marks, Some(&out[1])), [0, 1], "after the picked one");
assert_eq!(place_for(&marks, None), [2], "else last");
}
}