From 8dedb9bf4a7e605921f86011cf0e7889a9cf4012 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 17 Jul 2026 02:06:16 +0000 Subject: [PATCH 1/6] Make wasm wrapper not depend on editor on native --- Cargo.lock | 8 +- desktop/src/app.rs | 3 +- desktop/src/event.rs | 3 +- desktop/src/lib.rs | 5 +- desktop/src/window.rs | 2 +- desktop/wrapper/Cargo.toml | 2 +- desktop/wrapper/src/lib.rs | 21 +- document/graph-storage/src/resources.rs | 2 +- .../src/messages/frontend/frontend_message.rs | 5 +- editor/src/messages/frontend/utility_types.rs | 9 + editor/src/node_graph_executor.rs | 20 +- frontend/wrapper/Cargo.toml | 27 +- frontend/wrapper/src/editor_commands.rs | 684 ++++++++++++++ frontend/wrapper/src/editor_wrapper.rs | 875 ++---------------- frontend/wrapper/src/helpers.rs | 41 +- frontend/wrapper/src/lib.rs | 106 ++- frontend/wrapper/src/native_communication.rs | 102 +- frontend/wrapper/src/wasm_value.rs | 444 +++++++++ proc-macros/Cargo.toml | 1 + proc-macros/src/editor_commands.rs | 128 +++ proc-macros/src/lib.rs | 8 + 21 files changed, 1563 insertions(+), 933 deletions(-) create mode 100644 frontend/wrapper/src/editor_commands.rs create mode 100644 frontend/wrapper/src/wasm_value.rs create mode 100644 proc-macros/src/editor_commands.rs diff --git a/Cargo.lock b/Cargo.lock index b1deb08a70..2d81925f98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2312,9 +2312,9 @@ dependencies = [ "graph-craft", "graphene-std", "graphite-editor", + "graphite-wasm-wrapper", "image", "keyboard-types", - "ron", "serde", "serde_json", "thiserror 2.0.18", @@ -2374,6 +2374,7 @@ dependencies = [ name = "graphite-proc-macros" version = "0.0.0" dependencies = [ + "convert_case", "graphite-editor", "proc-macro2", "quote", @@ -2385,17 +2386,18 @@ dependencies = [ name = "graphite-wasm-wrapper" version = "0.0.0" dependencies = [ - "bytemuck", "graph-craft", "graphene-std", "graphite-editor", + "graphite-proc-macros", "js-sys", "log", "node-macro", - "ron", "serde", "serde-wasm-bindgen", + "serde_bytes", "serde_json", + "tsify", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 37e66ecdb8..cc20d3abcd 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -14,13 +14,12 @@ use winit::event::{ButtonSource, ElementState, MouseButton, StartCause, WindowEv use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; use winit::window::WindowId; -use graphite_desktop_ui::{UiCommand, UiInstance}; - use crate::dirs; use crate::event::{AppEvent, AppEventScheduler}; use crate::persist; use crate::preferences; use crate::render::{RenderError, RenderState}; +use crate::ui::{UiCommand, UiInstance}; use crate::window::Window; use crate::wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, InputMessage, MouseKeys, MouseState, Preferences}; use crate::wrapper::{DesktopWrapper, MmapResourceStorage, NodeGraphExecutionResult, WgpuContext, serialize_frontend_messages}; diff --git a/desktop/src/event.rs b/desktop/src/event.rs index 6420a76765..1d7a8283de 100644 --- a/desktop/src/event.rs +++ b/desktop/src/event.rs @@ -1,9 +1,10 @@ +use crate::ui::Cursor; use crate::wrapper::NodeGraphExecutionResult; use crate::wrapper::messages::DesktopWrapperMessage; pub(crate) enum AppEvent { UiUpdate(wgpu::Texture), - CursorChange(graphite_desktop_ui::Cursor), + CursorChange(Cursor), WebCommunicationInitialized, DesktopWrapperMessage(DesktopWrapperMessage), NodeGraphExecutionResult(NodeGraphExecutionResult), diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index 4328c752e0..c87f70c19f 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -3,12 +3,13 @@ use crate::cli::Cli; use crate::consts::APP_LOCK_FILE_NAME; use crate::event::{AppEvent, CreateAppEventSchedulerEventLoopExt}; use clap::Parser; -use graphite_desktop_ui::{Acceleration, UiConfig, UiContext, UiEvent, UiSetupResult}; use std::io::Write; use std::process::ExitCode; use tracing_subscriber::EnvFilter; +use ui::{Acceleration, UiConfig, UiContext, UiEvent, UiSetupResult}; use winit::event_loop::EventLoop; +pub(crate) use graphite_desktop_ui as ui; pub(crate) use graphite_desktop_wrapper as wrapper; mod app; @@ -69,7 +70,7 @@ pub fn start() -> ExitCode { } }; - dirs::clear_dir(&graphite_desktop_ui::temp_dir_root()); + dirs::clear_dir(&ui::temp_dir_root()); // TODO: Eventually remove this cleanup code for the old "browser" CEF directory dirs::delete_old_cef_browser_directory(); diff --git a/desktop/src/window.rs b/desktop/src/window.rs index 703980244c..7693719b03 100644 --- a/desktop/src/window.rs +++ b/desktop/src/window.rs @@ -1,8 +1,8 @@ use crate::consts::APP_NAME; use crate::event::AppEventScheduler; +use crate::ui::Cursor; use crate::wrapper::messages::MenuItem; use crate::wrapper::{WgpuInstance, WgpuSurface}; -use graphite_desktop_ui::Cursor; use std::collections::HashMap; use std::sync::Arc; use winit::cursor::{CustomCursor, CustomCursorSource}; diff --git a/desktop/wrapper/Cargo.toml b/desktop/wrapper/Cargo.toml index 99a9ff7815..be949c8353 100644 --- a/desktop/wrapper/Cargo.toml +++ b/desktop/wrapper/Cargo.toml @@ -14,6 +14,7 @@ gpu = ["graphite-editor/gpu", "graphene-std/shader-nodes"] [dependencies] # Local dependencies graphite-editor = { workspace = true } +graphite-wasm-wrapper = { path = "../../frontend/wrapper", default-features = false, features = ["editor"] } graphene-std = { workspace = true } graph-craft = { workspace = true } wgpu-executor = { workspace = true } @@ -22,7 +23,6 @@ wgpu = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } dirs = { workspace = true } -ron = { workspace = true} vello = { workspace = true } image = { workspace = true } serde = { workspace = true } diff --git a/desktop/wrapper/src/lib.rs b/desktop/wrapper/src/lib.rs index 475ed93dc7..5f2adff705 100644 --- a/desktop/wrapper/src/lib.rs +++ b/desktop/wrapper/src/lib.rs @@ -1,7 +1,9 @@ use graph_craft::application_io::PlatformApplicationIo; use graph_craft::application_io::resource::ResourceStorage; use graphite_editor::application::{Editor, Environment, Host, Platform}; -use graphite_editor::messages::prelude::{FrontendMessage, Message, Wake}; +use graphite_editor::messages::frontend::FrontendMessage; +use graphite_editor::messages::prelude::Wake; + use message_dispatcher::DesktopWrapperMessageDispatcher; use messages::{DesktopFrontendMessage, DesktopWrapperMessage}; use std::sync::Arc; @@ -65,21 +67,10 @@ pub enum NodeGraphExecutionResult { } pub fn deserialize_editor_message(data: &[u8]) -> Option { - if let Ok(string) = std::str::from_utf8(data) { - if let Ok(message) = ron::de::from_str::(string) { - Some(DesktopWrapperMessage::FromWeb(message.into())) - } else { - None - } - } else { - None - } + let message = graphite_wasm_wrapper::native_communication::decode_editor_command(data)?; + Some(DesktopWrapperMessage::FromWeb(message.into())) } pub fn serialize_frontend_messages(messages: Vec) -> Option> { - if let Ok(serialized) = ron::ser::to_string(&messages) { - Some(serialized.into_bytes()) - } else { - None - } + graphite_wasm_wrapper::native_communication::encode_frontend_messages(messages) } diff --git a/document/graph-storage/src/resources.rs b/document/graph-storage/src/resources.rs index a29a37e2b3..025c1d24e3 100644 --- a/document/graph-storage/src/resources.rs +++ b/document/graph-storage/src/resources.rs @@ -45,7 +45,7 @@ impl<'de> Deserialize<'de> for ResourceEntry { } let Raw { mut sources, hash, hash_timestamp } = Raw::deserialize(deserializer)?; - sources.sort_by(|(a, _), (b, _)| a.cmp(b)); + sources.sort_by_key(|(a, _)| *a); sources.dedup_by(|(later_key, later_value), (kept_key, kept_value)| { // `dedup_by` keeps the first of each run; sorting is stable, so resolve duplicates by LWW. if later_key != kept_key { diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 5d9e73e765..e227c6821a 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -1,7 +1,7 @@ use super::IconName; use super::utility_types::{MouseCursorIcon, PersistedState}; use crate::messages::app_window::app_window_message_handler::AppWindowPlatform; -use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage}; +use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, RasterizedImage}; use crate::messages::input_mapper::utility_types::misc::ActionShortcut; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::node_graph::utility_types::{ @@ -14,7 +14,6 @@ use crate::messages::prelude::*; use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary; use graph_craft::document::NodeId; use graphene_std::color::SRGBA8; -use graphene_std::raster::Image; use graphene_std::vector::style::FillChoiceUI; use std::path::PathBuf; @@ -235,7 +234,7 @@ pub enum FrontendMessage { svg: String, }, UpdateImageData { - image_data: Vec<(u64, Image)>, + image_data: Vec, }, UpdateDocumentLayerDetails { data: LayerPanelEntry, diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index 025793b192..bf614b1eea 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -82,3 +82,12 @@ pub struct EyedropperPreviewImage { pub width: u32, pub height: u32, } + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))] +#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] +pub struct RasterizedImage { + pub id: u64, + pub width: u32, + pub height: u32, + pub pixels: serde_bytes::ByteBuf, +} diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 895533b3df..18e7b77a20 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -1,4 +1,4 @@ -use crate::messages::frontend::utility_types::{ExportBounds, FileType}; +use crate::messages::frontend::utility_types::{ExportBounds, FileType, RasterizedImage}; use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; use crate::messages::prelude::*; use glam::{DAffine2, DVec2, UVec2}; @@ -660,19 +660,15 @@ impl NodeGraphExecutor { match render_output.data { RenderOutputType::Svg { svg, image_data } => { - // Convert each linear-light `Image` into the JS-boundary `Image` form (gamma byte channels) before dispatching. + // Convert each linear-light `Image` into gamma sRGB bytes (the DOM boundary form) before dispatching. + let rgba8_bytes = |&color: &_| <[u8; 4]>::from(SRGBA8::from(color)); let image_data = image_data .into_iter() - .map(|(id, image)| { - ( - id, - graphene_std::raster::Image { - width: image.width, - height: image.height, - data: image.data.iter().map(|&c| SRGBA8::from(c)).collect(), - base64_string: image.base64_string, - }, - ) + .map(|(id, image)| RasterizedImage { + id, + width: image.width, + height: image.height, + pixels: image.data.iter().flat_map(rgba8_bytes).collect::>().into(), }) .collect(); responses.add(FrontendMessage::UpdateImageData { image_data }); diff --git a/frontend/wrapper/Cargo.toml b/frontend/wrapper/Cargo.toml index 57a13f03c9..988d6b21df 100644 --- a/frontend/wrapper/Cargo.toml +++ b/frontend/wrapper/Cargo.toml @@ -11,33 +11,38 @@ repository = "https://github.com/GraphiteEditor/Graphite" license = "Apache-2.0" [features] -default = ["gpu", "shader-nodes"] -gpu = ["editor/gpu"] -shader-nodes = ["graphene-std/shader-nodes", "gpu"] -native = ["node-macro/disable-registration"] +default = ["gpu", "shader-nodes", "web"] +web = ["editor", "editor?/wasm"] +editor = ["dep:editor", "dep:graphene-std", "dep:graph-craft", "dep:node-macro", "dep:wgpu"] +gpu = ["editor?/gpu"] +shader-nodes = ["graphene-std?/shader-nodes", "gpu"] +native = ["node-macro?/disable-registration"] [lib] crate-type = ["cdylib", "rlib"] [dependencies] # Local dependencies -editor = { path = "../../editor", package = "graphite-editor", features = ["gpu", "wasm"] } -graphene-std = { workspace = true } +editor = { path = "../../editor", package = "graphite-editor", optional = true } +graphite-proc-macros = { workspace = true } +graphene-std = { workspace = true, optional = true } # Workspace dependencies -bytemuck = { workspace = true } -graph-craft = { workspace = true } +graph-craft = { workspace = true, optional = true } log = { workspace = true } serde = { workspace = true } wasm-bindgen = { workspace = true } serde-wasm-bindgen = { workspace = true } js-sys = { workspace = true } wasm-bindgen-futures = { workspace = true } -wgpu = { workspace = true } +wgpu = { workspace = true, optional = true } web-sys = { workspace = true } -ron = { workspace = true } serde_json = { workspace = true } -node-macro = { workspace = true } +tsify = { workspace = true } +node-macro = { workspace = true, optional = true } + +[dev-dependencies] +serde_bytes = { workspace = true } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = [ diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs new file mode 100644 index 0000000000..f79d66d4e2 --- /dev/null +++ b/frontend/wrapper/src/editor_commands.rs @@ -0,0 +1,684 @@ +#![allow(clippy::too_many_arguments)] +#[cfg(target_family = "wasm")] +use crate::editor_wrapper::EditorWrapper; +use graphite_proc_macros::editor_commands; +use serde::{Deserialize, Serialize}; +use tsify::Tsify; +#[cfg(target_family = "wasm")] +use wasm_bindgen::prelude::*; + +editor_commands! { + use crate::helpers::translate_key; + use editor::messages::clipboard::utility_types::ClipboardContentRaw; + use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; + use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta}; + 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::PanelGroupId; + use editor::messages::prelude::*; + use editor::messages::tool::tool_messages::tool_prelude::WidgetId; + use graph_craft::document::NodeId; + use graphene_std::raster::color::Color; + use graphene_std::vector::style::FillChoice; + use std::path::PathBuf; + + /// Re-sends all UI layouts to the frontend. Called during HMR re-mounts when the frontend has lost its layout state. + fn resend_all_layouts() -> Message { + LayoutMessage::ResendAllLayouts.into() + } + + /// First message of a session, sent once the frontend is ready + fn init_portfolio() -> Message { + PortfolioMessage::Init.into() + } + + /// Per-frame tick: advances the animation clock and broadcasts the animation frame event + fn animation_frame(timestamp: u64) -> Message { + Message::Batched { + messages: Box::new([ + InputPreprocessorMessage::CurrentTime { timestamp }.into(), + AnimationMessage::IncrementFrameCounter.into(), + // Used by auto-panning, but this could possibly be refactored in the future, see: + // + BroadcastMessage::TriggerEvent(EventMessage::AnimationFrame).into(), + ]), + } + } + + fn auto_save_all_documents() -> Message { + PortfolioMessage::AutoSaveAllDocuments.into() + } + + fn add_primary_import() -> Message { + Message::Batched { + messages: Box::new([DocumentMessage::AddTransaction.into(), NodeGraphMessage::AddPrimaryImport.into()]), + } + } + + fn add_secondary_import() -> Message { + Message::Batched { + messages: Box::new([DocumentMessage::AddTransaction.into(), NodeGraphMessage::AddSecondaryImport.into()]), + } + } + + fn add_primary_export() -> Message { + Message::Batched { + messages: Box::new([DocumentMessage::AddTransaction.into(), NodeGraphMessage::AddPrimaryExport.into()]), + } + } + + fn add_secondary_export() -> Message { + Message::Batched { + messages: Box::new([DocumentMessage::AddTransaction.into(), NodeGraphMessage::AddSecondaryExport.into()]), + } + } + + /// Start Pointer Lock + fn app_window_pointer_lock() -> Message { + AppWindowMessage::PointerLock.into() + } + + /// Minimizes the application window to the taskbar or dock + fn app_window_minimize() -> Message { + AppWindowMessage::Minimize.into() + } + + /// Toggles minimizing or restoring down the application window + fn app_window_maximize() -> Message { + AppWindowMessage::Maximize.into() + } + + fn app_window_fullscreen() -> Message { + AppWindowMessage::Fullscreen.into() + } + + /// Closes the application window + fn app_window_close() -> Message { + AppWindowMessage::Close.into() + } + + /// Drag the application window + fn app_window_drag() -> Message { + AppWindowMessage::Drag.into() + } + + /// Displays a dialog with an error message + fn error_dialog(title: String, description: String) -> Message { + DialogMessage::DisplayDialogError { title, description }.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) + fn widget_value_update(layout_target: LayoutTarget, widget_id: u64, value: Any, resend_widget: bool) -> Message { + let widget_id = WidgetId(widget_id); + let update = LayoutMessage::WidgetValueUpdate { + layout_target, + widget_id, + value: value.cast(), + }; + if resend_widget { + Message::Batched { + messages: Box::new([update.into(), LayoutMessage::ResendActiveWidget { layout_target, widget_id }.into()]), + } + } else { + update.into() + } + } + + /// Commit the value of a given UI widget to the history + fn widget_value_commit(layout_target: LayoutTarget, widget_id: u64, value: Any) -> Message { + LayoutMessage::WidgetValueCommit { + layout_target, + widget_id: WidgetId(widget_id), + value: value.cast(), + } + .into() + } + + /// Update the value of a given UI widget, and commit it to the history + fn widget_value_commit_and_update(layout_target: LayoutTarget, widget_id: u64, value: Any, resend_widget: bool) -> Message { + let widget_id = WidgetId(widget_id); + let mut messages: Vec = vec![ + LayoutMessage::WidgetValueCommit { + layout_target, + widget_id, + value: value.cast(), + } + .into(), + LayoutMessage::WidgetValueUpdate { + layout_target, + widget_id, + value: value.cast(), + } + .into(), + ]; + if resend_widget { + messages.push(LayoutMessage::ResendActiveWidget { layout_target, widget_id }.into()); + } + // Close out a transaction that the widget's `on_commit` opened (if any), so a single click on widgets like the + // NumberInput's increment buttons collapses into one history step instead of leaving the transaction in `Modified` + messages.push(DocumentMessage::EndTransaction.into()); + Message::Batched { messages: messages.into() } + } + + /// Fire a widget's drag-drop action (e.g. when a draggable item is dropped on a button) + fn widget_value_drag_drop(layout_target: LayoutTarget, widget_id: u64) -> Message { + let widget_id = WidgetId(widget_id); + LayoutMessage::WidgetValueDragDrop { layout_target, widget_id }.into() + } + + /// Closes out the current transaction (drag-end / text-commit end), so emits during a slider drag collapse into one history step instead of N + fn end_transaction() -> Message { + DocumentMessage::EndTransaction.into() + } + + fn load_preferences(preferences: Option) -> Message { + let Some(preferences) = preferences else { return Message::NoOp }; + let Ok(preferences) = serde_json::from_str(&preferences) else { + log::error!("Failed to deserialize preferences"); + return Message::NoOp; + }; + PreferencesMessage::Load { preferences }.into() + } + + fn load_document_content(document_id: u64, document: String) -> Message { + PersistentStateMessage::LoadDocument { + document_id: DocumentId(document_id), + document, + } + .into() + } + + fn select_document(document_id: u64) -> Message { + PortfolioMessage::SelectDocument { document_id: DocumentId(document_id) }.into() + } + + /// Rename the currently active document. + fn rename_document(new_name: String) -> Message { + PortfolioMessage::RenameDocument { new_name }.into() + } + + fn new_document_dialog() -> Message { + DialogMessage::RequestNewDocumentDialog.into() + } + + fn open_file(path: String, content: Vec) -> Message { + PortfolioMessage::OpenFile { path: PathBuf::from(path), content }.into() + } + + fn import_file(path: String, content: Vec) -> Message { + PortfolioMessage::ImportFile { path: PathBuf::from(path), content }.into() + } + + fn trigger_auto_save(document_id: u64) -> Message { + PortfolioMessage::AutoSaveDocument { document_id: DocumentId(document_id) }.into() + } + + fn reorder_document(document_id: u64, new_index: usize) -> Message { + PortfolioMessage::ReorderDocument { + document_id: DocumentId(document_id), + new_index, + } + .into() + } + + fn reorder_panel_group_tab(group: u64, old_index: usize, new_index: usize) -> Message { + PortfolioMessage::ReorderPanelGroupTab { + group: PanelGroupId(group), + old_index, + new_index, + } + .into() + } + + fn move_all_panel_tabs(source_group: u64, target_group: u64, insert_index: usize) -> Message { + PortfolioMessage::MoveAllPanelTabs { + source_group: PanelGroupId(source_group), + target_group: PanelGroupId(target_group), + insert_index, + } + .into() + } + + fn move_panel_tab(source_group: u64, target_group: u64, insert_index: usize) -> Message { + PortfolioMessage::MovePanelTab { + source_group: PanelGroupId(source_group), + target_group: PanelGroupId(target_group), + insert_index, + } + .into() + } + + fn set_panel_group_active_tab(group: u64, tab_index: usize) -> Message { + PortfolioMessage::SetPanelGroupActiveTab { + group: PanelGroupId(group), + tab_index, + } + .into() + } + + fn split_panel_group(target_group: u64, direction: DockingSplitDirection, tabs: PanelTypes, active_tab_index: usize) -> Message { + PortfolioMessage::SplitPanelGroup { + target_group: PanelGroupId(target_group), + direction, + tabs, + active_tab_index, + } + .into() + } + + fn set_panel_group_sizes(split_path: Vec, sizes: Vec) -> Message { + let split_path = split_path.into_iter().map(|i| i as usize).collect(); + PortfolioMessage::SetPanelGroupSizes { split_path, sizes }.into() + } + + fn close_document_with_confirmation(document_id: u64) -> Message { + PortfolioMessage::CloseDocumentWithConfirmation { document_id: DocumentId(document_id) }.into() + } + + fn request_about_graphite_dialog_with_localized_commit_date(localized_commit_date: String, localized_commit_year: String) -> Message { + DialogMessage::RequestAboutGraphiteDialogWithLocalizedCommitDate { + localized_commit_date, + localized_commit_year, + } + .into() + } + + fn request_licenses_third_party_dialog_with_license_text(license_text: String) -> Message { + DialogMessage::RequestLicensesThirdPartyDialogWithLicenseText { license_text }.into() + } + + /// Send new viewport info to the backend + fn update_viewport(x: f64, y: f64, width: f64, height: f64, scale: f64) -> Message { + ViewportMessage::Update { x, y, width, height, scale }.into() + } + + /// Mouse movement within the screenspace bounds of the viewport + fn on_mouse_move(x: f64, y: f64, mouse_keys: u8, modifiers: u8) -> Message { + 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"); + InputPreprocessorMessage::PointerMove { editor_mouse_state, modifier_keys }.into() + } + + /// Mouse scrolling within the screenspace bounds of the viewport + fn on_wheel_scroll(x: f64, y: f64, mouse_keys: u8, wheel_delta_x: f64, wheel_delta_y: f64, wheel_delta_z: f64, modifiers: u8) -> Message { + 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"); + InputPreprocessorMessage::WheelScroll { editor_mouse_state, modifier_keys }.into() + } + + /// A mouse button depressed within screenspace the bounds of the viewport + fn on_mouse_down(x: f64, y: f64, mouse_keys: u8, modifiers: u8) -> Message { + 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"); + InputPreprocessorMessage::PointerDown { editor_mouse_state, modifier_keys }.into() + } + + /// A mouse button released + fn on_mouse_up(x: f64, y: f64, mouse_keys: u8, modifiers: u8) -> Message { + 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"); + InputPreprocessorMessage::PointerUp { editor_mouse_state, modifier_keys }.into() + } + + /// Mouse shaken + fn on_mouse_shake(x: f64, y: f64, mouse_keys: u8, modifiers: u8) -> Message { + 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"); + InputPreprocessorMessage::PointerShake { editor_mouse_state, modifier_keys }.into() + } + + /// Mouse double clicked + fn on_double_click(x: f64, y: f64, mouse_keys: u8, modifiers: u8) -> Message { + 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"); + InputPreprocessorMessage::DoubleClick { editor_mouse_state, modifier_keys }.into() + } + + /// A keyboard button depressed within screenspace the bounds of the viewport + fn on_key_down(name: String, modifiers: u8, key_repeat: bool) -> Message { + 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}"); + InputPreprocessorMessage::KeyDown { key, key_repeat, modifier_keys }.into() + } + + /// A keyboard button released + fn on_key_up(name: String, modifiers: u8, key_repeat: bool) -> Message { + 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}"); + InputPreprocessorMessage::KeyUp { key, key_repeat, modifier_keys }.into() + } + + /// A text box was committed + fn on_change_text(new_text: String, is_left_or_right_click: bool) -> Message { + TextToolMessage::TextChange { new_text, is_left_or_right_click }.into() + } + + /// Dialog got dismissed + fn on_dialog_dismiss() -> Message { + DialogMessage::Dismiss.into() + } + + /// A text box was changed + fn update_bounds(new_text: String) -> Message { + TextToolMessage::UpdateBounds { new_text }.into() + } + + /// Update primary color from sRGB bytes (the wire format at the JS boundary). + fn update_primary_color(color: SRGBA8) -> Message { + ToolMessage::SelectWorkingColor { + color: Color::from(color), + primary: true, + } + .into() + } + + /// Update secondary color from sRGB bytes (the wire format at the JS boundary). + fn update_secondary_color(color: SRGBA8) -> Message { + ToolMessage::SelectWorkingColor { + color: Color::from(color), + primary: false, + } + .into() + } + + /// Initialize the Rust color picker handler with a starting value (used when the frontend `` opens). + fn open_color_picker(initial_value: FillChoiceUI, allow_none: bool, disabled: bool) -> Message { + ColorPickerMessage::Open { + initial_value: FillChoice::from(&initial_value), + allow_none, + disabled, + } + .into() + } + + /// Tell the Rust color picker handler that the popover is closing. + fn close_color_picker() -> Message { + ColorPickerMessage::Close.into() + } + + /// Update the color of the currently-edited gradient stop, from sRGB bytes (the wire format at the JS boundary). + fn update_gradient_stop_color(color: SRGBA8) -> Message { + GradientToolMessage::UpdateStopColor { color: Color::from(color) }.into() + } + + /// Start a new undo transaction for gradient stop color editing + fn start_gradient_stop_color_transaction() -> Message { + GradientToolMessage::StartTransactionForColorStop.into() + } + + /// Commit the current gradient stop color transaction (called on pointer-up after each drag/click) + fn commit_gradient_stop_color_transaction() -> Message { + GradientToolMessage::CommitTransactionForColorStop.into() + } + + /// Close the gradient stop color picker and commit any pending transaction + fn close_gradient_stop_color_picker() -> Message { + GradientToolMessage::CloseStopColorPicker.into() + } + + /// Toggle clipping the alpha of a layer to the alpha of the layer below it in the layer stack + fn clip_layer(id: u64) -> Message { + DocumentMessage::ClipLayer { id: NodeId(id) }.into() + } + + /// 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 + fn select_layer(id: u64, ctrl: bool, shift: bool) -> Message { + DocumentMessage::SelectLayer { id: NodeId(id), ctrl, shift }.into() + } + + /// Deselect all layers + fn deselect_all_layers() -> Message { + DocumentMessage::DeselectAllLayers.into() + } + + /// 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. + fn move_layer_in_tree(insert_parent_id: Option, insert_index: Option) -> Message { + let insert_parent_id = insert_parent_id.map(NodeId); + let parent = insert_parent_id.map(LayerNodeIdentifier::new_unchecked).unwrap_or_default(); + + DocumentMessage::MoveSelectedLayersTo { + parent, + insert_index: insert_index.unwrap_or_default(), + } + .into() + } + + /// Reorder a draggable Properties panel section to the given index among its peers. + fn reorder_properties_section(node_id: u64, insert_index: usize) -> Message { + DocumentMessage::ReorderPropertiesSection { + node_id: NodeId(node_id), + insert_index, + } + .into() + } + + /// Duplicate the selected layers, placing the copies within the given folder at the given index. + /// If the folder is `None`, they are inserted into the document root. + /// If the insert index is `None`, they are inserted at the start of the folder. + fn duplicate_layer_in_tree(insert_parent_id: Option, insert_index: Option) -> Message { + DocumentMessage::DuplicateSelectedLayersTo { + parent: insert_parent_id.map(NodeId).map(LayerNodeIdentifier::new_unchecked).unwrap_or_default(), + insert_index: insert_index.unwrap_or_default(), + } + .into() + } + + /// Set the name for the layer + fn set_layer_name(id: u64, name: String) -> Message { + let layer = LayerNodeIdentifier::new_unchecked(NodeId(id)); + NodeGraphMessage::SetDisplayName { + node_id: layer.to_node(), + network_path: Vec::new(), + alias: name, + skip_adding_history_step: false, + } + .into() + } + + /// Translates document (in viewport coords) + fn pan_canvas_abort_prepare(x_not_y_axis: bool) -> Message { + NavigationMessage::CanvasPanAbortPrepare { x_not_y_axis }.into() + } + + fn pan_canvas_abort(x_not_y_axis: bool) -> Message { + NavigationMessage::CanvasPanAbort { x_not_y_axis }.into() + } + + /// Translates document (in viewport coords) + fn pan_canvas(delta_x: f64, delta_y: f64) -> Message { + NavigationMessage::CanvasPan { delta: (delta_x, delta_y).into() }.into() + } + + /// Translates document (in viewport coords) + fn pan_canvas_by_fraction(delta_x: f64, delta_y: f64) -> Message { + NavigationMessage::CanvasPanByViewportFraction { delta: (delta_x, delta_y).into() }.into() + } + + /// Merge the selected nodes into a subnetwork + fn merge_selected_nodes() -> Message { + NodeGraphMessage::MergeSelectedNodes.into() + } + + /// Toggle lock state of all selected layers + fn toggle_selected_locked() -> Message { + NodeGraphMessage::ToggleSelectedLocked.into() + } + + /// Creates a new document node in the node graph + fn create_node(node_type: Any, x: i32, y: i32) -> Message { + let id = NodeId::new(); + NodeGraphMessage::CreateNodeFromContextMenu { + node_id: Some(id), + node_type: node_type.cast(), + xy: Some((x / 24, y / 24)), + add_transaction: true, + } + .into() + } + + /// Respond to selection read + fn read_selection(content: Option, cut: bool) -> Message { + ClipboardMessage::ReadSelection { content, cut }.into() + } + + /// Paste from a serialized JSON representation + fn paste_text(data: String) -> Message { + ClipboardMessage::ReadClipboard { + content: ClipboardContentRaw::Text(data), + } + .into() + } + + /// Pastes an image + fn paste_image( + name: Option, + image_data: Vec, + width: u32, + height: u32, + mouse_x: Option, + mouse_y: Option, + insert_parent_id: Option, + insert_index: Option, + ) -> Message { + 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 + }; + + PortfolioMessage::InsertImage { + name, + image, + mouse, + parent_and_insert_index, + } + .into() + } + + /// Pastes an SVG given its string representation + fn paste_svg(name: Option, svg: String, mouse_x: Option, mouse_y: Option, insert_parent_id: Option, insert_index: Option) -> Message { + 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 + }; + + PortfolioMessage::InsertSvg { + name, + svg, + mouse, + parent_and_insert_index, + } + .into() + } + + /// Toggle visibility of a layer or node given its node ID + fn toggle_node_visibility_layer_panel(id: u64) -> Message { + NodeGraphMessage::ToggleVisibility { + node_id: NodeId(id), + network_path: Vec::new(), + } + .into() + } + + /// Pin or unpin a node given its node ID + fn set_node_pinned(id: u64, pinned: bool) -> Message { + DocumentMessage::SetNodePinned { node_id: NodeId(id), pinned }.into() + } + + /// Collapse or expand a node's section in the Properties panel + fn toggle_node_properties_section_expanded(id: u64) -> Message { + DocumentMessage::ToggleNodePropertiesSectionExpanded { node_id: NodeId(id) }.into() + } + + /// Delete a layer or node given its node ID + fn delete_node(id: u64) -> Message { + DocumentMessage::DeleteNode { node_id: NodeId(id) }.into() + } + + /// Toggle lock state of a layer from the layer list + fn toggle_layer_lock(node_id: u64) -> Message { + NodeGraphMessage::ToggleLocked { + node_id: NodeId(node_id), + network_path: Vec::new(), + } + .into() + } + + /// Toggle expansions state of a layer from the layer list + fn toggle_layer_expansion(tree_path: Vec, recursive: bool) -> Message { + let tree_path = tree_path.into_iter().map(NodeId).collect(); + DocumentMessage::ToggleLayerExpansion { tree_path, recursive }.into() + } + + /// Set the active panel to the most recently clicked panel + fn set_active_panel(panel: String) -> Message { + DocumentMessage::SetActivePanel { active_panel: panel.into() }.into() + } + + /// Toggle display type for a layer + fn set_to_node_or_layer(id: u64, is_layer: bool) -> Message { + DocumentMessage::SetToNodeOrLayer { node_id: NodeId(id), is_layer }.into() + } + + /// Set the name of an import or export + fn set_import_name(index: usize, name: String) -> Message { + NodeGraphMessage::SetImportExportName { + name, + index: ImportOrExport::Import(index), + } + .into() + } + + /// Set the name of an export + fn set_export_name(index: usize, name: String) -> Message { + NodeGraphMessage::SetImportExportName { + name, + index: ImportOrExport::Export(index), + } + .into() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(from_wasm_abi)] +pub struct Any(#[tsify(type = "any")] serde_json::Value); +impl Any { + #[cfg(feature = "editor")] + pub(crate) fn cast Deserialize<'de>>(&self) -> T { + serde_json::from_value(self.0.clone()).unwrap() + } +} + +macro_rules! editor_proxy_types { + ($($name:ident = $real:ty;)*) => { + $( + #[cfg(feature = "editor")] + pub type $name = $real; + #[cfg(not(feature = "editor"))] + pub type $name = Any; + )* + }; +} + +editor_proxy_types! { + LayoutTarget = editor::messages::layout::utility_types::layout_widget::LayoutTarget; + DockingSplitDirection = editor::messages::portfolio::utility_types::DockingSplitDirection; + PanelTypes = Vec; + SRGBA8 = graphene_std::color::SRGBA8; + FillChoiceUI = graphene_std::vector::style::FillChoiceUI; +} diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 81843a6f6d..24f6614297 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -1,44 +1,28 @@ -#![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. -// #[cfg(not(feature = "native"))] use crate::EDITOR; #[cfg(not(feature = "native"))] -use crate::helpers::poll_node_graph_evaluation; -use crate::helpers::{auto_save_all_documents, calculate_hash, render_image_data_to_canvases, request_animation_frame, set_timeout, translate_key, wrapper}; -use crate::{EDITOR_HAS_CRASHED, Error, FRONTEND_READY, MESSAGE_BUFFER}; +use crate::MESSAGE_BUFFER; +#[cfg(feature = "native")] +use crate::editor_commands::EditorCommand; #[cfg(not(feature = "native"))] +use crate::helpers::poll_node_graph_evaluation; +#[cfg(feature = "editor")] +use crate::helpers::{calculate_hash, render_image_data_to_canvases}; +use crate::helpers::{request_animation_frame, set_timeout, wrapper}; +use crate::{EDITOR_HAS_CRASHED, FRONTEND_READY}; #[cfg(all(not(feature = "native"), target_family = "wasm"))] use editor::application::{Editor, Environment, Host, Platform}; -use editor::consts::{FILE_EXTENSION, GDD_FILE_EXTENSION}; -use editor::messages::clipboard::utility_types::ClipboardContentRaw; -use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; -use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta}; -use editor::messages::layout::utility_types::layout_widget::LayoutTarget; -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::{DockingSplitDirection, PanelGroupId, PanelType}; +#[cfg(feature = "editor")] use editor::messages::prelude::*; -use editor::messages::tool::tool_messages::tool_prelude::WidgetId; -use graph_craft::document::NodeId; -use graphene_std::color::SRGBA8; -use graphene_std::graphene_hash::CacheHashWrapper; -use graphene_std::raster::color::Color; -use graphene_std::vector::style::{FillChoice, FillChoiceUI}; +#[cfg(feature = "editor")] use serde::Serialize; -use serde_wasm_bindgen::{self, from_value}; use std::cell::RefCell; -use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use wasm_bindgen::prelude::*; -static IMAGE_DATA_HASH: AtomicU64 = AtomicU64::new(0); +pub(crate) static IMAGE_DATA_HASH: AtomicU64 = AtomicU64::new(0); -/// 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 EditorWrapper { @@ -46,13 +30,7 @@ pub struct EditorWrapper { 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 EditorWrapper { - pub fn send_frontend_message_to_js_rust_proxy(&self, message: FrontendMessage) { - self.send_frontend_message_to_js(message); - } - #[cfg(any(feature = "native", target_family = "wasm"))] fn initialize_wrapper(frontend_message_handler_callback: js_sys::Function) -> EditorWrapper { use crate::{EDITOR_WRAPPER, PANIC_DIALOG_MESSAGE_CALLBACK}; @@ -69,10 +47,6 @@ impl EditorWrapper { #[wasm_bindgen] impl EditorWrapper { - // ======================== - // Editor wrapper machinery - // ======================== - #[cfg(all(not(feature = "native"), target_family = "wasm"))] pub async fn create(platform: String, uuid_random_seed: u64, frontend_message_handler_callback: js_sys::Function) -> EditorWrapper { use graph_craft::application_io::PlatformApplicationIo; @@ -115,7 +89,6 @@ impl EditorWrapper { #[cfg(not(feature = "native"))] pub(crate) fn dispatch>(&self, message: T) { // Process no further messages after a crash to avoid spamming the console - use crate::MESSAGE_BUFFER; if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { return; } @@ -137,24 +110,15 @@ impl EditorWrapper { self.send_frontend_message_to_js(message); } } - #[cfg(feature = "native")] - pub(crate) fn dispatch>(&self, message: T) { - let message: Message = message.into(); - let Ok(serialized_message) = ron::to_string(&message) else { - log::error!("Failed to serialize message"); - return; - }; - crate::native_communication::send_message_to_cef(serialized_message) - } - // Sends a FrontendMessage to JavaScript + #[cfg(feature = "editor")] pub(crate) fn send_frontend_message_to_js(&self, message: FrontendMessage) { if let FrontendMessage::UpdateImageData { ref image_data } = message { - let new_hash = calculate_hash(&CacheHashWrapper(image_data)); + 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()); + render_image_data_to_canvases(image_data.iter()); IMAGE_DATA_HASH.store(new_hash, Ordering::Relaxed); } return; @@ -172,16 +136,31 @@ impl EditorWrapper { } } - // ================================================ - // Functions for calling the editor in Rust from JS - // ================================================ + pub(crate) fn forward_serialized_frontend_message_to_js(&self, name: &str, data: crate::wasm_value::WasmValue) { + let js_return_value = self.frontend_message_handler_callback.call2(&JsValue::null(), &JsValue::from(name), &data.into()); + + if let Err(error) = js_return_value { + error!("While handling FrontendMessage {name:?}, JavaScript threw an error:\n{error:?}") + } + } + + #[cfg(feature = "native")] + pub(crate) fn send(&self, command: EditorCommand) { + // Process no further commands after a crash to avoid spamming the console + if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { + return; + } - /// Re-sends all UI layouts to the frontend. Called during HMR re-mounts when the frontend has lost its layout state. - #[wasm_bindgen(js_name = resendAllLayouts)] - pub fn resend_all_layouts(&self) { - self.dispatch(LayoutMessage::ResendAllLayouts); + let Ok(serialized) = serde_json::to_string(&command) else { + log::error!("Failed to serialize editor command"); + return; + }; + crate::native_communication::send_message_to_cef(serialized) } +} +#[wasm_bindgen] +impl EditorWrapper { #[wasm_bindgen(js_name = initAfterFrontendReady)] pub fn init_after_frontend_ready(&self) { // Enforce idempotency, so if this is called again during an HMR re-mount, we don't initialize the editor backend twice @@ -192,7 +171,8 @@ impl EditorWrapper { #[cfg(feature = "native")] crate::native_communication::initialize_native_communication(); - self.dispatch(PortfolioMessage::Init); + #[cfg(target_family = "wasm")] + self.init_portfolio(); // Poll node graph evaluation on `requestAnimationFrame` { @@ -203,25 +183,18 @@ impl EditorWrapper { #[cfg(not(feature = "native"))] wasm_bindgen_futures::spawn_local(poll_node_graph_evaluation()); - if !EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { - wrapper(|wrapper| { - // Process all messages that have been queued up - let mut messages = MESSAGE_BUFFER.take(); - messages.push( - InputPreprocessorMessage::CurrentTime { - timestamp: js_sys::Date::now() as u64, - } - .into(), - ); - messages.push(AnimationMessage::IncrementFrameCounter.into()); - - // Used by auto-panning, but this could possibly be refactored in the future, see: - // - messages.push(BroadcastMessage::TriggerEvent(EventMessage::AnimationFrame).into()); - - wrapper.dispatch(Message::Batched { messages: messages.into() }); - }); - } + wrapper(|wrapper| { + // On web, flush the messages that queued up while the editor was locked before this frame's tick + #[cfg(not(feature = "native"))] + { + let messages = MESSAGE_BUFFER.take(); + if !messages.is_empty() { + wrapper.dispatch(Message::Batched { messages: messages.into() }); + } + } + #[cfg(target_family = "wasm")] + wrapper.animation_frame(js_sys::Date::now() as u64); + }); // Schedule ourself for another requestAnimationFrame callback request_animation_frame(f.borrow().as_ref().unwrap()); @@ -230,94 +203,25 @@ impl EditorWrapper { request_animation_frame(g.borrow().as_ref().unwrap()); } + const AUTO_SAVE_TIMEOUT_SECONDS: u64 = 1; + // 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(); + #[cfg(target_family = "wasm")] + wrapper(|wrapper| wrapper.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(f.borrow().as_ref().unwrap(), Duration::from_secs(AUTO_SAVE_TIMEOUT_SECONDS)); })); - set_timeout(g.borrow().as_ref().unwrap(), Duration::from_secs(editor::consts::AUTO_SAVE_TIMEOUT_SECONDS)); + set_timeout(g.borrow().as_ref().unwrap(), Duration::from_secs(AUTO_SAVE_TIMEOUT_SECONDS)); } } - #[wasm_bindgen(js_name = addPrimaryImport)] - pub fn add_primary_import(&self) { - self.dispatch(DocumentMessage::AddTransaction); - self.dispatch(NodeGraphMessage::AddPrimaryImport); - } - - #[wasm_bindgen(js_name = addSecondaryImport)] - pub fn add_secondary_import(&self) { - self.dispatch(DocumentMessage::AddTransaction); - self.dispatch(NodeGraphMessage::AddSecondaryImport); - } - - #[wasm_bindgen(js_name = addPrimaryExport)] - pub fn add_primary_export(&self) { - self.dispatch(DocumentMessage::AddTransaction); - self.dispatch(NodeGraphMessage::AddPrimaryExport); - } - - #[wasm_bindgen(js_name = addSecondaryExport)] - pub fn add_secondary_export(&self) { - self.dispatch(DocumentMessage::AddTransaction); - self.dispatch(NodeGraphMessage::AddSecondaryExport); - } - - /// Start Pointer Lock - #[wasm_bindgen(js_name = appWindowPointerLock)] - pub fn app_window_pointer_lock(&self) { - let message = AppWindowMessage::PointerLock; - self.dispatch(message); - } - - /// Minimizes the application window to the taskbar or dock - #[wasm_bindgen(js_name = appWindowMinimize)] - pub fn app_window_minimize(&self) { - let message = AppWindowMessage::Minimize; - self.dispatch(message); - } - - /// Toggles minimizing or restoring down the application window - #[wasm_bindgen(js_name = appWindowMaximize)] - pub fn app_window_maximize(&self) { - let message = AppWindowMessage::Maximize; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = appWindowFullscreen)] - pub fn app_window_fullscreen(&self) { - let message = AppWindowMessage::Fullscreen; - self.dispatch(message); - } - - /// Closes the application window - #[wasm_bindgen(js_name = appWindowClose)] - pub fn app_window_close(&self) { - let message = AppWindowMessage::Close; - self.dispatch(message); - } - - /// Drag the application window - #[wasm_bindgen(js_name = appWindowDrag)] - pub fn app_window_start_drag(&self) { - let message = AppWindowMessage::Drag; - 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 has crashed #[wasm_bindgen(js_name = hasCrashed)] pub fn has_crashed(&self) -> bool { @@ -330,676 +234,25 @@ impl EditorWrapper { cfg!(debug_assertions) } - /// Get the constant `FILE_EXTENSION` #[wasm_bindgen(js_name = fileExtension)] pub fn file_extension(&self) -> String { - FILE_EXTENSION.into() + "graphite".into() } - /// Get the constant `GDD_FILE_EXTENSION` #[wasm_bindgen(js_name = gddFileExtension)] pub fn gdd_file_extension(&self) -> String { - GDD_FILE_EXTENSION.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: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { - self.widget_value_update_helper(layout_target, widget_id, value, resend_widget) - } - - /// Commit the value of a given UI widget to the history - #[wasm_bindgen(js_name = widgetValueCommit)] - pub fn widget_value_commit(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue) -> Result<(), JsValue> { - self.widget_value_commit_helper(layout_target, widget_id, value) - } - - /// 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: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { - self.widget_value_commit_helper(layout_target, widget_id, value.clone())?; - self.widget_value_update_helper(layout_target, widget_id, value, resend_widget)?; - // Close out a transaction that the widget's `on_commit` opened (if any), so a single click on widgets like the - // NumberInput's increment buttons collapses into one history step instead of leaving the transaction in `Modified` - self.dispatch(DocumentMessage::EndTransaction); - Ok(()) - } - - /// Fire a widget's drag-drop action (e.g. when a draggable item is dropped on a button) - #[wasm_bindgen(js_name = widgetValueDragDrop)] - pub fn widget_value_drag_drop(&self, layout_target: LayoutTarget, widget_id: u64) { - let widget_id = WidgetId(widget_id); - self.dispatch(LayoutMessage::WidgetValueDragDrop { layout_target, widget_id }); - } - - /// Closes out the current transaction (drag-end / text-commit end), so emits during a slider drag collapse into one history step instead of N - #[wasm_bindgen(js_name = endTransaction)] - pub fn end_transaction(&self) { - self.dispatch(DocumentMessage::EndTransaction); - } - - pub fn widget_value_update_helper(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { - let widget_id = WidgetId(widget_id); - let value: serde_json::Value = from_value(value).map_err(|e| Error::new(&format!("Could not update UI: {e}")))?; - let message = LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value }; - self.dispatch(message); - if resend_widget { - let resend_message = LayoutMessage::ResendActiveWidget { layout_target, widget_id }; - self.dispatch(resend_message); - } - Ok(()) - } - - pub fn widget_value_commit_helper(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue) -> Result<(), JsValue> { - let widget_id = WidgetId(widget_id); - let value: serde_json::Value = from_value(value).map_err(|e| Error::new(&format!("Could not commit UI: {e}")))?; - let message = LayoutMessage::WidgetValueCommit { layout_target, widget_id, value }; - self.dispatch(message); - Ok(()) - } - - #[wasm_bindgen(js_name = loadPreferences)] - pub fn load_preferences(&self, preferences: Option) { - if let Some(preferences) = preferences { - let Ok(preferences) = serde_json::from_str(&preferences) else { - log::error!("Failed to deserialize preferences"); - return; - }; - let message = PreferencesMessage::Load { preferences }; - self.dispatch(message); - } + "gdd".into() } + /// Load persisted browser storage state (web only; on desktop, persistence is handled natively and this is never triggered) + #[cfg(all(feature = "web", not(feature = "native")))] #[wasm_bindgen(js_name = loadPersistedState)] pub fn load_persisted_state(&self, state: editor::messages::frontend::utility_types::PersistedState) { self.dispatch(PersistentStateMessage::LoadState { state }); } - - #[wasm_bindgen(js_name = loadDocumentContent)] - pub fn load_document_content(&self, document_id: u64, document: String) { - let message = PersistentStateMessage::LoadDocument { - document_id: DocumentId(document_id), - document, - }; - 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); - } - - /// Rename the currently active document. - #[wasm_bindgen(js_name = renameDocument)] - pub fn rename_document(&self, new_name: String) { - let message = PortfolioMessage::RenameDocument { new_name }; - 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 = openFile)] - pub fn open_file(&self, path: String, content: Vec) { - let message = PortfolioMessage::OpenFile { path: PathBuf::from(path), content }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = importFile)] - pub fn import_file(&self, path: String, content: Vec) { - let message = PortfolioMessage::ImportFile { path: PathBuf::from(path), content }; - 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 = reorderDocument)] - pub fn reorder_document(&self, document_id: u64, new_index: usize) { - let document_id = DocumentId(document_id); - let message = PortfolioMessage::ReorderDocument { document_id, new_index }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = reorderPanelGroupTab)] - pub fn reorder_panel_group_tab(&self, group: u64, old_index: usize, new_index: usize) { - let message = PortfolioMessage::ReorderPanelGroupTab { - group: PanelGroupId(group), - old_index, - new_index, - }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = moveAllPanelTabs)] - pub fn move_all_panel_tabs(&self, source_group: u64, target_group: u64, insert_index: usize) { - let message = PortfolioMessage::MoveAllPanelTabs { - source_group: PanelGroupId(source_group), - target_group: PanelGroupId(target_group), - insert_index, - }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = movePanelTab)] - pub fn move_panel_tab(&self, source_group: u64, target_group: u64, insert_index: usize) { - let message = PortfolioMessage::MovePanelTab { - source_group: PanelGroupId(source_group), - target_group: PanelGroupId(target_group), - insert_index, - }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = setPanelGroupActiveTab)] - pub fn set_panel_group_active_tab(&self, group: u64, tab_index: usize) { - let message = PortfolioMessage::SetPanelGroupActiveTab { - group: PanelGroupId(group), - tab_index, - }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = splitPanelGroup)] - pub fn split_panel_group(&self, target_group: u64, direction: DockingSplitDirection, tabs: Vec, active_tab_index: usize) { - let message = PortfolioMessage::SplitPanelGroup { - target_group: PanelGroupId(target_group), - direction, - tabs, - active_tab_index, - }; - self.dispatch(message); - } - - #[wasm_bindgen(js_name = setPanelGroupSizes)] - pub fn set_panel_group_sizes(&self, split_path: Vec, sizes: Vec) { - let split_path = split_path.into_iter().map(|i| i as usize).collect(); - let message = PortfolioMessage::SetPanelGroupSizes { split_path, sizes }; - 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); - } - - #[wasm_bindgen(js_name = requestLicensesThirdPartyDialogWithLicenseText)] - pub fn request_licenses_third_party_dialog_with_license_text(&self, license_text: String) { - let message = DialogMessage::RequestLicensesThirdPartyDialogWithLicenseText { license_text }; - self.dispatch(message); - } - - /// Send new viewport info to the backend - #[wasm_bindgen(js_name = updateViewport)] - pub fn update_viewport(&self, x: f64, y: f64, width: f64, height: f64, scale: f64) { - let message = ViewportMessage::Update { x, y, width, height, scale }; - 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(()) - } - - /// Dialog got dismissed - #[wasm_bindgen(js_name = onDialogDismiss)] - pub fn on_dialog_dismiss(&self) { - let message = DialogMessage::Dismiss; - self.dispatch(message); - } - - /// 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(()) - } - - /// Update primary color from sRGB bytes (the wire format at the JS boundary). - #[wasm_bindgen(js_name = updatePrimaryColor)] - pub fn update_primary_color(&self, color: SRGBA8) { - self.dispatch(ToolMessage::SelectWorkingColor { - color: Color::from(color), - primary: true, - }); - } - - /// Update secondary color from sRGB bytes (the wire format at the JS boundary). - #[wasm_bindgen(js_name = updateSecondaryColor)] - pub fn update_secondary_color(&self, color: SRGBA8) { - self.dispatch(ToolMessage::SelectWorkingColor { - color: Color::from(color), - primary: false, - }); - } - - /// Initialize the Rust color picker handler with a starting value (used when the frontend `` opens). - #[wasm_bindgen(js_name = openColorPicker)] - pub fn open_color_picker(&self, initial_value: FillChoiceUI, allow_none: bool, disabled: bool) { - let initial_value = FillChoice::from(&initial_value); - self.dispatch(ColorPickerMessage::Open { initial_value, allow_none, disabled }); - } - - /// Tell the Rust color picker handler that the popover is closing. - #[wasm_bindgen(js_name = closeColorPicker)] - pub fn close_color_picker(&self) { - self.dispatch(ColorPickerMessage::Close); - } - - /// Update the color of the currently-edited gradient stop, from sRGB bytes (the wire format at the JS boundary). - #[wasm_bindgen(js_name = updateGradientStopColor)] - pub fn update_gradient_stop_color(&self, color: SRGBA8) { - self.dispatch(GradientToolMessage::UpdateStopColor { color: Color::from(color) }); - } - - /// Start a new undo transaction for gradient stop color editing - #[wasm_bindgen(js_name = startGradientStopColorTransaction)] - pub fn start_gradient_stop_color_transaction(&self) { - self.dispatch(GradientToolMessage::StartTransactionForColorStop); - } - - /// Commit the current gradient stop color transaction (called on pointer-up after each drag/click) - #[wasm_bindgen(js_name = commitGradientStopColorTransaction)] - pub fn commit_gradient_stop_color_transaction(&self) { - self.dispatch(GradientToolMessage::CommitTransactionForColorStop); - } - - /// Close the gradient stop color picker and commit any pending transaction - #[wasm_bindgen(js_name = closeGradientStopColorPicker)] - pub fn close_gradient_stop_color_picker(&self) { - self.dispatch(GradientToolMessage::CloseStopColorPicker); - } - - /// Toggle clipping the alpha of a layer to the alpha of the layer below it in the layer stack - #[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); - } - - /// Reorder a draggable Properties panel section to the given index among its peers. - #[wasm_bindgen(js_name = reorderPropertiesSection)] - pub fn reorder_properties_section(&self, node_id: u64, insert_index: usize) { - self.dispatch(DocumentMessage::ReorderPropertiesSection { - node_id: NodeId(node_id), - insert_index, - }); - } - - /// Duplicate the selected layers, placing the copies within the given folder at the given index. - /// If the folder is `None`, they are inserted into the document root. - /// If the insert index is `None`, they are inserted at the start of the folder. - #[wasm_bindgen(js_name = duplicateLayerInTree)] - pub fn duplicate_layer_in_tree(&self, insert_parent_id: Option, insert_index: Option) { - let message = DocumentMessage::DuplicateSelectedLayersTo { - parent: insert_parent_id.map(NodeId).map(LayerNodeIdentifier::new_unchecked).unwrap_or_default(), - 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(), - network_path: Vec::new(), - 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); - } - - /// Merge the selected nodes into a subnetwork - #[wasm_bindgen(js_name = mergeSelectedNodes)] - pub fn merge_nodes(&self) { - let message = NodeGraphMessage::MergeSelectedNodes; - self.dispatch(message); - } - - /// Toggle lock state of all selected layers - #[wasm_bindgen(js_name = toggleSelectedLocked)] - pub fn toggle_selected_locked(&self) { - let message = NodeGraphMessage::ToggleSelectedLocked; - self.dispatch(message); - } - - /// Creates a new document node in the node graph - #[wasm_bindgen(js_name = createNode)] - pub fn create_node(&self, node_type: JsValue, x: i32, y: i32) { - let value: serde_json::Value = serde_wasm_bindgen::from_value(node_type).unwrap(); - - let id = NodeId::new(); - let message = NodeGraphMessage::CreateNodeFromContextMenu { - node_id: Some(id), - node_type: value.into(), - xy: Some((x / 24, y / 24)), - add_transaction: true, - }; - self.dispatch(message); - } - - /// Respond to selection read - #[wasm_bindgen(js_name = readSelection)] - pub fn read_selection(&self, content: Option, cut: bool) { - let message = ClipboardMessage::ReadSelection { content, cut }; - self.dispatch(message); - } - - /// Paste from a serialized JSON representation - #[wasm_bindgen(js_name = pasteText)] - pub fn paste_text(&self, data: String) { - let message = ClipboardMessage::ReadClipboard { - content: ClipboardContentRaw::Text(data), - }; - 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::InsertImage { - name, - image, - mouse, - parent_and_insert_index, - }; - self.dispatch(message); - } - - /// Pastes an SVG given its string representation - #[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::InsertSvg { - 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, network_path: Vec::new() }; - 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 }); - } - - /// Collapse or expand a node's section in the Properties panel - #[wasm_bindgen(js_name = toggleNodePropertiesSectionExpanded)] - pub fn toggle_node_properties_section_expanded(&self, id: u64) { - self.dispatch(DocumentMessage::ToggleNodePropertiesSectionExpanded { node_id: NodeId(id) }); - } - - /// 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), - network_path: Vec::new(), - }; - self.dispatch(message); - } - - /// Toggle expansions state of a layer from the layer list - #[wasm_bindgen(js_name = toggleLayerExpansion)] - pub fn toggle_layer_expansion(&self, tree_path: &[u64], recursive: bool) { - let tree_path = tree_path.iter().map(|&id| NodeId(id)).collect(); - let message = DocumentMessage::ToggleLayerExpansion { tree_path, 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 = DocumentMessage::SetActivePanel { active_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); + #[cfg(feature = "native")] + #[wasm_bindgen(js_name = loadPersistedState)] + pub fn load_persisted_state(&self, _state: JsValue) { + log::error!("loadPersistedState is unavailable on desktop; persistence is handled natively"); } } diff --git a/frontend/wrapper/src/helpers.rs b/frontend/wrapper/src/helpers.rs index d33fb045b3..1296bd3934 100644 --- a/frontend/wrapper/src/helpers.rs +++ b/frontend/wrapper/src/helpers.rs @@ -1,14 +1,18 @@ #[cfg(not(feature = "native"))] use crate::EDITOR; +#[cfg(not(feature = "native"))] +use crate::EDITOR_HAS_CRASHED; +use crate::EDITOR_WRAPPER; use crate::editor_wrapper::EditorWrapper; -use crate::{EDITOR_HAS_CRASHED, EDITOR_WRAPPER}; +use crate::native_communication::RasterizedImage; #[cfg(not(feature = "native"))] use editor::application::Editor; +#[cfg(feature = "editor")] use editor::messages::input_mapper::utility_types::input_keyboard::Key; +#[cfg(not(feature = "native"))] use editor::messages::prelude::*; -use graphene_std::color::SRGBA8; -use graphene_std::raster::Image; use js_sys::{Object, Reflect}; +#[cfg(not(feature = "native"))] use std::sync::atomic::Ordering; use std::time::Duration; use wasm_bindgen::JsCast; @@ -119,18 +123,8 @@ pub(crate) fn async_wake_callback() -> Wake { }) } -pub(crate) 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; - } - - wrapper(|wrapper| { - wrapper.dispatch(PortfolioMessage::AutoSaveAllDocuments); - }); -} - -pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image)]) { +/// Blits each rasterized image to a canvas registered on `window.imageCanvases` +pub(crate) fn render_image_data_to_canvases<'a>(image_data: impl IntoIterator) { let window = match window() { Some(window) => window, None => { @@ -155,11 +149,13 @@ pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image)]) }; let canvases_obj = Object::from(canvases_obj); - for (placeholder_id, image) in image_data.iter() { + for image in image_data { + let (placeholder_id, width, height) = (image.id, image.width, image.height); + let pixels = image.pixels.as_slice(); 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 { + if Reflect::has(&canvases_obj, &js_key).unwrap_or(false) || width == 0 || height == 0 { continue; } @@ -169,8 +165,8 @@ pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image)]) .dyn_into::() .expect("Failed to cast element to HtmlCanvasElement"); - canvas.set_width(image.width); - canvas.set_height(image.height); + canvas.set_width(width); + canvas.set_height(height); let context: CanvasRenderingContext2d = canvas .get_context("2d") @@ -178,10 +174,8 @@ pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image)]) .expect("2d context was not found") .dyn_into::() .expect("Failed to cast context to CanvasRenderingContext2d"); - // `SRGBA8` is `#[repr(C)]` of four `u8`s, so the data buffer is already the byte format the canvas expects - let u8_data: &[u8] = bytemuck::cast_slice(&image.data); - 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) { + let clamped_pixels = wasm_bindgen::Clamped(pixels); + match ImageData::new_with_u8_clamped_array_and_sh(clamped_pixels, width, 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}"); @@ -209,6 +203,7 @@ pub(crate) fn calculate_hash(t: &T) -> u64 { } /// Translate a keyboard key from its JS name to its Rust `Key` enum +#[cfg(feature = "editor")] pub(crate) fn translate_key(name: &str) -> Key { use Key::*; diff --git a/frontend/wrapper/src/lib.rs b/frontend/wrapper/src/lib.rs index c184bc981d..c27805ed94 100644 --- a/frontend/wrapper/src/lib.rs +++ b/frontend/wrapper/src/lib.rs @@ -4,11 +4,18 @@ #[macro_use] extern crate log; +pub mod editor_commands; pub mod editor_wrapper; pub mod helpers; pub mod native_communication; +mod wasm_value; +#[cfg(any(feature = "native", not(target_family = "wasm")))] +pub use editor_commands::EditorCommand; + +#[cfg(feature = "editor")] use crate::helpers::wrapper; +#[cfg(feature = "editor")] use editor::messages::prelude::*; use std::panic; use std::sync::Mutex; @@ -24,6 +31,7 @@ pub static LOGGER: WasmLog = WasmLog; thread_local! { #[cfg(not(feature = "native"))] pub static EDITOR: Mutex> = const { Mutex::new(None) }; + #[cfg(not(feature = "native"))] pub static MESSAGE_BUFFER: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; pub static EDITOR_WRAPPER: Mutex> = const { Mutex::new(None) }; pub static PANIC_DIALOG_MESSAGE_CALLBACK: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; @@ -43,36 +51,41 @@ pub fn init_graphite() { /// 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() }; - } + // Node graph panics can only originate here when the node graph runs inside this wasm module + #[cfg(feature = "editor")] + { + 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); + wrapper(|wrapper| { + 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(); + wrapper.send_frontend_message_to_js(FrontendMessage::UpdateDocumentArtwork { svg: error }); + }); + } - if !NODE_GRAPH_ERROR_DISPLAYED.load(Ordering::SeqCst) { - NODE_GRAPH_ERROR_DISPLAYED.store(true, Ordering::SeqCst); - wrapper(|wrapper| { - 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(); - wrapper.send_frontend_message_to_js_rust_proxy(FrontendMessage::UpdateDocumentArtwork { svg: error }); - }); + return; } - - return; - } else { - EDITOR_HAS_CRASHED.store(true, Ordering::SeqCst); } + EDITOR_HAS_CRASHED.store(true, Ordering::SeqCst); + log::error!("{info}"); // Prefer using the raw JS callback to avoid mutex lock contention inside the panic hook. @@ -82,30 +95,20 @@ pub fn panic_hook(info: &panic::PanicHookInfo) { } fn send_panic_dialog_via_callback(panic_info: String) -> Result<(), String> { - let message = FrontendMessage::DisplayDialogPanic { panic_info }; - let message_type = message.to_discriminant().local_name(); + let message = PanicDialogMessage::DisplayDialogPanic { panic_info: panic_info.clone() }; let Ok(message_data) = serde_wasm_bindgen::to_value(&message) else { log::error!("Failed to serialize crash dialog panic message"); - let FrontendMessage::DisplayDialogPanic { panic_info } = message else { - unreachable!("Message variant changed unexpectedly") - }; return Err(panic_info); }; PANIC_DIALOG_MESSAGE_CALLBACK.with(|callback| { let callback_ref = callback.borrow(); let Some(callback) = callback_ref.as_ref() else { - let FrontendMessage::DisplayDialogPanic { panic_info } = message else { - unreachable!("Message variant changed unexpectedly") - }; return Err(panic_info); }; - if let Err(error) = callback.call2(&JsValue::null(), &JsValue::from(message_type), &message_data) { - log::error!("Failed to send crash dialog panic message to JS: {:?}", error); - let FrontendMessage::DisplayDialogPanic { panic_info } = message else { - unreachable!("Message variant changed unexpectedly") - }; + if let Err(error) = callback.call2(&JsValue::null(), &JsValue::from("DisplayDialogPanic"), &message_data) { + log::error!("Failed to send crash dialog panic message to JS: {error:?}"); return Err(panic_info); } @@ -201,3 +204,30 @@ impl log::Log for WasmLog { fn flush(&self) {} } + +#[cfg(feature = "editor")] +use editor::messages::prelude::FrontendMessage as PanicDialogMessage; + +#[cfg(not(feature = "editor"))] +use PanicDialogMessageCopy as PanicDialogMessage; + +#[cfg(any(not(feature = "editor"), test))] +#[derive(serde::Serialize)] +enum PanicDialogMessageCopy { + DisplayDialogPanic { + #[serde(rename = "panicInfo")] + panic_info: String, + }, +} + +#[cfg(all(test, feature = "editor"))] +mod tests { + use super::*; + + #[test] + fn panic_dialog_copy_matches_editor_shape() { + let copy = serde_json::to_value(PanicDialogMessageCopy::DisplayDialogPanic { panic_info: "info".into() }).unwrap(); + let real = serde_json::to_value(FrontendMessage::DisplayDialogPanic { panic_info: "info".into() }).unwrap(); + assert_eq!(copy, real); + } +} diff --git a/frontend/wrapper/src/native_communication.rs b/frontend/wrapper/src/native_communication.rs index 11d858303b..a66659ea40 100644 --- a/frontend/wrapper/src/native_communication.rs +++ b/frontend/wrapper/src/native_communication.rs @@ -1,25 +1,74 @@ -use crate::editor_wrapper::EditorWrapper; -use crate::helpers::wrapper; -use editor::messages::prelude::FrontendMessage; +use crate::editor_wrapper::{EditorWrapper, IMAGE_DATA_HASH}; +use crate::helpers::{calculate_hash, render_image_data_to_canvases, wrapper}; +use crate::wasm_value::WasmValue; use js_sys::{ArrayBuffer, Uint8Array}; +use std::sync::atomic::Ordering; use wasm_bindgen::prelude::*; #[wasm_bindgen(js_name = "receiveNativeMessage")] pub fn receive_native_message(buffer: ArrayBuffer) { let buffer = Uint8Array::new(buffer.as_ref()).to_vec(); - match ron::from_str::>(str::from_utf8(buffer.as_slice()).unwrap()) { + match serde_json::from_slice::>(&buffer) { Ok(messages) => { - let callback = move |wrapper: &mut EditorWrapper| { - for message in messages { - wrapper.send_frontend_message_to_js_rust_proxy(message); + wrapper(move |wrapper: &mut EditorWrapper| { + for (name, data) in messages { + match name.as_str() { + "UpdateImageData" => { + let new_hash = calculate_hash(&data); + if IMAGE_DATA_HASH.swap(new_hash, Ordering::Relaxed) == new_hash { + return; + } + + match serde_json::from_str::>(&data) { + Ok(image_data) => render_image_data_to_canvases(image_data.iter()), + Err(e) => error!("Failed to deserialize image data: {e:?}"), + } + } + _ => match serde_json::from_str::(&data) { + Ok(value) => wrapper.forward_serialized_frontend_message_to_js(&name, value), + Err(e) => log::error!("Failed to deserialize frontend message {name}: {e:?}"), + }, + } } - }; - wrapper(callback); + }); } Err(e) => log::error!("Failed to deserialize frontend messages: {e:?}"), } } +#[cfg(feature = "editor")] +pub fn encode_frontend_messages(messages: Vec) -> Option> { + use editor::messages::prelude::FrontendMessage; + use editor::utility_traits::{AsMessage, ToDiscriminant}; + + let messages: Vec<(String, String)> = messages + .into_iter() + .map(|message| { + let name = message.to_discriminant().local_name(); + let data = match message { + FrontendMessage::UpdateImageData { image_data } => serde_json::to_string(&image_data).inspect_err(|e| log::error!("Failed to serialize {name} payload: {e}")).ok()?, + message => crate::wasm_value::encode(&message) + .inspect_err(|e| log::error!("Failed to serialize FrontendMessage {name}: {e}")) + .ok()?, + }; + Some((name, data)) + }) + .collect::>()?; + + serde_json::to_vec(&messages).ok() +} + +#[cfg(all(feature = "editor", any(feature = "native", not(target_family = "wasm"))))] +pub fn decode_editor_command(data: &[u8]) -> Option { + match serde_json::from_slice::(data) { + Ok(command) => Some(command.into()), + Err(e) => { + log::error!("Failed to deserialize editor command: {e}"); + None + } + } +} + pub fn initialize_native_communication() { let global = js_sys::global(); @@ -44,3 +93,38 @@ pub fn send_message_to_cef(message: String) { // Call it with argument func.call1(&JsValue::NULL, &JsValue::from(buffer)).expect("Function call failed"); } + +#[cfg(feature = "editor")] +pub(crate) use editor::messages::frontend::utility_types::RasterizedImage; + +#[cfg(not(feature = "editor"))] +pub(crate) use RasterizedImageCopy as RasterizedImage; + +#[cfg(any(not(feature = "editor"), test))] +#[derive(serde::Deserialize)] +pub(crate) struct RasterizedImageCopy { + pub(crate) id: u64, + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) pixels: Vec, +} + +#[cfg(all(test, feature = "editor"))] +mod tests { + use super::*; + + #[test] + fn rasterized_image_copy_matches_editor_shape() { + let editor_image = RasterizedImage { + id: 7, + width: 2, + height: 1, + pixels: serde_bytes::ByteBuf::from(vec![1, 2, 3, 4, 5, 6, 7, 8]), + }; + let json = serde_json::to_string(&vec![editor_image]).unwrap(); + let decoded: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded[0].id, 7); + assert_eq!((decoded[0].width, decoded[0].height), (2, 1)); + assert_eq!(decoded[0].pixels, [1, 2, 3, 4, 5, 6, 7, 8]); + } +} diff --git a/frontend/wrapper/src/wasm_value.rs b/frontend/wrapper/src/wasm_value.rs new file mode 100644 index 0000000000..e168705077 --- /dev/null +++ b/frontend/wrapper/src/wasm_value.rs @@ -0,0 +1,444 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "editor")] +use serde_json::Error; +use wasm_bindgen::JsValue; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) enum WasmValue { + Undefined, + Bool(bool), + Number(f64), + NonFinite(NonFinite), + Int(i64), + UInt(u64), + String(String), + Bytes(Vec), + Seq(Vec), + Object(Vec<(String, WasmValue)>), + Map(Vec<(WasmValue, WasmValue)>), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum NonFinite { + Nan, + PositiveInfinity, + NegativeInfinity, +} + +impl From for f64 { + fn from(value: NonFinite) -> Self { + match value { + NonFinite::Nan => f64::NAN, + NonFinite::PositiveInfinity => f64::INFINITY, + NonFinite::NegativeInfinity => f64::NEG_INFINITY, + } + } +} + +impl From for JsValue { + fn from(value: WasmValue) -> Self { + match value { + WasmValue::Undefined => JsValue::UNDEFINED, + WasmValue::Bool(value) => JsValue::from_bool(value), + WasmValue::Number(value) => JsValue::from_f64(value), + WasmValue::NonFinite(value) => JsValue::from_f64(value.into()), + WasmValue::Int(value) => JsValue::from(value), + WasmValue::UInt(value) => JsValue::from(value), + WasmValue::String(value) => JsValue::from_str(&value), + WasmValue::Bytes(bytes) => js_sys::Uint8Array::from(bytes.as_slice()).into(), + WasmValue::Seq(values) => { + let array = js_sys::Array::new(); + for value in values { + array.push(&value.into()); + } + array.into() + } + WasmValue::Object(fields) => { + let object = js_sys::Object::new(); + for (key, value) in fields { + let _ = js_sys::Reflect::set(&object, &JsValue::from_str(&key), &value.into()); + } + object.into() + } + WasmValue::Map(entries) => { + let map = js_sys::Map::new(); + for (key, value) in entries { + map.set(&key.into(), &value.into()); + } + map.into() + } + } + } +} + +#[cfg(feature = "editor")] +pub(crate) fn encode(value: &T) -> Result { + serde_json::to_string(&ser::to_value(value)?) +} + +#[cfg(feature = "editor")] +mod ser { + use super::*; + use serde::ser::{self, Error as _}; + + pub(super) fn to_value(value: &T) -> Result { + value.serialize(ValueSerializer) + } + + struct ValueSerializer; + + impl ser::Serializer for ValueSerializer { + type Ok = WasmValue; + type Error = Error; + type SerializeSeq = SeqSerializer; + type SerializeTuple = SeqSerializer; + type SerializeTupleStruct = SeqSerializer; + type SerializeTupleVariant = VariantSeqSerializer; + type SerializeMap = MapSerializer; + type SerializeStruct = ObjectSerializer; + type SerializeStructVariant = VariantObjectSerializer; + + fn serialize_bool(self, v: bool) -> Result { + Ok(WasmValue::Bool(v)) + } + + fn serialize_i8(self, v: i8) -> Result { + Ok(WasmValue::Number(v as f64)) + } + + fn serialize_i16(self, v: i16) -> Result { + Ok(WasmValue::Number(v as f64)) + } + + fn serialize_i32(self, v: i32) -> Result { + Ok(WasmValue::Number(v as f64)) + } + + fn serialize_i64(self, v: i64) -> Result { + Ok(WasmValue::Int(v)) + } + + fn serialize_i128(self, v: i128) -> Result { + if let Ok(v) = i64::try_from(v) { + Ok(WasmValue::Int(v)) + } else { + u64::try_from(v) + .map(WasmValue::UInt) + .map_err(|_| Error::custom(format!("i128 value {v} exceeds the wire BigInt range"))) + } + } + + fn serialize_u8(self, v: u8) -> Result { + Ok(WasmValue::Number(v as f64)) + } + + fn serialize_u16(self, v: u16) -> Result { + Ok(WasmValue::Number(v as f64)) + } + + fn serialize_u32(self, v: u32) -> Result { + Ok(WasmValue::Number(v as f64)) + } + + fn serialize_u64(self, v: u64) -> Result { + Ok(WasmValue::UInt(v)) + } + + fn serialize_u128(self, v: u128) -> Result { + u64::try_from(v) + .map(WasmValue::UInt) + .map_err(|_| Error::custom(format!("u128 value {v} exceeds the wire BigInt range"))) + } + + fn serialize_f32(self, v: f32) -> Result { + self.serialize_f64(v as f64) + } + + fn serialize_f64(self, v: f64) -> Result { + Ok(if v.is_finite() { + WasmValue::Number(v) + } else if v.is_nan() { + WasmValue::NonFinite(NonFinite::Nan) + } else if v > 0. { + WasmValue::NonFinite(NonFinite::PositiveInfinity) + } else { + WasmValue::NonFinite(NonFinite::NegativeInfinity) + }) + } + + fn serialize_char(self, v: char) -> Result { + Ok(WasmValue::String(v.to_string())) + } + + fn serialize_str(self, v: &str) -> Result { + Ok(WasmValue::String(v.to_string())) + } + + fn serialize_bytes(self, v: &[u8]) -> Result { + Ok(WasmValue::Bytes(v.to_vec())) + } + + fn serialize_none(self) -> Result { + Ok(WasmValue::Undefined) + } + + fn serialize_some(self, value: &T) -> Result { + value.serialize(self) + } + + fn serialize_unit(self) -> Result { + Ok(WasmValue::Undefined) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Ok(WasmValue::Undefined) + } + + fn serialize_unit_variant(self, _name: &'static str, _variant_index: u32, variant: &'static str) -> Result { + Ok(WasmValue::String(variant.to_string())) + } + + fn serialize_newtype_struct(self, _name: &'static str, value: &T) -> Result { + value.serialize(self) + } + + fn serialize_newtype_variant(self, _name: &'static str, _variant_index: u32, variant: &'static str, value: &T) -> Result { + Ok(WasmValue::Object(vec![(variant.to_string(), value.serialize(ValueSerializer)?)])) + } + + fn serialize_seq(self, len: Option) -> Result { + Ok(SeqSerializer(Vec::with_capacity(len.unwrap_or_default()))) + } + + fn serialize_tuple(self, len: usize) -> Result { + self.serialize_seq(Some(len)) + } + + fn serialize_tuple_struct(self, _name: &'static str, len: usize) -> Result { + self.serialize_seq(Some(len)) + } + + fn serialize_tuple_variant(self, _name: &'static str, _variant_index: u32, variant: &'static str, len: usize) -> Result { + Ok(VariantSeqSerializer { + variant, + elements: Vec::with_capacity(len), + }) + } + + fn serialize_map(self, len: Option) -> Result { + Ok(MapSerializer { + entries: Vec::with_capacity(len.unwrap_or_default()), + pending_key: None, + }) + } + + fn serialize_struct(self, _name: &'static str, len: usize) -> Result { + Ok(ObjectSerializer(Vec::with_capacity(len))) + } + + fn serialize_struct_variant(self, _name: &'static str, _variant_index: u32, variant: &'static str, len: usize) -> Result { + Ok(VariantObjectSerializer { + variant, + fields: Vec::with_capacity(len), + }) + } + } + + struct SeqSerializer(Vec); + + impl ser::SerializeSeq for SeqSerializer { + type Ok = WasmValue; + type Error = Error; + + fn serialize_element(&mut self, value: &T) -> Result<(), Error> { + self.0.push(value.serialize(ValueSerializer)?); + Ok(()) + } + + fn end(self) -> Result { + Ok(WasmValue::Seq(self.0)) + } + } + + impl ser::SerializeTuple for SeqSerializer { + type Ok = WasmValue; + type Error = Error; + + fn serialize_element(&mut self, value: &T) -> Result<(), Error> { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + ser::SerializeSeq::end(self) + } + } + + impl ser::SerializeTupleStruct for SeqSerializer { + type Ok = WasmValue; + type Error = Error; + + fn serialize_field(&mut self, value: &T) -> Result<(), Error> { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + ser::SerializeSeq::end(self) + } + } + + struct VariantSeqSerializer { + variant: &'static str, + elements: Vec, + } + + impl ser::SerializeTupleVariant for VariantSeqSerializer { + type Ok = WasmValue; + type Error = Error; + + fn serialize_field(&mut self, value: &T) -> Result<(), Error> { + self.elements.push(value.serialize(ValueSerializer)?); + Ok(()) + } + + fn end(self) -> Result { + Ok(WasmValue::Object(vec![(self.variant.to_string(), WasmValue::Seq(self.elements))])) + } + } + + struct MapSerializer { + entries: Vec<(WasmValue, WasmValue)>, + pending_key: Option, + } + + impl ser::SerializeMap for MapSerializer { + type Ok = WasmValue; + type Error = Error; + + fn serialize_key(&mut self, key: &T) -> Result<(), Error> { + self.pending_key = Some(key.serialize(ValueSerializer)?); + Ok(()) + } + + fn serialize_value(&mut self, value: &T) -> Result<(), Error> { + let key = self.pending_key.take().ok_or_else(|| Error::custom("Map value serialized without a key"))?; + self.entries.push((key, value.serialize(ValueSerializer)?)); + Ok(()) + } + + fn end(self) -> Result { + Ok(WasmValue::Map(self.entries)) + } + } + + struct ObjectSerializer(Vec<(String, WasmValue)>); + + impl ser::SerializeStruct for ObjectSerializer { + type Ok = WasmValue; + type Error = Error; + + fn serialize_field(&mut self, key: &'static str, value: &T) -> Result<(), Error> { + self.0.push((key.to_string(), value.serialize(ValueSerializer)?)); + Ok(()) + } + + fn end(self) -> Result { + Ok(WasmValue::Object(self.0)) + } + } + + struct VariantObjectSerializer { + variant: &'static str, + fields: Vec<(String, WasmValue)>, + } + + impl ser::SerializeStructVariant for VariantObjectSerializer { + type Ok = WasmValue; + type Error = Error; + + fn serialize_field(&mut self, key: &'static str, value: &T) -> Result<(), Error> { + self.fields.push((key.to_string(), value.serialize(ValueSerializer)?)); + Ok(()) + } + + fn end(self) -> Result { + Ok(WasmValue::Object(vec![(self.variant.to_string(), WasmValue::Object(self.fields))])) + } + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::wasm_value::encode; + use serde::Serialize; + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + enum TestMessage { + UnitVariant, + StructVariant { + #[serde(rename = "nodeId")] + node_id: u64, + width: u32, + scale: f64, + min: f64, + max: f64, + label: Option, + widths: std::collections::BTreeMap, + flags: Vec, + bytes: serde_bytes::ByteBuf, + }, + } + + #[test] + fn mirrors_serde_wasm_bindgen_representations() { + assert_eq!(to_value(&TestMessage::UnitVariant).unwrap(), WasmValue::String("unitVariant".into())); + + let message = TestMessage::StructVariant { + node_id: u64::MAX, + width: 8, + scale: f64::NAN, + min: f64::NEG_INFINITY, + max: f64::INFINITY, + label: None, + widths: [(42, 7)].into(), + flags: vec![true, false], + bytes: serde_bytes::ByteBuf::from(vec![0, 255]), + }; + let expected = WasmValue::Object(vec![( + "structVariant".into(), + WasmValue::Object(vec![ + ("nodeId".into(), WasmValue::UInt(u64::MAX)), + ("width".into(), WasmValue::Number(8.)), + ("scale".into(), WasmValue::NonFinite(NonFinite::Nan)), + ("min".into(), WasmValue::NonFinite(NonFinite::NegativeInfinity)), + ("max".into(), WasmValue::NonFinite(NonFinite::PositiveInfinity)), + ("label".into(), WasmValue::Undefined), + ("widths".into(), WasmValue::Map(vec![(WasmValue::UInt(42), WasmValue::Number(7.))])), + ("flags".into(), WasmValue::Seq(vec![WasmValue::Bool(true), WasmValue::Bool(false)])), + ("bytes".into(), WasmValue::Bytes(vec![0, 255])), + ]), + )]); + assert_eq!(to_value(&message).unwrap(), expected); + } + + #[test] + fn encode_decode_roundtrip() { + let message = TestMessage::StructVariant { + node_id: 1, + width: 2, + scale: f64::NAN, + min: f64::NEG_INFINITY, + max: 1.5, + label: Some("hi".into()), + widths: [(3, 4)].into(), + flags: vec![true], + bytes: serde_bytes::ByteBuf::from(vec![9]), + }; + + let json: &str = &encode(&message).unwrap(); + let decoded = serde_json::from_str(json).unwrap(); + + assert_eq!(decoded, to_value(&message).unwrap()); + } + } +} diff --git a/proc-macros/Cargo.toml b/proc-macros/Cargo.toml index b6d47d4161..b161c09839 100644 --- a/proc-macros/Cargo.toml +++ b/proc-macros/Cargo.toml @@ -23,6 +23,7 @@ serde-discriminant = [] proc-macro2 = { workspace = true } syn = { workspace = true } quote = { workspace = true } +convert_case = { workspace = true } [dev-dependencies] # Local dependencies diff --git a/proc-macros/src/editor_commands.rs b/proc-macros/src/editor_commands.rs new file mode 100644 index 0000000000..79a7962d85 --- /dev/null +++ b/proc-macros/src/editor_commands.rs @@ -0,0 +1,128 @@ +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::spanned::Spanned; +use syn::{Error, FnArg, Ident, ItemFn, ItemUse, Pat, Token, Visibility}; + +pub struct EditorCommands { + imports: Vec, + functions: Vec, +} + +impl Parse for EditorCommands { + fn parse(input: ParseStream) -> syn::Result { + let mut imports = Vec::new(); + let mut functions = Vec::new(); + while !input.is_empty() { + if input.peek(Token![use]) { + imports.push(input.parse()?); + } else { + functions.push(input.parse()?); + } + } + Ok(Self { imports, functions }) + } +} + +pub fn editor_commands_impl(input: EditorCommands) -> syn::Result { + let imports = &input.imports; + + let mut variants = TokenStream::new(); + let mut stubs = TokenStream::new(); + let mut arms = TokenStream::new(); + + for function in &input.functions { + for attr in &function.attrs { + if !attr.path().is_ident("doc") { + return Err(Error::new( + attr.span(), + "command functions may not have attributes; anything that doesn't fit the `fn name(args…) -> Message` contract belongs in a plain impl block", + )); + } + } + if !matches!(function.vis, Visibility::Inherited) { + return Err(Error::new( + function.span(), + "command functions have no visibility modifier; the macro generates the public JS-facing stub", + )); + } + + let signature = &function.sig; + if let Some(receiver) = signature.receiver() { + return Err(Error::new(receiver.span(), "command functions take no `self`; they are pure `args… -> Message` translations")); + } + if !signature.generics.params.is_empty() || signature.asyncness.is_some() || signature.unsafety.is_some() { + return Err(Error::new(signature.span(), "command functions must be plain non-generic, non-async, safe functions")); + } + + let docs = &function.attrs; + let fn_name = &signature.ident; + let variant = Ident::new(&fn_name.to_string().to_case(Case::Pascal), fn_name.span()); + let js_name = Ident::new(&fn_name.to_string().to_case(Case::Camel), fn_name.span()); + + let mut param_names = Vec::new(); + let mut param_types = Vec::new(); + for parameter in &signature.inputs { + let FnArg::Typed(pat_type) = parameter else { unreachable!("receiver is rejected above") }; + let Pat::Ident(pat_ident) = &*pat_type.pat else { + return Err(Error::new(pat_type.span(), "command parameters must be plain identifiers")); + }; + param_names.push(&pat_ident.ident); + param_types.push(&*pat_type.ty); + } + + let return_type = &signature.output; + let body = &function.block; + + variants.extend(quote! { + #(#docs)* + #variant { #(#param_names: #param_types,)* }, + }); + stubs.extend(quote! { + #(#docs)* + #[cfg(not(feature = "native"))] + #[wasm_bindgen(js_name = #js_name)] + pub fn #fn_name(&self, #(#param_names: #param_types,)*) { + self.dispatch((move || #return_type #body)()) + } + #(#docs)* + #[cfg(feature = "native")] + #[wasm_bindgen(js_name = #js_name)] + pub fn #fn_name(&self, #(#param_names: #param_types,)*) { + self.send(EditorCommand::#variant { #(#param_names,)* }) + } + }); + arms.extend(quote! { + EditorCommand::#variant { #(#param_names,)* } => #body, + }); + } + + Ok(quote! { + #( + #[cfg(feature = "editor")] + #imports + )* + + #[cfg(any(feature = "native", not(target_family = "wasm")))] + #[derive(serde::Serialize, serde::Deserialize)] + pub enum EditorCommand { + #variants + } + + #[cfg(all(feature = "editor", any(feature = "native", not(target_family = "wasm"))))] + impl From for Message { + fn from(command: EditorCommand) -> Self { + match command { + #arms + } + } + } + + #[cfg(target_family = "wasm")] + #[wasm_bindgen] + impl EditorWrapper { + #stubs + } + }) +} diff --git a/proc-macros/src/lib.rs b/proc-macros/src/lib.rs index 8d68df75c8..de081830d3 100644 --- a/proc-macros/src/lib.rs +++ b/proc-macros/src/lib.rs @@ -3,6 +3,7 @@ mod as_message; mod combined_message_attrs; mod discriminant; +mod editor_commands; mod extract_fields; mod helper_structs; mod helpers; @@ -15,6 +16,7 @@ mod widget_builder; use crate::as_message::derive_as_message_impl; use crate::combined_message_attrs::combined_message_attrs_impl; use crate::discriminant::derive_discriminant_impl; +use crate::editor_commands::editor_commands_impl; use crate::extract_fields::derive_extract_field_impl; use crate::helper_structs::AttrInnerSingleString; use crate::hierarchical_tree::generate_hierarchical_tree; @@ -302,6 +304,12 @@ pub fn message_handler_data(attr: TokenStream, input_item: TokenStream) -> Token TokenStream::from(message_handler_data_attr_impl(attr.into(), input_item.into()).unwrap_or_else(|err| err.to_compile_error())) } +#[proc_macro] +pub fn editor_commands(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as editor_commands::EditorCommands); + TokenStream::from(editor_commands_impl(input).unwrap_or_else(|err| err.to_compile_error())) +} + #[cfg(test)] mod tests { use super::*; From 6a42d6d23dd34463389c2e5f4d29de10d5f17e58 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 17 Jul 2026 09:41:15 +0000 Subject: [PATCH 2/6] Introduce pkg-native --- frontend/.gitignore | 1 + frontend/vite.config.ts | 5 ++++- tools/cargo-run/internal/watch/src/main.rs | 2 +- tools/cargo-run/src/frontend.rs | 18 +++++++++++------- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/frontend/.gitignore b/frontend/.gitignore index 9056a3bde9..d5e07ad546 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,4 +1,5 @@ node_modules/ wrapper/pkg/ +wrapper/pkg-native/ public/build/ dist/ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8aee163687..3f2b6d9354 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -23,7 +23,10 @@ export default defineConfig(({ mode }) => { // Default for builds that exclude the `wasmSplitting` plugin, which overrides this when active define: { __WASM_PART_COUNT__: "1" }, resolve: { - alias: [{ find: /\/..\/branding\/(.*\.svg)/, replacement: path.resolve(projectRootDir, "../branding", "$1?raw") }], + alias: [ + ...(mode === "native" ? [{ find: /^\/wrapper\/pkg\//, replacement: "/wrapper/pkg-native/" }] : []), + { find: /\/..\/branding\/(.*\.svg)/, replacement: path.resolve(projectRootDir, "../branding", "$1?raw") }, + ], }, server: { port: 8080, diff --git a/tools/cargo-run/internal/watch/src/main.rs b/tools/cargo-run/internal/watch/src/main.rs index d520c60548..8646515ea8 100644 --- a/tools/cargo-run/internal/watch/src/main.rs +++ b/tools/cargo-run/internal/watch/src/main.rs @@ -8,7 +8,7 @@ use std::path::Path; use std::process::ExitCode; use std::time::Duration; -const EXCLUDED_DIRECTORIES: &[&str] = &["target", ".git", "frontend/node_modules", "frontend/dist", "frontend/wrapper/pkg", "tools"]; +const EXCLUDED_DIRECTORIES: &[&str] = &["target", ".git", "frontend/node_modules", "frontend/dist", "frontend/wrapper/pkg", "frontend/wrapper/pkg-native", "tools"]; const INCLUDED_EXTENSIONS: &[&str] = &["rs"]; const DEBOUNCE: Duration = Duration::from_millis(500); diff --git a/tools/cargo-run/src/frontend.rs b/tools/cargo-run/src/frontend.rs index 9aaadaaa6f..36674919ea 100644 --- a/tools/cargo-run/src/frontend.rs +++ b/tools/cargo-run/src/frontend.rs @@ -10,8 +10,12 @@ pub fn frontend_dir() -> PathBuf { workspace_dir().join("frontend") } -fn wasm_glue_path() -> PathBuf { - frontend_dir().join("wrapper").join("pkg").join(format!("{OUT_NAME}.js")) +fn pkg_dir(native: bool) -> PathBuf { + frontend_dir().join("wrapper").join(if native { "pkg-native" } else { "pkg" }) +} + +fn wasm_glue_path(native: bool) -> PathBuf { + pkg_dir(native).join(format!("{OUT_NAME}.js")) } pub fn setup() -> Result<(), Error> { @@ -50,15 +54,15 @@ pub fn build_wasm(release: bool, native: bool) -> Result<(), Error> { pub fn build_wasm_steps(release: bool, native: bool) -> Vec { let wasm_artifact = target_dir().join(WASM_TARGET).join(if release { "release" } else { "debug" }).join(format!("{OUT_NAME}.wasm")); - let pkg_dir = frontend_dir().join("wrapper").join("pkg"); + let pkg_dir = pkg_dir(native); let mut steps = vec![ cmd!("cargo", "build", "--lib", "--package", WRAPPER_CRATE, "--target", WASM_TARGET) .arg_if(release, "--release") .args_if(native, ["--no-default-features", "--features", "native"]) .dir(workspace_dir()) - .before_spawn(|_| { - if is_build_corrupted() { + .before_spawn(move |_| { + if is_build_corrupted(wasm_glue_path(native)) { clean_wasm(); } Ok(()) @@ -90,8 +94,8 @@ pub fn clean_wasm() -> bool { true } -pub fn is_build_corrupted() -> bool { - let Ok(js) = std::fs::read_to_string(wasm_glue_path()) else { +pub fn is_build_corrupted(path: PathBuf) -> bool { + let Ok(js) = std::fs::read_to_string(&path) else { return false; }; js.contains("from \"env\"") || js.contains("from 'env'") From e000cc64235c0c21cfda4eb0ad38ab93b74ed872 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 17 Jul 2026 10:27:02 +0000 Subject: [PATCH 3/6] Fix tests --- frontend/wrapper/src/editor_wrapper.rs | 9 ++++++--- frontend/wrapper/src/wasm_value.rs | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 24f6614297..326f418aba 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -2,13 +2,15 @@ use crate::EDITOR; #[cfg(not(feature = "native"))] use crate::MESSAGE_BUFFER; -#[cfg(feature = "native")] +#[cfg(all(feature = "native", target_family = "wasm"))] use crate::editor_commands::EditorCommand; #[cfg(not(feature = "native"))] use crate::helpers::poll_node_graph_evaluation; #[cfg(feature = "editor")] use crate::helpers::{calculate_hash, render_image_data_to_canvases}; -use crate::helpers::{request_animation_frame, set_timeout, wrapper}; +use crate::helpers::{request_animation_frame, set_timeout}; +#[cfg(any(not(feature = "native"), target_family = "wasm"))] +use crate::helpers::wrapper; use crate::{EDITOR_HAS_CRASHED, FRONTEND_READY}; #[cfg(all(not(feature = "native"), target_family = "wasm"))] use editor::application::{Editor, Environment, Host, Platform}; @@ -144,7 +146,7 @@ impl EditorWrapper { } } - #[cfg(feature = "native")] + #[cfg(all(feature = "native", target_family = "wasm"))] pub(crate) fn send(&self, command: EditorCommand) { // Process no further commands after a crash to avoid spamming the console if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { @@ -183,6 +185,7 @@ impl EditorWrapper { #[cfg(not(feature = "native"))] wasm_bindgen_futures::spawn_local(poll_node_graph_evaluation()); + #[cfg(any(not(feature = "native"), target_family = "wasm"))] wrapper(|wrapper| { // On web, flush the messages that queued up while the editor was locked before this frame's tick #[cfg(not(feature = "native"))] diff --git a/frontend/wrapper/src/wasm_value.rs b/frontend/wrapper/src/wasm_value.rs index e168705077..3117823165 100644 --- a/frontend/wrapper/src/wasm_value.rs +++ b/frontend/wrapper/src/wasm_value.rs @@ -436,7 +436,7 @@ mod ser { }; let json: &str = &encode(&message).unwrap(); - let decoded = serde_json::from_str(json).unwrap(); + let decoded: WasmValue = serde_json::from_str(json).unwrap(); assert_eq!(decoded, to_value(&message).unwrap()); } From 33eb74fe4326ad791c03897d30b510fb06d45f55 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 17 Jul 2026 10:36:55 +0000 Subject: [PATCH 4/6] Fix fmt --- frontend/wrapper/src/editor_wrapper.rs | 4 ++-- tools/cargo-run/internal/watch/src/main.rs | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 326f418aba..b378e2fb76 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -6,11 +6,11 @@ use crate::MESSAGE_BUFFER; use crate::editor_commands::EditorCommand; #[cfg(not(feature = "native"))] use crate::helpers::poll_node_graph_evaluation; +#[cfg(any(not(feature = "native"), target_family = "wasm"))] +use crate::helpers::wrapper; #[cfg(feature = "editor")] use crate::helpers::{calculate_hash, render_image_data_to_canvases}; use crate::helpers::{request_animation_frame, set_timeout}; -#[cfg(any(not(feature = "native"), target_family = "wasm"))] -use crate::helpers::wrapper; use crate::{EDITOR_HAS_CRASHED, FRONTEND_READY}; #[cfg(all(not(feature = "native"), target_family = "wasm"))] use editor::application::{Editor, Environment, Host, Platform}; diff --git a/tools/cargo-run/internal/watch/src/main.rs b/tools/cargo-run/internal/watch/src/main.rs index 8646515ea8..a41c1e4e1a 100644 --- a/tools/cargo-run/internal/watch/src/main.rs +++ b/tools/cargo-run/internal/watch/src/main.rs @@ -8,7 +8,15 @@ use std::path::Path; use std::process::ExitCode; use std::time::Duration; -const EXCLUDED_DIRECTORIES: &[&str] = &["target", ".git", "frontend/node_modules", "frontend/dist", "frontend/wrapper/pkg", "frontend/wrapper/pkg-native", "tools"]; +const EXCLUDED_DIRECTORIES: &[&str] = &[ + "target", + ".git", + "frontend/node_modules", + "frontend/dist", + "frontend/wrapper/pkg", + "frontend/wrapper/pkg-native", + "tools", +]; const INCLUDED_EXTENSIONS: &[&str] = &["rs"]; const DEBOUNCE: Duration = Duration::from_millis(500); From a0ecff4f2d2b8715b3e15e73911442fd06ec4308 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 17 Jul 2026 12:07:56 +0000 Subject: [PATCH 5/6] Correct span and make cargo fmt work inside editor_commands block --- frontend/wrapper/src/editor_commands.rs | 3 +- proc-macros/src/editor_commands.rs | 52 ++++++++++++------------- proc-macros/src/lib.rs | 8 ++-- 3 files changed, 32 insertions(+), 31 deletions(-) diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index f79d66d4e2..c8d1913f18 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -7,7 +7,8 @@ use tsify::Tsify; #[cfg(target_family = "wasm")] use wasm_bindgen::prelude::*; -editor_commands! { +#[editor_commands] +mod editor_commands { use crate::helpers::translate_key; use editor::messages::clipboard::utility_types::ClipboardContentRaw; use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; diff --git a/proc-macros/src/editor_commands.rs b/proc-macros/src/editor_commands.rs index 79a7962d85..ac7fa4d495 100644 --- a/proc-macros/src/editor_commands.rs +++ b/proc-macros/src/editor_commands.rs @@ -1,38 +1,37 @@ use convert_case::{Case, Casing}; use proc_macro2::TokenStream; -use quote::quote; -use syn::parse::{Parse, ParseStream}; +use quote::{quote, quote_spanned}; use syn::spanned::Spanned; -use syn::{Error, FnArg, Ident, ItemFn, ItemUse, Pat, Token, Visibility}; +use syn::{Error, FnArg, Ident, Item, ItemFn, ItemMod, ItemUse, Pat, Visibility}; -pub struct EditorCommands { - imports: Vec, - functions: Vec, -} - -impl Parse for EditorCommands { - fn parse(input: ParseStream) -> syn::Result { - let mut imports = Vec::new(); - let mut functions = Vec::new(); - while !input.is_empty() { - if input.peek(Token![use]) { - imports.push(input.parse()?); - } else { - functions.push(input.parse()?); - } +pub fn editor_commands_impl(attr: TokenStream, module: ItemMod) -> syn::Result { + if !attr.is_empty() { + return Err(Error::new(attr.span(), "#[editor_commands] takes no arguments")); + } + for attr in &module.attrs { + if !attr.path().is_ident("doc") { + return Err(Error::new(attr.span(), "the #[editor_commands] module may not have other attributes")); } - Ok(Self { imports, functions }) } -} + let Some((_, items)) = module.content else { + return Err(Error::new(module.mod_token.span, "#[editor_commands] requires a module with an inline body")); + }; -pub fn editor_commands_impl(input: EditorCommands) -> syn::Result { - let imports = &input.imports; + let mut imports: Vec = Vec::new(); + let mut functions: Vec = Vec::new(); + for item in items { + match item { + Item::Use(import) => imports.push(import), + Item::Fn(function) => functions.push(function), + other => return Err(Error::new(other.span(), "only `use` imports and command functions may appear in an #[editor_commands] module")), + } + } let mut variants = TokenStream::new(); let mut stubs = TokenStream::new(); let mut arms = TokenStream::new(); - for function in &input.functions { + for function in &functions { for attr in &function.attrs { if !attr.path().is_ident("doc") { return Err(Error::new( @@ -75,11 +74,12 @@ pub fn editor_commands_impl(input: EditorCommands) -> syn::Result { let return_type = &signature.output; let body = &function.block; - variants.extend(quote! { + let span = fn_name.span(); + variants.extend(quote_spanned! {span=> #(#docs)* #variant { #(#param_names: #param_types,)* }, }); - stubs.extend(quote! { + stubs.extend(quote_spanned! {span=> #(#docs)* #[cfg(not(feature = "native"))] #[wasm_bindgen(js_name = #js_name)] @@ -93,7 +93,7 @@ pub fn editor_commands_impl(input: EditorCommands) -> syn::Result { self.send(EditorCommand::#variant { #(#param_names,)* }) } }); - arms.extend(quote! { + arms.extend(quote_spanned! {span=> EditorCommand::#variant { #(#param_names,)* } => #body, }); } diff --git a/proc-macros/src/lib.rs b/proc-macros/src/lib.rs index de081830d3..7f34eea336 100644 --- a/proc-macros/src/lib.rs +++ b/proc-macros/src/lib.rs @@ -304,10 +304,10 @@ pub fn message_handler_data(attr: TokenStream, input_item: TokenStream) -> Token TokenStream::from(message_handler_data_attr_impl(attr.into(), input_item.into()).unwrap_or_else(|err| err.to_compile_error())) } -#[proc_macro] -pub fn editor_commands(input: TokenStream) -> TokenStream { - let input = syn::parse_macro_input!(input as editor_commands::EditorCommands); - TokenStream::from(editor_commands_impl(input).unwrap_or_else(|err| err.to_compile_error())) +#[proc_macro_attribute] +pub fn editor_commands(attr: TokenStream, input_item: TokenStream) -> TokenStream { + let module = syn::parse_macro_input!(input_item as syn::ItemMod); + TokenStream::from(editor_commands_impl(attr.into(), module).unwrap_or_else(|err| err.to_compile_error())) } #[cfg(test)] From cb45177fb140bf527e315372bfdd61c83ffdf4e0 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 17 Jul 2026 12:52:38 +0000 Subject: [PATCH 6/6] Review --- frontend/wrapper/src/editor_commands.rs | 4 ++++ frontend/wrapper/src/editor_wrapper.rs | 3 +++ frontend/wrapper/src/native_communication.rs | 5 ++++- frontend/wrapper/src/wasm_value.rs | 4 ++++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index c8d1913f18..34b85a8b3a 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -1,3 +1,7 @@ +//! Commands that JS can call on the editor. Each function maps its arguments to a `Message`. On web the generated +//! stub dispatches it directly, on native the arguments are sent as an `EditorCommand` and the same body runs in +//! the editor process. + #![allow(clippy::too_many_arguments)] #[cfg(target_family = "wasm")] use crate::editor_wrapper::EditorWrapper; diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index b378e2fb76..4dc1f9ce1e 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -1,3 +1,6 @@ +//! JS-facing editor handle. Owns the callback that delivers `FrontendMessage`s to JS; on web `dispatch` runs +//! messages through the in-process editor, on native `send` forwards them as `EditorCommand`s. + #[cfg(not(feature = "native"))] use crate::EDITOR; #[cfg(not(feature = "native"))] diff --git a/frontend/wrapper/src/native_communication.rs b/frontend/wrapper/src/native_communication.rs index a66659ea40..c43b2ba690 100644 --- a/frontend/wrapper/src/native_communication.rs +++ b/frontend/wrapper/src/native_communication.rs @@ -1,3 +1,6 @@ +//! Messaging with the native process. Serialized `FrontendMessage`s come in and are forwarded to JS, +//! `EditorCommand`s go back out. Encoding and decoding both live here so the wire format stays in one place. + use crate::editor_wrapper::{EditorWrapper, IMAGE_DATA_HASH}; use crate::helpers::{calculate_hash, render_image_data_to_canvases, wrapper}; use crate::wasm_value::WasmValue; @@ -16,7 +19,7 @@ pub fn receive_native_message(buffer: ArrayBuffer) { "UpdateImageData" => { let new_hash = calculate_hash(&data); if IMAGE_DATA_HASH.swap(new_hash, Ordering::Relaxed) == new_hash { - return; + continue; } match serde_json::from_str::>(&data) { diff --git a/frontend/wrapper/src/wasm_value.rs b/frontend/wrapper/src/wasm_value.rs index 3117823165..17855582e7 100644 --- a/frontend/wrapper/src/wasm_value.rs +++ b/frontend/wrapper/src/wasm_value.rs @@ -1,3 +1,7 @@ +//! Serializable stand-in for `JsValue`, used to send `FrontendMessage`s from the native editor process to JS. +//! Keeps the types JSON can't represent (`BigInt`, `Uint8Array`, `Map`, `undefined`, `NaN`) so the wasm side +//! can reconstruct the same `JsValue`s that `serde_wasm_bindgen` produces on web. + use serde::{Deserialize, Serialize}; #[cfg(feature = "editor")] use serde_json::Error;