C14.14: export HTML, HTML and folder, PDF (MuPDF story in the PDF worker) and plain text; print; rich copy
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f1cb7eff99
commit
d7ded183ae
27 files changed
+1427
-24
No files matched your search
@@ -0,0 +1,309 @@
|
||||
//! Export, print and rich copy (MARK-11).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, SegmentedControl},
|
||||
overlays::Dialog,
|
||||
primitives::Severity,
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use ely_platform::clipboard::{self, Clip};
|
||||
use ely_ui::{
|
||||
commands::{Export, Print},
|
||||
i18n::text,
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, Context, Div, InteractiveElement, IntoElement, ParentElement as _, TaskExt as _,
|
||||
actions,
|
||||
};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::output::{self, Format, Page, Pictures, Target};
|
||||
|
||||
actions!(mark, [CopyRich]);
|
||||
|
||||
const FORMATS: [(Format, &str, &str); 4] = [
|
||||
(Format::Page, "page", "html"),
|
||||
(Format::Linked, "linked", "html"),
|
||||
(Format::Pdf, "pdf", "pdf"),
|
||||
(Format::Text, "text", "txt"),
|
||||
];
|
||||
|
||||
fn key(format: Format) -> &'static str {
|
||||
FORMATS
|
||||
.iter()
|
||||
.find(|(each, ..)| *each == format)
|
||||
.expect("listed")
|
||||
.1
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
pub fn output_actions(root: Div, cx: &mut Context<Self>) -> Div {
|
||||
root.on_action(cx.listener(|desk, _: &Export, _, cx| desk.ask_export(cx)))
|
||||
.on_action(cx.listener(|desk, _: &Print, _, cx| desk.print(cx)))
|
||||
.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy_rich(cx)))
|
||||
}
|
||||
|
||||
fn no_document(&self, cx: &mut Context<Self>) {
|
||||
let said = text(cx, "command.blocked.no_document", &[]);
|
||||
self.toast(said, Severity::Warning, cx);
|
||||
}
|
||||
|
||||
fn ask_export(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.core.active else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
self.exporting = Some((tab, Format::Page));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn export_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let (_, format) = self.exporting?;
|
||||
let (picking, going, closing) = (cx.entity(), cx.entity(), cx.entity());
|
||||
let formats = FORMATS
|
||||
.iter()
|
||||
.fold(
|
||||
SegmentedControl::new("mark-export-format", key(format)),
|
||||
|control, (_, value, _)| {
|
||||
control.segment(*value, text(cx, &format!("mark.export.{value}"), &[]), None)
|
||||
},
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_change(move |value, _, cx| {
|
||||
let Some((chosen, ..)) =
|
||||
FORMATS.iter().find(|(_, each, _)| *each == value.as_ref())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let chosen = *chosen;
|
||||
picking.update(cx, |desk, cx| {
|
||||
if let Some((_, format)) = &mut desk.exporting {
|
||||
*format = chosen;
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
let go = text(cx, "mark.export.go", &[]);
|
||||
let card = Dialog::new("mark-export", text(cx, "mark.export", &[]), move |_, cx| {
|
||||
closing.update(cx, |desk, cx| {
|
||||
desk.exporting = None;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.child(formats)
|
||||
.action(ely_office::cancel(cx))
|
||||
.action(move |_| {
|
||||
Button::new("mark-export-go", go)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, _, cx| going.update(cx, |desk, cx| desk.export_asked(cx)))
|
||||
});
|
||||
Some(card.into_any_element())
|
||||
}
|
||||
|
||||
/// Asks where, then writes the chosen format.
|
||||
fn export_asked(&mut self, cx: &mut Context<Self>) {
|
||||
let Some((tab, format)) = self.exporting.take() else {
|
||||
return;
|
||||
};
|
||||
cx.notify();
|
||||
let Some(source) = self.tab(tab) else {
|
||||
return;
|
||||
};
|
||||
let Some(dir) = ely_office::folder(source) else {
|
||||
return self.export_failed("no documents folder", cx);
|
||||
};
|
||||
let stem = source
|
||||
.name
|
||||
.rsplit_once('.')
|
||||
.map_or(source.name.as_ref(), |(stem, _)| stem);
|
||||
let extension = FORMATS
|
||||
.iter()
|
||||
.find(|(each, ..)| *each == format)
|
||||
.expect("listed")
|
||||
.2;
|
||||
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{extension}")));
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let chosen = match chosen.await {
|
||||
Ok(Ok(path)) => Ok(path),
|
||||
Ok(Err(error)) => Err(error.to_string()),
|
||||
Err(error) => Err(error.to_string()),
|
||||
};
|
||||
desk.update(cx, |desk, cx| match chosen {
|
||||
Ok(Some(path)) => desk.export_to(tab, path, format, cx),
|
||||
Ok(None) => log::info!("mark: the export panel was cancelled"),
|
||||
Err(error) => desk.export_failed(&error, cx),
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
pub fn export_to(&mut self, tab: u64, path: PathBuf, format: Format, cx: &mut Context<Self>) {
|
||||
if !self.settled(tab, cx) {
|
||||
return self.when_settled(tab, move |desk, _, cx| {
|
||||
desk.export_to(tab, path, format, cx)
|
||||
});
|
||||
}
|
||||
let (Some(source), Some(markdown)) = (self.tab(tab), self.text_of(tab)) else {
|
||||
return;
|
||||
};
|
||||
match self.holder(&path) {
|
||||
Ok(None) => {}
|
||||
Ok(Some(_)) => return self.export_failed("an open document is there", cx),
|
||||
Err(error) => return self.export_failed(&error.to_string(), cx),
|
||||
}
|
||||
let session = source.session.path().map(PathBuf::from);
|
||||
let beside = session
|
||||
.as_deref()
|
||||
.and_then(|path| path.parent())
|
||||
.map(PathBuf::from);
|
||||
let (revision, work) = (source.session.revision(), source.work.clone());
|
||||
let opened = path.clone();
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let written = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
let target = Target {
|
||||
path: &path,
|
||||
session: session.as_deref(),
|
||||
revision,
|
||||
work: &work,
|
||||
};
|
||||
output::write(format, &markdown, beside.as_deref(), &target)
|
||||
})
|
||||
.await;
|
||||
desk.update(cx, |desk, cx| match written {
|
||||
Ok(page) => {
|
||||
let name = ely_office::files::file_name(&opened);
|
||||
desk.toast_output(text(cx, "mark.export.done", &[("name", &name)]), opened, cx);
|
||||
desk.left_out(&page, cx);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
let said = text(
|
||||
cx,
|
||||
"mark.export.folder_taken",
|
||||
&[("name", &error.to_string())],
|
||||
);
|
||||
desk.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
Err(error) => desk.export_failed(&error.to_string(), cx),
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
/// Says what an export could not carry.
|
||||
fn left_out(&self, page: &Page, cx: &mut Context<Self>) {
|
||||
// Names and errors quote the document: not logged.
|
||||
if !page.missing.is_empty() {
|
||||
let said = text(
|
||||
cx,
|
||||
"mark.export.missing",
|
||||
&[("list", &page.missing.join(", "))],
|
||||
);
|
||||
self.toast_unlogged(said, Severity::Warning, cx);
|
||||
}
|
||||
if !page.diagrams.is_empty() {
|
||||
let lines: Vec<String> = page.diagrams.iter().map(usize::to_string).collect();
|
||||
let said = text(cx, "mark.export.diagrams", &[("list", &lines.join(", "))]);
|
||||
self.toast(said, Severity::Warning, cx);
|
||||
}
|
||||
if !page.formulas.is_empty() {
|
||||
let each = page.formulas.iter().map(|(line, error)| {
|
||||
text(
|
||||
cx,
|
||||
"mark.export.formula",
|
||||
&[("line", &line.to_string()), ("error", error)],
|
||||
)
|
||||
.to_string()
|
||||
});
|
||||
let said = text(
|
||||
cx,
|
||||
"mark.export.formulas",
|
||||
&[("list", &each.collect::<Vec<_>>().join("; "))],
|
||||
);
|
||||
self.toast_unlogged(said, Severity::Warning, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn export_failed(&self, error: &str, cx: &mut Context<Self>) {
|
||||
let said = text(cx, "mark.export.failed", &[("error", error)]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
|
||||
/// Prints the PDF export through the system panel.
|
||||
fn print(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.core.active else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
if !self.settled(tab, cx) {
|
||||
return self.when_settled(tab, |desk, _, cx| desk.print(cx));
|
||||
}
|
||||
let (Some(source), Some(markdown)) = (self.tab(tab), self.text_of(tab)) else {
|
||||
return;
|
||||
};
|
||||
let beside = source
|
||||
.session
|
||||
.path()
|
||||
.and_then(|path| path.parent())
|
||||
.map(PathBuf::from);
|
||||
let (revision, work) = (source.session.revision(), source.work.clone());
|
||||
let pdf = work.join("print.pdf");
|
||||
log::info!("mark: prints tab {tab}");
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let written = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
let target = Target {
|
||||
path: &pdf,
|
||||
session: None,
|
||||
revision,
|
||||
work: &work,
|
||||
};
|
||||
output::write(Format::Pdf, &markdown, beside.as_deref(), &target)
|
||||
.map(|page| (page, pdf))
|
||||
})
|
||||
.await;
|
||||
desk.update(cx, |desk, cx| match written {
|
||||
Ok((page, pdf)) => {
|
||||
desk.left_out(&page, cx);
|
||||
ely_office::print_later(pdf, cx);
|
||||
}
|
||||
Err(error) => desk.print_failed(&error.to_string(), cx),
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
/// The selection, or the whole text, as HTML.
|
||||
fn copy_rich(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let document = view.read(cx).document().clone();
|
||||
let selected = document.read(cx).selected(cx);
|
||||
let markdown = match selected.is_empty() {
|
||||
true => document.read(cx).text(cx),
|
||||
false => selected,
|
||||
};
|
||||
let beside = self
|
||||
.active_tab()
|
||||
.and_then(|tab| tab.session.path())
|
||||
.and_then(|path| path.parent());
|
||||
let copied = output::body(&markdown, beside, &Pictures::Inline).and_then(|page| {
|
||||
let clip = Clip::Html {
|
||||
html: page.html,
|
||||
text: markdown,
|
||||
};
|
||||
clipboard::write(&clip).map_err(|error| std::io::Error::other(format!("{error:#}")))
|
||||
});
|
||||
if let Err(error) = copied {
|
||||
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,88 @@
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use crate::desk::tests::{open, start};
|
||||
use crate::output::Format;
|
||||
|
||||
#[gpui::test]
|
||||
fn export_asks_for_a_format_then_writes_it(cx: &mut TestAppContext) {
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("note.md");
|
||||
std::fs::write(&path, "# Note\n\nText $x$.\n").expect("a file");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, &path, cx);
|
||||
cx.dispatch_action(ely_ui::commands::Export);
|
||||
cx.run_until_parked();
|
||||
let (tab, format) = desk.read_with(cx, |desk, _| desk.exporting.expect("the dialog"));
|
||||
assert_eq!(format, Format::Page);
|
||||
let out = files.path().join("note.html");
|
||||
desk.update(cx, |desk, cx| {
|
||||
desk.exporting = None;
|
||||
desk.export_to(tab, out.clone(), Format::Page, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let html = std::fs::read_to_string(&out).expect("written");
|
||||
assert!(
|
||||
html.contains("<h1>Note</h1>") && html.contains("<svg"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn export_never_writes_over_the_open_file(cx: &mut TestAppContext) {
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("note.md");
|
||||
std::fs::write(&path, "# Note\n").expect("a file");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, &path, cx);
|
||||
let tab = desk.read_with(cx, |desk, _| desk.core.active.expect("a tab"));
|
||||
desk.update(cx, |desk, cx| {
|
||||
desk.export_to(tab, path.clone(), Format::Text, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(std::fs::read_to_string(&path).expect("reads"), "# Note\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn rich_copy_takes_the_selection_as_html(cx: &mut TestAppContext) {
|
||||
if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() {
|
||||
eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard");
|
||||
return;
|
||||
}
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("note.md");
|
||||
std::fs::write(&path, "# Note\n\n**Bold** words.\n").expect("a file");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, &path, cx);
|
||||
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
|
||||
view.update_in(cx, |view, window, cx| {
|
||||
view.switch(crate::view::Mode::Source, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let document = view.read_with(cx, |view, _| view.document().clone());
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.editor()
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(8..22), cx))
|
||||
});
|
||||
cx.dispatch_action(crate::export::CopyRich);
|
||||
let clip = ely_platform::clipboard::read()
|
||||
.expect("reads")
|
||||
.expect("a clip");
|
||||
let ely_platform::clipboard::Clip::Html { html, text } = clip else {
|
||||
panic!("not HTML: {clip:?}");
|
||||
};
|
||||
assert_eq!(text, "**Bold** words");
|
||||
assert!(
|
||||
html.contains("<strong>Bold</strong>") && !html.contains("Note"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user