1
0
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:
Jochen Görtler
2026-08-03 17:07:37 +02:00
committed by GitHub
parent 2e7a92bc37
commit 49d4befe6b
13 changed files with 197 additions and 120 deletions

View File

@@ -1284,7 +1284,6 @@ dependencies = [
"serde",
"static_assertions",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"web-time",
"wgpu",
@@ -1311,6 +1310,7 @@ dependencies = [
"serde",
"smallvec",
"unicode-segmentation",
"web-sys",
]
[[package]]

View File

@@ -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",

View 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)
}
}

View 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();
}
})?;

View File

@@ -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);
}
}

View 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())
}
}

View File

@@ -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,

View File

@@ -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"] }

View 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>;

View File

@@ -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,

View File

@@ -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).
///

View File

@@ -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);
}

View File

@@ -20,7 +20,7 @@ fn main() -> eframe::Result {
#[derive(Default)]
struct MyApp {
dropped_files: Vec<egui::DroppedFile>,
dropped_files: Vec<egui::DroppedFileHandle>,
picked_path: Option<String>,
}
@@ -48,27 +48,23 @@ impl eframe::App for MyApp {
ui.label("Dropped files:");
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"))]
ui.label(file.path().display().to_string());
let mut additional_info = vec![];
if !file.mime.is_empty() {
additional_info.push(format!("type: {}", file.mime));
#[cfg(target_arch = "wasm32")]
{
let Some(web_file) = file.web_file() else {
continue;
};
let name = web_file.name();
let mime = web_file.type_();
let size = web_file.size();
if mime.is_empty() {
ui.label(format!("{name} ({size} bytes)"));
} else {
ui.label(format!("{name} (type: {mime}, {size} bytes)"));
}
}
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();
}
ui.label(info);
}
});
}