diff --git a/Cargo.lock b/Cargo.lock index 3f0015bbcc..3289705ccd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1837,7 +1837,9 @@ dependencies = [ "cef", "dirs", "futures", + "graphite-editor", "include_dir", + "serde_json", "thiserror 2.0.12", "tracing", "tracing-subscriber", @@ -1904,6 +1906,7 @@ dependencies = [ "math-parser", "serde", "serde-wasm-bindgen", + "serde_json", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/Cargo.toml b/Cargo.toml index 3a9485b7e3..c84b8c16d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -181,7 +181,6 @@ specta-macros = { opt-level = 1 } syn = { opt-level = 1 } [profile.release] -lto = "thin" debug = true [profile.profiling] diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 581f0ed604..83c1003856 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -9,17 +9,18 @@ edition = "2024" rust-version = "1.87" [features] -# default = ["gpu"] -# gpu = ["graphite-editor/gpu"] +default = ["gpu"] +gpu = ["graphite-editor/gpu"] [dependencies] -# Local dependencies -# graphite-editor = { path = "../editor", features = [ -# "gpu", -# "ron", -# "vello", -# "decouple-execution", -# ] } +# # Local dependencies +graphite-editor = { path = "../editor", features = [ + "gpu", + "ron", + "vello", + "decouple-execution", +] } + wgpu = { workspace = true } winit = { workspace = true, features = ["serde"] } thiserror = { workspace = true } @@ -29,3 +30,4 @@ include_dir = { workspace = true } tracing-subscriber = { workspace = true } tracing = { workspace = true } dirs = {workspace = true} +serde_json = { workspace = true } diff --git a/desktop/src/app.rs b/desktop/src/app.rs index eced6556c1..9eebd77df0 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -2,6 +2,10 @@ use crate::CustomEvent; use crate::WindowSize; use crate::render::GraphicsState; use crate::render::WgpuContext; +use graphite_editor::application::Editor; +use graphite_editor::dispatcher::Dispatcher; +use graphite_editor::messages::prelude::Message; +use std::collections::VecDeque; use std::sync::Arc; use std::sync::mpsc::Sender; use std::time::Duration; @@ -21,11 +25,13 @@ pub(crate) struct WinitApp { pub(crate) cef_context: cef::Context, pub(crate) window: Option>, cef_schedule: Option, + // Cached frame buffer from CEF, used to check if mouse is on a transparent pixel _ui_frame_buffer: Option, window_size_sender: Sender, _viewport_frame_buffer: Option, graphics_state: Option, wgpu_context: WgpuContext, + pub(crate) editor: Editor, } impl WinitApp { @@ -39,6 +45,7 @@ impl WinitApp { graphics_state: None, window_size_sender, wgpu_context, + editor: Editor::new(), } } } @@ -97,6 +104,16 @@ impl ApplicationHandler for WinitApp { self.cef_schedule = Some(instant); } } + CustomEvent::MessageReceived { message } => { + let Ok(message) = serde_json::from_str::(&message) else { + tracing::error!("Message could not be deserialized: {:?}", message); + return; + }; + println!("Message received: {message:?}"); + let responses = self.editor.handle_message(message); + println!("responses: {:?}", responses); + // Send response to CEF + } } } diff --git a/desktop/src/cef.rs b/desktop/src/cef.rs index d84e9ad672..64dc53d673 100644 --- a/desktop/src/cef.rs +++ b/desktop/src/cef.rs @@ -19,6 +19,8 @@ pub(crate) trait CefEventHandler: Clone { /// Scheudule the main event loop to run the cef event loop after the timeout /// [`_cef_browser_process_handler_t::on_schedule_message_pump_work`] for more documentation. fn schedule_cef_message_loop_work(&self, scheduled_time: Instant); + + fn send_message_to_editior(&self, message: String); } #[derive(Clone, Copy)] @@ -116,4 +118,7 @@ impl CefEventHandler for CefHandler { fn schedule_cef_message_loop_work(&self, scheduled_time: std::time::Instant) { let _ = self.event_loop_proxy.send_event(CustomEvent::ScheduleBrowserWork(scheduled_time)); } + fn send_message_to_editior(&self, message: String) { + let _ = self.event_loop_proxy.send_event(CustomEvent::MessageReceived { message }); + } } diff --git a/desktop/src/cef/context.rs b/desktop/src/cef/context.rs index 04ebffb4ab..90eec13e5a 100644 --- a/desktop/src/cef/context.rs +++ b/desktop/src/cef/context.rs @@ -77,8 +77,8 @@ impl Context { return Err(InitError::InitializationFailed); } - let render_handler = RenderHandlerImpl::new(event_handler.clone()); - let mut client = Client::new(ClientImpl::new(RenderHandler::new(render_handler))); + let render_handler = RenderHandler::new(RenderHandlerImpl::new(event_handler.clone())); + let mut client = Client::new(ClientImpl::new(render_handler, event_handler.clone())); let url = CefString::from(format!("{GRAPHITE_SCHEME}://{FRONTEND_DOMAIN}/").as_str()); diff --git a/desktop/src/cef/internal.rs b/desktop/src/cef/internal.rs index 8473884fd3..37e140e4bf 100644 --- a/desktop/src/cef/internal.rs +++ b/desktop/src/cef/internal.rs @@ -2,6 +2,8 @@ mod app; mod browser_process_handler; mod client; mod non_browser_app; +mod non_browser_render_process_handler; +mod non_browser_v8_handler; mod render_handler; pub(crate) use app::AppImpl; diff --git a/desktop/src/cef/internal/app.rs b/desktop/src/cef/internal/app.rs index 5da815d162..33404334da 100644 --- a/desktop/src/cef/internal/app.rs +++ b/desktop/src/cef/internal/app.rs @@ -3,6 +3,7 @@ use cef::sys::{_cef_app_t, cef_base_ref_counted_t}; use cef::{BrowserProcessHandler, CefString, ImplApp, ImplCommandLine, SchemeRegistrar, WrapApp}; use crate::cef::CefEventHandler; + use crate::cef::scheme_handler::GraphiteSchemeHandlerFactory; use super::browser_process_handler::BrowserProcessHandlerImpl; diff --git a/desktop/src/cef/internal/client.rs b/desktop/src/cef/internal/client.rs index 543f9c590d..91f28442b7 100644 --- a/desktop/src/cef/internal/client.rs +++ b/desktop/src/cef/internal/client.rs @@ -1,21 +1,25 @@ use cef::rc::{Rc, RcImpl}; use cef::sys::{_cef_client_t, cef_base_ref_counted_t}; -use cef::{ImplClient, RenderHandler, WrapClient}; +use cef::{ImplClient, ImplProcessMessage, RenderHandler, WrapClient}; -pub(crate) struct ClientImpl { +use crate::cef::CefEventHandler; + +pub(crate) struct ClientImpl { object: *mut RcImpl<_cef_client_t, Self>, render_handler: RenderHandler, + event_handler: H, } -impl ClientImpl { - pub(crate) fn new(render_handler: RenderHandler) -> Self { +impl ClientImpl { + pub(crate) fn new(render_handler: RenderHandler, event_handler: H) -> Self { Self { object: std::ptr::null_mut(), render_handler, + event_handler, } } } -impl ImplClient for ClientImpl { +impl ImplClient for ClientImpl { fn render_handler(&self) -> Option { Some(self.render_handler.clone()) } @@ -23,9 +27,33 @@ impl ImplClient for ClientImpl { fn get_raw(&self) -> *mut _cef_client_t { self.object.cast() } + + fn on_process_message_received( + &self, + browser: Option<&mut cef::Browser>, + frame: Option<&mut cef::Frame>, + source_process: cef::ProcessId, + message: Option<&mut cef::ProcessMessage>, + ) -> ::std::os::raw::c_int { + let Some(message) = message else { + tracing::event!(tracing::Level::ERROR, "No message in RenderProcessHandlerImpl::on_process_message_received"); + return 1; + }; + + let pointer: *mut cef::sys::_cef_string_utf16_t = message.name().into(); + let message = unsafe { + let str = (*pointer).str_; + let len = (*pointer).length; + let slice = std::slice::from_raw_parts(str, len as usize); + String::from_utf16(slice).unwrap() + }; + + let _ = self.event_handler.send_message_to_editior(message); + 0 + } } -impl Clone for ClientImpl { +impl Clone for ClientImpl { fn clone(&self) -> Self { unsafe { let rc_impl = &mut *self.object; @@ -34,10 +62,11 @@ impl Clone for ClientImpl { Self { object: self.object, render_handler: self.render_handler.clone(), + event_handler: self.event_handler.clone(), } } } -impl Rc for ClientImpl { +impl Rc for ClientImpl { fn as_base(&self) -> &cef_base_ref_counted_t { unsafe { let base = &*self.object; @@ -45,7 +74,7 @@ impl Rc for ClientImpl { } } } -impl WrapClient for ClientImpl { +impl WrapClient for ClientImpl { fn wrap_rc(&mut self, object: *mut RcImpl<_cef_client_t, Self>) { self.object = object; } diff --git a/desktop/src/cef/internal/non_browser_app.rs b/desktop/src/cef/internal/non_browser_app.rs index 04007d729d..f460b6c0d2 100644 --- a/desktop/src/cef/internal/non_browser_app.rs +++ b/desktop/src/cef/internal/non_browser_app.rs @@ -2,6 +2,7 @@ use cef::rc::{Rc, RcImpl}; use cef::sys::{_cef_app_t, cef_base_ref_counted_t}; use cef::{App, ImplApp, SchemeRegistrar, WrapApp}; +use crate::cef::internal::non_browser_render_process_handler::NonBrowserRenderProcessHandlerImpl; use crate::cef::scheme_handler::GraphiteSchemeHandlerFactory; pub(crate) struct NonBrowserAppImpl { @@ -14,6 +15,10 @@ impl NonBrowserAppImpl { } impl ImplApp for NonBrowserAppImpl { + fn render_process_handler(&self) -> Option { + Some(cef::RenderProcessHandler::new(NonBrowserRenderProcessHandlerImpl::new())) + } + fn on_register_custom_schemes(&self, registrar: Option<&mut SchemeRegistrar>) { GraphiteSchemeHandlerFactory::register_schemes(registrar); } diff --git a/desktop/src/cef/internal/non_browser_render_process_handler.rs b/desktop/src/cef/internal/non_browser_render_process_handler.rs new file mode 100644 index 0000000000..721f94fdd0 --- /dev/null +++ b/desktop/src/cef/internal/non_browser_render_process_handler.rs @@ -0,0 +1,61 @@ +use cef::rc::{Rc, RcImpl}; +use cef::sys::{_cef_render_process_handler_t, cef_base_ref_counted_t}; +use cef::{CefString, ImplRenderProcessHandler, ImplV8Context, ImplV8Value, V8Handler, V8Propertyattribute, WrapRenderProcessHandler, v8_value_create_function}; + +use crate::cef::internal::non_browser_v8_handler::NonBrowserV8HandlerImpl; + +pub(crate) struct NonBrowserRenderProcessHandlerImpl { + object: *mut RcImpl<_cef_render_process_handler_t, Self>, +} +impl NonBrowserRenderProcessHandlerImpl { + pub(crate) fn new() -> Self { + Self { object: std::ptr::null_mut() } + } +} + +impl ImplRenderProcessHandler for NonBrowserRenderProcessHandlerImpl { + fn on_context_created(&self, _browser: Option<&mut cef::Browser>, _frame: Option<&mut cef::Frame>, context: Option<&mut cef::V8Context>) { + let Some(context) = context else { + tracing::event!(tracing::Level::ERROR, "No browser in RenderProcessHandlerImpl::on_context_created"); + return; + }; + let mut v8_handler = V8Handler::new(NonBrowserV8HandlerImpl::new()); + let Some(mut function) = v8_value_create_function(Some(&CefString::from("sendMessageToCef")), Some(&mut v8_handler)) else { + tracing::event!(tracing::Level::ERROR, "Failed to create V8 function"); + return; + }; + let Some(global) = context.global() else { + tracing::event!(tracing::Level::ERROR, "No global object in RenderProcessHandlerImpl::on_context_created"); + return; + }; + + global.set_value_bykey(Some(&CefString::from("sendMessageToCef")), Some(&mut function), V8Propertyattribute::default()); + } + + fn get_raw(&self) -> *mut _cef_render_process_handler_t { + self.object.cast() + } +} + +impl Clone for NonBrowserRenderProcessHandlerImpl { + fn clone(&self) -> Self { + unsafe { + let rc_impl = &mut *self.object; + rc_impl.interface.add_ref(); + } + Self { object: self.object } + } +} +impl Rc for NonBrowserRenderProcessHandlerImpl { + fn as_base(&self) -> &cef_base_ref_counted_t { + unsafe { + let base = &*self.object; + std::mem::transmute(&base.cef_object) + } + } +} +impl WrapRenderProcessHandler for NonBrowserRenderProcessHandlerImpl { + fn wrap_rc(&mut self, object: *mut RcImpl<_cef_render_process_handler_t, Self>) { + self.object = object; + } +} diff --git a/desktop/src/cef/internal/non_browser_v8_handler.rs b/desktop/src/cef/internal/non_browser_v8_handler.rs new file mode 100644 index 0000000000..a06d437b1e --- /dev/null +++ b/desktop/src/cef/internal/non_browser_v8_handler.rs @@ -0,0 +1,77 @@ +use cef::{CefString, ImplFrame, ImplV8Context, ImplV8Handler, ImplV8Value, V8Value, WrapV8Handler, process_message_create, rc::Rc, sys::cef_process_id_t, v8_context_get_current_context}; + +pub struct NonBrowserV8HandlerImpl { + object: *mut cef::rc::RcImpl, +} + +impl NonBrowserV8HandlerImpl { + pub(crate) fn new() -> Self { + Self { object: std::ptr::null_mut() } + } +} + +impl ImplV8Handler for NonBrowserV8HandlerImpl { + fn execute( + &self, + name: Option<&cef::CefString>, + _object: Option<&mut V8Value>, + arguments: Option<&[Option]>, + _retval: Option<&mut Option>, + _exception: Option<&mut cef::CefString>, + ) -> ::std::os::raw::c_int { + if let Some(name) = name { + if name.to_string() == "sendMessageToCef" { + let string = arguments.unwrap().first().unwrap().as_ref().unwrap().string_value(); + + let pointer: *mut cef::sys::_cef_string_utf16_t = string.into(); + let message = unsafe { + let str = (*pointer).str_; + let len = (*pointer).length; + let slice = std::slice::from_raw_parts(str, len); + String::from_utf16(slice).unwrap() + }; + + let Some(mut process_message) = process_message_create(Some(&CefString::from(message.as_str()))) else { + tracing::event!(tracing::Level::ERROR, "Failed to create process message"); + return 0; + }; + + let Some(frame) = v8_context_get_current_context().and_then(|context| context.frame()) else { + tracing::event!(tracing::Level::ERROR, "No current V8 context in V8HandlerImpl::execute"); + return 0; + }; + frame.send_process_message(cef_process_id_t::PID_BROWSER.into(), Some(&mut process_message)); + } + } + 0 + } + + fn get_raw(&self) -> *mut cef::sys::_cef_v8_handler_t { + self.object.cast() + } +} + +impl Clone for NonBrowserV8HandlerImpl { + fn clone(&self) -> Self { + unsafe { + let rc_impl = &mut *self.object; + rc_impl.interface.add_ref(); + } + Self { object: self.object } + } +} + +impl Rc for NonBrowserV8HandlerImpl { + fn as_base(&self) -> &cef::sys::cef_base_ref_counted_t { + unsafe { + let base = &*self.object; + std::mem::transmute(&base.cef_object) + } + } +} + +impl WrapV8Handler for NonBrowserV8HandlerImpl { + fn wrap_rc(&mut self, object: *mut cef::rc::RcImpl) { + self.object = object; + } +} diff --git a/desktop/src/main.rs b/desktop/src/main.rs index c53848f8be..7e4c454316 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -20,6 +20,9 @@ mod dirs; pub(crate) enum CustomEvent { UiUpdate(wgpu::Texture), ScheduleBrowserWork(Instant), + MessageReceived { message: String }, + // // Called from the editor if the render node is evaluated and returns an UpdateViewport message + // ViewportUpdate { texture: wgpu::TextureView }, } fn main() { diff --git a/desktop/src/render.rs b/desktop/src/render.rs index 5141544666..8b7a68a336 100644 --- a/desktop/src/render.rs +++ b/desktop/src/render.rs @@ -99,10 +99,16 @@ pub(crate) struct GraphicsState { surface: wgpu::Surface<'static>, context: WgpuContext, config: wgpu::SurfaceConfiguration, - texture: Option, - bind_group: Option, render_pipeline: wgpu::RenderPipeline, sampler: wgpu::Sampler, + + // Cached texture for UI rendering + ui_texture: Option, + ui_bind_group: Option, + // Cached texture for node graph output + // viewport_texture: Option, + // // Returned from CEF js event callback + pub viewport_top_left: (u32, u32), } impl GraphicsState { @@ -211,10 +217,11 @@ impl GraphicsState { surface, context, config, - texture: None, - bind_group: None, render_pipeline, sampler, + ui_texture: None, + ui_bind_group: None, + viewport_top_left: (0, 0), } } @@ -228,9 +235,9 @@ impl GraphicsState { pub(crate) fn bind_texture(&mut self, texture: &wgpu::Texture) { let bind_group = self.create_bindgroup(texture); - self.texture = Some(texture.clone()); + self.ui_texture = Some(texture.clone()); - self.bind_group = Some(bind_group); + self.ui_bind_group = Some(bind_group); } fn create_bindgroup(&self, texture: &wgpu::Texture) -> wgpu::BindGroup { @@ -275,7 +282,7 @@ impl GraphicsState { }); render_pass.set_pipeline(&self.render_pipeline); - if let Some(bind_group) = &self.bind_group { + if let Some(bind_group) = &self.ui_bind_group { render_pass.set_bind_group(0, bind_group, &[]); render_pass.draw(0..6, 0..1); // Draw 3 vertices for fullscreen triangle } else { diff --git a/editor/Cargo.toml b/editor/Cargo.toml index f54ba79471..d5124a93fd 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -14,7 +14,6 @@ license = "Apache-2.0" default = ["wasm"] wasm = ["wasm-bindgen", "graphene-std/wasm", "wasm-bindgen-futures"] gpu = ["interpreted-executor/gpu", "wgpu-executor"] -tauri = ["ron", "decouple-execution"] decouple-execution = [] resvg = ["graphene-std/resvg"] vello = ["graphene-std/vello", "resvg"] diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 7c55c05a68..0525f10ca0 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -149,7 +149,6 @@ pub const COLOR_OVERLAY_BLACK_75: &str = "#000000bf"; 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; // INPUT pub const DOUBLE_CLICK_MILLISECONDS: u64 = 500; diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index da92ad313b..f3215762a5 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -382,14 +382,6 @@ pub async fn introspect_node(path: &[NodeId]) -> Result bool { - let Some(mut runtime) = NODE_RUNTIME.try_lock() else { return false }; - if let Some(ref mut runtime) = runtime.as_mut() { - runtime.run().await; - } - true -} - pub async fn replace_node_runtime(runtime: NodeRuntime) -> Option { let mut node_runtime = NODE_RUNTIME.lock(); node_runtime.replace(runtime) diff --git a/editor/src/node_graph_executor/runtime_io.rs b/editor/src/node_graph_executor/runtime_io.rs index e4e6f1df40..93cf591dec 100644 --- a/editor/src/node_graph_executor/runtime_io.rs +++ b/editor/src/node_graph_executor/runtime_io.rs @@ -1,24 +1,10 @@ use super::*; use std::sync::mpsc::{Receiver, Sender}; -use wasm_bindgen::prelude::*; -#[wasm_bindgen] -extern "C" { - // Invoke with arguments (default) - #[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"])] - async fn invoke(cmd: &str, args: JsValue) -> JsValue; - #[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"], js_name="invoke")] - async fn invoke_without_arg(cmd: &str) -> JsValue; -} - -/// Handles communication with the NodeRuntime, either locally or via Tauri +/// Handles communication with the NodeRuntime, either locally or via the native event loop proxy #[derive(Debug)] pub struct NodeRuntimeIO { - // Send to - #[cfg(any(not(feature = "tauri"), test))] sender: Sender, - #[cfg(all(feature = "tauri", not(test)))] - sender: Sender, receiver: Receiver, } @@ -29,27 +15,15 @@ impl Default for NodeRuntimeIO { } impl NodeRuntimeIO { - /// Creates a new NodeRuntimeIO instance + /// Creates a new NodeRuntimeIO instance on web pub fn new() -> Self { - #[cfg(any(not(feature = "tauri"), test))] - { - let (response_sender, response_receiver) = std::sync::mpsc::channel(); - let (request_sender, request_receiver) = std::sync::mpsc::channel(); - futures::executor::block_on(replace_node_runtime(NodeRuntime::new(request_receiver, response_sender))); + let (response_sender, response_receiver) = std::sync::mpsc::channel(); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + futures::executor::block_on(replace_node_runtime(NodeRuntime::new(request_receiver, response_sender))); - Self { - sender: request_sender, - receiver: response_receiver, - } - } - - #[cfg(all(feature = "tauri", not(test)))] - { - let (response_sender, response_receiver) = std::sync::mpsc::channel(); - Self { - sender: response_sender, - receiver: response_receiver, - } + Self { + sender: request_sender, + receiver: response_receiver, } } #[cfg(test)] @@ -59,44 +33,11 @@ impl NodeRuntimeIO { /// Sends a message to the NodeRuntime pub fn send(&self, message: GraphRuntimeRequest) -> Result<(), String> { - #[cfg(any(not(feature = "tauri"), test))] - { - self.sender.send(message).map_err(|e| e.to_string()) - } - - #[cfg(all(feature = "tauri", not(test)))] - { - let serialized = ron::to_string(&message).map_err(|e| e.to_string()).unwrap(); - wasm_bindgen_futures::spawn_local(async move { - let js_message = create_message_object(&serialized); - invoke("runtime_message", js_message).await; - }); - Ok(()) - } + self.sender.send(message).map_err(|e| e.to_string()) } /// Receives any pending updates from the NodeRuntime pub fn receive(&self) -> impl Iterator + use<'_> { - // TODO: This introduces extra latency - #[cfg(all(feature = "tauri", not(test)))] - { - let sender = self.sender.clone(); - // In the Tauri case, responses are handled separately via poll_node_runtime_updates - wasm_bindgen_futures::spawn_local(async move { - let messages = invoke_without_arg("poll_node_graph").await; - let vec: Vec<_> = ron::from_str(&messages.as_string().unwrap()).unwrap(); - for message in vec { - sender.send(message).unwrap(); - } - }); - } self.receiver.try_iter() } } - -#[cfg(all(feature = "tauri", not(test)))] -pub fn create_message_object(message: &str) -> JsValue { - let obj = js_sys::Object::new(); - js_sys::Reflect::set(&obj, &JsValue::from_str("message"), &JsValue::from_str(message)).unwrap(); - obj.into() -} diff --git a/frontend/package.json b/frontend/package.json index 07fc0460b0..40d36d4c88 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "production": "npm run setup && npm run wasm:build-production && concurrently -k -n \"VITE,RUST\" \"vite\" \"npm run wasm:watch-production\"", "---------- BUILDS ----------": "", "build-dev": "npm run wasm:build-dev && vite build", + "build-native": "npm run native:build-dev && vite build", "build-profiling": "npm run wasm:build-profiling && vite build", "build": "npm run wasm:build-production && vite build", "---------- UTILITIES ----------": "", @@ -19,6 +20,7 @@ "lint-fix": "eslint . --fix && tsc --noEmit", "---------- INTERNAL ----------": "", "setup": "node package-installer.js", + "native:build-dev": "wasm-pack build ./wasm --dev --target=web --features native", "wasm:build-dev": "wasm-pack build ./wasm --dev --target=web", "wasm:build-profiling": "wasm-pack build ./wasm --profiling --target=web", "wasm:build-production": "wasm-pack build ./wasm --release --target=web", diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a43fb1a6ad..cb7a23e83c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -7,15 +7,29 @@ let editor: GraphiteEditor | undefined = undefined; + let autoSaveAllDocumentsId: ReturnType | undefined = undefined; + let autoPanningId: ReturnType | undefined = undefined; onMount(async () => { await initWasm(); editor = createEditor(); + + // Auto save every 15 seconds + autoSaveAllDocumentsId = setInterval(() => { + editor?.handle.autoSaveAllDocuments(); + }, 15000); + + // Check for autoPanning every 15ms + autoPanningId = setInterval(() => { + editor?.handle.autoPanning(); + }, 15); }); onDestroy(() => { // Destroy the WASM editor handle editor?.handle.free(); + clearInterval(autoSaveAllDocumentsId); + clearInterval(autoPanningId); }); diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index bd36152f04..7f82625079 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -171,7 +171,6 @@ function canvasPointerDown(e: PointerEvent) { const onEditbox = e.target instanceof HTMLDivElement && e.target.contentEditable; - if (!onEditbox) viewport?.setPointerCapture(e.pointerId); if (window.document.activeElement instanceof HTMLElement) { window.document.activeElement.blur(); diff --git a/frontend/wasm/Cargo.toml b/frontend/wasm/Cargo.toml index 13e45bd07e..1ba3ae2de3 100644 --- a/frontend/wasm/Cargo.toml +++ b/frontend/wasm/Cargo.toml @@ -13,7 +13,7 @@ license = "Apache-2.0" [features] default = ["gpu"] gpu = ["editor/gpu"] -tauri = ["editor/tauri"] +native = [] [lib] crate-type = ["cdylib", "rlib"] @@ -31,6 +31,7 @@ graphene-std = { workspace = true } graph-craft = { workspace = true } log = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } wasm-bindgen = { workspace = true } serde-wasm-bindgen = { workspace = true } js-sys = { workspace = true } diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs deleted file mode 100644 index c649ee17ac..0000000000 --- a/frontend/wasm/src/editor_api.rs +++ /dev/null @@ -1,951 +0,0 @@ -#![allow(clippy::too_many_arguments)] -// -// This file is where functions are defined to be called directly from JS. -// It serves as a thin wrapper over the editor backend API that relies -// on the dispatcher messaging system and more complex Rust data types. -// -use crate::helpers::translate_key; -use crate::{EDITOR, EDITOR_HANDLE, EDITOR_HAS_CRASHED, Error}; -use editor::application::Editor; -use editor::consts::FILE_SAVE_SUFFIX; -use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; -use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta, ViewportBounds}; -use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; -use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport; -use editor::messages::portfolio::utility_types::Platform; -use editor::messages::prelude::*; -use editor::messages::tool::tool_messages::tool_prelude::WidgetId; -use graph_craft::document::NodeId; -use graphene_std::raster::Image; -use graphene_std::raster::color::Color; -use js_sys::{Object, Reflect}; -use serde::Serialize; -use serde_wasm_bindgen::{self, from_value}; -use std::cell::RefCell; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; -use wasm_bindgen::JsCast; -use wasm_bindgen::prelude::*; -use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, window}; - -static IMAGE_DATA_HASH: AtomicU64 = AtomicU64::new(0); - -fn calculate_hash(t: &T) -> u64 { - use std::collections::hash_map::DefaultHasher; - use std::hash::Hasher; - let mut hasher = DefaultHasher::new(); - t.hash(&mut hasher); - hasher.finish() -} - -/// Set the random seed used by the editor by calling this from JS upon initialization. -/// This is necessary because WASM doesn't have a random number generator. -#[wasm_bindgen(js_name = setRandomSeed)] -pub fn set_random_seed(seed: u64) { - editor::application::set_uuid_seed(seed); -} - -/// Provides a handle to access the raw WASM memory. -#[wasm_bindgen(js_name = wasmMemory)] -pub fn wasm_memory() -> JsValue { - wasm_bindgen::memory() -} - -fn render_image_data_to_canvases(image_data: &[(u64, Image)]) { - let window = match window() { - Some(window) => window, - None => { - error!("Cannot render canvas: window object not found"); - return; - } - }; - let document = window.document().expect("window should have a document"); - let window_obj = Object::from(window); - let image_canvases_key = JsValue::from_str("imageCanvases"); - - let canvases_obj = match Reflect::get(&window_obj, &image_canvases_key) { - Ok(obj) if !obj.is_undefined() && !obj.is_null() => obj, - _ => { - let new_obj = Object::new(); - if Reflect::set(&window_obj, &image_canvases_key, &new_obj).is_err() { - error!("Failed to create and set imageCanvases object on window"); - return; - } - new_obj.into() - } - }; - let canvases_obj = Object::from(canvases_obj); - - for (placeholder_id, image) in image_data.iter() { - let canvas_name = placeholder_id.to_string(); - let js_key = JsValue::from_str(&canvas_name); - - if Reflect::has(&canvases_obj, &js_key).unwrap_or(false) || image.width == 0 || image.height == 0 { - continue; - } - - let canvas: HtmlCanvasElement = document - .create_element("canvas") - .expect("Failed to create canvas element") - .dyn_into::() - .expect("Failed to cast element to HtmlCanvasElement"); - - canvas.set_width(image.width); - canvas.set_height(image.height); - - let context: CanvasRenderingContext2d = canvas - .get_context("2d") - .expect("Failed to get 2d context") - .expect("2d context was not found") - .dyn_into::() - .expect("Failed to cast context to CanvasRenderingContext2d"); - let u8_data: Vec = image.data.iter().flat_map(|color| color.to_rgba8_srgb()).collect(); - let clamped_u8_data = wasm_bindgen::Clamped(&u8_data[..]); - match ImageData::new_with_u8_clamped_array_and_sh(clamped_u8_data, image.width, image.height) { - Ok(image_data_obj) => { - if context.put_image_data(&image_data_obj, 0., 0.).is_err() { - error!("Failed to put image data on canvas for id: {placeholder_id}"); - } - } - Err(e) => { - error!("Failed to create ImageData for id: {placeholder_id}: {e:?}"); - } - } - - let js_value = JsValue::from(canvas); - - if Reflect::set(&canvases_obj, &js_key, &js_value).is_err() { - error!("Failed to set canvas '{canvas_name}' on imageCanvases object"); - } - } -} - -// ============================================================================ - -/// This struct is, via wasm-bindgen, used by JS to interact with the editor backend. It does this by calling functions, which are `impl`ed -#[wasm_bindgen] -#[derive(Clone)] -pub struct EditorHandle { - /// This callback is called by the editor's dispatcher when directing FrontendMessages from Rust to JS - frontend_message_handler_callback: js_sys::Function, -} - -// Defined separately from the `impl` block below since this `impl` block lacks the `#[wasm_bindgen]` attribute. -// Quirks in wasm-bindgen prevent functions in `#[wasm_bindgen]` `impl` blocks from being made publicly accessible from Rust. -impl EditorHandle { - pub fn send_frontend_message_to_js_rust_proxy(&self, message: FrontendMessage) { - self.send_frontend_message_to_js(message); - } -} - -#[wasm_bindgen] -impl EditorHandle { - #[wasm_bindgen(constructor)] - pub fn new(frontend_message_handler_callback: js_sys::Function) -> Self { - let editor = Editor::new(); - let editor_handle = EditorHandle { frontend_message_handler_callback }; - if EDITOR.with(|handle| handle.lock().ok().map(|mut guard| *guard = Some(editor))).is_none() { - log::error!("Attempted to initialize the editor more than once"); - } - if EDITOR_HANDLE.with(|handle| handle.lock().ok().map(|mut guard| *guard = Some(editor_handle.clone()))).is_none() { - log::error!("Attempted to initialize the editor handle more than once"); - } - editor_handle - } - - // Sends a message to the dispatcher in the Editor Backend - fn dispatch>(&self, message: T) { - // Process no further messages after a crash to avoid spamming the console - if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { - return; - } - - // Get the editor, dispatch the message, and store the `FrontendMessage` queue response - let frontend_messages = editor(|editor| editor.handle_message(message.into())); - - // Send each `FrontendMessage` to the JavaScript frontend - for message in frontend_messages.into_iter() { - self.send_frontend_message_to_js(message); - } - } - - // Sends a FrontendMessage to JavaScript - fn send_frontend_message_to_js(&self, mut message: FrontendMessage) { - if let FrontendMessage::UpdateImageData { ref image_data } = message { - let new_hash = calculate_hash(image_data); - let prev_hash = IMAGE_DATA_HASH.load(Ordering::Relaxed); - - if new_hash != prev_hash { - render_image_data_to_canvases(image_data.as_slice()); - IMAGE_DATA_HASH.store(new_hash, Ordering::Relaxed); - } - return; - } - - if let FrontendMessage::UpdateDocumentLayerStructure { data_buffer } = message { - message = FrontendMessage::UpdateDocumentLayerStructureJs { data_buffer: data_buffer.into() }; - } - - let message_type = message.to_discriminant().local_name(); - - let serializer = serde_wasm_bindgen::Serializer::new().serialize_large_number_types_as_bigints(true); - let message_data = message.serialize(&serializer).expect("Failed to serialize FrontendMessage"); - - let js_return_value = self.frontend_message_handler_callback.call2(&JsValue::null(), &JsValue::from(message_type), &message_data); - - if let Err(error) = js_return_value { - error!( - "While handling FrontendMessage \"{:?}\", JavaScript threw an error: {:?}", - message.to_discriminant().local_name(), - error, - ) - } - } - - // ======================================================================== - // Add additional JS -> Rust wrapper functions below as needed for calling - // the backend from the web frontend. - // ======================================================================== - - #[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(PortfolioMessage::Init); - - // 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 |_timestamp| { - wasm_bindgen_futures::spawn_local(poll_node_graph_evaluation()); - - if !EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { - editor_and_handle(|editor, handle| { - for message in editor.handle_message(InputPreprocessorMessage::CurrentTime { - timestamp: js_sys::Date::now() as u64, - }) { - handle.send_frontend_message_to_js(message); - } - - for message in editor.handle_message(AnimationMessage::IncrementFrameCounter) { - handle.send_frontend_message_to_js(message); - } - - // Used by auto-panning, but this could possibly be refactored in the future, see: - // - for message in editor.handle_message(BroadcastMessage::TriggerEvent(BroadcastEvent::AnimationFrame)) { - handle.send_frontend_message_to_js(message); - } - }); - } - - // Schedule ourself for another requestAnimationFrame callback - request_animation_frame(f.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)); - } - } - - /// Displays a dialog with an error message - #[wasm_bindgen(js_name = errorDialog)] - pub fn error_dialog(&self, title: String, description: String) { - let message = DialogMessage::DisplayDialogError { title, description }; - self.dispatch(message); - } - - /// Answer whether or not the editor has crashed - #[wasm_bindgen(js_name = hasCrashed)] - pub fn has_crashed(&self) -> bool { - EDITOR_HAS_CRASHED.load(Ordering::SeqCst) - } - - /// Answer whether or not the editor is in development mode - #[wasm_bindgen(js_name = inDevelopmentMode)] - pub fn in_development_mode(&self) -> bool { - cfg!(debug_assertions) - } - - /// Get the constant `FILE_SAVE_SUFFIX` - #[wasm_bindgen(js_name = fileSaveSuffix)] - pub fn file_save_suffix(&self) -> String { - FILE_SAVE_SUFFIX.into() - } - - /// Update the value of a given UI widget, but don't commit it to the history (unless `commit_layout()` is called, which handles that) - #[wasm_bindgen(js_name = widgetValueUpdate)] - pub fn widget_value_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { - let widget_id = WidgetId(widget_id); - match (from_value(layout_target), from_value(value)) { - (Ok(layout_target), Ok(value)) => { - let message = LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value }; - self.dispatch(message); - Ok(()) - } - (target, val) => Err(Error::new(&format!("Could not update UI\nDetails:\nTarget: {target:?}\nValue: {val:?}")).into()), - } - } - - /// Commit the value of a given UI widget to the history - #[wasm_bindgen(js_name = widgetValueCommit)] - pub fn widget_value_commit(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { - let widget_id = WidgetId(widget_id); - match (from_value(layout_target), from_value(value)) { - (Ok(layout_target), Ok(value)) => { - let message = LayoutMessage::WidgetValueCommit { layout_target, widget_id, value }; - self.dispatch(message); - Ok(()) - } - (target, val) => Err(Error::new(&format!("Could not commit UI\nDetails:\nTarget: {target:?}\nValue: {val:?}")).into()), - } - } - - /// Update the value of a given UI widget, and commit it to the history - #[wasm_bindgen(js_name = widgetValueCommitAndUpdate)] - pub fn widget_value_commit_and_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { - self.widget_value_commit(layout_target.clone(), widget_id, value.clone())?; - self.widget_value_update(layout_target, widget_id, value)?; - Ok(()) - } - - #[wasm_bindgen(js_name = loadPreferences)] - pub fn load_preferences(&self, preferences: String) { - let message = PreferencesMessage::Load { preferences }; - - self.dispatch(message); - } - - #[wasm_bindgen(js_name = selectDocument)] - pub fn select_document(&self, document_id: u64) { - let document_id = DocumentId(document_id); - let message = PortfolioMessage::SelectDocument { document_id }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = newDocumentDialog)] - pub fn new_document_dialog(&self) { - let message = DialogMessage::RequestNewDocumentDialog; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = openDocument)] - pub fn open_document(&self) { - let message = PortfolioMessage::OpenDocument; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = demoArtworkDialog)] - pub fn demo_artwork_dialog(&self) { - let message = DialogMessage::RequestDemoArtworkDialog; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = openDocumentFile)] - pub fn open_document_file(&self, document_name: String, document_serialized_content: String) { - let message = PortfolioMessage::OpenDocumentFile { - document_name, - document_serialized_content, - }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = openAutoSavedDocument)] - pub fn open_auto_saved_document(&self, document_id: u64, document_name: String, document_is_saved: bool, document_serialized_content: String, to_front: bool) { - let document_id = DocumentId(document_id); - let message = PortfolioMessage::OpenDocumentFileWithId { - document_id, - document_name, - document_is_auto_saved: true, - document_is_saved, - document_serialized_content, - to_front, - }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = triggerAutoSave)] - pub fn trigger_auto_save(&self, document_id: u64) { - let document_id = DocumentId(document_id); - let message = PortfolioMessage::AutoSaveDocument { document_id }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = closeDocumentWithConfirmation)] - pub fn close_document_with_confirmation(&self, document_id: u64) { - let document_id = DocumentId(document_id); - let message = PortfolioMessage::CloseDocumentWithConfirmation { document_id }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = requestAboutGraphiteDialogWithLocalizedCommitDate)] - pub fn request_about_graphite_dialog_with_localized_commit_date(&self, localized_commit_date: String, localized_commit_year: String) { - let message = DialogMessage::RequestAboutGraphiteDialogWithLocalizedCommitDate { - localized_commit_date, - localized_commit_year, - }; - self.dispatch(message); - } - - /// Send new bounds when document panel viewports get resized or moved within the editor - /// [left, top, right, bottom]... - #[wasm_bindgen(js_name = boundsOfViewports)] - pub fn bounds_of_viewports(&self, bounds_of_viewports: &[f64]) { - let chunked: Vec<_> = bounds_of_viewports.chunks(4).map(ViewportBounds::from_slice).collect(); - - let message = InputPreprocessorMessage::BoundsOfViewports { bounds_of_viewports: chunked }; - self.dispatch(message); - } - - /// Zoom the canvas to fit all content - #[wasm_bindgen(js_name = zoomCanvasToFitAll)] - pub fn zoom_canvas_to_fit_all(&self) { - let message = DocumentMessage::ZoomCanvasToFitAll; - self.dispatch(message); - } - - /// Inform the overlays system of the current device pixel ratio - #[wasm_bindgen(js_name = setDevicePixelRatio)] - pub fn set_device_pixel_ratio(&self, ratio: f64) { - let message = PortfolioMessage::SetDevicePixelRatio { ratio }; - self.dispatch(message); - } - - /// Mouse movement within the screenspace bounds of the viewport - #[wasm_bindgen(js_name = onMouseMove)] - pub fn on_mouse_move(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { - let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); - - let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); - - let message = InputPreprocessorMessage::PointerMove { editor_mouse_state, modifier_keys }; - self.dispatch(message); - } - - /// Mouse scrolling within the screenspace bounds of the viewport - #[wasm_bindgen(js_name = onWheelScroll)] - pub fn on_wheel_scroll(&self, x: f64, y: f64, mouse_keys: u8, wheel_delta_x: f64, wheel_delta_y: f64, wheel_delta_z: f64, modifiers: u8) { - let mut editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); - editor_mouse_state.scroll_delta = ScrollDelta::new(wheel_delta_x, wheel_delta_y, wheel_delta_z); - - let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); - - let message = InputPreprocessorMessage::WheelScroll { editor_mouse_state, modifier_keys }; - self.dispatch(message); - } - - /// A mouse button depressed within screenspace the bounds of the viewport - #[wasm_bindgen(js_name = onMouseDown)] - pub fn on_mouse_down(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { - let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); - - let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); - - let message = InputPreprocessorMessage::PointerDown { editor_mouse_state, modifier_keys }; - self.dispatch(message); - } - - /// A mouse button released - #[wasm_bindgen(js_name = onMouseUp)] - pub fn on_mouse_up(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { - let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); - - let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); - - let message = InputPreprocessorMessage::PointerUp { editor_mouse_state, modifier_keys }; - self.dispatch(message); - } - - /// Mouse shaken - #[wasm_bindgen(js_name = onMouseShake)] - pub fn on_mouse_shake(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { - let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); - - let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); - - let message = InputPreprocessorMessage::PointerShake { editor_mouse_state, modifier_keys }; - self.dispatch(message); - } - - /// Mouse double clicked - #[wasm_bindgen(js_name = onDoubleClick)] - pub fn on_double_click(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { - let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); - - let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); - - let message = InputPreprocessorMessage::DoubleClick { editor_mouse_state, modifier_keys }; - self.dispatch(message); - } - - /// A keyboard button depressed within screenspace the bounds of the viewport - #[wasm_bindgen(js_name = onKeyDown)] - pub fn on_key_down(&self, name: String, modifiers: u8, key_repeat: bool) { - let key = translate_key(&name); - let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); - - trace!("Key down {key:?}, name: {name}, modifiers: {modifiers:?}, key repeat: {key_repeat}"); - - let message = InputPreprocessorMessage::KeyDown { key, key_repeat, modifier_keys }; - self.dispatch(message); - } - - /// A keyboard button released - #[wasm_bindgen(js_name = onKeyUp)] - pub fn on_key_up(&self, name: String, modifiers: u8, key_repeat: bool) { - let key = translate_key(&name); - let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); - - trace!("Key up {key:?}, name: {name}, modifiers: {modifier_keys:?}, key repeat: {key_repeat}"); - - let message = InputPreprocessorMessage::KeyUp { key, key_repeat, modifier_keys }; - self.dispatch(message); - } - - /// A text box was committed - #[wasm_bindgen(js_name = onChangeText)] - pub fn on_change_text(&self, new_text: String, is_left_or_right_click: bool) -> Result<(), JsValue> { - let message = TextToolMessage::TextChange { new_text, is_left_or_right_click }; - self.dispatch(message); - - Ok(()) - } - - /// A font has been downloaded - #[wasm_bindgen(js_name = onFontLoad)] - pub fn on_font_load(&self, font_family: String, font_style: String, preview_url: String, data: Vec) -> Result<(), JsValue> { - let message = PortfolioMessage::FontLoaded { - font_family, - font_style, - preview_url, - data, - }; - self.dispatch(message); - - Ok(()) - } - - /// A text box was changed - #[wasm_bindgen(js_name = updateBounds)] - pub fn update_bounds(&self, new_text: String) -> Result<(), JsValue> { - let message = TextToolMessage::UpdateBounds { new_text }; - self.dispatch(message); - - Ok(()) - } - - /// Begin sampling a pixel color from the document by entering eyedropper sampling mode - #[wasm_bindgen(js_name = eyedropperSampleForColorPicker)] - pub fn eyedropper_sample_for_color_picker(&self) -> Result<(), JsValue> { - let message = DialogMessage::RequestComingSoonDialog { issue: Some(832) }; - self.dispatch(message); - - Ok(()) - } - - /// Update primary color with values on a scale from 0 to 1. - #[wasm_bindgen(js_name = updatePrimaryColor)] - pub fn update_primary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { - let Some(primary_color) = Color::from_rgbaf32(red, green, blue, alpha) else { - return Err(Error::new("Invalid color").into()); - }; - - let message = ToolMessage::SelectWorkingColor { - color: primary_color.to_linear_srgb(), - primary: true, - }; - self.dispatch(message); - - Ok(()) - } - - /// Update secondary color with values on a scale from 0 to 1. - #[wasm_bindgen(js_name = updateSecondaryColor)] - pub fn update_secondary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { - let Some(secondary_color) = Color::from_rgbaf32(red, green, blue, alpha) else { - return Err(Error::new("Invalid color").into()); - }; - - let message = ToolMessage::SelectWorkingColor { - color: secondary_color.to_linear_srgb(), - primary: false, - }; - self.dispatch(message); - - Ok(()) - } - - /// Visit the given URL - #[wasm_bindgen(js_name = visitUrl)] - pub fn visit_url(&self, url: String) { - let message = FrontendMessage::TriggerVisitLink { url }; - self.dispatch(message); - } - - /// Paste layers from a serialized json representation - #[wasm_bindgen(js_name = pasteSerializedData)] - pub fn paste_serialized_data(&self, data: String) { - let message = PortfolioMessage::PasteSerializedData { data }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = clipLayer)] - pub fn clip_layer(&self, id: u64) { - let id = NodeId(id); - let message = DocumentMessage::ClipLayer { id }; - self.dispatch(message); - } - - /// Modify the layer selection based on the layer which is clicked while holding down the Ctrl and/or Shift modifier keys used for range selection behavior - #[wasm_bindgen(js_name = selectLayer)] - pub fn select_layer(&self, id: u64, ctrl: bool, shift: bool) { - let id = NodeId(id); - let message = DocumentMessage::SelectLayer { id, ctrl, shift }; - self.dispatch(message); - } - - /// Deselect all layers - #[wasm_bindgen(js_name = deselectAllLayers)] - pub fn deselect_all_layers(&self) { - let message = DocumentMessage::DeselectAllLayers; - self.dispatch(message); - } - - /// Move a layer to within a folder and placed down at the given index. - /// If the folder is `None`, it is inserted into the document root. - /// If the insert index is `None`, it is inserted at the start of the folder. - #[wasm_bindgen(js_name = moveLayerInTree)] - pub fn move_layer_in_tree(&self, insert_parent_id: Option, insert_index: Option) { - let insert_parent_id = insert_parent_id.map(NodeId); - let parent = insert_parent_id.map(LayerNodeIdentifier::new_unchecked).unwrap_or_default(); - - let message = DocumentMessage::MoveSelectedLayersTo { - parent, - insert_index: insert_index.unwrap_or_default(), - }; - self.dispatch(message); - } - - /// Set the name for the layer - #[wasm_bindgen(js_name = setLayerName)] - pub fn set_layer_name(&self, id: u64, name: String) { - let layer = LayerNodeIdentifier::new_unchecked(NodeId(id)); - let message = NodeGraphMessage::SetDisplayName { - node_id: layer.to_node(), - alias: name, - skip_adding_history_step: false, - }; - self.dispatch(message); - } - - /// Translates document (in viewport coords) - #[wasm_bindgen(js_name = panCanvasAbortPrepare)] - pub fn pan_canvas_abort_prepare(&self, x_not_y_axis: bool) { - let message = NavigationMessage::CanvasPanAbortPrepare { x_not_y_axis }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = panCanvasAbort)] - pub fn pan_canvas_abort(&self, x_not_y_axis: bool) { - let message = NavigationMessage::CanvasPanAbort { x_not_y_axis }; - self.dispatch(message); - } - - /// Translates document (in viewport coords) - #[wasm_bindgen(js_name = panCanvas)] - pub fn pan_canvas(&self, delta_x: f64, delta_y: f64) { - let message = NavigationMessage::CanvasPan { delta: (delta_x, delta_y).into() }; - self.dispatch(message); - } - - /// Translates document (in viewport coords) - #[wasm_bindgen(js_name = panCanvasByFraction)] - pub fn pan_canvas_by_fraction(&self, delta_x: f64, delta_y: f64) { - let message = NavigationMessage::CanvasPanByViewportFraction { delta: (delta_x, delta_y).into() }; - self.dispatch(message); - } - - /// Snaps the import/export edges to a grid space when the scroll bar is released - #[wasm_bindgen(js_name = setGridAlignedEdges)] - pub fn set_grid_aligned_edges(&self) { - let message = NodeGraphMessage::SetGridAlignedEdges; - self.dispatch(message); - } - - /// Merge a group of nodes into a subnetwork - #[wasm_bindgen(js_name = mergeSelectedNodes)] - pub fn merge_nodes(&self) { - let message = NodeGraphMessage::MergeSelectedNodes; - self.dispatch(message); - } - - /// Creates a new document node in the node graph - #[wasm_bindgen(js_name = createNode)] - pub fn create_node(&self, node_type: String, x: i32, y: i32) { - let id = NodeId::new(); - let message = NodeGraphMessage::CreateNodeFromContextMenu { - node_id: Some(id), - node_type, - xy: Some((x / 24, y / 24)), - add_transaction: true, - }; - self.dispatch(message); - } - - /// Pastes the nodes based on serialized data - #[wasm_bindgen(js_name = pasteSerializedNodes)] - pub fn paste_serialized_nodes(&self, serialized_nodes: String) { - let message = NodeGraphMessage::PasteNodes { serialized_nodes }; - self.dispatch(message); - } - - /// Pastes an image - #[wasm_bindgen(js_name = pasteImage)] - pub fn paste_image( - &self, - name: Option, - image_data: Vec, - width: u32, - height: u32, - mouse_x: Option, - mouse_y: Option, - insert_parent_id: Option, - insert_index: Option, - ) { - let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y))); - let image = graphene_std::raster::Image::from_image_data(&image_data, width, height); - - let parent_and_insert_index = if let (Some(insert_parent_id), Some(insert_index)) = (insert_parent_id, insert_index) { - let insert_parent_id = NodeId(insert_parent_id); - let parent = LayerNodeIdentifier::new_unchecked(insert_parent_id); - Some((parent, insert_index)) - } else { - None - }; - - let message = PortfolioMessage::PasteImage { - name, - image, - mouse, - parent_and_insert_index, - }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = pasteSvg)] - pub fn paste_svg(&self, name: Option, svg: String, mouse_x: Option, mouse_y: Option, insert_parent_id: Option, insert_index: Option) { - let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y))); - - let parent_and_insert_index = if let (Some(insert_parent_id), Some(insert_index)) = (insert_parent_id, insert_index) { - let insert_parent_id = NodeId(insert_parent_id); - let parent = LayerNodeIdentifier::new_unchecked(insert_parent_id); - Some((parent, insert_index)) - } else { - None - }; - - let message = PortfolioMessage::PasteSvg { - name, - svg, - mouse, - parent_and_insert_index, - }; - self.dispatch(message); - } - - /// Toggle visibility of a layer or node given its node ID - #[wasm_bindgen(js_name = toggleNodeVisibilityLayerPanel)] - pub fn toggle_node_visibility_layer(&self, id: u64) { - let node_id = NodeId(id); - let message = NodeGraphMessage::ToggleVisibility { node_id }; - self.dispatch(message); - } - - /// Pin or unpin a node given its node ID - #[wasm_bindgen(js_name = setNodePinned)] - pub fn set_node_pinned(&self, id: u64, pinned: bool) { - self.dispatch(DocumentMessage::SetNodePinned { node_id: NodeId(id), pinned }); - } - - /// Delete a layer or node given its node ID - #[wasm_bindgen(js_name = deleteNode)] - pub fn delete_node(&self, id: u64) { - self.dispatch(DocumentMessage::DeleteNode { node_id: NodeId(id) }); - } - - /// Toggle lock state of a layer from the layer list - #[wasm_bindgen(js_name = toggleLayerLock)] - pub fn toggle_layer_lock(&self, node_id: u64) { - let message = NodeGraphMessage::ToggleLocked { node_id: NodeId(node_id) }; - self.dispatch(message); - } - - /// Toggle expansions state of a layer from the layer list - #[wasm_bindgen(js_name = toggleLayerExpansion)] - pub fn toggle_layer_expansion(&self, id: u64, recursive: bool) { - let id = NodeId(id); - let message = DocumentMessage::ToggleLayerExpansion { id, recursive }; - self.dispatch(message); - } - - /// Set the active panel to the most recently clicked panel - #[wasm_bindgen(js_name = setActivePanel)] - pub fn set_active_panel(&self, panel: String) { - let message = PortfolioMessage::SetActivePanel { panel: panel.into() }; - self.dispatch(message); - } - - /// Toggle display type for a layer - #[wasm_bindgen(js_name = setToNodeOrLayer)] - pub fn set_to_node_or_layer(&self, id: u64, is_layer: bool) { - self.dispatch(DocumentMessage::SetToNodeOrLayer { node_id: NodeId(id), is_layer }); - } - - /// Set the name of an import or export - #[wasm_bindgen(js_name = setImportName)] - pub fn set_import_name(&self, index: usize, name: String) { - let message = NodeGraphMessage::SetImportExportName { - name, - index: ImportOrExport::Import(index), - }; - self.dispatch(message); - } - - /// Set the name of an export - #[wasm_bindgen(js_name = setExportName)] - pub fn set_export_name(&self, index: usize, name: String) { - let message = NodeGraphMessage::SetImportExportName { - name, - index: ImportOrExport::Export(index), - }; - self.dispatch(message); - } -} - -// ============================================================================ - -#[wasm_bindgen(js_name = evaluateMathExpression)] -pub fn evaluate_math_expression(expression: &str) -> Option { - let value = math_parser::evaluate(expression) - .inspect_err(|err| error!("Math parser error on \"{expression}\": {err}")) - .ok()? - .0 - .inspect_err(|err| error!("Math evaluate error on \"{expression}\": {err} ")) - .ok()?; - let Some(real) = value.as_real() else { - error!("{value} was not a real; skipping."); - return None; - }; - Some(real) -} - -/// 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`"); -} - -/// Provides access to the `Editor` by calling the given closure with it as an argument. -fn editor(callback: impl FnOnce(&mut editor::application::Editor) -> T) -> T { - EDITOR.with(|editor| { - let mut guard = editor.try_lock(); - let Ok(Some(editor)) = guard.as_deref_mut() else { return T::default() }; - - callback(editor) - }) -} - -/// Provides access to the `Editor` and its `EditorHandle` by calling the given closure with them as arguments. -pub(crate) fn editor_and_handle(mut callback: impl FnMut(&mut Editor, &mut EditorHandle)) { - EDITOR_HANDLE.with(|editor_handle| { - editor(|editor| { - let mut guard = editor_handle.try_lock(); - let Ok(Some(editor_handle)) = guard.as_deref_mut() else { - log::error!("Failed to borrow editor handle"); - return; - }; - - // Call the closure with the editor and its handle - callback(editor, editor_handle); - }) - }); -} - -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; - } - - if !editor::node_graph_executor::run_node_graph().await { - return; - }; - - editor_and_handle(|editor, handle| { - let mut messages = VecDeque::new(); - if let Err(e) = editor.poll_node_graph_evaluation(&mut messages) { - // TODO: This is a hacky way to suppress the error, but it shouldn't be generated in the first place - if e != "No active document" { - error!("Error evaluating node graph:\n{e}"); - } - } - - // Clear the error display if there are no more errors - if !messages.is_empty() { - crate::NODE_GRAPH_ERROR_DISPLAYED.store(false, Ordering::SeqCst); - } - - // 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); - } - - // 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; - } - - editor_and_handle(|editor, handle| { - for message in editor.handle_message(PortfolioMessage::AutoSaveAllDocuments) { - handle.send_frontend_message_to_js(message); - } - }); -} diff --git a/frontend/wasm/src/lib.rs b/frontend/wasm/src/lib.rs index 730fb4215c..be14e2f388 100644 --- a/frontend/wasm/src/lib.rs +++ b/frontend/wasm/src/lib.rs @@ -1,144 +1,681 @@ -#![doc = include_str!("../README.md")] +// +// This file is where functions are defined to be called directly from JS. +// It serves as a thin wrapper over the editor backend API that relies +// on the dispatcher messaging system and more complex Rust data types. +// +#![doc = include_str!("../README.md")] // `macro_use` puts the log macros (`error!`, `warn!`, `debug!`, `info!` and `trace!`) in scope for the crate #[macro_use] extern crate log; -pub mod editor_api; pub mod helpers; -use editor::messages::prelude::*; -use std::panic; -use std::sync::Mutex; -use std::sync::atomic::{AtomicBool, Ordering}; -use wasm_bindgen::prelude::*; +#[cfg(feature = "native")] +mod native; +#[cfg(not(feature = "native"))] +mod web; -// Set up the persistent editor backend state -pub static EDITOR_HAS_CRASHED: AtomicBool = AtomicBool::new(false); -pub static NODE_GRAPH_ERROR_DISPLAYED: AtomicBool = AtomicBool::new(false); -pub static LOGGER: WasmLog = WasmLog; +#[cfg(feature = "native")] +pub use native::*; +#[cfg(not(feature = "native"))] +pub use web::*; -thread_local! { - pub static EDITOR: Mutex> = const { Mutex::new(None) }; - pub static EDITOR_HANDLE: Mutex> = const { Mutex::new(None) }; -} +use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; +use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta, ViewportBounds}; +use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use editor::messages::portfolio::utility_types::Platform; +use editor::messages::tool::tool_messages::tool_prelude::WidgetId; +use graphene_std::Color; +use graphene_std::uuid::NodeId; +use serde_wasm_bindgen::from_value; + +use editor::messages::{portfolio::document::utility_types::network_interface::ImportOrExport, prelude::*}; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; -/// Initialize the backend -#[wasm_bindgen(start)] -pub fn init_graphite() { - // Set up the panic hook - panic::set_hook(Box::new(panic_hook)); +use crate::helpers::translate_key; - // Set up the logger with a default level of debug - log::set_logger(&LOGGER).expect("Failed to set logger"); - log::set_max_level(log::LevelFilter::Debug); +/// Set the random seed used by the editor by calling this from JS upon initialization. +/// This is necessary because WASM doesn't have a random number generator. +#[wasm_bindgen(js_name = setRandomSeed)] +pub fn set_random_seed(seed: u64) { + editor::application::set_uuid_seed(seed); } -/// When a panic occurs, notify the user and log the error to the JS console before the backend dies -pub fn panic_hook(info: &panic::PanicHookInfo) { - let info = info.to_string(); - let backtrace = Error::new("stack").stack().to_string(); - if backtrace.contains("DynAnyNode") { - log::error!("Node graph evaluation panicked {info}"); +/// Provides a handle to access the raw WASM memory. +#[wasm_bindgen(js_name = wasmMemory)] +pub fn wasm_memory() -> JsValue { + wasm_bindgen::memory() +} - // When the graph panics, the node runtime lock may not be released properly - if editor::node_graph_executor::NODE_RUNTIME.try_lock().is_none() { - unsafe { editor::node_graph_executor::NODE_RUNTIME.force_unlock() }; +/// The functions in here are used both when compiling for native or web. +/// However, when compiling for the native desktop app, received messages are sent directly to the browser runtime (CEF), which forwards them to the main native thread +/// When compiling for web, the messages are sent to the editor, processed, and sent back to the browser +#[wasm_bindgen] +impl EditorHandle { + #[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(PortfolioMessage::Init); + + #[cfg(not(feature = "native"))] + run_and_poll_node_graph_evaluation_loop(); + } + + // Called from interval in App.Svelte + #[wasm_bindgen(js_name = autoSaveAllDocuments)] + pub fn auto_save_all_documents(&self) { + let message = PortfolioMessage::AutoSaveAllDocuments; + self.dispatch(message); + } + + // Called from interval in App.Svelte + #[wasm_bindgen(js_name = autoPanning)] + pub fn auto_panning(&self) { + let message = BroadcastMessage::TriggerEvent(BroadcastEvent::AnimationFrame); + self.dispatch(message); + } + + /// Displays a dialog with an error message + #[wasm_bindgen(js_name = errorDialog)] + pub fn error_dialog(&self, title: String, description: String) { + let message = DialogMessage::DisplayDialogError { title, description }; + self.dispatch(message); + } + + /// Answer whether or not the editor is in development mode + #[wasm_bindgen(js_name = inDevelopmentMode)] + pub fn in_development_mode(&self) -> bool { + cfg!(debug_assertions) + } + + /// Get the constant `FILE_SAVE_SUFFIX` + #[wasm_bindgen(js_name = fileSaveSuffix)] + pub fn file_save_suffix(&self) -> String { + editor::consts::FILE_SAVE_SUFFIX.into() + } + + /// Update the value of a given UI widget, but don't commit it to the history (unless `commit_layout()` is called, which handles that) + #[wasm_bindgen(js_name = widgetValueUpdate)] + pub fn widget_value_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { + let widget_id = WidgetId(widget_id); + match (from_value(layout_target), from_value(value)) { + (Ok(layout_target), Ok(value)) => { + let message = LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value }; + self.dispatch(message); + Ok(()) + } + (target, val) => Err(Error::new(&format!("Could not update UI\nDetails:\nTarget: {target:?}\nValue: {val:?}")).into()), } + } - if !NODE_GRAPH_ERROR_DISPLAYED.load(Ordering::SeqCst) { - NODE_GRAPH_ERROR_DISPLAYED.store(true, Ordering::SeqCst); - editor_api::editor_and_handle(|_, handle| { - let error = r#" - - - The document crashed while being rendered in its current state. - The editor is now unstable! Undo your last action to restore the artwork, - then save your document and restart the editor before continuing work. - /text>"# - // It's a mystery why the `/text>` tag above needs to be missing its `<`, but when it exists it prints the `<` character in the text. However this works with it removed. - .to_string(); - handle.send_frontend_message_to_js_rust_proxy(FrontendMessage::UpdateDocumentArtwork { svg: error }); - }); + /// Commit the value of a given UI widget to the history + #[wasm_bindgen(js_name = widgetValueCommit)] + pub fn widget_value_commit(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { + let widget_id = WidgetId(widget_id); + match (from_value(layout_target), from_value(value)) { + (Ok(layout_target), Ok(value)) => { + let message = LayoutMessage::WidgetValueCommit { layout_target, widget_id, value }; + self.dispatch(message); + Ok(()) + } + (target, val) => Err(Error::new(&format!("Could not commit UI\nDetails:\nTarget: {target:?}\nValue: {val:?}")).into()), } + } - return; - } else { - EDITOR_HAS_CRASHED.store(true, Ordering::SeqCst); + /// Update the value of a given UI widget, and commit it to the history + #[wasm_bindgen(js_name = widgetValueCommitAndUpdate)] + pub fn widget_value_commit_and_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { + self.widget_value_commit(layout_target.clone(), widget_id, value.clone())?; + self.widget_value_update(layout_target, widget_id, value)?; + Ok(()) } - log::error!("{info}"); + #[wasm_bindgen(js_name = loadPreferences)] + pub fn load_preferences(&self, preferences: String) { + let message = PreferencesMessage::Load { preferences }; - EDITOR_HANDLE.with(|editor_handle| { - let mut guard = editor_handle.lock(); - if let Ok(Some(handle)) = guard.as_deref_mut() { - handle.send_frontend_message_to_js_rust_proxy(FrontendMessage::DisplayDialogPanic { panic_info: info.to_string() }); - } - }); -} + self.dispatch(message); + } -#[wasm_bindgen] -extern "C" { - /// The JavaScript `Error` type - #[derive(Clone, Debug)] - pub type Error; + #[wasm_bindgen(js_name = selectDocument)] + pub fn select_document(&self, document_id: u64) { + let document_id = DocumentId(document_id); + let message = PortfolioMessage::SelectDocument { document_id }; + self.dispatch(message); + } - #[wasm_bindgen(constructor)] - pub fn new(msg: &str) -> Error; + #[wasm_bindgen(js_name = newDocumentDialog)] + pub fn new_document_dialog(&self) { + let message = DialogMessage::RequestNewDocumentDialog; + self.dispatch(message); + } - #[wasm_bindgen(structural, method, getter)] - fn stack(error: &Error) -> String; -} + #[wasm_bindgen(js_name = openDocument)] + pub fn open_document(&self) { + let message = PortfolioMessage::OpenDocument; + self.dispatch(message); + } -/// Logging to the JS console -#[wasm_bindgen] -extern "C" { - #[wasm_bindgen(js_namespace = console)] - fn log(msg: &str, format: &str); - #[wasm_bindgen(js_namespace = console)] - fn info(msg: &str, format: &str); - #[wasm_bindgen(js_namespace = console)] - fn warn(msg: &str, format: &str); - #[wasm_bindgen(js_namespace = console)] - fn error(msg: &str, format: &str); - #[wasm_bindgen(js_namespace = console)] - fn trace(msg: &str, format: &str); -} + #[wasm_bindgen(js_name = demoArtworkDialog)] + pub fn demo_artwork_dialog(&self) { + let message = DialogMessage::RequestDemoArtworkDialog; + self.dispatch(message); + } + + #[wasm_bindgen(js_name = openDocumentFile)] + pub fn open_document_file(&self, document_name: String, document_serialized_content: String) { + let message = PortfolioMessage::OpenDocumentFile { + document_name, + document_serialized_content, + }; + self.dispatch(message); + } -#[derive(Default)] -pub struct WasmLog; + #[wasm_bindgen(js_name = openAutoSavedDocument)] + pub fn open_auto_saved_document(&self, document_id: u64, document_name: String, document_is_saved: bool, document_serialized_content: String, to_front: bool) { + let document_id = DocumentId(document_id); + let message = PortfolioMessage::OpenDocumentFileWithId { + document_id, + document_name, + document_is_auto_saved: true, + document_is_saved, + document_serialized_content, + to_front, + }; + self.dispatch(message); + } -impl log::Log for WasmLog { - #[inline] - fn enabled(&self, metadata: &log::Metadata) -> bool { - metadata.level() <= log::max_level() + #[wasm_bindgen(js_name = triggerAutoSave)] + pub fn trigger_auto_save(&self, document_id: u64) { + let document_id = DocumentId(document_id); + let message = PortfolioMessage::AutoSaveDocument { document_id }; + self.dispatch(message); } - fn log(&self, record: &log::Record) { - if !self.enabled(record.metadata()) { - return; - } + #[wasm_bindgen(js_name = closeDocumentWithConfirmation)] + pub fn close_document_with_confirmation(&self, document_id: u64) { + let document_id = DocumentId(document_id); + let message = PortfolioMessage::CloseDocumentWithConfirmation { document_id }; + self.dispatch(message); + } - let (log, name, color): (fn(&str, &str), &str, &str) = match record.level() { - log::Level::Trace => (log, "trace", "color:plum"), - log::Level::Debug => (log, "debug", "color:cyan"), - log::Level::Warn => (warn, "warn", "color:goldenrod"), - log::Level::Info => (info, "info", "color:mediumseagreen"), - log::Level::Error => (error, "error", "color:red"), + #[wasm_bindgen(js_name = requestAboutGraphiteDialogWithLocalizedCommitDate)] + pub fn request_about_graphite_dialog_with_localized_commit_date(&self, localized_commit_date: String, localized_commit_year: String) { + let message = DialogMessage::RequestAboutGraphiteDialogWithLocalizedCommitDate { + localized_commit_date, + localized_commit_year, }; + self.dispatch(message); + } + + /// Send new bounds when document panel viewports get resized or moved within the editor + /// [left, top, right, bottom]... + #[wasm_bindgen(js_name = boundsOfViewports)] + pub fn bounds_of_viewports(&self, bounds_of_viewports: &[f64]) { + let chunked: Vec<_> = bounds_of_viewports.chunks(4).map(ViewportBounds::from_slice).collect(); + + let message = InputPreprocessorMessage::BoundsOfViewports { bounds_of_viewports: chunked }; + self.dispatch(message); + } + + /// Zoom the canvas to fit all content + #[wasm_bindgen(js_name = zoomCanvasToFitAll)] + pub fn zoom_canvas_to_fit_all(&self) { + let message = DocumentMessage::ZoomCanvasToFitAll; + self.dispatch(message); + } + + /// Inform the overlays system of the current device pixel ratio + #[wasm_bindgen(js_name = setDevicePixelRatio)] + pub fn set_device_pixel_ratio(&self, ratio: f64) { + let message = PortfolioMessage::SetDevicePixelRatio { ratio }; + self.dispatch(message); + } + + /// Mouse movement within the screenspace bounds of the viewport + #[wasm_bindgen(js_name = onMouseMove)] + pub fn on_mouse_move(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { + let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); + + let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); + + let message = InputPreprocessorMessage::PointerMove { editor_mouse_state, modifier_keys }; + self.dispatch(message); + } + + /// Mouse scrolling within the screenspace bounds of the viewport + #[wasm_bindgen(js_name = onWheelScroll)] + pub fn on_wheel_scroll(&self, x: f64, y: f64, mouse_keys: u8, wheel_delta_x: f64, wheel_delta_y: f64, wheel_delta_z: f64, modifiers: u8) { + let mut editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); + editor_mouse_state.scroll_delta = ScrollDelta::new(wheel_delta_x, wheel_delta_y, wheel_delta_z); + + let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); + + let message = InputPreprocessorMessage::WheelScroll { editor_mouse_state, modifier_keys }; + self.dispatch(message); + } + + /// A mouse button depressed within screenspace the bounds of the viewport + #[wasm_bindgen(js_name = onMouseDown)] + pub fn on_mouse_down(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { + let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); + + let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); + + let message = InputPreprocessorMessage::PointerDown { editor_mouse_state, modifier_keys }; + self.dispatch(message); + } + + /// A mouse button released + #[wasm_bindgen(js_name = onMouseUp)] + pub fn on_mouse_up(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { + let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); + + let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); + + let message = InputPreprocessorMessage::PointerUp { editor_mouse_state, modifier_keys }; + self.dispatch(message); + } + + /// Mouse shaken + #[wasm_bindgen(js_name = onMouseShake)] + pub fn on_mouse_shake(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { + let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); + + let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); + + let message = InputPreprocessorMessage::PointerShake { editor_mouse_state, modifier_keys }; + self.dispatch(message); + } + + /// Mouse double clicked + #[wasm_bindgen(js_name = onDoubleClick)] + pub fn on_double_click(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { + let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); + + let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); + + let message = InputPreprocessorMessage::DoubleClick { editor_mouse_state, modifier_keys }; + self.dispatch(message); + } + + /// A keyboard button depressed within screenspace the bounds of the viewport + #[wasm_bindgen(js_name = onKeyDown)] + pub fn on_key_down(&self, name: String, modifiers: u8, key_repeat: bool) { + let key = translate_key(&name); + let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); + + trace!("Key down {key:?}, name: {name}, modifiers: {modifiers:?}, key repeat: {key_repeat}"); + + let message = InputPreprocessorMessage::KeyDown { key, key_repeat, modifier_keys }; + self.dispatch(message); + } + + /// A keyboard button released + #[wasm_bindgen(js_name = onKeyUp)] + pub fn on_key_up(&self, name: String, modifiers: u8, key_repeat: bool) { + let key = translate_key(&name); + let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); + + trace!("Key up {key:?}, name: {name}, modifiers: {modifier_keys:?}, key repeat: {key_repeat}"); + + let message = InputPreprocessorMessage::KeyUp { key, key_repeat, modifier_keys }; + self.dispatch(message); + } + + /// A text box was committed + #[wasm_bindgen(js_name = onChangeText)] + pub fn on_change_text(&self, new_text: String, is_left_or_right_click: bool) -> Result<(), JsValue> { + let message = TextToolMessage::TextChange { new_text, is_left_or_right_click }; + self.dispatch(message); + + Ok(()) + } + + /// A font has been downloaded + #[wasm_bindgen(js_name = onFontLoad)] + pub fn on_font_load(&self, font_family: String, font_style: String, preview_url: String, data: Vec) -> Result<(), JsValue> { + let message = PortfolioMessage::FontLoaded { + font_family, + font_style, + preview_url, + data, + }; + self.dispatch(message); + + Ok(()) + } + + /// A text box was changed + #[wasm_bindgen(js_name = updateBounds)] + pub fn update_bounds(&self, new_text: String) -> Result<(), JsValue> { + let message = TextToolMessage::UpdateBounds { new_text }; + self.dispatch(message); - // The %c is replaced by the message color - if record.level() == log::Level::Info { - // We don't print the file name and line number for info-level logs because it's used for printing the message system logs - log(&format!("%c{}\t{}", name, record.args()), color); + Ok(()) + } + + /// Begin sampling a pixel color from the document by entering eyedropper sampling mode + #[wasm_bindgen(js_name = eyedropperSampleForColorPicker)] + pub fn eyedropper_sample_for_color_picker(&self) -> Result<(), JsValue> { + let message = DialogMessage::RequestComingSoonDialog { issue: Some(832) }; + self.dispatch(message); + + Ok(()) + } + + /// Update primary color with values on a scale from 0 to 1. + #[wasm_bindgen(js_name = updatePrimaryColor)] + pub fn update_primary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { + let Some(primary_color) = Color::from_rgbaf32(red, green, blue, alpha) else { + return Err(Error::new("Invalid color").into()); + }; + + let message = ToolMessage::SelectWorkingColor { + color: primary_color.to_linear_srgb(), + primary: true, + }; + self.dispatch(message); + + Ok(()) + } + + /// Update secondary color with values on a scale from 0 to 1. + #[wasm_bindgen(js_name = updateSecondaryColor)] + pub fn update_secondary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { + let Some(secondary_color) = Color::from_rgbaf32(red, green, blue, alpha) else { + return Err(Error::new("Invalid color").into()); + }; + + let message = ToolMessage::SelectWorkingColor { + color: secondary_color.to_linear_srgb(), + primary: false, + }; + self.dispatch(message); + + Ok(()) + } + + /// Visit the given URL + #[wasm_bindgen(js_name = visitUrl)] + pub fn visit_url(&self, url: String) { + let message = FrontendMessage::TriggerVisitLink { url }; + self.dispatch(message); + } + + /// Paste layers from a serialized json representation + #[wasm_bindgen(js_name = pasteSerializedData)] + pub fn paste_serialized_data(&self, data: String) { + let message = PortfolioMessage::PasteSerializedData { data }; + self.dispatch(message); + } + + #[wasm_bindgen(js_name = clipLayer)] + pub fn clip_layer(&self, id: u64) { + let id = NodeId(id); + let message = DocumentMessage::ClipLayer { id }; + self.dispatch(message); + } + + /// Modify the layer selection based on the layer which is clicked while holding down the Ctrl and/or Shift modifier keys used for range selection behavior + #[wasm_bindgen(js_name = selectLayer)] + pub fn select_layer(&self, id: u64, ctrl: bool, shift: bool) { + let id = NodeId(id); + let message = DocumentMessage::SelectLayer { id, ctrl, shift }; + self.dispatch(message); + } + + /// Deselect all layers + #[wasm_bindgen(js_name = deselectAllLayers)] + pub fn deselect_all_layers(&self) { + let message = DocumentMessage::DeselectAllLayers; + self.dispatch(message); + } + + /// Move a layer to within a folder and placed down at the given index. + /// If the folder is `None`, it is inserted into the document root. + /// If the insert index is `None`, it is inserted at the start of the folder. + #[wasm_bindgen(js_name = moveLayerInTree)] + pub fn move_layer_in_tree(&self, insert_parent_id: Option, insert_index: Option) { + let insert_parent_id = insert_parent_id.map(NodeId); + let parent = insert_parent_id.map(LayerNodeIdentifier::new_unchecked).unwrap_or_default(); + + let message = DocumentMessage::MoveSelectedLayersTo { + parent, + insert_index: insert_index.unwrap_or_default(), + }; + self.dispatch(message); + } + + /// Set the name for the layer + #[wasm_bindgen(js_name = setLayerName)] + pub fn set_layer_name(&self, id: u64, name: String) { + let layer = LayerNodeIdentifier::new_unchecked(NodeId(id)); + let message = NodeGraphMessage::SetDisplayName { + node_id: layer.to_node(), + alias: name, + skip_adding_history_step: false, + }; + self.dispatch(message); + } + + /// Translates document (in viewport coords) + #[wasm_bindgen(js_name = panCanvasAbortPrepare)] + pub fn pan_canvas_abort_prepare(&self, x_not_y_axis: bool) { + let message = NavigationMessage::CanvasPanAbortPrepare { x_not_y_axis }; + self.dispatch(message); + } + + #[wasm_bindgen(js_name = panCanvasAbort)] + pub fn pan_canvas_abort(&self, x_not_y_axis: bool) { + let message = NavigationMessage::CanvasPanAbort { x_not_y_axis }; + self.dispatch(message); + } + + /// Translates document (in viewport coords) + #[wasm_bindgen(js_name = panCanvas)] + pub fn pan_canvas(&self, delta_x: f64, delta_y: f64) { + let message = NavigationMessage::CanvasPan { delta: (delta_x, delta_y).into() }; + self.dispatch(message); + } + + /// Translates document (in viewport coords) + #[wasm_bindgen(js_name = panCanvasByFraction)] + pub fn pan_canvas_by_fraction(&self, delta_x: f64, delta_y: f64) { + let message = NavigationMessage::CanvasPanByViewportFraction { delta: (delta_x, delta_y).into() }; + self.dispatch(message); + } + + /// Snaps the import/export edges to a grid space when the scroll bar is released + #[wasm_bindgen(js_name = setGridAlignedEdges)] + pub fn set_grid_aligned_edges(&self) { + let message = NodeGraphMessage::SetGridAlignedEdges; + self.dispatch(message); + } + + /// Merge a group of nodes into a subnetwork + #[wasm_bindgen(js_name = mergeSelectedNodes)] + pub fn merge_nodes(&self) { + let message = NodeGraphMessage::MergeSelectedNodes; + self.dispatch(message); + } + + /// Creates a new document node in the node graph + #[wasm_bindgen(js_name = createNode)] + pub fn create_node(&self, node_type: String, x: i32, y: i32) { + let id = NodeId::new(); + let message = NodeGraphMessage::CreateNodeFromContextMenu { + node_id: Some(id), + node_type, + xy: Some((x / 24, y / 24)), + add_transaction: true, + }; + self.dispatch(message); + } + + /// Pastes the nodes based on serialized data + #[wasm_bindgen(js_name = pasteSerializedNodes)] + pub fn paste_serialized_nodes(&self, serialized_nodes: String) { + let message = NodeGraphMessage::PasteNodes { serialized_nodes }; + self.dispatch(message); + } + + /// Pastes an image + #[wasm_bindgen(js_name = pasteImage)] + pub fn paste_image( + &self, + name: Option, + image_data: Vec, + width: u32, + height: u32, + mouse_x: Option, + mouse_y: Option, + insert_parent_id: Option, + insert_index: Option, + ) { + let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y))); + let image = graphene_std::raster::Image::from_image_data(&image_data, width, height); + + let parent_and_insert_index = if let (Some(insert_parent_id), Some(insert_index)) = (insert_parent_id, insert_index) { + let insert_parent_id = NodeId(insert_parent_id); + let parent = LayerNodeIdentifier::new_unchecked(insert_parent_id); + Some((parent, insert_index)) } else { - let file = record.file().unwrap_or_else(|| record.target()); - let line = record.line().map_or_else(|| "[Unknown]".to_string(), |line| line.to_string()); - let args = record.args(); + None + }; - log(&format!("%c{name}\t{file}:{line}\n{args}"), color); - } + let message = PortfolioMessage::PasteImage { + name, + image, + mouse, + parent_and_insert_index, + }; + self.dispatch(message); + } + + #[wasm_bindgen(js_name = pasteSvg)] + pub fn paste_svg(&self, name: Option, svg: String, mouse_x: Option, mouse_y: Option, insert_parent_id: Option, insert_index: Option) { + let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y))); + + let parent_and_insert_index = if let (Some(insert_parent_id), Some(insert_index)) = (insert_parent_id, insert_index) { + let insert_parent_id = NodeId(insert_parent_id); + let parent = LayerNodeIdentifier::new_unchecked(insert_parent_id); + Some((parent, insert_index)) + } else { + None + }; + + let message = PortfolioMessage::PasteSvg { + name, + svg, + mouse, + parent_and_insert_index, + }; + self.dispatch(message); + } + + /// Toggle visibility of a layer or node given its node ID + #[wasm_bindgen(js_name = toggleNodeVisibilityLayerPanel)] + pub fn toggle_node_visibility_layer(&self, id: u64) { + let node_id = NodeId(id); + let message = NodeGraphMessage::ToggleVisibility { node_id }; + self.dispatch(message); + } + + /// Pin or unpin a node given its node ID + #[wasm_bindgen(js_name = setNodePinned)] + pub fn set_node_pinned(&self, id: u64, pinned: bool) { + self.dispatch(DocumentMessage::SetNodePinned { node_id: NodeId(id), pinned }); + } + + /// Delete a layer or node given its node ID + #[wasm_bindgen(js_name = deleteNode)] + pub fn delete_node(&self, id: u64) { + self.dispatch(DocumentMessage::DeleteNode { node_id: NodeId(id) }); + } + + /// Toggle lock state of a layer from the layer list + #[wasm_bindgen(js_name = toggleLayerLock)] + pub fn toggle_layer_lock(&self, node_id: u64) { + let message = NodeGraphMessage::ToggleLocked { node_id: NodeId(node_id) }; + self.dispatch(message); + } + + /// Toggle expansions state of a layer from the layer list + #[wasm_bindgen(js_name = toggleLayerExpansion)] + pub fn toggle_layer_expansion(&self, id: u64, recursive: bool) { + let id = NodeId(id); + let message = DocumentMessage::ToggleLayerExpansion { id, recursive }; + self.dispatch(message); } - fn flush(&self) {} + /// Set the active panel to the most recently clicked panel + #[wasm_bindgen(js_name = setActivePanel)] + pub fn set_active_panel(&self, panel: String) { + let message = PortfolioMessage::SetActivePanel { panel: panel.into() }; + self.dispatch(message); + } + + /// Toggle display type for a layer + #[wasm_bindgen(js_name = setToNodeOrLayer)] + pub fn set_to_node_or_layer(&self, id: u64, is_layer: bool) { + self.dispatch(DocumentMessage::SetToNodeOrLayer { node_id: NodeId(id), is_layer }); + } + + /// Set the name of an import or export + #[wasm_bindgen(js_name = setImportName)] + pub fn set_import_name(&self, index: usize, name: String) { + let message = NodeGraphMessage::SetImportExportName { + name, + index: ImportOrExport::Import(index), + }; + self.dispatch(message); + } + + /// Set the name of an export + #[wasm_bindgen(js_name = setExportName)] + pub fn set_export_name(&self, index: usize, name: String) { + let message = NodeGraphMessage::SetImportExportName { + name, + index: ImportOrExport::Export(index), + }; + self.dispatch(message); + } +} + +#[wasm_bindgen(js_name = evaluateMathExpression)] +pub fn evaluate_math_expression(expression: &str) -> Option { + let value = math_parser::evaluate(expression) + .inspect_err(|err| error!("Math parser error on \"{expression}\": {err}")) + .ok()? + .0 + .inspect_err(|err| error!("Math evaluate error on \"{expression}\": {err} ")) + .ok()?; + let Some(real) = value.as_real() else { + error!("{value} was not a real; skipping."); + return None; + }; + Some(real) +} + +#[wasm_bindgen] +extern "C" { + /// The JavaScript `Error` type + #[derive(Clone, Debug)] + pub type Error; + + #[wasm_bindgen(constructor)] + pub fn new(msg: &str) -> Error; + + #[wasm_bindgen(structural, method, getter)] + fn stack(error: &Error) -> String; } diff --git a/frontend/wasm/src/native.rs b/frontend/wasm/src/native.rs new file mode 100644 index 0000000000..53ed32a5e9 --- /dev/null +++ b/frontend/wasm/src/native.rs @@ -0,0 +1,45 @@ +use std::cell::RefCell; +use std::time::Duration; + +use crate::Message; +use editor::messages::prelude::*; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::{JsCast, JsValue}; + +#[wasm_bindgen] +#[derive(Clone)] +pub struct EditorHandle; + +#[wasm_bindgen] +impl EditorHandle { + #[wasm_bindgen(constructor)] + pub fn new(frontend_message_handler_callback: js_sys::Function) -> Self { + EditorHandle + } +} + +impl EditorHandle { + // Instead of dispatching the message to be run by the editor in wasm, we forward it to CEF, which transfers it to the editor running in the main native thread + pub fn dispatch>(&self, message: T) { + send_message_to_cef(message) + } +} + +pub fn send_message_to_cef>(message: T) { + let message: Message = message.into(); + let Ok(serialized_message) = serde_json::to_string(&message) else { + log::error!("Failed to serialize message"); + return; + }; + + let global = js_sys::global(); + + // Get the function by name + let func = js_sys::Reflect::get(&global, &JsValue::from_str("sendMessageToCef")).expect("Function not found"); + + let func = func.dyn_into::().expect("Not a function"); + + // Call it with argument + func.call1(&JsValue::NULL, &JsValue::from_str(&serialized_message)).expect("Function call failed"); +} diff --git a/frontend/wasm/src/web.rs b/frontend/wasm/src/web.rs new file mode 100644 index 0000000000..7dcdb387fc --- /dev/null +++ b/frontend/wasm/src/web.rs @@ -0,0 +1,388 @@ +use editor::application::Editor; +use editor::messages::prelude::*; +use graphene_std::Color; +use graphene_std::raster::Image; +use js_sys::{Object, Reflect}; +use serde::ser::Serialize; +use std::cell::RefCell; +use std::panic; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use wasm_bindgen::prelude::*; +use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, window}; + +use crate::Error; + +// Set up the persistent editor backend state +pub static EDITOR_HAS_CRASHED: AtomicBool = AtomicBool::new(false); +pub static NODE_GRAPH_ERROR_DISPLAYED: AtomicBool = AtomicBool::new(false); +pub static LOGGER: WasmLog = WasmLog; +thread_local! { + pub static EDITOR: Mutex> = const { Mutex::new(None) }; + pub static EDITOR_HANDLE: Mutex> = const { Mutex::new(None) }; +} +static IMAGE_DATA_HASH: AtomicU64 = AtomicU64::new(0); + +/// Provides access to the `Editor` by calling the given closure with it as an argument. +fn editor(callback: impl FnOnce(&mut editor::application::Editor) -> T) -> T { + EDITOR.with(|editor| { + let mut guard = editor.try_lock(); + let Ok(Some(editor)) = guard.as_deref_mut() else { return T::default() }; + + callback(editor) + }) +} + +/// Provides access to the `Editor` and its `EditorHandle` by calling the given closure with them as arguments. +pub(crate) fn editor_and_handle(mut callback: impl FnMut(&mut Editor, &mut EditorHandle)) { + EDITOR_HANDLE.with(|editor_handle| { + editor(|editor| { + let mut guard = editor_handle.try_lock(); + let Ok(Some(editor_handle)) = guard.as_deref_mut() else { + log::error!("Failed to borrow editor handle"); + return; + }; + + // Call the closure with the editor and its handle + callback(editor, editor_handle); + }) + }); +} + +fn calculate_hash(t: &T) -> u64 { + use std::collections::hash_map::DefaultHasher; + use std::hash::Hasher; + let mut hasher = DefaultHasher::new(); + t.hash(&mut hasher); + hasher.finish() +} + +/// Initialize the backend +#[wasm_bindgen(start)] +pub fn init_graphite() { + // Set up the panic hook + panic::set_hook(Box::new(panic_hook)); + + // Set up the logger with a default level of debug + log::set_logger(&LOGGER).expect("Failed to set logger"); + log::set_max_level(log::LevelFilter::Debug); +} + +/// This struct is, via wasm-bindgen, used by JS to interact with the editor backend. +#[wasm_bindgen] +#[derive(Clone)] +pub struct EditorHandle { + /// This callback is called by the editor's dispatcher when directing FrontendMessages from Rust to JS + frontend_message_handler_callback: js_sys::Function, +} + +// Defined separately from the `impl` block below since this `impl` block lacks the `#[wasm_bindgen]` attribute. +// Quirks in wasm-bindgen prevent functions in `#[wasm_bindgen]` `impl` blocks from being made publicly accessible from Rust. +impl EditorHandle { + pub fn send_frontend_message_to_js_rust_proxy(&self, message: FrontendMessage) { + self.send_frontend_message_to_js(message); + } + + pub fn dispatch>(&self, message: T) { + // Process no further messages after a crash to avoid spamming the console + if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { + return; + } + + // Get the editor, dispatch the message, and store the `FrontendMessage` queue response + let frontend_messages = editor(|editor| editor.handle_message(message.into())); + + // Send each `FrontendMessage` to the JavaScript frontend + for message in frontend_messages.into_iter() { + self.send_frontend_message_to_js(message); + } + } + + // Sends a FrontendMessage to JavaScript, which is only possible on web + pub fn send_frontend_message_to_js(&self, mut message: FrontendMessage) { + if let FrontendMessage::UpdateImageData { ref image_data } = message { + let new_hash = calculate_hash(image_data); + let prev_hash = IMAGE_DATA_HASH.load(Ordering::Relaxed); + + if new_hash != prev_hash { + render_image_data_to_canvases(image_data.as_slice()); + IMAGE_DATA_HASH.store(new_hash, Ordering::Relaxed); + } + return; + } + + if let FrontendMessage::UpdateDocumentLayerStructure { data_buffer } = message { + message = FrontendMessage::UpdateDocumentLayerStructureJs { data_buffer: data_buffer.into() }; + } + + let message_type = message.to_discriminant().local_name(); + + let serializer = serde_wasm_bindgen::Serializer::new().serialize_large_number_types_as_bigints(true); + let message_data = message.serialize(&serializer).expect("Failed to serialize FrontendMessage"); + + let js_return_value = self.frontend_message_handler_callback.call2(&JsValue::null(), &JsValue::from(message_type), &message_data); + + if let Err(error) = js_return_value { + error!( + "While handling FrontendMessage \"{:?}\", JavaScript threw an error: {:?}", + message.to_discriminant().local_name(), + error, + ) + } + } +} + +#[wasm_bindgen] +impl EditorHandle { + #[wasm_bindgen(constructor)] + pub fn new(frontend_message_handler_callback: js_sys::Function) -> Self { + let editor = Editor::new(); + let editor_handle = EditorHandle { frontend_message_handler_callback }; + // If on native, all messages passed into wasm from the browser just get forwarded to the main native thread, so theres no need to create an editor + if EDITOR.with(|handle| handle.lock().ok().map(|mut guard| *guard = Some(editor))).is_none() { + log::error!("Attempted to initialize the editor more than once"); + } + if EDITOR_HANDLE.with(|handle| handle.lock().ok().map(|mut guard| *guard = Some(editor_handle.clone()))).is_none() { + log::error!("Attempted to initialize the editor handle more than once"); + } + editor_handle + } + + /// Answer whether or not the editor has crashed + #[wasm_bindgen(js_name = hasCrashed)] + pub fn has_crashed(&self) -> bool { + EDITOR_HAS_CRASHED.load(Ordering::SeqCst) + } +} + +pub fn run_and_poll_node_graph_evaluation_loop() { + let f = std::rc::Rc::new(RefCell::new(None)); + let g = f.clone(); + + *g.borrow_mut() = Some(Closure::new(move |_timestamp| { + // On native we run the node graph in another thread and block until messages are received, but on web we need to run it in the main thread and poll it + wasm_bindgen_futures::spawn_local(run_and_poll_node_graph_evaluation()); + // Schedule ourself for another requestAnimationFrame callback + request_animation_frame(f.borrow().as_ref().unwrap()); + })); + + request_animation_frame(g.borrow().as_ref().unwrap()); +} + +/// 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`"); +} + +// Used to run the node graph from WASM, which cannot be in another thread and block until requests are made +// Returns a boolean since this is run from request animation frame, and we do not want to poll the runtime after requests which get cancelled +async fn run_and_poll_node_graph_evaluation() { + // Process no further messages after a crash to avoid spamming the console + if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { + return; + } + + async fn try_run_runtime() -> bool { + let Some(mut runtime) = editor::node_graph_executor::NODE_RUNTIME.try_lock() else { return true }; + if let Some(ref mut runtime) = runtime.as_mut() { + runtime.run().await; + } + false + } + + let runtime_busy = try_run_runtime().await; + + if runtime_busy { + return; + }; + + editor_and_handle(|editor, handle| { + let mut messages = VecDeque::new(); + if let Err(e) = editor.poll_node_graph_evaluation(&mut messages) { + // TODO: This is a hacky way to suppress the error, but it shouldn't be generated in the first place + if e != "No active document" { + error!("Error evaluating node graph:\n{e}"); + } + } + + // Clear the error display if there are no more errors + if !messages.is_empty() { + crate::NODE_GRAPH_ERROR_DISPLAYED.store(false, Ordering::SeqCst); + } + + // 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); + } + + // If the editor cannot be borrowed then it has encountered a panic - we should just ignore new dispatches + }); +} + +fn render_image_data_to_canvases(image_data: &[(u64, Image)]) { + let window = match window() { + Some(window) => window, + None => { + error!("Cannot render canvas: window object not found"); + return; + } + }; + let document = window.document().expect("window should have a document"); + let window_obj = Object::from(window); + let image_canvases_key = JsValue::from_str("imageCanvases"); + + let canvases_obj = match Reflect::get(&window_obj, &image_canvases_key) { + Ok(obj) if !obj.is_undefined() && !obj.is_null() => obj, + _ => { + let new_obj = Object::new(); + if Reflect::set(&window_obj, &image_canvases_key, &new_obj).is_err() { + error!("Failed to create and set imageCanvases object on window"); + return; + } + new_obj.into() + } + }; + let canvases_obj = Object::from(canvases_obj); + + for (placeholder_id, image) in image_data.iter() { + let canvas_name = placeholder_id.to_string(); + let js_key = JsValue::from_str(&canvas_name); + + if Reflect::has(&canvases_obj, &js_key).unwrap_or(false) || image.width == 0 || image.height == 0 { + continue; + } + + let canvas: HtmlCanvasElement = document + .create_element("canvas") + .expect("Failed to create canvas element") + .dyn_into::() + .expect("Failed to cast element to HtmlCanvasElement"); + + canvas.set_width(image.width); + canvas.set_height(image.height); + + let context: CanvasRenderingContext2d = canvas + .get_context("2d") + .expect("Failed to get 2d context") + .expect("2d context was not found") + .dyn_into::() + .expect("Failed to cast context to CanvasRenderingContext2d"); + let u8_data: Vec = image.data.iter().flat_map(|color| color.to_rgba8_srgb()).collect(); + let clamped_u8_data = wasm_bindgen::Clamped(&u8_data[..]); + match ImageData::new_with_u8_clamped_array_and_sh(clamped_u8_data, image.width, image.height) { + Ok(image_data_obj) => { + if context.put_image_data(&image_data_obj, 0., 0.).is_err() { + error!("Failed to put image data on canvas for id: {placeholder_id}"); + } + } + Err(e) => { + error!("Failed to create ImageData for id: {placeholder_id}: {e:?}"); + } + } + + let js_value = JsValue::from(canvas); + + if Reflect::set(&canvases_obj, &js_key, &js_value).is_err() { + error!("Failed to set canvas '{canvas_name}' on imageCanvases object"); + } + } +} + +/// When a panic occurs, notify the user and log the error to the JS console before the backend dies +pub fn panic_hook(info: &panic::PanicHookInfo) { + let info = info.to_string(); + let backtrace = Error::new("stack").stack().to_string(); + if backtrace.contains("DynAnyNode") { + log::error!("Node graph evaluation panicked {info}"); + + // When the graph panics, the node runtime lock may not be released properly + if editor::node_graph_executor::NODE_RUNTIME.try_lock().is_none() { + unsafe { editor::node_graph_executor::NODE_RUNTIME.force_unlock() }; + } + + if !NODE_GRAPH_ERROR_DISPLAYED.load(Ordering::SeqCst) { + NODE_GRAPH_ERROR_DISPLAYED.store(true, Ordering::SeqCst); + editor_and_handle(|_, handle| { + let error = r#" + + + The document crashed while being rendered in its current state. + The editor is now unstable! Undo your last action to restore the artwork, + then save your document and restart the editor before continuing work. + /text>"# + // It's a mystery why the `/text>` tag above needs to be missing its `<`, but when it exists it prints the `<` character in the text. However this works with it removed. + .to_string(); + handle.send_frontend_message_to_js_rust_proxy(FrontendMessage::UpdateDocumentArtwork { svg: error }); + }); + } + + return; + } else { + EDITOR_HAS_CRASHED.store(true, Ordering::SeqCst); + } + + log::error!("{info}"); + + EDITOR_HANDLE.with(|editor_handle| { + let mut guard = editor_handle.lock(); + if let Ok(Some(handle)) = guard.as_deref_mut() { + handle.send_frontend_message_to_js_rust_proxy(FrontendMessage::DisplayDialogPanic { panic_info: info.to_string() }); + } + }); +} + +/// Logging to the JS console +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console)] + fn log(msg: &str, format: &str); + #[wasm_bindgen(js_namespace = console)] + fn info(msg: &str, format: &str); + #[wasm_bindgen(js_namespace = console)] + fn warn(msg: &str, format: &str); + #[wasm_bindgen(js_namespace = console)] + fn error(msg: &str, format: &str); + #[wasm_bindgen(js_namespace = console)] + fn trace(msg: &str, format: &str); +} + +#[derive(Default)] +pub struct WasmLog; + +impl log::Log for WasmLog { + #[inline] + fn enabled(&self, metadata: &log::Metadata) -> bool { + metadata.level() <= log::max_level() + } + + fn log(&self, record: &log::Record) { + if !self.enabled(record.metadata()) { + return; + } + + let (log, name, color): (fn(&str, &str), &str, &str) = match record.level() { + log::Level::Trace => (log, "trace", "color:plum"), + log::Level::Debug => (log, "debug", "color:cyan"), + log::Level::Warn => (warn, "warn", "color:goldenrod"), + log::Level::Info => (info, "info", "color:mediumseagreen"), + log::Level::Error => (error, "error", "color:red"), + }; + + // The %c is replaced by the message color + if record.level() == log::Level::Info { + // We don't print the file name and line number for info-level logs because it's used for printing the message system logs + log(&format!("%c{}\t{}", name, record.args()), color); + } else { + let file = record.file().unwrap_or_else(|| record.target()); + let line = record.line().map_or_else(|| "[Unknown]".to_string(), |line| line.to_string()); + let args = record.args(); + + log(&format!("%c{name}\t{file}:{line}\n{args}"), color); + } + } + + fn flush(&self) {} +}