ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
265 lines
11 KiB
Rust
265 lines
11 KiB
Rust
//! Writing text, previews, objects and new images (PDF-03, PDF-04).
|
|
|
|
use std::{io, path::PathBuf, sync::Arc};
|
|
|
|
use ely_gpui_component::{
|
|
buttons::{Button, ButtonVariant},
|
|
overlays::Dialog as Card,
|
|
primitives::Severity,
|
|
};
|
|
use ely_office::{Closing as _, Tabs as _};
|
|
use ely_pdf_engine::{Writing, Wrote};
|
|
use gpui::{
|
|
AnyElement, App, Context, IntoElement, KeyBinding, MenuItem, ParentElement as _, Styled as _, Window, actions,
|
|
div, img, px,
|
|
};
|
|
|
|
use crate::content::{Aim, Tool};
|
|
use crate::editing::Preview;
|
|
use crate::viewer::Viewer;
|
|
|
|
actions!(pdf, [EditContent, AddText, AddImage, StopEditing]);
|
|
|
|
/// The key context while an editing tool is on.
|
|
pub const EDITING: &str = "PdfEditing";
|
|
|
|
pub fn bind(cx: &mut App) {
|
|
cx.bind_keys([KeyBinding::new("escape", StopEditing, Some(EDITING))]);
|
|
}
|
|
|
|
/// The editing items of the Edit menu.
|
|
pub fn items(cx: &App) -> [MenuItem; 3] {
|
|
let label = |key: &str| Viewer::text(cx, key, &[]);
|
|
[
|
|
MenuItem::action(label("pdf.tool.edit"), EditContent),
|
|
MenuItem::action(label("pdf.tool.text"), AddText),
|
|
MenuItem::action(label("pdf.tool.image"), AddImage),
|
|
]
|
|
}
|
|
|
|
/// Pixels a point in a preview.
|
|
const PREVIEW: f32 = 2.0;
|
|
|
|
impl Viewer {
|
|
/// Turns `tool` on, or off when it is on.
|
|
pub(crate) fn toggle_tool(&mut self, tool: Tool, cx: &mut Context<Self>) {
|
|
let Some(view) = self.active_view() else {
|
|
return self.no_document(cx);
|
|
};
|
|
view.update(cx, |view, cx| {
|
|
let now = if view.tool == tool { Tool::Read } else { tool };
|
|
view.set_tool(now, cx)
|
|
});
|
|
}
|
|
|
|
/// Writes the panel's text, or empties the block when `erase`.
|
|
pub fn write_text(&mut self, erase: bool, cx: &mut Context<Self>) {
|
|
let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else {
|
|
return;
|
|
};
|
|
let Some(aim) = view.read(cx).aim else {
|
|
return;
|
|
};
|
|
let Some((page, mut writing)) = self.edit_panel.read(cx).writing(view.read(cx), aim, cx) else {
|
|
return;
|
|
};
|
|
if erase {
|
|
writing.text.clear();
|
|
}
|
|
// A replacement that changes the layout is shown first.
|
|
let (replacing, version) = (matches!(aim, Aim::Text { .. }), view.read(cx).version);
|
|
let (asked, viewer) = (writing.clone(), cx.weak_entity());
|
|
view.update(cx, |view, cx| {
|
|
let doc = view.doc;
|
|
view.read(
|
|
move |worker| worker.put_text(doc, page, &asked, PREVIEW),
|
|
move |_, answer, cx| {
|
|
// The viewer edits this view; wait until it is free.
|
|
cx.defer(move |cx| {
|
|
if let Some(viewer) = viewer.upgrade() {
|
|
viewer.update(cx, |viewer, cx| viewer.previewed((tab, version, page), writing, answer, replacing, cx));
|
|
}
|
|
})
|
|
},
|
|
cx,
|
|
);
|
|
});
|
|
}
|
|
|
|
/// Shows a layout change, a missing glyph, or writes.
|
|
fn previewed(
|
|
&mut self,
|
|
(tab, version, page): (u64, u64, usize),
|
|
writing: Writing,
|
|
answer: io::Result<Wrote>,
|
|
replacing: bool,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match answer {
|
|
Ok(Wrote::Missing(c)) => {
|
|
let note = Self::text(cx, "pdf.text.missing", &[("char", &c.to_string())]);
|
|
self.edit_panel.update(cx, |panel, cx| {
|
|
panel.note = Some(note);
|
|
cx.notify();
|
|
});
|
|
}
|
|
Ok(Wrote::Preview(pixels, size, grew)) if replacing && (grew || writing.font != "original") => {
|
|
let Some(image) = crate::pages::picture(size, pixels) else {
|
|
return log::error!("pdf: a preview came back the wrong size");
|
|
};
|
|
self.end_preview();
|
|
self.preview = Some(Preview { tab, version, page, writing, image });
|
|
cx.notify();
|
|
}
|
|
Ok(_) => self.commit_text((tab, version, page), writing, cx),
|
|
Err(error) => self.failed_read("previewing text", &error, cx),
|
|
}
|
|
}
|
|
|
|
/// Writes for real if tab `tab` is still at `version`.
|
|
pub fn commit_text(&mut self, (tab, version, page): (u64, u64, usize), writing: Writing, 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, |view, cx| {
|
|
view.edit(
|
|
"writing text",
|
|
move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
|
|
Wrote::Done(mark) => Ok(mark),
|
|
Wrote::Missing(c) => Err(io::Error::other(format!("the font has no {c}"))),
|
|
Wrote::Preview(..) => Err(io::Error::other("a write came back as a preview")),
|
|
},
|
|
cx,
|
|
)
|
|
});
|
|
}
|
|
|
|
fn failed_read(&self, what: &str, error: &io::Error, cx: &mut App) {
|
|
log::error!("pdf: {what} failed ({:?})", error.kind());
|
|
let text = Self::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]);
|
|
self.toast(text, Severity::Danger, cx);
|
|
}
|
|
|
|
/// The aimed object: its page and index.
|
|
fn aimed_object(&self, cx: &App) -> Option<(usize, usize)> {
|
|
match self.active_view()?.read(cx).aim? {
|
|
Aim::Object { page, index } => Some((page, index)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn delete_object(&mut self, cx: &mut Context<Self>) {
|
|
let (Some((page, index)), Some(view)) = (self.aimed_object(cx), self.active_view()) else {
|
|
return;
|
|
};
|
|
let version = view.read(cx).version;
|
|
self.on_same(&view, version, cx, |view, cx| {
|
|
view.edit("deleting an object", move |worker, doc| worker.delete_object(doc, page, index), cx)
|
|
});
|
|
}
|
|
|
|
/// Asks for a picture to take the aimed image's place.
|
|
pub fn replace_image(&mut self, cx: &mut Context<Self>) {
|
|
let (Some((page, index)), Some(view)) = (self.aimed_object(cx), self.active_view()) else {
|
|
return;
|
|
};
|
|
let version = view.read(cx).version;
|
|
self.pick(false, cx, move |viewer, path: PathBuf, cx| {
|
|
viewer.on_same(&view, version, cx, |view, cx| {
|
|
view.edit("replacing an image", move |worker, doc| worker.replace_image(doc, page, index, &path), cx)
|
|
})
|
|
});
|
|
}
|
|
|
|
/// Runs `step` on `view` if no edit came since `version`.
|
|
pub(crate) fn on_same(
|
|
&self,
|
|
view: &gpui::Entity<crate::view::PdfView>,
|
|
version: u64,
|
|
cx: &mut Context<Self>,
|
|
step: impl FnOnce(&mut crate::view::PdfView, &mut Context<crate::view::PdfView>),
|
|
) {
|
|
// Landed or queued, an edit since may move the target.
|
|
let open = self.core.tabs.iter().any(|tab| tab.view == *view);
|
|
if !open || view.read(cx).version != version || !view.read(cx).settled() {
|
|
let text = Self::text(cx, "pdf.object.changed", &[]);
|
|
return self.toast(text, Severity::Warning, cx);
|
|
}
|
|
view.update(cx, step);
|
|
}
|
|
|
|
/// Asks for a picture; it lands mid-page.
|
|
fn add_image(&mut self, cx: &mut Context<Self>) {
|
|
let Some(view) = self.active_view() else {
|
|
return self.no_document(cx);
|
|
};
|
|
let (version, page) = (view.read(cx).version, view.read(cx).current);
|
|
self.pick(false, cx, move |viewer, path: PathBuf, cx| {
|
|
let (w, h) = match image::image_dimensions(&path) {
|
|
Ok(size) => size,
|
|
Err(error) => return viewer.failed_read("reading the picture", &io::Error::other(error), cx),
|
|
};
|
|
viewer.on_same(&view, version, cx, |view, cx| {
|
|
let (pw, ph) = view.sizes[page];
|
|
// Pixels as points, shrunk to half the page.
|
|
let fit = (pw * 0.5 / w as f32).min(ph * 0.5 / h as f32).min(0.75);
|
|
let (iw, ih) = (w as f32 * fit, h as f32 * fit);
|
|
let rect = [(pw - iw) / 2.0, (ph - ih) / 2.0, (pw + iw) / 2.0, (ph + ih) / 2.0];
|
|
view.edit("adding an image", move |worker, doc| worker.add_image(doc, page, &path, rect), cx)
|
|
});
|
|
});
|
|
}
|
|
|
|
/// The preview of a layout change, while open.
|
|
pub fn preview_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
|
let preview = self.preview.as_ref()?;
|
|
let this = cx.entity();
|
|
let dismiss = {
|
|
let this = this.clone();
|
|
move |_: &mut Window, cx: &mut App| {
|
|
this.update(cx, |viewer, cx| {
|
|
viewer.end_preview();
|
|
cx.notify();
|
|
})
|
|
}
|
|
};
|
|
let go = Self::text(cx, "pdf.text.apply", &[]);
|
|
let picture = img(Arc::clone(&preview.image)).max_w(px(560.0)).max_h(px(420.0));
|
|
let card = Card::new("pdf-preview", Self::text(cx, "pdf.preview.title", &[]), dismiss)
|
|
.detail(Self::text(cx, "pdf.preview.detail", &[]))
|
|
.child(div().flex().justify_center().child(picture))
|
|
.action(ely_office::cancel(cx))
|
|
.action(move |_| {
|
|
Button::new("pdf-preview-go", go).variant(ButtonVariant::Primary).on_click(move |_, _, cx| {
|
|
this.update(cx, |viewer, cx| {
|
|
if let Some(Preview { tab, version, page, writing, image }) = viewer.preview.take() {
|
|
viewer.retired.push(image);
|
|
viewer.commit_text((tab, version, page), writing, cx);
|
|
}
|
|
cx.notify();
|
|
})
|
|
})
|
|
});
|
|
Some(card.into_any_element())
|
|
}
|
|
}
|
|
|
|
/// Wires the editing actions onto the root.
|
|
pub fn on(root: gpui::Div, cx: &mut Context<Viewer>) -> gpui::Div {
|
|
use gpui::InteractiveElement as _;
|
|
root.on_action(cx.listener(|v, _: &EditContent, _, cx| v.toggle_tool(Tool::Edit, cx)))
|
|
.on_action(cx.listener(|v, _: &AddText, _, cx| v.toggle_tool(Tool::AddText, cx)))
|
|
.on_action(cx.listener(|v, _: &AddImage, _, cx| v.add_image(cx)))
|
|
.on_action(cx.listener(|v, _: &StopEditing, _, cx| v.on_view(|view, cx| view.set_tool(Tool::Read, cx), cx)))
|
|
}
|
|
|
|
impl Viewer {
|
|
/// Drops the text preview; its picture is freed next render.
|
|
pub fn end_preview(&mut self) {
|
|
if let Some(preview) = self.preview.take() {
|
|
self.retired.push(preview.image);
|
|
}
|
|
}
|
|
}
|