Skip to main content

blockworx/
export.rs

1//! "Export…" file save: writing out the bytes the editor handed back — to a
2//! path chosen in a native save dialog, or straight to the browser's download
3//! picker on the web. Rendering them is the editor's own work; what is left
4//! here is the delivery.
5//!
6//! The format is chosen up front (rather than inferred from the dialog's
7//! filename) because the web save dialog offers no format selection — it just
8//! downloads the default-named file — so a dialog-driven choice would always
9//! yield SVG there.
10
11pub use blockworx_export::{ExportContent, bytes_for};
12// The formats and the surfaces that offer them are command vocabulary — the
13// registry names an export by its format — so they live with the commands and
14// are reached here under their old names. Which content a format is written
15// from is stated here, above both: the export crate knows what it rendered,
16// not what the toolbar called it.
17pub use blockworx_tools::commands::{ExportFormat, ExportScope};
18
19/// The format `content` was rendered for.
20fn format(content: &ExportContent) -> ExportFormat {
21    match content {
22        ExportContent::Svg(_) => ExportFormat::Svg,
23        ExportContent::Png(_) => ExportFormat::Png,
24        ExportContent::Pdf(_) => ExportFormat::Pdf,
25    }
26}
27
28/// Native save-dialog filter label.
29#[cfg(not(target_arch = "wasm32"))]
30fn filter_name(format: ExportFormat) -> &'static str {
31    match format {
32        ExportFormat::Svg => "SVG image",
33        ExportFormat::Png => "PNG image",
34        ExportFormat::Pdf => "PDF document",
35    }
36}
37
38/// MIME type for the web download's Blob.
39#[cfg(target_arch = "wasm32")]
40fn mime(format: ExportFormat) -> &'static str {
41    match format {
42        ExportFormat::Svg => "image/svg+xml",
43        ExportFormat::Png => "image/png",
44        ExportFormat::Pdf => "application/pdf",
45    }
46}
47
48/// The rendered content for the chosen format, with the name the save dialog
49/// suggests before that format's extension — the document's own name, so a rev
50/// saved out of the history arrives called something a reader can place.
51pub struct ExportPayload {
52    pub name: String,
53    pub content: ExportContent,
54}
55
56/// Open the save dialog off the UI thread (so the canvas keeps repainting) and
57/// write `payload` in its format.
58///
59/// What became of it reaches the user through the toast: an export is a file
60/// operation, so a write that failed is said out loud rather than only in the
61/// console. A cancelled dialog says nothing — the user already knows.
62#[cfg(not(target_arch = "wasm32"))]
63pub fn spawn_export(ctx: &egui::Context, payload: ExportPayload) {
64    let thread_ctx = ctx.clone();
65    std::thread::spawn(move || {
66        let format = format(&payload.content);
67        let ext = format.extension();
68        let picked = rfd::FileDialog::new()
69            .set_file_name(format!("{}.{ext}", payload.name))
70            .add_filter(filter_name(format), &[ext])
71            .save_file();
72        if let Some(path) = picked {
73            let named = crate::file::container_name(&path);
74            match blockworx_store::atomic::write_atomically(&path, &bytes_for(&payload.content)) {
75                Ok(()) => crate::shell::status_line::say(&thread_ctx, format!("Exported {named}")),
76                Err(e) => {
77                    tracing::error!("Failed to export {}: {e}", path.display());
78                    crate::shell::toast::say(&thread_ctx, format!("Could not export {named}: {e}"));
79                }
80            }
81        }
82        thread_ctx.request_repaint();
83    });
84    ctx.request_repaint();
85}
86
87/// Web variant: the browser can't silently write to a path, so instead of a save
88/// dialog (rfd's shows a "click to download" link) the backend's download
89/// helper builds a Blob and clicks a synthetic `<a download>`, and the file
90/// saves straight away under a name carrying the chosen format's extension.
91// The signature must match the native variant, which moves the payload onto a
92// worker thread.
93#[expect(clippy::needless_pass_by_value)]
94#[cfg(target_arch = "wasm32")]
95pub fn spawn_export(ctx: &egui::Context, payload: ExportPayload) {
96    let format = format(&payload.content);
97    let name = format!("{}.{}", payload.name, format.extension());
98    match blockworx_canvas2d::download(&name, mime(format), &bytes_for(&payload.content)) {
99        Ok(()) => crate::shell::status_line::say(ctx, format!("Exported {name}")),
100        Err(e) => {
101            web_sys::console::error_1(&format!("Failed to export {name}: {e:?}").into());
102            crate::shell::toast::say(ctx, format!("Could not export {name}"));
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::{ExportFormat, ExportScope};
110
111    /// The two surfaces differ in exactly one format, and PDF is the one the
112    /// selection cannot offer: an excerpt has no scope hierarchy to navigate.
113    #[test]
114    fn only_the_view_offers_pdf() {
115        assert!(ExportScope::View.formats().contains(&ExportFormat::Pdf));
116        assert!(
117            !ExportScope::Selection
118                .formats()
119                .contains(&ExportFormat::Pdf)
120        );
121        for format in ExportScope::Selection.formats() {
122            assert!(
123                ExportScope::View.formats().contains(format),
124                "{format:?} is offered on a selection but not on the view",
125            );
126        }
127    }
128}