Skip to content

Commit 0c3eb7e

Browse files
wasm save_file (#134)
Wasm32 AsyncFileSaveDialogImpl Use as ``` rfd::AsyncFileDialog::new() .set_title("title") .set_file_name("demofile.txt") .save_file() .await .unwrap() .write(Box::new(*b"file content")) .await; ``` --------- Co-authored-by: PolyMeilex <[email protected]>
1 parent af0e429 commit 0c3eb7e

File tree

9 files changed

+285
-61
lines changed

9 files changed

+285
-61
lines changed

Cargo.toml

+4
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,14 @@ web-sys = { version = "0.3.46", features = [
6262
'Element',
6363
'HtmlInputElement',
6464
'HtmlButtonElement',
65+
'HtmlAnchorElement',
6566
'Window',
6667
'File',
6768
'FileList',
6869
'FileReader',
70+
'Blob',
71+
'BlobPropertyBag',
72+
'Url',
6973
] }
7074
wasm-bindgen-futures = "0.4.19"
7175

examples/winit-example/src/main.rs

+8-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ fn main() {
4141
}
4242
event::Event::WindowEvent { event, .. } => match event {
4343
WindowEvent::CloseRequested { .. } => *control_flow = ControlFlow::Exit,
44-
#[cfg(not(target_arch = "wasm32"))]
4544
WindowEvent::KeyboardInput {
4645
input:
4746
event::KeyboardInput {
@@ -60,6 +59,14 @@ fn main() {
6059
let event_loop_proxy = event_loop_proxy.clone();
6160
executor.execut(async move {
6261
let file = dialog.await;
62+
63+
let file = if let Some(file) = file {
64+
file.write(b"Hi! This is a test file").await.unwrap();
65+
Some(file)
66+
} else {
67+
None
68+
};
69+
6370
event_loop_proxy
6471
.send_event(format!("saved file name: {:#?}", file))
6572
.ok();

src/backend/wasm.rs

+157-44
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,40 @@
1+
mod file_dialog;
2+
3+
use crate::{
4+
file_dialog::FileDialog, file_handle::WasmFileHandleKind, FileHandle, MessageDialogResult,
5+
};
16
use wasm_bindgen::prelude::*;
27
use wasm_bindgen::JsCast;
3-
use web_sys::Element;
8+
use web_sys::{Element, HtmlAnchorElement, HtmlButtonElement, HtmlElement, HtmlInputElement};
49

5-
use web_sys::{HtmlButtonElement, HtmlElement, HtmlInputElement};
10+
#[derive(Clone, Debug)]
11+
pub enum FileKind<'a> {
12+
In(FileDialog),
13+
Out(FileDialog, &'a [u8]),
14+
}
615

7-
use crate::file_dialog::FileDialog;
8-
use crate::{FileHandle, MessageDialogResult};
16+
#[derive(Clone, Debug)]
17+
enum HtmlIoElement<'a> {
18+
Input(HtmlInputElement),
19+
Output {
20+
element: HtmlAnchorElement,
21+
name: String,
22+
data: &'a [u8],
23+
},
24+
}
925

10-
pub struct WasmDialog {
26+
pub struct WasmDialog<'a> {
1127
overlay: Element,
1228
card: Element,
1329
title: Option<HtmlElement>,
14-
input: HtmlInputElement,
30+
io: HtmlIoElement<'a>,
1531
button: HtmlButtonElement,
1632

1733
style: Element,
1834
}
1935

20-
impl WasmDialog {
21-
pub fn new(opt: &FileDialog) -> Self {
36+
impl<'a> WasmDialog<'a> {
37+
pub fn new(opt: &FileKind<'a>) -> Self {
2238
let window = web_sys::window().expect("Window not found");
2339
let document = window.document().expect("Document not found");
2440

@@ -33,9 +49,13 @@ impl WasmDialog {
3349
card
3450
};
3551

36-
let title = opt.title.as_ref().map(|title| {
37-
let title_el: web_sys::HtmlElement =
38-
document.create_element("div").unwrap().dyn_into().unwrap();
52+
let title = match opt {
53+
FileKind::In(dialog) => &dialog.title,
54+
FileKind::Out(dialog, _) => &dialog.title,
55+
}
56+
.as_ref()
57+
.map(|title| {
58+
let title_el: HtmlElement = document.create_element("div").unwrap().dyn_into().unwrap();
3959

4060
title_el.set_id("rfd-title");
4161
title_el.set_inner_html(title);
@@ -44,25 +64,41 @@ impl WasmDialog {
4464
title_el
4565
});
4666

47-
let input = {
48-
let input_el = document.create_element("input").unwrap();
49-
let input: HtmlInputElement = wasm_bindgen::JsCast::dyn_into(input_el).unwrap();
67+
let io = match opt {
68+
FileKind::In(dialog) => {
69+
let input_el = document.create_element("input").unwrap();
70+
let input: HtmlInputElement = wasm_bindgen::JsCast::dyn_into(input_el).unwrap();
5071

51-
input.set_id("rfd-input");
52-
input.set_type("file");
72+
input.set_id("rfd-input");
73+
input.set_type("file");
5374

54-
let mut accept: Vec<String> = Vec::new();
75+
let mut accept: Vec<String> = Vec::new();
5576

56-
for filter in opt.filters.iter() {
57-
accept.append(&mut filter.extensions.to_vec());
58-
}
77+
for filter in dialog.filters.iter() {
78+
accept.append(&mut filter.extensions.to_vec());
79+
}
5980

60-
accept.iter_mut().for_each(|ext| ext.insert_str(0, "."));
81+
accept.iter_mut().for_each(|ext| ext.insert_str(0, "."));
6182

62-
input.set_accept(&accept.join(","));
83+
input.set_accept(&accept.join(","));
6384

64-
card.append_child(&input).unwrap();
65-
input
85+
card.append_child(&input).unwrap();
86+
HtmlIoElement::Input(input)
87+
}
88+
FileKind::Out(dialog, data) => {
89+
let output_el = document.create_element("a").unwrap();
90+
let output: HtmlAnchorElement = wasm_bindgen::JsCast::dyn_into(output_el).unwrap();
91+
92+
output.set_id("rfd-output");
93+
output.set_inner_text("click here to download your file");
94+
95+
card.append_child(&output).unwrap();
96+
HtmlIoElement::Output {
97+
element: output,
98+
name: dialog.file_name.clone().unwrap_or_default(),
99+
data,
100+
}
101+
}
66102
};
67103

68104
let button = {
@@ -85,7 +121,7 @@ impl WasmDialog {
85121
card,
86122
title,
87123
button,
88-
input,
124+
io,
89125

90126
style,
91127
}
@@ -94,26 +130,78 @@ impl WasmDialog {
94130
async fn show(&self) {
95131
let window = web_sys::window().expect("Window not found");
96132
let document = window.document().expect("Document not found");
97-
let body = document.body().expect("document should have a body");
133+
let body = document.body().expect("Document should have a body");
98134

99135
let overlay = self.overlay.clone();
100136
let button = self.button.clone();
101137

102-
let promise = js_sys::Promise::new(&mut move |res, _rej| {
103-
let closure = Closure::wrap(Box::new(move || {
104-
res.call0(&JsValue::undefined()).unwrap();
105-
}) as Box<dyn FnMut()>);
138+
let promise = match &self.io {
139+
HtmlIoElement::Input(_) => js_sys::Promise::new(&mut move |res, _rej| {
140+
let resolve_promise = Closure::wrap(Box::new(move || {
141+
res.call0(&JsValue::undefined()).unwrap();
142+
}) as Box<dyn FnMut()>);
143+
144+
button.set_onclick(Some(resolve_promise.as_ref().unchecked_ref()));
145+
resolve_promise.forget();
146+
body.append_child(&overlay).ok();
147+
}),
148+
HtmlIoElement::Output {
149+
element,
150+
name,
151+
data,
152+
} => {
153+
js_sys::Promise::new(&mut |res, _rej| {
154+
// Moved to keep closure as FnMut
155+
let output = element.clone();
156+
let file_name = name.clone();
157+
158+
let resolve_promise = Closure::wrap(Box::new(move || {
159+
res.call1(&JsValue::undefined(), &JsValue::from(true))
160+
.unwrap();
161+
}) as Box<dyn FnMut()>);
162+
163+
// Resolve the promise once the user clicks the download link or the button.
164+
output.set_onclick(Some(resolve_promise.as_ref().unchecked_ref()));
165+
button.set_onclick(Some(resolve_promise.as_ref().unchecked_ref()));
166+
resolve_promise.forget();
167+
168+
let set_download_link = move |in_array: &[u8], name: &str| {
169+
// See <https://stackoverflow.com/questions/69556755/web-sysurlcreate-object-url-with-blobblob-not-formatting-binary-data-co>
170+
let array = js_sys::Array::new();
171+
let uint8arr = js_sys::Uint8Array::new(
172+
// Safety: No wasm allocations happen between creating the view and consuming it in the array.push
173+
&unsafe { js_sys::Uint8Array::view(&in_array) }.into(),
174+
);
175+
array.push(&uint8arr.buffer());
176+
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(
177+
&array,
178+
web_sys::BlobPropertyBag::new().type_("application/octet-stream"),
179+
)
180+
.unwrap();
181+
let download_url =
182+
web_sys::Url::create_object_url_with_blob(&blob).unwrap();
183+
184+
output.set_href(&download_url);
185+
output.set_download(&name);
186+
};
187+
188+
set_download_link(&*data, &file_name);
189+
190+
body.append_child(&overlay).ok();
191+
})
192+
}
193+
};
106194

107-
button.set_onclick(Some(closure.as_ref().unchecked_ref()));
108-
closure.forget();
109-
body.append_child(&overlay).ok();
110-
});
111195
let future = wasm_bindgen_futures::JsFuture::from(promise);
112196
future.await.unwrap();
113197
}
114198

115199
fn get_results(&self) -> Option<Vec<FileHandle>> {
116-
if let Some(files) = self.input.files() {
200+
let input = match &self.io {
201+
HtmlIoElement::Input(input) => input,
202+
_ => panic!("Internal Error: Results only exist for input dialog"),
203+
};
204+
if let Some(files) = input.files() {
117205
let len = files.length();
118206
if len > 0 {
119207
let mut file_handles = Vec::new();
@@ -136,26 +224,41 @@ impl WasmDialog {
136224
}
137225

138226
async fn pick_files(self) -> Option<Vec<FileHandle>> {
139-
self.input.set_multiple(true);
227+
if let HtmlIoElement::Input(input) = &self.io {
228+
input.set_multiple(true);
229+
} else {
230+
panic!("Internal error: Pick files only on input wasm dialog")
231+
}
140232

141233
self.show().await;
142234

143235
self.get_results()
144236
}
145237

146238
async fn pick_file(self) -> Option<FileHandle> {
147-
self.input.set_multiple(false);
239+
if let HtmlIoElement::Input(input) = &self.io {
240+
input.set_multiple(false);
241+
} else {
242+
panic!("Internal error: Pick file only on input wasm dialog")
243+
}
148244

149245
self.show().await;
150246

151247
self.get_result()
152248
}
249+
250+
fn io_element(&self) -> Element {
251+
match self.io.clone() {
252+
HtmlIoElement::Input(element) => element.unchecked_into(),
253+
HtmlIoElement::Output { element, .. } => element.unchecked_into(),
254+
}
255+
}
153256
}
154257

155-
impl Drop for WasmDialog {
258+
impl<'a> Drop for WasmDialog<'a> {
156259
fn drop(&mut self) {
157260
self.button.remove();
158-
self.input.remove();
261+
self.io_element().remove();
159262
self.title.as_ref().map(|elem| elem.remove());
160263
self.card.remove();
161264

@@ -168,11 +271,11 @@ use super::{AsyncFilePickerDialogImpl, DialogFutureType};
168271

169272
impl AsyncFilePickerDialogImpl for FileDialog {
170273
fn pick_file_async(self) -> DialogFutureType<Option<FileHandle>> {
171-
let dialog = WasmDialog::new(&self);
274+
let dialog = WasmDialog::new(&FileKind::In(self));
172275
Box::pin(dialog.pick_file())
173276
}
174277
fn pick_files_async(self) -> DialogFutureType<Option<Vec<FileHandle>>> {
175-
let dialog = WasmDialog::new(&self);
278+
let dialog = WasmDialog::new(&FileKind::In(self));
176279
Box::pin(dialog.pick_files())
177280
}
178281
}
@@ -209,11 +312,21 @@ impl MessageDialogImpl for MessageDialog {
209312
}
210313
}
211314

212-
use crate::backend::AsyncMessageDialogImpl;
213-
214-
impl AsyncMessageDialogImpl for MessageDialog {
315+
impl crate::backend::AsyncMessageDialogImpl for MessageDialog {
215316
fn show_async(self) -> DialogFutureType<MessageDialogResult> {
216317
let val = MessageDialogImpl::show(self);
217318
Box::pin(std::future::ready(val))
218319
}
219320
}
321+
322+
impl FileHandle {
323+
pub async fn write(&self, data: &[u8]) -> std::io::Result<()> {
324+
let dialog = match &self.0 {
325+
WasmFileHandleKind::Writable(dialog) => dialog,
326+
_ => panic!("This File Handle doesn't support writing. Use `save_file` to get a writeable FileHandle in Wasm"),
327+
};
328+
let dialog = WasmDialog::new(&FileKind::Out(dialog.clone(), data));
329+
dialog.show().await;
330+
Ok(())
331+
}
332+
}

src/backend/wasm/file_dialog.rs

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//
2+
// File Save
3+
//
4+
5+
use crate::{
6+
backend::{AsyncFileSaveDialogImpl, DialogFutureType},
7+
file_dialog::FileDialog,
8+
FileHandle,
9+
};
10+
use std::future::ready;
11+
impl AsyncFileSaveDialogImpl for FileDialog {
12+
fn save_file_async(self) -> DialogFutureType<Option<FileHandle>> {
13+
let file = FileHandle::writable(self);
14+
Box::pin(ready(Some(file)))
15+
}
16+
}

src/backend/wasm/style.css

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
#rfd-title {
2525
line-height: 1.6;
2626
}
27-
#rfd-input {
27+
#rfd-input,#rfd-output {
2828
text-align: center;
2929
}
3030
#rfd-button {

0 commit comments

Comments
 (0)