diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 2c82c6b3..bbc7ac59 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -76,3 +76,4 @@ pub const DEFAULT_FONT_STYLE: &str = "Normal (400)"; pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document"; pub const FILE_SAVE_SUFFIX: &str = ".graphite"; pub const MAX_UNDO_HISTORY_LEN: usize = 100; // TODO: Add this to user preferences +pub const AUTO_SAVE_TIMEOUT_SECONDS: u64 = 15; diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index c728dba7..ae909f33 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -26,6 +26,7 @@ pub enum PortfolioMessage { message: DocumentMessage, }, AutoSaveActiveDocument, + AutoSaveAllDocuments, AutoSaveDocument { document_id: DocumentId, }, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 3b621b4c..0bd44b95 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -76,8 +76,16 @@ impl MessageHandler { + for (document_id, document) in self.documents.iter_mut() { + if !document.is_auto_saved() { + document.set_auto_save_state(true); + responses.add(PortfolioMessage::AutoSaveDocument { document_id: *document_id }); } - responses.add(PortfolioMessage::AutoSaveDocument { document_id }); } } PortfolioMessage::AutoSaveDocument { document_id } => { diff --git a/frontend/wasm/Cargo.toml b/frontend/wasm/Cargo.toml index 1cd75815..a6e05c58 100644 --- a/frontend/wasm/Cargo.toml +++ b/frontend/wasm/Cargo.toml @@ -41,6 +41,7 @@ features = [ "CanvasRenderingContext2d", "Document", "HtmlCanvasElement", + "IdleRequestOptions" ] [package.metadata.wasm-pack.profile.dev] diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index 2f2eb35b..750293b3 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -24,6 +24,7 @@ use serde::Serialize; use serde_wasm_bindgen::{self, from_value}; use std::cell::RefCell; use std::sync::atomic::Ordering; +use std::time::Duration; use wasm_bindgen::prelude::*; /// Set the random seed used by the editor by calling this from JS upon initialization. @@ -47,8 +48,27 @@ pub fn wasm_memory() -> JsValue { wasm_bindgen::memory() } -// To avoid wasm-bindgen from checking mutable reference issues using WasmRefCell we must make all methods take a non mutable reference to self. -// Not doing this creates an issue when rust calls into JS which calls back to rust in the same call stack. +/// Helper function for calling JS's `requestAnimationFrame` with the given closure +fn request_animation_frame(f: &Closure) { + web_sys::window() + .expect("No global `window` exists") + .request_animation_frame(f.as_ref().unchecked_ref()) + .expect("Failed to call `requestAnimationFrame`"); +} + +/// Helper function for calling JS's `setTimeout` with the given closure and delay +fn set_timeout(f: &Closure, delay: Duration) { + let delay = delay.clamp(Duration::ZERO, Duration::from_millis(i32::MAX as u64)).as_millis() as i32; + web_sys::window() + .expect("No global `window` exists") + .set_timeout_with_callback_and_timeout_and_arguments_0(f.as_ref().unchecked_ref(), delay) + .expect("Failed to call `setTimeout`"); +} + +// ============================================================================ + +/// To avoid wasm-bindgen from checking mutable reference issues using WasmRefCell we must make all methods take a non-mutable reference to self. +/// Not doing this creates an issue when Rust calls into JS which calls back to Rust in the same call stack. #[wasm_bindgen] #[derive(Clone)] pub struct JsEditorHandle { @@ -56,53 +76,68 @@ pub struct JsEditorHandle { frontend_message_handler_callback: js_sys::Function, } -fn window() -> web_sys::Window { - web_sys::window().expect("no global `window` exists") +/// Provides access to the `Editor` instance and its `JsEditorHandle` by calling the given closure with them as arguments. +fn call_closure_with_editor_and_handle(mut f: impl FnMut(&mut Editor, &mut JsEditorHandle)) { + EDITOR_INSTANCES.with(|instances| { + JS_EDITOR_HANDLES.with(|handles| { + instances + .try_borrow_mut() + .map(|mut editors| { + for (id, editor) in editors.iter_mut() { + let Ok(mut handles) = handles.try_borrow_mut() else { + log::error!("Failed to borrow editor handles"); + continue; + }; + let Some(js_editor) = handles.get_mut(&id) else { + log::error!("Editor ID ({id}) has no corresponding JsEditorHandle ID"); + continue; + }; + + // Call the closure with the editor and its handle + f(editor, js_editor) + } + }) + .unwrap_or_else(|_| log::error!("Failed to borrow editor instances")); + }) + }); } -fn request_animation_frame(f: &Closure) { - //window().request_idle_callback(f.as_ref().unchecked_ref()).unwrap(); - window().request_animation_frame(f.as_ref().unchecked_ref()).expect("should register `requestAnimationFrame` OK"); -} - -// Sends a message to the dispatcher in the Editor Backend async fn poll_node_graph_evaluation() { // Process no further messages after a crash to avoid spamming the console if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { return; } + editor::node_graph_executor::run_node_graph().await; - // Get the editor instances, dispatch the message, and store the `FrontendMessage` queue response - EDITOR_INSTANCES - .with(|instances| { - JS_EDITOR_HANDLES.with(|handles| { - // Mutably borrow the editors, and if successful, we can access them in the closure - instances.try_borrow_mut().map(|mut editors| { - // Get the editor instance for this editor ID, then dispatch the message to the backend, and return its response `FrontendMessage` queue - for (id, editor) in editors.iter_mut() { - let handles = handles.borrow_mut(); - let handle = handles.get(id).unwrap(); - let mut messages = VecDeque::new(); - editor.poll_node_graph_evaluation(&mut messages); - // Send each `FrontendMessage` to the JavaScript frontend + call_closure_with_editor_and_handle(|editor, handle| { + let mut messages = VecDeque::new(); + editor.poll_node_graph_evaluation(&mut messages); - let mut responses = Vec::new(); - for message in messages.into_iter() { - responses.extend(editor.handle_message(message)); - } + // Send each `FrontendMessage` to the JavaScript frontend + for response in messages.into_iter().flat_map(|message| editor.handle_message(message)) { + handle.send_frontend_message_to_js(response); + } - for response in responses.into_iter() { - handle.send_frontend_message_to_js(response); - } - // If the editor cannot be borrowed then it has encountered a panic - we should just ignore new dispatches - } - }) - }) - }) - .unwrap_or_else(|_| log::error!("Failed to borrow editor instances")); + // If the editor cannot be borrowed then it has encountered a panic - we should just ignore new dispatches + }) } +fn auto_save_all_documents() { + // Process no further messages after a crash to avoid spamming the console + if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { + return; + } + + call_closure_with_editor_and_handle(|editor, handle| { + for message in editor.handle_message(PortfolioMessage::AutoSaveAllDocuments) { + handle.send_frontend_message_to_js(message); + } + }); +} + +// ============================================================================ + #[wasm_bindgen] impl JsEditorHandle { #[wasm_bindgen(constructor)] @@ -186,27 +221,45 @@ impl JsEditorHandle { #[wasm_bindgen(js_name = initAfterFrontendReady)] pub fn init_after_frontend_ready(&self, platform: String) { + // Send initialization messages let platform = match platform.as_str() { "Windows" => Platform::Windows, "Mac" => Platform::Mac, "Linux" => Platform::Linux, _ => Platform::Unknown, }; - self.dispatch(GlobalsMessage::SetPlatform { platform }); self.dispatch(Message::Init); - let f = std::rc::Rc::new(RefCell::new(None)); - let g = f.clone(); + // Poll node graph evaluation on `requestAnimationFrame` + { + let f = std::rc::Rc::new(RefCell::new(None)); + let g = f.clone(); - *g.borrow_mut() = Some(Closure::new(move || { - wasm_bindgen_futures::spawn_local(poll_node_graph_evaluation()); + *g.borrow_mut() = Some(Closure::new(move || { + wasm_bindgen_futures::spawn_local(poll_node_graph_evaluation()); - // Schedule ourself for another requestAnimationFrame callback. - request_animation_frame(f.borrow().as_ref().unwrap()); - })); + // Schedule ourself for another requestAnimationFrame callback + request_animation_frame(f.borrow().as_ref().unwrap()); + })); - request_animation_frame(g.borrow().as_ref().unwrap()); + request_animation_frame(g.borrow().as_ref().unwrap()); + } + + // Auto save all documents on `setTimeout` + { + let f = std::rc::Rc::new(RefCell::new(None)); + let g = f.clone(); + + *g.borrow_mut() = Some(Closure::new(move || { + auto_save_all_documents(); + + // Schedule ourself for another setTimeout callback + set_timeout(f.borrow().as_ref().unwrap(), Duration::from_secs(editor::consts::AUTO_SAVE_TIMEOUT_SECONDS)); + })); + + set_timeout(g.borrow().as_ref().unwrap(), Duration::from_secs(editor::consts::AUTO_SAVE_TIMEOUT_SECONDS)); + } } #[wasm_bindgen(js_name = tauriResponse)]