Make the data model use Item and List types universally, with nodes authored as rank-polymorphic kernels (#4335)

* Add rank polymorphism node audit classifying all 271 nodes

* Implement StaticType for Item<T>

* Generate Item and mapped List wire variants for nodes declaring an Item<T> primary input

* Migrate nine nodes to Item element-wise kernels, dissolving the blending trait boilerplate

* Document the Item kernel implementation and staging plan

* Route Item<Vector> through TaggedValue::TypeDefault

* Add executor integration tests covering the Item and List wire variants

* Collapse element-wise Item/List wire pairs to the List form for conversion insertion

* Migrate sixteen vector modifier nodes to Item element-wise kernels

* Migrate Sample Image, Extend Image to Bounds, and Dehaze to Item element-wise kernels

* Fix bevel_with_transform test to actually exercise the transform attribute

* Implement From<T> for Item<T>

* Register PromoteNode rank adapters wrapping bare values into Item wires

* Insert PromoteNode adapters for Item/List wire pair fields in the preprocessor

* Define a real promote node backing the PromoteNode registry identifiers

* Zip ranked Item connectors by frame slot in the mapped element-wise variant

* Register ItemToListNode singleton raise adapters

* Resolve Item wires against List connectors by inserting promotion adapters at construction

* Rank the Offset Points distance connector and prove mixed-rank resolution end-to-end

* Implement Clampable for Item and List wires with per-variant clamp bounds

* Rank the Round Corners radius connector, exercising hard bounds on a ranked wire

* Implement ApplyTransform for Item

* Add Item wire implementations to the Transform node, keeping rank-0 chains rank 0

* Detect element-wise nodes by lazy primary connectors declaring Output = Item

* Convert Transform to an Item kernel with ranked parameters, delivering the broadcast milestone

* Rename Apply Transform to Bake Transform, baking item transforms on Vector, DAffine2, and DVec2

* Promote bare wires onto Item connectors at resolution via WrapItemNode adapters

* Rank the numeric, vector, and boolean parameters across the migrated element-wise nodes

* Rank the enum, integer, and seed parameters, registering their rank adapters via a consolidated macro

* Amend the audit with the DashPattern value type resolution

* Migrate the string family to Item element-wise kernels

* Unwrap Item wires into bare legacy connectors at resolution via UnwrapItemNode adapters

* Shadow owned node parameters in bodies instead of mut in signatures

* Migrate the math family and string measure nodes to Item element-wise kernels

* Convert the comparison and clamp nodes to Item kernels, dropping unreachable &str rows

* Flat-map expander kernels returning List under the mapped variant's frame

* Migrate the expander nodes to Item kernels flat-mapping under the frame

* Remove the unused peel_list helper

* Rank the raster adjustment and blending kernels, recontextualizing shader nodes onto an Item stand-in

Migrate the 16 adjustment nodes, Mix, Color Overlay, and Gradient Map from whole-List kernels to rank-0 Item kernels, letting the macro derive the List-mapped (zip) variants. Move the Adjust and Blend per-element seams off List onto the element types (add the Raster<CPU> impls, drop the now-dead List impls).

Shader nodes keep their bodies verbatim: PerPixelAdjust re-emits the identical kernel against a transparent no_std Item stand-in, so every Item<T> connector and .element() call resolves to a zero-cost identity on the GPU while the uniform buffer stays bare repr(C). The macro peels Item off ranked uniform params, wraps the fetched texel and uniforms at the entry point, and unwraps the result. This drops the shader_node/Item incompatibility guard. Register rank adapters for the adjustment enums.

* Update the rank polymorphism roadmap for the landed shader-node and adjustments chunk

* Rename the GPU Item stand-in to ShaderItem, aliased as Item at its shader-node import sites

* Flip the vector shape generators to emit rank-0 Item<Vector>

The shape generators (Rectangle, Circle, Ellipse, Arc, Spiral, Polygon, Star, Arrow, Line, Grid, QR Code) each produced exactly one shape wrapped in a singleton List<Vector>. Emit Item<Vector> directly so they connect to the rank-0 content connector of the migrated Transform node. Downstream List consumers receive the value through the existing Item to List promotion.

Relax the element-wise validation so a `()` (generator) primary may return Item<T> without being element-wise. Adapt the Repeat on Points test, which still takes a List content connector, by raising the generator's Item output through a singleton wrapper node.

* Parse ranked Item<T> parameter defaults against the bare element type

A ranked `Item<T>` parameter's default value is a bare, unranked `T` (promoted to the wire at resolution), but the preprocessor was handed the wrapped `Item<T>` type and could not parse the literal, flooding the console with warnings and dropping the defaults. Key the field's default_type metadata off the peeled element type for concrete ranked parameters, leaving generic `Item<T>` primaries and skip_impl nodes untouched.

* Parse an element-wise primary's scalar default against the bare element type

An element-wise node's primary reports its default_type as the List wire form so an unconnected primary defaults to an empty list. But when the primary carries a scalar `#[default]` (such as Root's radicand), that literal must parse as a bare element, not a List. Key the primary's default_type off the bare element type when it has a Default value source, keeping the List form otherwise.

* Add the DashPattern value type for stroke dash sequences

Introduce a rank-0 DashPattern value type (a Vec<f64> of alternating dash and gap lengths) so a stroke's dash pattern is a single frameable value rather than a rank-1 List<f64>. Register it as an auto-generated TaggedValue variant, parse its default from a comma or space separated string, and register its rank adapters. Not yet wired into the Stroke node.

* Rank the Fill and Stroke nodes element-wise and give Stroke a DashPattern connector

Migrate Fill and Stroke to element-wise Item<V> primaries (over Vector and Graphic element types) via a new element-level VectorItemMut trait, so styling one shape yields one shape and rank is preserved instead of promoting the input to a singleton List and emitting a List. The macro derives the List-mapped variant for genuine collections.

Wire the Stroke dash sequence to the new rank-0 DashPattern value type, collapsing the old content x paint x dash cartesian and dropping the IntoF64Vec trait. Update the stroke properties dash widget, the drawing tool, and graph-operation plumbing to read and write DashPattern, and migrate legacy F64Array, F64, and String dash inputs on document open.

Assign Colors stays a whole-collection node: each element's gradient position depends on its index among all siblings, which the element frame does not expose, so it keeps its List primary and the VectorListIterMut trait.

* Register rank adapters for the ranked Stroke enum parameters

The element-wise Stroke node ranks its align, cap, and paint order parameters as Item<StrokeAlign>, Item<StrokeCap>, and Item<PaintOrder>, but those enums lacked promotion adapters, so a bare default enum value could not be promoted to its Item wire and no Stroke variant resolved ("No construct found for node"). Register their rank adapters alongside StrokeJoin.

* Display Item wires in the Data panel without a List's ID column

Add a TableItemLayout impl for Item<T> and recognize Item wire types when introspecting graph data. An Item holds a single element, so it renders as a one-row table of the element plus its attributes with no leading index column, and it labels as its element type T rather than a List's T[]. Add ItemAttributeValues::get_any for the attribute widget dispatch.

* Register MonitorNode for Item wire types so the Data panel introspects them directly

Graph introspection wraps the inspected output in a generic MonitorNode typed to the wire. Without Item<T> monitor registrations, an Item<Vector> output could only be monitored after an Item to List promotion, so the Data panel captured and displayed a List<Vector> despite the connector being Item<Vector>. Register monitors for the Item types the element-wise nodes emit, and add the matching Data panel downcast entries.

* Color and double Item/List wires and cleave layer-stack connectors in the node graph

* Route wire color and rank through hidden nodes and refresh them on type changes

* Rework the DashPattern connector conversions with element-wise promotion and an explicit reducer node

* Rank the remaining value, context, aggregation, and transform nodes onto Item<T> wires

* Back DashPattern with a List<f64> so the Data panel can introspect its lengths

* Carry a single Item<T> through varargs so the Read context nodes emit Item<T> not List<T>

* Relax rank validation for aggregation shapes, add element adapters, and match variants by fewest promotions

* Rank the remaining bare and unnecessarily-List connectors across the node catalog

* Add Graphic::None and the FillChoice paint value, making colors and gradients plain values

* Rename GradientStops to Gradient and the legacy Gradient/Fill structs to LegacyGradient/LegacyFill

* Restore generator frame-from-params ranking to the roadmap as a planned stage

* Rename the ranked-field adapter identifier from PromoteNode to FieldAdapterNode to reflect its full contract

* Unload only the wires whose displayed style changed when types update

* Peel wire rank in the editor's semantic type checks so rank-0 layers are recognized

* Restore the whole-List Transform variant so rank-1 content wires resolve again

* Register the Item wire forms for the Memoize and Context Modification infrastructure nodes

* Give every ranked connector a field adapter and add numeric cast variants for legacy wires

* Key a ranked param's type default off its Item wire form when no literal default exists

* Inherit the layer's content value when splicing a node into an empty chain

* Migrate stale List-form TypeDefault inputs to the definition's current default

* Generate the mapped wire variant only when the element-wise node has a frame source

* Let a bare wire feed a List connector via a wrap-raise adapter, costed as two rank steps

* Add a zip companion to the whole-List Transform so ranked List parameters pair per slot

* Add the Sum, Average, Minimum, Maximum, Any, and All list reducers

* Convert the measure family to element-wise Item kernels per the audit classification

* Prefer the bare element value over the Item type default so ranked params keep their widgets

* Rename GradientStopsUI to GradientUI

* Split Fill's optional transform into a _has_transform bool and a ranked _transform matrix

* Rename the migration-only OptionalDAffine2 TaggedValue to LegacyOptionalDAffine2

* Flow byte buffers as Item<Resource> instead of List<u8> across the byte nodes

* Macro-generate the list-content wire variant, retiring the hand-written Transform-zip, Area, and Centroid companions

* Let ()-primary generators take ranked params and frame over them via the mapped variant, ranking Circle's radius

* Rank the vector shape generators' params to Item, adding a rank-aware input grab to the introspection harness

* Rank the value, color, and text generator params to Item

* Rank the raster, web-request, and context-reader generator params to Item

* Fix the repeat and brush test wirings left behind by the param-ranking sweeps

* Delete the vestigial Some, Unwrap Option, and Size Of debug nodes

* Delete the Attach Attribute node, folding its role into Write Attribute

* Add the Filter and Sort list companion nodes

* Guard the removed-definition migration swap target with a test

* Add the Box Corners value type in place of the rectangle corner radius list

* Split Text to Vector's per-glyph mode into a Text to Vector Glyphs node

* Rank the Combine Channels node's channel connectors to Item

* Make Map Points an element-wise node

* Delete the deprecated Upload Texture node

* Update the implementation roadmap to reflect the landed stages

* Let monitor introspection read rank-0 wires, locking in the layer coercion promotion path

* Prefer the rank-0 default when disconnecting a rank-capable input

* Make Path Modify an element-wise node

* Wrap node paths in a NodeIdPath newtype so they flow as a single Item

* Give Item<Raster<CPU>> a default so an unconnected Brush background resolves

* Stop the Brush node from setting layer attributes its paint operation doesn't produce

* Present-gate Flatten Path's adopted layer path like its fill and stroke

* Gate carried layer attributes on static column presence, not runtime values

* Give the remaining graphic Item<T> types a default so unconnected primaries resolve

* Dispatch a ranked param's Properties widget from its rank-0 element type

* Make Extract Transform an element-wise node, restoring the Origins to Polyline body

* Rename Flatten Path to Combine Paths

* Stamp Legacy Layer Extend's adopted layer path as a readable NodeIdPath

* Drop the dead List<u8> and List<NodeId> wire rows

* Rank Flatten Graphic's Fully Flatten toggle to Item

* Update the implementation roadmap with the endgame scope

* Make Combine Paths a reducer that collapses the whole frame into one path

* Stop type-converter nodes from carrying the source's unrelated attributes

* Format the Origins to Polyline regression test

* Wrap the Brush node's trace in a BrushTrace newtype so it flows as one value

* Make Switch a framed element-wise select, bundling whole collections

* Widen and align element-type coverage across the list and graphic nodes

* Register the compiler's cache chain pair for every ranked enum and newtype wire

* Fix wire colors for Passthrough outputs, bundled lists, and bools, and widen list wires

* Represent List wire types structurally with Type::List, replacing name-parsed rank promotion

* Treat scope and data fields as environment, rank scope wires as Item, and feed the render boundary through a context vararg

* Delete the vestigial Clone debug node

* Reinstate Upload Texture as an element-wise node and fix the GPU variants' scope executor and rank adapters

* Rename Combine Paths back to Flatten Path, deferring that rename to its own PR

* Deduplicate the promotion adapter registrations into the field adapter macro

* Rank Write Attribute's value connector to Item<AttributeValueDyn>, retiring the UnwrapItem bridge

* Vertical wire styling

* Store the editor layer path attribute as a bare NodeIdPath, not an Item<NodeIdPath>

* Rank Context Modification's features connector to Item<ContextFeatures>, dropping the dead memoize row

* Rank Path Modify's modification parameter to Item<Box<VectorModification>>

* Rename the field adapter node family to input adapter

* Drop the dead bare scalar rows from Context Modification's implementations list

* Move the dynamic executor's test module into its own file

* Drop the registry's unreachable bare rows for Memoize, the cache chain, and ConvertNode

* Materialize stored TaggedValues as ranked Item wires at the source

* Remove the bare-wire promotion and adapter machinery made dead by ranked value materialization

* Plant the input adapter for List-only inputs, composing position conversion from standard rows

* Consolidate Into/Convert conversions into the input adapter umbrella and rename the rank adapter identifiers

* Fix grouped layers gaining a phantom None stack element from the FillChoice default hijacking every List<Graphic> disconnect

* Enforce ranked node inputs in the macro, rejecting bare wire declarations

* Remove the unit Context => () machinery rows, leaving () purely as the no-primary sentinel

* Add a --signatures rank-audit mode to node-docs for the ranked-wire migration

* Remove the node-docs --signatures rank-audit mode now that ranked wires are enforced

* Migrate legacy no-color values on the Black & White, Color Overlay, and Empty Image color inputs

* Rewrite the element-wise accessor wire type at the primary input, not raw index 0

* Register the cache chain for Resource wires, replacing the lone hand-written Monitor row

* Gate the remaining Raster<GPU> registry rows behind the gpu feature

* Let List<DVec2> wires erase to ListDyn for the attribute reader and element counter

* Rename Extract Element to Item at Index, Count Elements to List Length, and Omit Element to Remove at Index

* Store paint picks as plain color/gradient values, removing the FillChoice value type

* Code review restructuring

* Sort by the consumed sort_key attribute or natural element order, adding the Sort Key node

* Remove the new list-combinator and reducer nodes to defer them to a follow-up PR

* Parse Fill and Stroke color defaults through the paint wire's Graphic element

* Emit ranked implementation-row default types structurally so their element TypeIds survive to default-literal parsing

* Exempt the deliberate no-paint choice from the stale List-form TypeDefault migration

* Migrate the legacy 4-input Fill directly to the split has-transform shape

* Upgrade the demo artwork

* Fix the valid AI review findings: Item eq/hash contract, table-era no-paint migration, quantize List rows, and other smaller issues

* Remove the rank polymorphism working documents

* Hash Item attribute values directly instead of debug-formatting them, speeding up cached evaluation

* Replace the data panel's dead bare-wire downcast arms with full coverage of the ranked monitor row types

* Derive PartialEq for Item now that attributes participate in equality

* Extend the data panel's attribute dispatchers with the newly supported scalar and choice enum types

* Add List monitor rows for the framed numeric conversion outputs so inspecting them resolves, with matching data panel arms
This commit is contained in:
Keavon Chambers
2026-09-08 16:03:01 +00:00
committed by Timon
parent 296185b7fc
commit a708a54492
3257 changed files with 766342 additions and 1829 deletions
@@ -0,0 +1,690 @@
//! 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;
use graphite_proc_macros::editor_commands;
use serde::{Deserialize, Serialize};
use tsify::Tsify;
#[cfg(target_family = "wasm")]
use wasm_bindgen::prelude::*;
#[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;
use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta};
use editor::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
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:
// <https://github.com/GraphiteEditor/Graphite/pull/2562#discussion_r2041102786>
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<Message> = 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<String>) -> 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<u8>) -> Message {
PortfolioMessage::OpenFile { path: PathBuf::from(path), content }.into()
}
fn import_file(path: String, content: Vec<u8>) -> 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<u32>, sizes: Vec<f64>) -> 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 `<ColorPicker />` 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 <kbd>Ctrl</kbd> and/or <kbd>Shift</kbd> 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<u64>, insert_index: Option<usize>) -> 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<u64>, insert_index: Option<usize>) -> 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: String, x: i32, y: i32) -> Message {
let id = NodeId::new();
NodeGraphMessage::CreateNodeFromContextMenu {
node_id: Some(id),
node_type: DefinitionIdentifier::from_serialized(&node_type),
xy: Some((x / 24, y / 24)),
add_transaction: true,
}
.into()
}
/// Respond to selection read
fn read_selection(content: Option<String>, 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<String>,
image_data: Vec<u8>,
width: u32,
height: u32,
mouse_x: Option<f64>,
mouse_y: Option<f64>,
insert_parent_id: Option<u64>,
insert_index: Option<usize>,
) -> 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<String>, svg: String, mouse_x: Option<f64>, mouse_y: Option<f64>, insert_parent_id: Option<u64>, insert_index: Option<usize>) -> 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<u64>, 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<T: for<'de> 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<editor::messages::portfolio::utility_types::PanelType>;
SRGBA8 = graphene_std::color::SRGBA8;
FillChoiceUI = graphene_std::vector::style::FillChoiceUI;
}
@@ -0,0 +1,264 @@
//! 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"))]
use crate::MESSAGE_BUFFER;
#[cfg(all(feature = "native", target_family = "wasm"))]
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};
use crate::{EDITOR_HAS_CRASHED, FRONTEND_READY};
#[cfg(all(not(feature = "native"), target_family = "wasm"))]
use editor::application::{Editor, Environment, Host, Platform};
#[cfg(feature = "editor")]
use editor::messages::prelude::*;
#[cfg(feature = "editor")]
use serde::Serialize;
use std::cell::RefCell;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use wasm_bindgen::prelude::*;
pub(crate) static IMAGE_DATA_HASH: AtomicU64 = AtomicU64::new(0);
#[wasm_bindgen]
#[derive(Clone)]
pub struct EditorWrapper {
/// This callback is called by the editor's dispatcher when directing `FrontendMessage`s from Rust to JS
frontend_message_handler_callback: js_sys::Function,
}
impl EditorWrapper {
#[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};
let panic_callback = frontend_message_handler_callback.clone();
let editor_wrapper = EditorWrapper { frontend_message_handler_callback };
if EDITOR_WRAPPER.with(|wrapper| wrapper.lock().ok().map(|mut guard| *guard = Some(editor_wrapper.clone()))).is_none() {
log::error!("Attempted to initialize the editor wrapper more than once");
}
PANIC_DIALOG_MESSAGE_CALLBACK.with_borrow_mut(|callback| *callback = Some(panic_callback));
editor_wrapper
}
}
#[wasm_bindgen]
impl EditorWrapper {
#[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;
use graph_craft::application_io::resource::*;
let host = match platform.as_str() {
"Linux" => Host::Linux,
"Mac" => Host::Mac,
"Windows" => Host::Windows,
_ => unreachable!(),
};
let storage: std::sync::Arc<dyn ResourceStorage> = match OpfsResourceStorage::load("resources").await {
Ok(storage) => std::sync::Arc::new(storage),
Err(error) => {
log::error!("Failed to open OPFS resource storage, falling back to in-memory: {error:?}");
std::sync::Arc::new(graph_craft::application_io::resource::HashMapResourceStorage::new())
}
};
let application_io = PlatformApplicationIo::new().await;
let wake = crate::helpers::async_wake_callback();
// On web the working-copy root is an OPFS directory name (no real filesystem path); each
// document mounts under `documents/<id_hex>`.
let working_copy_root = Some(std::path::PathBuf::from("documents"));
let editor = Editor::new(Environment { platform: Platform::Web, host }, uuid_random_seed, storage, working_copy_root, application_io, wake);
if EDITOR.with(|slot| slot.lock().ok().map(|mut guard| *guard = Some(editor))).is_none() {
log::error!("Attempted to initialize the editor more than once");
}
Self::initialize_wrapper(frontend_message_handler_callback)
}
#[cfg(feature = "native")]
pub fn create(_platform: String, _uuid_random_seed: u64, frontend_message_handler_callback: js_sys::Function) -> EditorWrapper {
Self::initialize_wrapper(frontend_message_handler_callback)
}
// Sends a message to the dispatcher in the Editor Backend
#[cfg(not(feature = "native"))]
pub(crate) fn dispatch<T: Into<Message>>(&self, message: T) {
// Process no further messages after a crash to avoid spamming the console
if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) {
return;
}
// Get the editor, dispatch the message, and store the `FrontendMessage` queue response
let frontend_messages = EDITOR.with(|editor| {
let mut guard = editor.try_lock();
let Ok(Some(editor)) = guard.as_deref_mut() else {
// Enqueue messages which can't be procssed currently
MESSAGE_BUFFER.with_borrow_mut(|buffer| buffer.push(message.into()));
return vec![];
};
editor.handle_message(message)
});
// Send each `FrontendMessage` to the JavaScript frontend
for message in frontend_messages.into_iter() {
self.send_frontend_message_to_js(message);
}
}
#[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(image_data);
let prev_hash = IMAGE_DATA_HASH.load(Ordering::Relaxed);
if new_hash != prev_hash {
render_image_data_to_canvases(image_data.iter());
IMAGE_DATA_HASH.store(new_hash, Ordering::Relaxed);
}
return;
}
let message_type = message.to_discriminant().local_name();
let serializer = serde_wasm_bindgen::Serializer::new().serialize_large_number_types_as_bigints(true);
let message_data = message.serialize(&serializer).expect("Failed to serialize FrontendMessage");
let js_return_value = self.frontend_message_handler_callback.call2(&JsValue::null(), &JsValue::from(message_type), &message_data);
if let Err(error) = js_return_value {
error!("While handling FrontendMessage {:?}, JavaScript threw an error:\n{:?}", message.to_discriminant().local_name(), error,)
}
}
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(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) {
return;
}
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
if FRONTEND_READY.swap(true, Ordering::SeqCst) {
return;
}
#[cfg(feature = "native")]
crate::native_communication::initialize_native_communication();
#[cfg(target_family = "wasm")]
self.init_portfolio();
// Poll node graph evaluation on `requestAnimationFrame`
{
let f = std::rc::Rc::new(RefCell::new(None));
let g = f.clone();
*g.borrow_mut() = Some(Closure::new(move |_timestamp| {
#[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"))]
{
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());
}));
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 || {
#[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(AUTO_SAVE_TIMEOUT_SECONDS));
}));
set_timeout(g.borrow().as_ref().unwrap(), Duration::from_secs(AUTO_SAVE_TIMEOUT_SECONDS));
}
}
/// Answer whether or not the editor has crashed
#[wasm_bindgen(js_name = hasCrashed)]
pub fn has_crashed(&self) -> bool {
EDITOR_HAS_CRASHED.load(Ordering::SeqCst)
}
/// Answer whether or not the editor is in development mode
#[wasm_bindgen(js_name = inDevelopmentMode)]
pub fn in_development_mode(&self) -> bool {
cfg!(debug_assertions)
}
#[wasm_bindgen(js_name = fileExtension)]
pub fn file_extension(&self) -> String {
"graphite".into()
}
#[wasm_bindgen(js_name = gddFileExtension)]
pub fn gdd_file_extension(&self) -> String {
"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 });
}
#[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");
}
}
@@ -0,0 +1,359 @@
#[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::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 js_sys::{Object, Reflect};
#[cfg(not(feature = "native"))]
use std::sync::atomic::Ordering;
use std::time::Duration;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, window};
/// Helper function for calling JS's `requestAnimationFrame` with the given closure
pub(crate) fn request_animation_frame(f: &Closure<dyn FnMut(f64)>) {
web_sys::window()
.expect("No global `window` exists")
.request_animation_frame(f.as_ref().unchecked_ref())
.expect("Failed to call `requestAnimationFrame`");
}
/// Helper function for calling JS's `setTimeout` with the given closure and delay
pub(crate) fn set_timeout(f: &Closure<dyn FnMut()>, delay: Duration) {
let delay = delay.clamp(Duration::ZERO, Duration::from_millis(i32::MAX as u64)).as_millis() as i32;
web_sys::window()
.expect("No global `window` exists")
.set_timeout_with_callback_and_timeout_and_arguments_0(f.as_ref().unchecked_ref(), delay)
.expect("Failed to call `setTimeout`");
}
/// Provides access to the `Editor` by calling the given closure with it as an argument.
#[cfg(not(feature = "native"))]
fn editor<T: Default>(callback: impl FnOnce(&mut editor::application::Editor) -> T) -> T {
EDITOR.with(|editor| {
let mut guard = editor.try_lock();
let Ok(Some(editor)) = guard.as_deref_mut() else {
log::error!("Failed to borrow editor");
return T::default();
};
callback(editor)
})
}
/// Provides access to the `Editor` and its `EditorWrapper` by calling the given closure with them as arguments.
#[cfg(not(feature = "native"))]
pub(crate) fn editor_and_wrapper(callback: impl FnOnce(&mut Editor, &mut EditorWrapper)) {
wrapper(|wrapper| {
editor(|editor| {
// Call the closure with the editor and its wrapper
callback(editor, wrapper);
})
});
}
/// Provides access to the `EditorWrapper` by calling the given closure with them as arguments.
pub(crate) fn wrapper(callback: impl FnOnce(&mut EditorWrapper)) {
EDITOR_WRAPPER.with(|wrapper| {
let mut guard = wrapper.try_lock();
let Ok(Some(wrapper)) = guard.as_deref_mut() else {
log::error!("Failed to borrow editor wrapper");
return;
};
// Call the closure with the editor and its wrapper
callback(wrapper);
});
}
#[cfg(not(feature = "native"))]
pub(crate) async fn poll_node_graph_evaluation() {
// Process no further messages after a crash to avoid spamming the console
if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) {
return;
}
if !editor::node_graph_executor::run_node_graph().await.0 {
return;
}
editor_and_wrapper(|editor, wrapper| {
let mut messages = VecDeque::new();
if let Err(e) = editor.poll_node_graph_evaluation(&mut messages) {
// TODO: This is a hacky way to suppress the error, but it shouldn't be generated in the first place
if e != "No active document" {
error!("Error evaluating node graph:\n{e}");
}
}
// Clear the error display if there are no more errors
if !messages.is_empty() {
crate::NODE_GRAPH_ERROR_DISPLAYED.store(false, Ordering::SeqCst);
}
// Batch responses to pool frontend updates
let batched = Message::Batched {
messages: messages.into_iter().collect(),
};
// Send each `FrontendMessage` to the JavaScript frontend
for response in editor.handle_message(batched) {
wrapper.send_frontend_message_to_js(response);
}
// If the editor cannot be borrowed then it has encountered a panic - we should just ignore new dispatches
});
}
/// Web wake callback: queues a microtask that dispatches [`FutureMessage::Wake`].
#[cfg(all(not(feature = "native"), target_family = "wasm"))]
pub(crate) fn async_wake_callback() -> Wake {
use std::sync::Arc;
Arc::new(|| {
wasm_bindgen_futures::spawn_local(async {
wrapper(|wrapper| wrapper.dispatch(FutureMessage::Wake));
});
})
}
/// Blits each rasterized image to a canvas registered on `window.imageCanvases`
pub(crate) fn render_image_data_to_canvases<'a>(image_data: impl IntoIterator<Item = &'a RasterizedImage>) {
let window = match window() {
Some(window) => window,
None => {
error!("Cannot render canvas: window object not found");
return;
}
};
let document = window.document().expect("window should have a document");
let window_obj = Object::from(window);
let image_canvases_key = JsValue::from_str("imageCanvases");
let canvases_obj = match Reflect::get(&window_obj, &image_canvases_key) {
Ok(obj) if !obj.is_undefined() && !obj.is_null() => obj,
_ => {
let new_obj = Object::new();
if Reflect::set(&window_obj, &image_canvases_key, &new_obj).is_err() {
error!("Failed to create and set imageCanvases object on window");
return;
}
new_obj.into()
}
};
let canvases_obj = Object::from(canvases_obj);
for 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) || width == 0 || height == 0 {
continue;
}
let canvas: HtmlCanvasElement = document
.create_element("canvas")
.expect("Failed to create canvas element")
.dyn_into::<HtmlCanvasElement>()
.expect("Failed to cast element to HtmlCanvasElement");
canvas.set_width(width);
canvas.set_height(height);
let context: CanvasRenderingContext2d = canvas
.get_context("2d")
.expect("Failed to get 2d context")
.expect("2d context was not found")
.dyn_into::<CanvasRenderingContext2d>()
.expect("Failed to cast context to CanvasRenderingContext2d");
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}");
}
}
Err(e) => {
error!("Failed to create ImageData for id: {placeholder_id}: {e:?}");
}
}
let js_value = JsValue::from(canvas);
if Reflect::set(&canvases_obj, &js_key, &js_value).is_err() {
error!("Failed to set canvas '{canvas_name}' on imageCanvases object");
}
}
}
pub(crate) fn calculate_hash<T: std::hash::Hash>(t: &T) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
let mut hasher = DefaultHasher::new();
t.hash(&mut hasher);
hasher.finish()
}
/// 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::*;
trace!("Key event received: {name}");
match name {
// Writing system keys
"Digit0" | "Numpad0" => Digit0,
"Digit1" | "Numpad1" => Digit1,
"Digit2" | "Numpad2" => Digit2,
"Digit3" | "Numpad3" => Digit3,
"Digit4" | "Numpad4" => Digit4,
"Digit5" | "Numpad5" => Digit5,
"Digit6" | "Numpad6" => Digit6,
"Digit7" | "Numpad7" => Digit7,
"Digit8" | "Numpad8" => Digit8,
"Digit9" | "Numpad9" => Digit9,
//
"KeyA" => KeyA,
"KeyB" => KeyB,
"KeyC" => KeyC,
"KeyD" => KeyD,
"KeyE" => KeyE,
"KeyF" => KeyF,
"KeyG" => KeyG,
"KeyH" => KeyH,
"KeyI" => KeyI,
"KeyJ" => KeyJ,
"KeyK" => KeyK,
"KeyL" => KeyL,
"KeyM" => KeyM,
"KeyN" => KeyN,
"KeyO" => KeyO,
"KeyP" => KeyP,
"KeyQ" => KeyQ,
"KeyR" => KeyR,
"KeyS" => KeyS,
"KeyT" => KeyT,
"KeyU" => KeyU,
"KeyV" => KeyV,
"KeyW" => KeyW,
"KeyX" => KeyX,
"KeyY" => KeyY,
"KeyZ" => KeyZ,
//
"Backquote" => Backquote,
"Backslash" => Backslash,
"BracketLeft" => BracketLeft,
"BracketRight" => BracketRight,
"Comma" | "NumpadComma" => Comma,
"Equal" | "NumpadEqual" => Equal,
"Minus" | "NumpadSubtract" => Minus,
"Period" | "NumpadDecimal" => Period,
"Quote" => Quote,
"Semicolon" => Semicolon,
"Slash" | "NumpadDivide" => Slash,
// Functional keys
"AltLeft" | "AltRight" | "AltGraph" => Alt,
"MetaLeft" | "MetaRight" => Meta,
"ShiftLeft" | "ShiftRight" => Shift,
"ControlLeft" | "ControlRight" => Control,
"Backspace" | "NumpadBackspace" => Backspace,
"CapsLock" => CapsLock,
"ContextMenu" => ContextMenu,
"Enter" | "NumpadEnter" => Enter,
"Space" => Space,
"Tab" => Tab,
// Control pad keys
"Delete" => Delete,
"End" => End,
"Help" => Help,
"Home" => Home,
"Insert" => Insert,
"PageDown" => PageDown,
"PageUp" => PageUp,
// Arrow pad keys
"ArrowDown" => ArrowDown,
"ArrowLeft" => ArrowLeft,
"ArrowRight" => ArrowRight,
"ArrowUp" => ArrowUp,
// Numpad keys
// "Numpad0" => KeyNumpad0,
// "Numpad1" => KeyNumpad1,
// "Numpad2" => KeyNumpad2,
// "Numpad3" => KeyNumpad3,
// "Numpad4" => KeyNumpad4,
// "Numpad5" => KeyNumpad5,
// "Numpad6" => KeyNumpad6,
// "Numpad7" => KeyNumpad7,
// "Numpad8" => KeyNumpad8,
// "Numpad9" => KeyNumpad9,
"NumLock" => NumLock,
"NumpadAdd" => NumpadAdd,
// "NumpadBackspace" => KeyNumpadBackspace,
// "NumpadClear" => NumpadClear,
// "NumpadClearEntry" => NumpadClearEntry,
// "NumpadComma" => KeyNumpadComma,
// "NumpadDecimal" => KeyNumpadDecimal,
// "NumpadDivide" => KeyNumpadDivide,
// "NumpadEnter" => KeyNumpadEnter,
// "NumpadEqual" => KeyNumpadEqual,
"NumpadHash" => NumpadHash,
// "NumpadMemoryAdd" => NumpadMemoryAdd,
// "NumpadMemoryClear" => NumpadMemoryClear,
// "NumpadMemoryRecall" => NumpadMemoryRecall,
// "NumpadMemoryStore" => NumpadMemoryStore,
// "NumpadMemorySubtract" => NumpadMemorySubtract,
"NumpadMultiply" | "NumpadStar" => NumpadMultiply,
"NumpadParenLeft" => NumpadParenLeft,
"NumpadParenRight" => NumpadParenRight,
// "NumpadStar" => NumpadStar,
// "NumpadSubtract" => KeyNumpadSubtract,
// Function keys
"Escape" => Escape,
"F1" => F1,
"F2" => F2,
"F3" => F3,
"F4" => F4,
"F5" => F5,
"F6" => F6,
"F7" => F7,
"F8" => F8,
"F9" => F9,
"F10" => F10,
"F11" => F11,
"F12" => F12,
"F13" => F13,
"F14" => F14,
"F15" => F15,
"F16" => F16,
"F17" => F17,
"F18" => F18,
"F19" => F19,
"F20" => F20,
"F21" => F21,
"F22" => F22,
"F23" => F23,
"F24" => F24,
"Fn" => Fn,
"FnLock" => FnLock,
"PrintScreen" => PrintScreen,
"ScrollLock" => ScrollLock,
"Pause" => Pause,
// Unidentified keys
_ => Unidentified,
}
}
@@ -0,0 +1,233 @@
#![doc = include_str!("../README.md")]
// `macro_use` puts the log macros (`error!`, `warn!`, `debug!`, `info!` and `trace!`) in scope for the crate
#[macro_use]
extern crate log;
pub mod editor_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;
use std::sync::atomic::{AtomicBool, Ordering};
use wasm_bindgen::prelude::*;
// Set up the persistent editor backend state
pub static EDITOR_HAS_CRASHED: AtomicBool = AtomicBool::new(false);
pub static FRONTEND_READY: AtomicBool = AtomicBool::new(false);
pub static NODE_GRAPH_ERROR_DISPLAYED: AtomicBool = AtomicBool::new(false);
pub static LOGGER: WasmLog = WasmLog;
thread_local! {
#[cfg(not(feature = "native"))]
pub static EDITOR: Mutex<Option<editor::application::Editor>> = const { Mutex::new(None) };
#[cfg(not(feature = "native"))]
pub static MESSAGE_BUFFER: std::cell::RefCell<Vec<Message>> = const { std::cell::RefCell::new(Vec::new()) };
pub static EDITOR_WRAPPER: Mutex<Option<editor_wrapper::EditorWrapper>> = const { Mutex::new(None) };
pub static PANIC_DIALOG_MESSAGE_CALLBACK: std::cell::RefCell<Option<js_sys::Function>> = const { std::cell::RefCell::new(None) };
}
/// Initialize the backend
#[wasm_bindgen(start)]
pub fn init_graphite() {
// Set up the panic hook
panic::set_hook(Box::new(panic_hook));
// Set up the logger with a default level of debug
log::set_logger(&LOGGER).expect("Failed to set logger");
log::set_max_level(log::LevelFilter::Debug);
}
/// 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();
// 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#"
<rect x="50%" y="50%" width="600" height="100" transform="translate(-300 -50)" rx="4" fill="var(--color-error-red)" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="18" fill="var(--color-2-mildblack)">
<tspan x="50%" dy="-24" font-weight="bold">The document crashed while being rendered in its current state.</tspan>
<tspan x="50%" dy="24">The editor is now unstable! Undo your last action to restore the artwork,</tspan>
<tspan x="50%" dy="24">then save your document and restart the editor before continuing work.</tspan>
/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 });
});
}
return;
}
}
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.
if let Err(info) = send_panic_dialog_via_callback(info) {
send_panic_dialog_deferred(info);
}
}
fn send_panic_dialog_via_callback(panic_info: String) -> Result<(), String> {
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");
return Err(panic_info);
};
PANIC_DIALOG_MESSAGE_CALLBACK.with(|callback| {
let callback_ref = callback.borrow();
let Some(callback) = callback_ref.as_ref() else {
return Err(panic_info);
};
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);
}
Ok(())
})
}
#[cfg(not(feature = "native"))]
fn send_panic_dialog_deferred(panic_info: String) {
let callback = Closure::once_into_js(move || {
if send_panic_dialog_via_callback(panic_info).is_err() {
log::error!("Failed to send crash dialog after panic because the editor wrapper is unavailable");
}
});
let Some(window) = web_sys::window() else {
log::error!("Failed to schedule crash dialog after panic because no window exists");
return;
};
if window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), 0).is_err() {
log::error!("Failed to schedule crash dialog after panic with setTimeout");
}
}
#[cfg(feature = "native")]
fn send_panic_dialog_deferred(_panic_info: String) {
// Native builds do not use `setTimeout`, so just log the failure in the caller's context.
}
#[wasm_bindgen]
extern "C" {
/// The JavaScript `Error` type
#[derive(Clone, Debug)]
pub type Error;
#[wasm_bindgen(constructor)]
pub fn new(msg: &str) -> Error;
#[wasm_bindgen(structural, method, getter)]
fn stack(error: &Error) -> String;
}
/// Logging to the JS console
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(msg: &str, format: &str);
#[wasm_bindgen(js_namespace = console)]
fn info(msg: &str, format: &str);
#[wasm_bindgen(js_namespace = console)]
fn warn(msg: &str, format: &str);
#[wasm_bindgen(js_namespace = console)]
fn error(msg: &str, format: &str);
#[wasm_bindgen(js_namespace = console)]
fn trace(msg: &str, format: &str);
}
#[derive(Default)]
pub struct WasmLog;
impl log::Log for WasmLog {
#[inline]
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= log::max_level()
}
fn log(&self, record: &log::Record) {
if !self.enabled(record.metadata()) {
return;
}
let (log, name, color): (fn(&str, &str), &str, &str) = match record.level() {
log::Level::Trace => (log, "trace", "color:plum"),
log::Level::Debug => (log, "debug", "color:cyan"),
log::Level::Warn => (warn, "warn", "color:goldenrod"),
log::Level::Info => (info, "info", "color:mediumseagreen"),
log::Level::Error => (error, "error", "color:red"),
};
// The %c is replaced by the message color
if record.level() == log::Level::Info {
// We don't print the file name and line number for info-level logs because it's used for printing the message system logs
log(&format!("%c{}\t{}", name, record.args()), color);
} else {
let file = record.file().unwrap_or_else(|| record.target());
let line = record.line().map_or_else(|| "[Unknown]".to_string(), |line| line.to_string());
let args = record.args();
log(&format!("%c{name}\t{file}:{line}\n{args}"), color);
}
}
fn flush(&self) {}
}
#[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);
}
}
@@ -0,0 +1,133 @@
//! 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;
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 serde_json::from_slice::<Vec<(String, String)>>(&buffer) {
Ok(messages) => {
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 {
continue;
}
match serde_json::from_str::<Vec<RasterizedImage>>(&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::<WasmValue>(&data) {
Ok(value) => wrapper.forward_serialized_frontend_message_to_js(&name, value),
Err(e) => log::error!("Failed to deserialize frontend message {name}: {e:?}"),
},
}
}
});
}
Err(e) => log::error!("Failed to deserialize frontend messages: {e:?}"),
}
}
#[cfg(feature = "editor")]
pub fn encode_frontend_messages(messages: Vec<editor::messages::prelude::FrontendMessage>) -> Option<Vec<u8>> {
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::<Option<_>>()?;
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<editor::messages::prelude::Message> {
match serde_json::from_slice::<crate::EditorCommand>(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();
// Get the function by name
let func = js_sys::Reflect::get(&global, &JsValue::from_str("initializeNativeCommunication")).expect("Function not found");
let func = func.dyn_into::<js_sys::Function>().expect("Not a function");
// Call it
func.call0(&JsValue::NULL).expect("Function call failed");
}
pub fn send_message_to_cef(message: String) {
let global = js_sys::global();
// Get the function by name
let func = js_sys::Reflect::get(&global, &JsValue::from_str("sendNativeMessage")).expect("Function not found");
let func = func.dyn_into::<js_sys::Function>().expect("Not a function");
let array = Uint8Array::from(message.as_bytes());
let buffer = array.buffer();
// 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<u8>,
}
#[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<RasterizedImageCopy> = 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]);
}
}
@@ -0,0 +1,448 @@
//! 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;
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<u8>),
Seq(Vec<WasmValue>),
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<NonFinite> for f64 {
fn from(value: NonFinite) -> Self {
match value {
NonFinite::Nan => f64::NAN,
NonFinite::PositiveInfinity => f64::INFINITY,
NonFinite::NegativeInfinity => f64::NEG_INFINITY,
}
}
}
impl From<WasmValue> 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<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
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<T: Serialize + ?Sized>(value: &T) -> Result<WasmValue, Error> {
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<WasmValue, Error> {
Ok(WasmValue::Bool(v))
}
fn serialize_i8(self, v: i8) -> Result<WasmValue, Error> {
Ok(WasmValue::Number(v as f64))
}
fn serialize_i16(self, v: i16) -> Result<WasmValue, Error> {
Ok(WasmValue::Number(v as f64))
}
fn serialize_i32(self, v: i32) -> Result<WasmValue, Error> {
Ok(WasmValue::Number(v as f64))
}
fn serialize_i64(self, v: i64) -> Result<WasmValue, Error> {
Ok(WasmValue::Int(v))
}
fn serialize_i128(self, v: i128) -> Result<WasmValue, Error> {
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<WasmValue, Error> {
Ok(WasmValue::Number(v as f64))
}
fn serialize_u16(self, v: u16) -> Result<WasmValue, Error> {
Ok(WasmValue::Number(v as f64))
}
fn serialize_u32(self, v: u32) -> Result<WasmValue, Error> {
Ok(WasmValue::Number(v as f64))
}
fn serialize_u64(self, v: u64) -> Result<WasmValue, Error> {
Ok(WasmValue::UInt(v))
}
fn serialize_u128(self, v: u128) -> Result<WasmValue, Error> {
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<WasmValue, Error> {
self.serialize_f64(v as f64)
}
fn serialize_f64(self, v: f64) -> Result<WasmValue, Error> {
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<WasmValue, Error> {
Ok(WasmValue::String(v.to_string()))
}
fn serialize_str(self, v: &str) -> Result<WasmValue, Error> {
Ok(WasmValue::String(v.to_string()))
}
fn serialize_bytes(self, v: &[u8]) -> Result<WasmValue, Error> {
Ok(WasmValue::Bytes(v.to_vec()))
}
fn serialize_none(self) -> Result<WasmValue, Error> {
Ok(WasmValue::Undefined)
}
fn serialize_some<T: Serialize + ?Sized>(self, value: &T) -> Result<WasmValue, Error> {
value.serialize(self)
}
fn serialize_unit(self) -> Result<WasmValue, Error> {
Ok(WasmValue::Undefined)
}
fn serialize_unit_struct(self, _name: &'static str) -> Result<WasmValue, Error> {
Ok(WasmValue::Undefined)
}
fn serialize_unit_variant(self, _name: &'static str, _variant_index: u32, variant: &'static str) -> Result<WasmValue, Error> {
Ok(WasmValue::String(variant.to_string()))
}
fn serialize_newtype_struct<T: Serialize + ?Sized>(self, _name: &'static str, value: &T) -> Result<WasmValue, Error> {
value.serialize(self)
}
fn serialize_newtype_variant<T: Serialize + ?Sized>(self, _name: &'static str, _variant_index: u32, variant: &'static str, value: &T) -> Result<WasmValue, Error> {
Ok(WasmValue::Object(vec![(variant.to_string(), value.serialize(ValueSerializer)?)]))
}
fn serialize_seq(self, len: Option<usize>) -> Result<SeqSerializer, Error> {
Ok(SeqSerializer(Vec::with_capacity(len.unwrap_or_default())))
}
fn serialize_tuple(self, len: usize) -> Result<SeqSerializer, Error> {
self.serialize_seq(Some(len))
}
fn serialize_tuple_struct(self, _name: &'static str, len: usize) -> Result<SeqSerializer, Error> {
self.serialize_seq(Some(len))
}
fn serialize_tuple_variant(self, _name: &'static str, _variant_index: u32, variant: &'static str, len: usize) -> Result<VariantSeqSerializer, Error> {
Ok(VariantSeqSerializer {
variant,
elements: Vec::with_capacity(len),
})
}
fn serialize_map(self, len: Option<usize>) -> Result<MapSerializer, Error> {
Ok(MapSerializer {
entries: Vec::with_capacity(len.unwrap_or_default()),
pending_key: None,
})
}
fn serialize_struct(self, _name: &'static str, len: usize) -> Result<ObjectSerializer, Error> {
Ok(ObjectSerializer(Vec::with_capacity(len)))
}
fn serialize_struct_variant(self, _name: &'static str, _variant_index: u32, variant: &'static str, len: usize) -> Result<VariantObjectSerializer, Error> {
Ok(VariantObjectSerializer {
variant,
fields: Vec::with_capacity(len),
})
}
}
struct SeqSerializer(Vec<WasmValue>);
impl ser::SerializeSeq for SeqSerializer {
type Ok = WasmValue;
type Error = Error;
fn serialize_element<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Error> {
self.0.push(value.serialize(ValueSerializer)?);
Ok(())
}
fn end(self) -> Result<WasmValue, Error> {
Ok(WasmValue::Seq(self.0))
}
}
impl ser::SerializeTuple for SeqSerializer {
type Ok = WasmValue;
type Error = Error;
fn serialize_element<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Error> {
ser::SerializeSeq::serialize_element(self, value)
}
fn end(self) -> Result<WasmValue, Error> {
ser::SerializeSeq::end(self)
}
}
impl ser::SerializeTupleStruct for SeqSerializer {
type Ok = WasmValue;
type Error = Error;
fn serialize_field<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Error> {
ser::SerializeSeq::serialize_element(self, value)
}
fn end(self) -> Result<WasmValue, Error> {
ser::SerializeSeq::end(self)
}
}
struct VariantSeqSerializer {
variant: &'static str,
elements: Vec<WasmValue>,
}
impl ser::SerializeTupleVariant for VariantSeqSerializer {
type Ok = WasmValue;
type Error = Error;
fn serialize_field<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Error> {
self.elements.push(value.serialize(ValueSerializer)?);
Ok(())
}
fn end(self) -> Result<WasmValue, Error> {
Ok(WasmValue::Object(vec![(self.variant.to_string(), WasmValue::Seq(self.elements))]))
}
}
struct MapSerializer {
entries: Vec<(WasmValue, WasmValue)>,
pending_key: Option<WasmValue>,
}
impl ser::SerializeMap for MapSerializer {
type Ok = WasmValue;
type Error = Error;
fn serialize_key<T: Serialize + ?Sized>(&mut self, key: &T) -> Result<(), Error> {
self.pending_key = Some(key.serialize(ValueSerializer)?);
Ok(())
}
fn serialize_value<T: Serialize + ?Sized>(&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<WasmValue, Error> {
Ok(WasmValue::Map(self.entries))
}
}
struct ObjectSerializer(Vec<(String, WasmValue)>);
impl ser::SerializeStruct for ObjectSerializer {
type Ok = WasmValue;
type Error = Error;
fn serialize_field<T: Serialize + ?Sized>(&mut self, key: &'static str, value: &T) -> Result<(), Error> {
self.0.push((key.to_string(), value.serialize(ValueSerializer)?));
Ok(())
}
fn end(self) -> Result<WasmValue, Error> {
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<T: Serialize + ?Sized>(&mut self, key: &'static str, value: &T) -> Result<(), Error> {
self.fields.push((key.to_string(), value.serialize(ValueSerializer)?));
Ok(())
}
fn end(self) -> Result<WasmValue, Error> {
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<String>,
widths: std::collections::BTreeMap<u64, u32>,
flags: Vec<bool>,
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: WasmValue = serde_json::from_str(json).unwrap();
assert_eq!(decoded, to_value(&message).unwrap());
}
}
}