Skip to main content

blockworx_web/
exchange.rs

1//! What comes into the document from outside it: an import, a block's icon,
2//! an image of its own — picked from a dialog, or dropped on the diagram.
3//!
4//! Three effects, one door. The browser will only open a file picker from
5//! inside a user gesture, and the gesture that asked is already over by the
6//! time the effect reaches the shell — so the input is *in* the page, hidden,
7//! and the effect clicks it. What the pick was for is the shell's own record
8//! of the flow ([`Wanted`]), so a pick that took a while still lands where
9//! the press that asked for it meant it to.
10//!
11//! A dropped file is read the same way wherever it lands: [`Dropped::of`]
12//! names what it is, and the zone it was dropped on says what to do with it.
13//! Dropping a document on the **diagram** embeds it as a block of this one;
14//! dropping it on the **Diagrams** section files it in the origin. Two
15//! zones, two meanings — and one reading of the name, so they cannot
16//! disagree about what a file is.
17//!
18//! What the bytes *mean* is `blockworx_editor::import`'s, and the size a
19//! document will accept is the kernel's; neither is decided here.
20
21use std::path::Path;
22
23use dioxus::prelude::*;
24use dioxus::web::WebEventExt as _;
25use wasm_bindgen::JsCast as _;
26use wasm_bindgen::closure::Closure;
27use web_sys::{FileReader, HtmlInputElement};
28
29use crate::shell::{Shell, Wanted};
30
31/// What the picker offers. The same two the desktop's dialog filters to: a
32/// document draws SVG symbols and PNG artwork and nothing else.
33const ACCEPTS: &str = ".svg,.png,image/svg+xml,image/png";
34
35#[component]
36pub fn FilePick(shell: Shell) -> Element {
37    let wanted = shell.wanted();
38    let mut input = use_signal(|| None::<HtmlInputElement>);
39    // A new ask opens the picker; the element is the same one every time, so
40    // its value is cleared first — picking one file twice running is two
41    // picks, and an unchanged value fires no `change`.
42    use_effect(move || {
43        if wanted.read().is_none() {
44            return;
45        }
46        if let Some(element) = input.read().clone() {
47            element.set_value("");
48            element.click();
49        }
50    });
51    let deliver = use_hook(|| CopyValue::new(shell));
52    rsx! {
53        input {
54            r#type: "file",
55            class: "hidden",
56            accept: ACCEPTS,
57            "aria-hidden": "true",
58            "data-pick": "file",
59            onmounted: move |event| {
60                input.set(
61                    event
62                        .try_as_web_event()
63                        .and_then(|element| element.dyn_into::<HtmlInputElement>().ok()),
64                );
65            },
66            onchange: move |event| {
67                let Some(what) = *wanted.peek() else { return };
68                let picked = event
69                    .try_as_web_event()
70                    .and_then(|event| event.target())
71                    .and_then(|target| target.dyn_into::<HtmlInputElement>().ok())
72                    .and_then(|element| element.files())
73                    .and_then(|files| files.get(0));
74                // A picker closed with nothing chosen changed nothing, so
75                // there is nothing to put back — only the flow to drop.
76                let Some(file) = picked else {
77                    deliver.read().unpicked();
78                    return;
79                };
80                let shell = deliver.read().clone();
81                let lands = shell.clone();
82                reads(&file, &shell, move |name, bytes| {
83                    lands.delivers(what, &name, bytes);
84                });
85            },
86        }
87    }
88}
89
90/// What a dropped file is, read off its name.
91#[derive(Clone, Copy, PartialEq, Eq, Debug)]
92pub enum Dropped {
93    /// A drawing, travelling as the one file it travels in.
94    Document,
95    /// Artwork, which the session places where a paste would land.
96    Artwork,
97}
98
99impl Dropped {
100    /// What `name` says it is.
101    ///
102    /// The suffix is asked about directly rather than through
103    /// [`Name::of_archive`](blockworx_store::storage::Name::of_archive),
104    /// which answers for every name: it says what a container *would* be
105    /// called, not whether this is one.
106    fn of(name: &str) -> Self {
107        let carried = Path::new(name).extension().is_some_and(|ext| {
108            ["zip", "bwx"]
109                .iter()
110                .any(|named| ext.eq_ignore_ascii_case(named))
111        });
112        if carried {
113            Dropped::Document
114        } else {
115            Dropped::Artwork
116        }
117    }
118}
119
120/// Where a file was dropped, which is what its being a document means.
121#[derive(Clone, Copy, PartialEq, Eq, Debug)]
122pub enum Zone {
123    /// The diagram: a document becomes a block of this one, artwork is
124    /// placed. Nothing is filed in the origin.
125    Diagram,
126    /// The Diagrams section: a document is filed in the origin and opened.
127    /// Artwork means nothing to a library, and is refused with a word about
128    /// where it would have landed.
129    Library,
130}
131
132/// The first file a drop carries. One at a time: a library door takes the
133/// document out of the session for as long as it is open, so a second file
134/// would be waiting on the first with nothing to say what it was for.
135pub fn carried_by(native: &web_sys::DragEvent) -> Option<web_sys::File> {
136    native
137        .data_transfer()
138        .and_then(|carried| carried.files())
139        .and_then(|files| files.get(0))
140}
141
142/// A dropped file, taken in by what it is and where it fell.
143pub fn drops(shell: &Shell, zone: Zone, file: &web_sys::File) {
144    let dropped = Dropped::of(&file.name());
145    let lands = shell.clone();
146    reads(file, shell, move |name, bytes| match (zone, dropped) {
147        (Zone::Diagram, Dropped::Document) => lands.embeds(&name, &bytes),
148        (Zone::Diagram, Dropped::Artwork) => lands.delivers(Wanted::Import, &name, bytes),
149        (Zone::Library, Dropped::Document) => lands.imports_archive(name, bytes),
150        (Zone::Library, Dropped::Artwork) => lands.failed(format!(
151            "{name} is not a diagram — drop a picture on the canvas to place it"
152        )),
153    });
154}
155
156/// Read one file and hand its bytes to whatever asked for them.
157/// `FileReader` is the only way to the bytes on the main thread, and it
158/// answers on an event rather than returning, so the hand-off happens in the
159/// callback.
160fn reads(file: &web_sys::File, shell: &Shell, then: impl FnOnce(String, Vec<u8>) + 'static) {
161    let Ok(reader) = FileReader::new() else {
162        shell.unpicked();
163        return;
164    };
165    let name = file.name();
166    let done = {
167        let reader = reader.clone();
168        let shell = shell.clone();
169        Closure::once_into_js(move || {
170            let bytes = reader
171                .result()
172                .ok()
173                .and_then(|value| value.dyn_into::<js_sys::ArrayBuffer>().ok())
174                .map(|buffer| js_sys::Uint8Array::new(&buffer).to_vec());
175            match bytes {
176                Some(bytes) => then(name, bytes),
177                None => shell.failed(format!("Could not read {name}")),
178            }
179        })
180    };
181    reader.set_onload(Some(done.unchecked_ref()));
182    if reader.read_as_array_buffer(file).is_err() {
183        shell.unpicked();
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    /// A drawing travels as one file; everything else is artwork, and what
192    /// artwork the session accepts is `blockworx_editor::import`'s to say,
193    /// not this module's. Both zones read the name this one way, so a file
194    /// cannot be a diagram on the canvas and a picture in the library.
195    #[test]
196    fn a_dropped_name_says_what_the_file_is_wherever_it_lands() {
197        for named in [
198            "engine.bwx.zip",
199            "engine.zip",
200            "engine.bwx",
201            "ENGINE.BWX.ZIP",
202        ] {
203            assert_eq!(Dropped::of(named), Dropped::Document, "{named}");
204        }
205        for named in ["logo.png", "symbol.svg", "SYMBOL.SVG", "notes.txt", "zip"] {
206            assert_eq!(Dropped::of(named), Dropped::Artwork, "{named}");
207        }
208    }
209}