mirror of
https://github.com/emilk/egui.git
synced 2026-08-29 04:40:03 -04:00
Store web_sys::File inside of DroppedFile (#8354)
* Closes #4654 * Related #4667 * [x] I have followed the instructions in the PR template This PR avoids materializing the contents of a file that was dragged into an egui application on the web. It does so by storing the `web_sys::File` handle directly on WASM. This breaks the existing API of `DroppedFile` on the web, because there is no way to retrieve the bytes synchronously form a `DroppedFile` anymore, forcing handling call sites to become asynchronous. The native API remains the same. --------- Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
This commit is contained in:
@@ -209,7 +209,6 @@ image = { workspace = true, features = ["png"] } # For copying images
|
||||
js-sys.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
wasm-bindgen.workspace = true
|
||||
wasm-bindgen-futures.workspace = true
|
||||
web-sys = { workspace = true, features = [
|
||||
"AddEventListenerOptions",
|
||||
"BinaryType",
|
||||
|
||||
48
crates/eframe/src/web/dropped_file.rs
Normal file
48
crates/eframe/src/web/dropped_file.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use std::{
|
||||
future::Future,
|
||||
path::{Path, PathBuf},
|
||||
pin::Pin,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WebFile {
|
||||
file: web_sys::File,
|
||||
// We store a `PathBuf` here so that we can hand out `Path`s
|
||||
// without allocating each time.
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl From<web_sys::File> for WebFile {
|
||||
fn from(file: web_sys::File) -> Self {
|
||||
let path = file.name().into();
|
||||
Self { file, path }
|
||||
}
|
||||
}
|
||||
|
||||
impl egui::DroppedFile for WebFile {
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn bytes_async(&self) -> Pin<Box<dyn Future<Output = Result<Vec<u8>, String>> + '_>> {
|
||||
let file = self.file.clone();
|
||||
Box::pin(async move {
|
||||
if file.size() > f64::from(u32::MAX) {
|
||||
return Err(format!(
|
||||
"File is too large: browser file reads are limited to {} bytes",
|
||||
u32::MAX
|
||||
));
|
||||
}
|
||||
|
||||
let array_buffer = file
|
||||
.array_buffer()
|
||||
.await
|
||||
.map_err(|err| crate::web::string_from_js_value(&err))?;
|
||||
Ok(js_sys::Uint8Array::new(&array_buffer).to_vec())
|
||||
})
|
||||
}
|
||||
|
||||
fn web_file(&self) -> Option<&web_sys::File> {
|
||||
Some(&self.file)
|
||||
}
|
||||
}
|
||||
@@ -973,62 +973,25 @@ fn install_drag_and_drop(runner_ref: &WebRunner, target: &EventTarget) -> Result
|
||||
event.prevent_default();
|
||||
})?;
|
||||
|
||||
runner_ref.add_event_listener(target, "drop", {
|
||||
let runner_ref = runner_ref.clone();
|
||||
runner_ref.add_event_listener(target, "drop", |event: web_sys::DragEvent, runner| {
|
||||
if let Some(data_transfer) = event.data_transfer() {
|
||||
// TODO(https://github.com/emilk/egui/issues/3702): support dropping folders
|
||||
runner.input.raw.hovered_files.clear();
|
||||
runner.needs_repaint.repaint_asap();
|
||||
|
||||
move |event: web_sys::DragEvent, runner| {
|
||||
if let Some(data_transfer) = event.data_transfer() {
|
||||
// TODO(https://github.com/emilk/egui/issues/3702): support dropping folders
|
||||
runner.input.raw.hovered_files.clear();
|
||||
runner.needs_repaint.repaint_asap();
|
||||
if let Some(files) = data_transfer.files() {
|
||||
for i in 0..files.length() {
|
||||
if let Some(file) = files.get(i) {
|
||||
log::debug!("Dropped {:?} ({} bytes)", file.name(), file.size());
|
||||
|
||||
if let Some(files) = data_transfer.files() {
|
||||
for i in 0..files.length() {
|
||||
if let Some(file) = files.get(i) {
|
||||
let name = file.name();
|
||||
let mime = file.type_();
|
||||
let last_modified = std::time::UNIX_EPOCH
|
||||
+ std::time::Duration::from_millis(file.last_modified() as u64);
|
||||
|
||||
log::debug!("Loading {:?} ({} bytes)…", name, file.size());
|
||||
|
||||
let future = wasm_bindgen_futures::JsFuture::from(file.array_buffer());
|
||||
|
||||
let runner_ref = runner_ref.clone();
|
||||
let future = async move {
|
||||
match future.await {
|
||||
Ok(array_buffer) => {
|
||||
let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec();
|
||||
log::debug!("Loaded {:?} ({} bytes).", name, bytes.len());
|
||||
|
||||
if let Some(mut runner_lock) = runner_ref.try_lock() {
|
||||
runner_lock.input.raw.dropped_files.push(
|
||||
egui::DroppedFile {
|
||||
name,
|
||||
mime,
|
||||
last_modified: Some(last_modified),
|
||||
bytes: Some(bytes.into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
runner_lock.needs_repaint.repaint_asap();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Failed to read file: {}",
|
||||
string_from_js_value(&err)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(future);
|
||||
}
|
||||
runner.input.raw.dropped_files.push(std::sync::Arc::new(
|
||||
super::dropped_file::WebFile::from(file),
|
||||
));
|
||||
}
|
||||
}
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
}
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
}
|
||||
})?;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
mod app_runner;
|
||||
mod backend;
|
||||
mod dropped_file;
|
||||
mod events;
|
||||
mod input;
|
||||
mod panic_handler;
|
||||
@@ -207,13 +208,12 @@ fn set_clipboard_text(s: &str) {
|
||||
return;
|
||||
}
|
||||
let promise = window.navigator().clipboard().write_text(s);
|
||||
let future = wasm_bindgen_futures::JsFuture::from(promise);
|
||||
let future = async move {
|
||||
if let Err(err) = future.await {
|
||||
if let Err(err) = promise.await {
|
||||
log::error!("Copy/cut action failed: {}", string_from_js_value(&err));
|
||||
}
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(future);
|
||||
js_sys::futures::spawn_local(future);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,16 +248,15 @@ fn set_clipboard_image(image: &egui::ColorImage) {
|
||||
};
|
||||
let items = js_sys::Array::of1(&item);
|
||||
let promise = window.navigator().clipboard().write(&items);
|
||||
let future = wasm_bindgen_futures::JsFuture::from(promise);
|
||||
let future = async move {
|
||||
if let Err(err) = future.await {
|
||||
if let Err(err) = promise.await {
|
||||
log::error!(
|
||||
"Copy/cut image action failed: {}",
|
||||
string_from_js_value(&err)
|
||||
);
|
||||
}
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(future);
|
||||
js_sys::futures::spawn_local(future);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
crates/egui-winit/src/dropped_file.rs
Normal file
22
crates/egui-winit/src/dropped_file.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct NativeFile {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl From<PathBuf> for NativeFile {
|
||||
fn from(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
impl egui::DroppedFile for NativeFile {
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn bytes(&self) -> Result<Vec<u8>, String> {
|
||||
std::fs::read(&self.path).map_err(|err| err.to_string())
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ use egui::{Pos2, Rect, Theme, Vec2, ViewportBuilder, ViewportCommand, ViewportId
|
||||
pub use winit;
|
||||
|
||||
pub mod clipboard;
|
||||
mod dropped_file;
|
||||
mod safe_area;
|
||||
mod window_settings;
|
||||
|
||||
@@ -28,6 +29,8 @@ pub use window_settings::WindowSettings;
|
||||
|
||||
use raw_window_handle::HasDisplayHandle;
|
||||
|
||||
use dropped_file::NativeFile;
|
||||
|
||||
use winit::{
|
||||
dpi::{PhysicalPosition, PhysicalSize},
|
||||
event::ElementState,
|
||||
@@ -470,10 +473,9 @@ impl State {
|
||||
}
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
self.egui_input.hovered_files.clear();
|
||||
self.egui_input.dropped_files.push(egui::DroppedFile {
|
||||
path: Some(path.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
self.egui_input
|
||||
.dropped_files
|
||||
.push(std::sync::Arc::new(NativeFile::from(path.clone())));
|
||||
EventResponse {
|
||||
repaint: true,
|
||||
consumed: false,
|
||||
|
||||
@@ -19,6 +19,7 @@ workspace = true
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
rustdoc-args = ["--generate-link-to-definition"]
|
||||
targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"]
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -90,3 +91,9 @@ document-features = { workspace = true, optional = true }
|
||||
|
||||
ron = { workspace = true, optional = true }
|
||||
serde = { workspace = true, optional = true, features = ["derive", "rc"] }
|
||||
|
||||
|
||||
# web:
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# For `DroppedFile`, which hands web apps a file handle instead of its contents.
|
||||
web-sys = { workspace = true, features = ["File"] }
|
||||
|
||||
@@ -1,19 +1,51 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::{future::Future, pin::Pin};
|
||||
|
||||
/// A file dropped into egui.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||
pub struct DroppedFile {
|
||||
/// Set by the `egui-winit` backend.
|
||||
pub path: Option<std::path::PathBuf>,
|
||||
///
|
||||
/// The integration owns the concrete file handle, letting egui remain independent of windowing
|
||||
/// backends and file APIs.
|
||||
pub trait DroppedFile: std::fmt::Debug {
|
||||
/// The path of the dropped file.
|
||||
///
|
||||
/// This is an absolute path on native platforms. On the web, it is a relative path containing
|
||||
/// only the file name because browsers do not expose the file's local path.
|
||||
fn path(&self) -> &Path;
|
||||
|
||||
/// Name of the file. Set by the `eframe` web backend.
|
||||
pub name: String,
|
||||
/// Read the file contents.
|
||||
///
|
||||
/// This is asynchronous because browsers can only read files asynchronously.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the browser cannot read the file.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn bytes_async(&self) -> Pin<Box<dyn Future<Output = Result<Vec<u8>, String>> + '_>>;
|
||||
|
||||
/// With the `eframe` web backend, this is set to the mime-type of the file (if available).
|
||||
pub mime: String,
|
||||
/// Read the file contents.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the file cannot be read.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn bytes(&self) -> Result<Vec<u8>, String>;
|
||||
|
||||
/// Set by the `eframe` web backend.
|
||||
pub last_modified: Option<std::time::SystemTime>,
|
||||
|
||||
/// Set by the `eframe` web backend.
|
||||
pub bytes: Option<std::sync::Arc<[u8]>>,
|
||||
/// The browser file handle, if this file was dropped on the web.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn web_file(&self) -> Option<&web_sys::File> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared reference to a dropped file.
|
||||
#[cfg(not(all(target_arch = "wasm32", target_feature = "atomics")))]
|
||||
pub type DroppedFileHandle = Arc<dyn DroppedFile + Send + Sync>;
|
||||
|
||||
/// A shared reference to a dropped file.
|
||||
///
|
||||
/// This is not necessarily `Send + Sync` when wasm threads are enabled, because
|
||||
/// [`web_sys::File`] is not thread-safe in that configuration.
|
||||
#[cfg(all(target_arch = "wasm32", target_feature = "atomics"))]
|
||||
pub type DroppedFileHandle = Arc<dyn DroppedFile>;
|
||||
|
||||
@@ -16,7 +16,7 @@ mod touch;
|
||||
mod viewport_info;
|
||||
|
||||
pub use self::{
|
||||
dropped_file::DroppedFile,
|
||||
dropped_file::{DroppedFile, DroppedFileHandle},
|
||||
event::Event,
|
||||
event_filter::EventFilter,
|
||||
hovered_file::HoveredFile,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{OrderedViewportIdMap, Theme, ViewportId, ViewportIdMap, emath::Rect};
|
||||
|
||||
use super::{DroppedFile, Event, HoveredFile, SafeAreaInsets, ViewportInfo};
|
||||
use super::{DroppedFileHandle, Event, HoveredFile, SafeAreaInsets, ViewportInfo};
|
||||
|
||||
/// What the integrations provides to egui at the start of each frame.
|
||||
///
|
||||
@@ -13,7 +13,7 @@ use super::{DroppedFile, Event, HoveredFile, SafeAreaInsets, ViewportInfo};
|
||||
///
|
||||
/// Ii "points" can be calculated from native physical pixels
|
||||
/// using `pixels_per_point` = [`crate::Context::zoom_factor`] * `native_pixels_per_point`;
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||
pub struct RawInput {
|
||||
/// The id of the active viewport.
|
||||
@@ -65,9 +65,20 @@ pub struct RawInput {
|
||||
|
||||
/// Dragged files dropped into egui.
|
||||
///
|
||||
/// egui never reads the file contents.
|
||||
#[cfg_attr(
|
||||
not(target_arch = "wasm32"),
|
||||
doc = "Call [`crate::DroppedFile::bytes`] to read a dropped file."
|
||||
)]
|
||||
#[cfg_attr(
|
||||
target_arch = "wasm32",
|
||||
doc = "Call [`crate::DroppedFile::bytes_async`] to read a dropped file."
|
||||
)]
|
||||
///
|
||||
/// Note: when using `eframe` on Windows, this will always be empty if drag-and-drop support has
|
||||
/// been disabled in [`crate::viewport::ViewportBuilder`].
|
||||
pub dropped_files: Vec<DroppedFile>,
|
||||
#[cfg_attr(feature = "serde", serde(skip))]
|
||||
pub dropped_files: Vec<DroppedFileHandle>,
|
||||
|
||||
/// The native window has the keyboard focus (i.e. is receiving key presses).
|
||||
///
|
||||
|
||||
@@ -183,7 +183,7 @@ pub struct WrapApp {
|
||||
#[cfg(any(feature = "glow", feature = "wgpu"))]
|
||||
custom3d: Option<crate::apps::Custom3d>,
|
||||
|
||||
dropped_files: Vec<egui::DroppedFile>,
|
||||
dropped_files: Vec<egui::DroppedFileHandle>,
|
||||
}
|
||||
|
||||
impl WrapApp {
|
||||
@@ -519,25 +519,23 @@ impl WrapApp {
|
||||
.open(&mut open)
|
||||
.show(ctx, |ui| {
|
||||
for file in &self.dropped_files {
|
||||
let mut info = if let Some(path) = &file.path {
|
||||
path.display().to_string()
|
||||
} else if file.name.is_empty() {
|
||||
"???".to_owned()
|
||||
} else {
|
||||
file.name.clone()
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let info = file.path().display().to_string();
|
||||
|
||||
let mut additional_info = vec![];
|
||||
if !file.mime.is_empty() {
|
||||
additional_info.push(format!("type: {}", file.mime));
|
||||
}
|
||||
if let Some(bytes) = &file.bytes {
|
||||
additional_info.push(format!("{} bytes", bytes.len()));
|
||||
}
|
||||
if !additional_info.is_empty() {
|
||||
use std::fmt::Write as _;
|
||||
write!(info, " ({})", additional_info.join(", ")).ok();
|
||||
}
|
||||
// The size and mime-type are free to read; the contents are not,
|
||||
// so we never touch them here.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let info = {
|
||||
let Some(web_file) = file.web_file() else {
|
||||
continue;
|
||||
};
|
||||
let (name, mime) = (web_file.name(), web_file.type_());
|
||||
if mime.is_empty() {
|
||||
format!("{name} ({} bytes)", web_file.size())
|
||||
} else {
|
||||
format!("{name} ({} bytes, type: {mime})", web_file.size())
|
||||
}
|
||||
};
|
||||
|
||||
ui.label(info);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user