diff --git a/apps/designcraft-web/Cargo.toml b/apps/designcraft-web/Cargo.toml index 75775c31e..42cdfce0a 100644 --- a/apps/designcraft-web/Cargo.toml +++ b/apps/designcraft-web/Cargo.toml @@ -29,4 +29,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } +web-sys = { version = "0.3", features = ["Event", "EventTarget", "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } diff --git a/apps/designcraft-web/src/graphics.rs b/apps/designcraft-web/src/graphics.rs new file mode 100644 index 000000000..4d9a56d64 --- /dev/null +++ b/apps/designcraft-web/src/graphics.rs @@ -0,0 +1,159 @@ +//! Stop editor work after graphics loss while retaining documents for recovery downloads. + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +use designcraft_engine::{Session, doc::Document}; + +#[derive(Clone, Default)] +pub(crate) struct GraphicsState(Arc); + +impl GraphicsState { + /// Only the first loss should replace the error panel or move keyboard focus. + pub(crate) fn fail(&self) -> bool { + !self.0.swap(true, Ordering::Relaxed) + } + + pub(crate) fn has_failed(&self) -> bool { + self.0.load(Ordering::Relaxed) + } + + pub(crate) fn run_if_healthy(&self, work: impl FnOnce() -> R) -> Option { + if self.has_failed() { None } else { Some(work()) } + } +} + +/// An immutable copy that outlives the session and its renderer. +#[derive(Clone)] +pub(crate) struct RecoveryDocument { + title: String, + doc: Arc, +} + +impl RecoveryDocument { + pub(crate) fn title(&self) -> &str { + &self.title + } + + pub(crate) fn save_copy(&self) -> Result<(String, Vec), String> { + let bytes = designcraft_format::save(&self.doc).map_err(|error| format!("Couldn't save a copy of {}: {error}", self.title))?; + Ok((format!("{}.designcraft", self.doc.title), bytes)) + } +} + +/// Retain all documents without activating one, changing dirty state, or committing a preview. +pub(crate) fn recovery_documents(session: &Session) -> Vec { + session + .documents() + .iter() + .map(|state| RecoveryDocument { title: state.title(), doc: state.preview_stash.as_ref().unwrap_or(&state.doc).clone() }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn recovery_document(session: &Session, index: usize) -> Result<(String, Vec), String> { + recovery_documents(session).get(index).ok_or_else(|| "This document is no longer open.".to_string())?.save_copy() + } + + // Synthetic 1x1 red RGBA PNG. + const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + + #[test] + fn graphics_loss_blocks_editor_work_and_cannot_be_cleared_by_another_callback() { + let graphics = GraphicsState::default(); + let device_callback = graphics.clone(); + let mut session = Session::new(); + session.execute("file.new", &json!({"pages": 1})).unwrap(); + graphics.run_if_healthy(|| session.execute("file.place", &json!({"name": "red.png", "base64": PNG}))).unwrap().unwrap(); + graphics.run_if_healthy(|| session.execute("edit.undo", &json!({}))).unwrap().unwrap(); + let (_, undone) = recovery_document(&session, 0).unwrap(); + assert!(designcraft_engine::cmd::from_bytes(&undone).unwrap().all_items().is_empty()); + graphics.run_if_healthy(|| session.execute("edit.redo", &json!({}))).unwrap().unwrap(); + let (_, before) = recovery_document(&session, 0).unwrap(); + assert_eq!(designcraft_engine::cmd::from_bytes(&before).unwrap().all_items().len(), 1); + + assert!(device_callback.fail()); + assert!(!graphics.fail(), "duplicate context/device notifications must not replace recovery controls"); + assert!(graphics.has_failed()); + assert!(graphics.run_if_healthy(|| session.execute("edit.undo", &json!({}))).is_none()); + assert_eq!(recovery_document(&session, 0).unwrap().1, before, "a hidden editor must not apply more edits"); + } + + #[test] + fn recovery_copies_all_open_documents_without_changing_session_state() { + let mut session = Session::new(); + session.execute("file.new", &json!({"title": "First", "pages": 1})).unwrap(); + session.execute("file.place", &json!({"name": "red.png", "base64": PNG})).unwrap(); + let first = session.doc().unwrap().doc.clone(); + session.execute("file.new", &json!({"title": "Second", "pages": 1})).unwrap(); + session.execute("frame.create", &json!({"rect": [10, 10, 110, 110]})).unwrap(); + let second = session.doc().unwrap().doc.clone(); + let active = session.active_index(); + let journal_len = session.journal.len(); + + for (index, expected) in [first.clone(), second.clone()].iter().enumerate() { + let (name, bytes) = recovery_document(&session, index).unwrap(); + assert_eq!(name, format!("{}.designcraft", expected.title)); + let reopened = designcraft_engine::cmd::from_bytes(&bytes).unwrap(); + assert_eq!(reopened, **expected, "the copy must retain document content and embedded PNG bytes"); + } + assert_eq!(session.active_index(), active); + assert_eq!(session.journal.len(), journal_len); + assert!(Arc::ptr_eq(&session.documents()[0].doc, &first)); + assert!(Arc::ptr_eq(&session.documents()[1].doc, &second)); + assert!(session.documents().iter().all(|state| state.is_dirty())); + assert!(recovery_document(&session, 2).is_err()); + } + + #[test] + fn recovery_saves_the_template_during_data_merge_preview() { + let mut session = Session::new(); + session.execute("file.new", &json!({"title": "Template", "pages": 1})).unwrap(); + let template = session.doc().unwrap().doc.clone(); + session.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap(); + session.doc_mut().unwrap().preview_stash = Some(template.clone()); + let (_, bytes) = recovery_document(&session, 0).unwrap(); + let reopened = designcraft_engine::cmd::from_bytes(&bytes).unwrap(); + assert_eq!(reopened, *template); + assert!(Arc::ptr_eq(session.doc().unwrap().preview_stash.as_ref().unwrap(), &template)); + } + + #[test] + fn recovery_snapshots_keep_all_documents_and_assets_after_session_drop() { + let mut session = Session::new(); + session.execute("file.new", &json!({"title": "First", "pages": 1})).unwrap(); + session.execute("file.place", &json!({"name": "red.png", "base64": PNG})).unwrap(); + let first = session.doc().unwrap().doc.clone(); + session.execute("file.new", &json!({"title": "Template", "pages": 1})).unwrap(); + session.execute("file.place", &json!({"name": "template.png", "base64": PNG})).unwrap(); + let template = session.doc().unwrap().doc.clone(); + session.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap(); + session.doc_mut().unwrap().preview_stash = Some(template.clone()); + + let documents = recovery_documents(&session); + assert_eq!(documents.len(), 2); + assert_eq!(documents[0].title(), "First"); + assert_eq!(documents[1].title(), "Template"); + assert!(Arc::ptr_eq(&documents[0].doc, &first)); + assert!(Arc::ptr_eq(&documents[1].doc, &template)); + session.stop_data_preview(); + session.execute("frame.create", &json!({"rect": [10, 10, 110, 110]})).unwrap(); + drop(session); + + for (snapshot, expected) in documents.iter().zip([first, template]) { + let (name, bytes) = snapshot.save_copy().unwrap(); + assert_eq!(name, format!("{}.designcraft", expected.title)); + assert!(bytes.starts_with(b"PK")); + let reopened = designcraft_engine::cmd::from_bytes(&bytes).unwrap(); + assert_eq!(reopened, *expected, "snapshots must retain every document and its embedded asset bytes"); + assert!(!reopened.assets.is_empty()); + assert!(reopened.assets.values().all(|asset| !asset.data.is_empty())); + } + } +} diff --git a/apps/designcraft-web/src/main.rs b/apps/designcraft-web/src/main.rs index 527baff47..1b28064a9 100644 --- a/apps/designcraft-web/src/main.rs +++ b/apps/designcraft-web/src/main.rs @@ -22,6 +22,9 @@ mod browser_policy; #[cfg(target_arch = "wasm32")] mod web; +#[cfg(any(target_arch = "wasm32", test))] +mod graphics; + #[cfg(target_arch = "wasm32")] fn main() { web::start(); diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index fe7cca4ae..c532b5dca 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -1,6 +1,7 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. use super::browser_policy::{has_query_flag, mime_for}; +use crate::graphics::{GraphicsState, RecoveryDocument, recovery_documents}; use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, ImportRequest, ImportedFile, Inbox, Services}; use wasm_bindgen::JsCast as _; @@ -37,8 +38,16 @@ pub fn start() { log::error!("no document"); return; }; + if let Err(e) = create_error_panel(&document) { + log::error!("couldn't create graphics error panel: {e:?}"); + if let Some(el) = document.get_element_by_id(LOADING_ID) { + el.set_text_content(Some("DesignCraft couldn't start. Reload this page to try again.")); + } + return; + } + let graphics = GraphicsState::default(); let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else { - log::error!("missing "); + graphics_failed(&graphics, "DesignCraft failed to start", "Reload this page to try again.", &format!("Missing canvas {CANVAS_ID}.")); return; }; let mut options = eframe::WebOptions::default(); @@ -47,32 +56,285 @@ pub fn start() { { create.instance_descriptor.backends = eframe::wgpu::Backends::GL; } - let result = eframe::WebRunner::new() + let runner = eframe::WebRunner::new(); + let creator_graphics = graphics.clone(); + let result = runner .start( - canvas, + canvas.clone(), options, Box::new(move |cc| { if let Some(rs) = &cc.wgpu_render_state { log::info!("designcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend); + let device_graphics = creator_graphics.clone(); + // This also observes an already-lost WebGPU device through its lost promise. + rs.device.set_device_lost_callback(move |reason, message| { + graphics_failed(&device_graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, &format!("Device loss ({reason:?}): {message}")); + }); } let inbox: Inbox = Inbox::default(); let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); if has_query_flag(&query(), "sample") { let _ = app.run("file.newSample", serde_json::json!({})); } - Ok(Box::new(WebShell { app, inbox })) + Ok(Box::new(WebShell { app, inbox, graphics: creator_graphics })) }), ) .await; - if let Some(el) = document.get_element_by_id(LOADING_ID) { - match result { - Ok(()) => el.remove(), - Err(e) => el.set_inner_html(&format!("

DesignCraft failed to start: {e:?}

A browser with WebGPU or WebGL2 is required.

")), + match result { + Ok(()) => { + if let Err(e) = install_recovery_controls(&runner, &canvas, &graphics) { + graphics_failed(&graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, &format!("Couldn't install graphics recovery controls: {e:?}")); + if let Some(shell) = runner.app_mut::() + && let Err(e) = prepare_document_recovery(&runner, &shell.app.session) + { + recovery_controls_failed(&e); + } + } + // A loss callback can run during startup. Never hide its failure panel. + if !graphics.has_failed() + && let Some(el) = document.get_element_by_id(LOADING_ID) + { + el.remove(); + } + } + Err(e) => { + graphics_failed( + &graphics, + "DesignCraft failed to start", + "Reload this page to try again. A browser with WebGPU or WebGL2 is required.", + &format!("{e:?}"), + ); + if let Some(shell) = runner.app_mut::() + && let Err(e) = prepare_document_recovery(&runner, &shell.app.session) + { + recovery_controls_failed(&e); + } } } }); } +const ERROR_ID: &str = "designcraft_graphics_error"; +const ERROR_TITLE_ID: &str = "designcraft_graphics_title"; +const ERROR_MESSAGE_ID: &str = "designcraft_graphics_message"; +const ERROR_DETAIL_ID: &str = "designcraft_graphics_detail"; +const ERROR_ACTIONS_ID: &str = "designcraft_graphics_actions"; +const ERROR_STATUS_ID: &str = "designcraft_graphics_status"; +const ERROR_DOCUMENTS_ID: &str = "designcraft_graphics_documents"; +const GRAPHICS_LOST_EVENT: &str = "designcraftgraphicslost"; +const GRAPHICS_TITLE: &str = "Graphics connection lost"; +const GRAPHICS_MESSAGE: &str = "Editing has stopped. Save a copy of each open document, then reload this page and reopen the copies. Reloading discards changes you have not downloaded."; + +fn create_error_panel(document: &web_sys::Document) -> Result<(), wasm_bindgen::JsValue> { + let panel: web_sys::HtmlElement = document.create_element("div")?.dyn_into()?; + panel.set_id(ERROR_ID); + panel.set_tab_index(-1); + panel.set_attribute("role", "alertdialog")?; + panel.set_attribute("aria-modal", "true")?; + panel.set_attribute("aria-labelledby", ERROR_TITLE_ID)?; + panel.set_attribute("aria-describedby", ERROR_MESSAGE_ID)?; + // DOM content remains visible when the GPU can no longer paint the egui canvas. + let colors = designcraft_ui_egui::theme::Tokens::for_brightness(designcraft_ui_egui::theme::Brightness::Dark); + panel.style().set_css_text(&format!( + "position:fixed;inset:0;z-index:1;display:none;flex-direction:column;align-items:center;justify-content:safe center;box-sizing:border-box;padding:24px;overflow:auto;background:{};color:{};font:14px system-ui,sans-serif;gap:12px;", + css_color(colors.app_bar), css_color(colors.text), + )); + for (tag, id) in [("h1", ERROR_TITLE_ID), ("p", ERROR_MESSAGE_ID), ("div", ERROR_ACTIONS_ID), ("p", ERROR_STATUS_ID), ("pre", ERROR_DETAIL_ID)] { + let child: web_sys::HtmlElement = document.create_element(tag)?.dyn_into()?; + child.set_id(id); + child.style().set_css_text("margin:0;max-width:640px;white-space:pre-wrap;overflow-wrap:anywhere;"); + if id == ERROR_ACTIONS_ID { + child.style().set_css_text("display:flex;flex-direction:column;gap:8px;max-width:100%;"); + add_reload_control(document, &child)?; + } else if id == ERROR_STATUS_ID { + child.set_attribute("aria-live", "polite")?; + } + panel.append_child(&child)?; + } + document.body().ok_or_else(|| wasm_bindgen::JsValue::from_str("no body"))?.append_child(&panel)?; + Ok(()) +} + +fn graphics_failed(graphics: &GraphicsState, title: &str, message: &str, detail: &str) { + if !graphics.fail() { + return; + } + log::error!("designcraft-web: {detail}"); + if let Err(e) = show_error(title, message, detail) { + log::error!("couldn't show graphics error: {e:?}"); + } + // A device callback may run while AppRunner is borrowed. Defer its notification; + // the WebGL listener already holding AppRunner captures documents directly. + if let Some(window) = web_sys::window() + && let Some(canvas) = window.document().and_then(|doc| doc.get_element_by_id(CANVAS_ID)) + { + let notify = wasm_bindgen::closure::Closure::once_into_js(move || { + if let Err(e) = web_sys::Event::new(GRAPHICS_LOST_EVENT).and_then(|event| canvas.dispatch_event(&event)) { + recovery_controls_failed(&e); + } + }); + window.queue_microtask(notify.unchecked_ref()); + } +} + +fn show_error(title: &str, message: &str, detail: &str) -> Result<(), wasm_bindgen::JsValue> { + let document = web_sys::window().and_then(|w| w.document()).ok_or_else(|| wasm_bindgen::JsValue::from_str("no document"))?; + let panel: web_sys::HtmlElement = + document.get_element_by_id(ERROR_ID).ok_or_else(|| wasm_bindgen::JsValue::from_str("no graphics error panel"))?.dyn_into()?; + for (id, text) in [(ERROR_TITLE_ID, title), (ERROR_MESSAGE_ID, message), (ERROR_DETAIL_ID, detail)] { + if let Some(el) = document.get_element_by_id(id) { + el.set_text_content(Some(text)); + } + } + panel.style().set_property("display", "flex")?; + if let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|el| el.dyn_into::().ok()) { + canvas.style().set_property("visibility", "hidden")?; + } + if let Some(loading) = document.get_element_by_id(LOADING_ID).and_then(|el| el.dyn_into::().ok()) { + loading.style().set_property("display", "none")?; + } + // Include eframe's hidden text input: neither pointer nor keyboard focus may reach the editor. + let mut sibling = document.body().and_then(|body| body.first_element_child()); + while let Some(el) = sibling { + sibling = el.next_element_sibling(); + if el.id() != ERROR_ID { + if let Some(html) = el.dyn_ref::() { + html.set_inert(true); + } + el.set_attribute("aria-hidden", "true")?; + } + } + panel.focus()?; + Ok(()) +} + +/// Recovery listeners are owned by their DOM targets, independently of WebRunner. +fn add_dom_event_listener( + target: &web_sys::EventTarget, + event_name: &str, + callback: impl FnMut(web_sys::Event) + 'static, +) -> Result<(), wasm_bindgen::JsValue> { + let callback = wasm_bindgen::closure::Closure::wrap(Box::new(callback) as Box); + target.add_event_listener_with_callback(event_name, callback.as_ref().unchecked_ref())?; + // EventTarget retains the JS function; weak references reclaim its captures with the target. + let _ = callback.into_js_value(); + Ok(()) +} + +fn css_color(color: egui::Color32) -> String { + format!("#{:02x}{:02x}{:02x}", color.r(), color.g(), color.b()) +} + +fn recovery_button(document: &web_sys::Document) -> Result { + let button: web_sys::HtmlElement = document.create_element("button")?.dyn_into()?; + button.set_attribute("type", "button")?; + let colors = designcraft_ui_egui::theme::Tokens::for_brightness(designcraft_ui_egui::theme::Brightness::Dark); + button.style().set_css_text(&format!( + "box-sizing:border-box;max-width:100%;padding:6px 12px;border:1px solid {};border-radius:{}px;background:{};color:{};font:inherit;white-space:normal;overflow-wrap:anywhere;cursor:pointer;", + css_color(colors.field_border), colors.radius, css_color(colors.button), css_color(colors.text), + )); + Ok(button) +} + +fn add_reload_control(document: &web_sys::Document, actions: &web_sys::HtmlElement) -> Result<(), wasm_bindgen::JsValue> { + let reload = recovery_button(document)?; + reload.set_text_content(Some("Reload")); + add_dom_event_listener(&reload, "click", move |event| { + event.stop_propagation(); + if let Some(window) = web_sys::window() + && let Err(e) = window.location().reload() + { + log::error!("couldn't reload: {e:?}"); + } + })?; + actions.append_child(&reload)?; + Ok(()) +} + +fn install_recovery_controls( + runner: &eframe::WebRunner, + canvas: &web_sys::HtmlCanvasElement, + graphics: &GraphicsState, +) -> Result<(), wasm_bindgen::JsValue> { + let loss_runner = runner.clone(); + runner.add_event_listener(canvas, GRAPHICS_LOST_EVENT, move |_: web_sys::Event, app_runner| { + let shell = app_runner.app_mut::(); + if shell.graphics.has_failed() + && let Err(e) = prepare_document_recovery(&loss_runner, &shell.app.session) + { + recovery_controls_failed(&e); + } + })?; + let context_graphics = graphics.clone(); + let context_runner = runner.clone(); + // wgpu's GLES backend does not observe browser WebGL context-loss events. + runner.add_event_listener(canvas, "webglcontextlost", move |_: web_sys::Event, app_runner| { + graphics_failed(&context_graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, "The browser lost the WebGL graphics context."); + if let Err(e) = prepare_document_recovery(&context_runner, &app_runner.app_mut::().app.session) { + recovery_controls_failed(&e); + } + })?; + + // A WebGPU device can be lost while start() is still awaiting initialization. + if graphics.has_failed() + && let Some(shell) = runner.app_mut::() + { + prepare_document_recovery(runner, &shell.app.session)?; + } + Ok(()) +} + +fn prepare_document_recovery(runner: &eframe::WebRunner, session: &Session) -> Result<(), wasm_bindgen::JsValue> { + let window = web_sys::window().ok_or_else(|| wasm_bindgen::JsValue::from_str("no window"))?; + let documents = recovery_documents(session); + if add_document_recovery_controls(documents)? { + // Controls now own the snapshots. Release AppRunner's borrow before stopping painting + // and removing its listeners/ResizeObserver; never discard documents on setup failure. + let runner = runner.clone(); + let stop = wasm_bindgen::closure::Closure::once_into_js(move || runner.destroy()); + window.queue_microtask(stop.unchecked_ref()); + } + Ok(()) +} + +fn recovery_controls_failed(error: &wasm_bindgen::JsValue) { + log::error!("couldn't prepare recovery downloads: {error:?}"); + if let Some(status) = web_sys::window().and_then(|w| w.document()).and_then(|doc| doc.get_element_by_id(ERROR_STATUS_ID)) { + status.set_text_content(Some("Couldn't prepare document downloads. Keep this page open to preserve unsaved documents.")); + } +} + +fn add_document_recovery_controls(snapshots: Vec) -> Result { + let document = web_sys::window().and_then(|w| w.document()).ok_or_else(|| wasm_bindgen::JsValue::from_str("no document"))?; + let actions = document.get_element_by_id(ERROR_ACTIONS_ID).ok_or_else(|| wasm_bindgen::JsValue::from_str("no recovery actions"))?; + if document.get_element_by_id(ERROR_DOCUMENTS_ID).is_some() { + return Ok(false); + } + let documents: web_sys::HtmlElement = document.create_element("div")?.dyn_into()?; + documents.set_id(ERROR_DOCUMENTS_ID); + documents.style().set_css_text("display:flex;flex-direction:column;gap:8px;max-width:100%;"); + for snapshot in snapshots { + let button = recovery_button(&document)?; + button.set_text_content(Some(&format!("Save a Copy: {}", snapshot.title()))); + add_dom_event_listener(&button, "click", move |event| { + event.stop_propagation(); + let result = snapshot.save_copy().and_then(|(name, bytes)| { + download(&name, &bytes)?; + Ok(format!("Requested download of {name}. Check your browser downloads before reloading.")) + }); + let text = result.unwrap_or_else(|e| e); + if let Some(status) = web_sys::window().and_then(|w| w.document()).and_then(|doc| doc.get_element_by_id(ERROR_STATUS_ID)) { + status.set_text_content(Some(&text)); + } + })?; + documents.append_child(&button)?; + } + // Build off-DOM so a setup error leaves the session intact and permits a clean retry. + // The inserted element's id also prevents duplicate buttons on repeated loss notifications. + actions.insert_before(&documents, actions.first_child().as_ref())?; + Ok(true) +} + fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } @@ -82,36 +344,46 @@ fn query() -> String { struct WebShell { app: DesignApp, inbox: Inbox, + graphics: GraphicsState, } impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - for f in dropped { - let request = self.app.import_request("drop"); - let inbox = self.inbox.clone(); - let ctx = ctx.clone(); - wasm_bindgen_futures::spawn_local(async move { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); - match f.bytes_async().await { - Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes }); - ctx.request_repaint(); + let graphics = self.graphics.clone(); + let _ = graphics.run_if_healthy(|| { + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + for f in dropped { + let request = self.app.import_request("drop"); + let inbox = self.inbox.clone(); + let ctx = ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); + match f.bytes_async().await { + Ok(bytes) => { + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes }); + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), - } - }); - } - self.app.logic(ctx); + }); + } + self.app.logic(ctx); + }); } fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused()); - self.app.raw_input_hook(raw); + let _ = self.graphics.run_if_healthy(|| { + designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused()); + self.app.raw_input_hook(raw); + }); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.app.ui(ui); + let _ = self.graphics.run_if_healthy(|| self.app.ui(ui)); + } + + fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { + Some(self) } }