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}