From f90bef159c2665d91fce86d8e85ff75a6032261f Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 18 Sep 2026 18:14:24 -0700 Subject: [PATCH] Add a dropdown for resource selection and adopt it in the 'Image' node (#4543) * Add a dropdown for resource selection and adopt it in the Image node * Route every image upload through a new ResourceUpload handler with desktop support and migrate wired 'Image' node inputs * Show each resource's user count in the resource picker dropdown * Send a picked resource file to the document that requested it, list every non-font resource in the picker, and link sibling message docs --- .../src/handle_desktop_wrapper_message.rs | 5 + .../wrapper/src/intercept_frontend_message.rs | 8 + desktop/wrapper/src/messages.rs | 1 + .../clipboard/clipboard_message_handler.rs | 11 +- .../src/messages/frontend/frontend_message.rs | 3 + .../portfolio/document/document_message.rs | 5 +- .../document/document_message_handler.rs | 3 +- .../graph_operation_message.rs | 4 +- .../graph_operation_message_handler.rs | 9 +- .../document/graph_operation/utility_types.rs | 11 +- .../node_graph/document_node_definitions.rs | 11 +- .../document/node_graph/node_properties.rs | 109 ++++++++- .../properties_panel_message_handler.rs | 3 + .../storage_tests/round_trip_tests.rs | 74 +++--- .../network_interface/queries.rs | 7 + .../utility_types/network_interface/types.rs | 72 ++++-- .../messages/portfolio/document_migration.rs | 53 ++++- editor/src/messages/portfolio/mod.rs | 3 + .../messages/portfolio/portfolio_message.rs | 15 +- .../portfolio/portfolio_message_handler.rs | 105 +++------ .../messages/portfolio/resource_upload/mod.rs | 9 + .../resource_upload_message.rs | 14 ++ .../resource_upload_message_handler.rs | 216 ++++++++++++++++++ .../resource_upload/utility_types.rs | 96 ++++++++ .../src/messages/portfolio/utility_types.rs | 7 +- editor/src/messages/prelude.rs | 1 + .../graph_modification_utils.rs | 13 +- editor/src/test_utils.rs | 8 +- frontend/src/stores/portfolio.ts | 6 + frontend/src/utility-functions/files.ts | 4 + frontend/wrapper/src/editor_commands.rs | 38 ++- frontend/wrapper/src/editor_wrapper.rs | 16 ++ node-graph/graph-craft/src/document/value.rs | 2 +- node-graph/nodes/raster/src/std_nodes.rs | 12 +- 34 files changed, 774 insertions(+), 180 deletions(-) create mode 100644 editor/src/messages/portfolio/resource_upload/mod.rs create mode 100644 editor/src/messages/portfolio/resource_upload/resource_upload_message.rs create mode 100644 editor/src/messages/portfolio/resource_upload/resource_upload_message_handler.rs create mode 100644 editor/src/messages/portfolio/resource_upload/utility_types.rs diff --git a/desktop/wrapper/src/handle_desktop_wrapper_message.rs b/desktop/wrapper/src/handle_desktop_wrapper_message.rs index 8887763d40..3ffeef87f9 100644 --- a/desktop/wrapper/src/handle_desktop_wrapper_message.rs +++ b/desktop/wrapper/src/handle_desktop_wrapper_message.rs @@ -22,6 +22,11 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess OpenFileDialogContext::Import => { dispatcher.queue_desktop_wrapper_message(DesktopWrapperMessage::ImportFile { path, content }); } + OpenFileDialogContext::UploadResource => { + let name = path.file_name().map(|name| name.to_string_lossy().to_string()); + let message = ResourceUploadMessage::ReceiveUpload { name, data: content.into() }; + dispatcher.queue_editor_message(message); + } }, DesktopWrapperMessage::SaveFileDialogResult { path, context } => match context { SaveFileDialogContext::Document { document_id, content } => { diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 6d13e4bc4c..736c8fda16 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -26,6 +26,14 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD context: OpenFileDialogContext::Import, }); } + FrontendMessage::TriggerUploadResource { filters } => { + dispatcher.respond(DesktopFrontendMessage::OpenFileDialog { + title: "Select File".to_string(), + filters, + multiple: false, + context: OpenFileDialogContext::UploadResource, + }); + } FrontendMessage::TriggerSaveDocument { document_id, name, diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 0559e02f36..cb90c4c262 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -107,6 +107,7 @@ pub enum DesktopWrapperMessage { pub enum OpenFileDialogContext { Open, Import, + UploadResource, } pub enum SaveFileDialogContext { diff --git a/editor/src/messages/clipboard/clipboard_message_handler.rs b/editor/src/messages/clipboard/clipboard_message_handler.rs index 73b72b7d6e..f62b1cdaff 100644 --- a/editor/src/messages/clipboard/clipboard_message_handler.rs +++ b/editor/src/messages/clipboard/clipboard_message_handler.rs @@ -5,6 +5,7 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions: use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface; use crate::messages::portfolio::document::utility_types::nodes::SelectedNodes; +use crate::messages::portfolio::resource_upload::utility_types::UploadTarget; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::utility_types::ToolType; @@ -60,11 +61,13 @@ impl MessageHandler> for Clipboard }); } ClipboardContentRaw::Image { data, width, height } => { - responses.add(PortfolioMessage::InsertImage { - image: Image::from_image_data(&data, width, height), + responses.add(ResourceUploadMessage::Upload { name: None, - mouse: None, - parent_and_insert_index: None, + data: Image::from_image_data(&data, width, height).to_png().into(), + target: UploadTarget::Layer { + mouse: None, + parent_and_insert_index: None, + }, }); } }, diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 29daaa269b..fb4dc88ab0 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -93,6 +93,9 @@ pub enum FrontendMessage { TriggerImport { filters: Vec, }, + TriggerUploadResource { + filters: Vec, + }, TriggerSaveDocument { document_id: DocumentId, name: String, diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index b8669543f7..9be7329b76 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -7,14 +7,13 @@ use crate::messages::portfolio::document::data_panel::DataPanelMessage; use crate::messages::portfolio::document::overlays::utility_types::{OverlayContext, OverlaysType}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GridSnapping}; +use crate::messages::portfolio::resource_upload::utility_types::ImageResource; use crate::messages::portfolio::utility_types::PanelType; use crate::messages::prelude::*; use glam::{DAffine2, IVec2}; use graph_craft::document::NodeId; use graphene_std::Appearance; -use graphene_std::Color; use graphene_std::raster::BlendMode; -use graphene_std::raster::Image; use graphene_std::transform::Footprint; use graphene_std::vector::Vector; use graphene_std::vector::click_target::ClickTarget; @@ -117,7 +116,7 @@ pub enum DocumentMessage { }, InsertImage { name: Option, - image: Image, + image: ImageResource, mouse: Option<(f64, f64)>, parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>, /// When true (file-open flow), place the image at the document origin so `WrapContentInArtboard` diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 8347239f0a..962beb8db9 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -249,6 +249,7 @@ impl MessageHandler> for DocumentMes DocumentMessage::PropertiesPanel(message) => { let context = PropertiesPanelMessageContext { executor, + document_id, network_interface: &mut self.network_interface, resources: &self.resources, selection_network_path: &self.selection_network_path, @@ -826,7 +827,7 @@ impl MessageHandler> for DocumentMes responses.add(DocumentMessage::StartTransaction); - let layer = graph_modification_utils::new_image_layer(image, layer_node_id, layer_parent, responses); + let layer = graph_modification_utils::new_image_layer(image.resource_id, layer_node_id, layer_parent, responses); if let Some(name) = name { responses.add(NodeGraphMessage::SetDisplayName { diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 2a3fef21cb..89d9fd5e0e 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -3,10 +3,10 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::NodeTemplate; use crate::messages::prelude::*; use glam::{DAffine2, DVec2}; +use graph_craft::application_io::resource::ResourceId; use graph_craft::document::NodeId; use graphene_std::Color; use graphene_std::raster::BlendMode; -use graphene_std::raster_types::Image; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, PaintOrder, Stroke}; use graphene_std::vector::{Gradient, VectorModificationType}; @@ -141,7 +141,7 @@ pub enum GraphOperationMessage { }, NewBitmapLayer { id: NodeId, - image: Image, + resource_id: ResourceId, parent: LayerNodeIdentifier, insert_index: usize, }, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index c3741f2c65..93a415ea82 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -267,10 +267,15 @@ impl MessageHandler> for responses.add_front(NodeGraphMessage::SelectedNodesSet { nodes: vec![id] }); responses.add(NodeGraphMessage::RunDocumentGraph); } - GraphOperationMessage::NewBitmapLayer { id, image, parent, insert_index } => { + GraphOperationMessage::NewBitmapLayer { + id, + resource_id, + parent, + insert_index, + } => { let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); let layer = modify_inputs.create_layer(id); - modify_inputs.insert_image_data(image, layer); + modify_inputs.insert_image_data(resource_id, layer); network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); responses.add(NodeGraphMessage::RunDocumentGraph); } diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index e72ad3bcab..8982daab52 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -14,7 +14,6 @@ use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graph_craft::{ProtoNodeIdentifier, list}; use graphene_std::raster::BlendMode; -use graphene_std::raster_types::Image; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, PaintOrder, Stroke}; use graphene_std::vector::{Gradient, GradientRamp, Vector, VectorModification, VectorModificationType}; @@ -270,20 +269,14 @@ impl<'a> ModifyInputsContext<'a> { self.network_interface.set_chain_position(node_id, &[]); } - pub fn insert_image_data(&mut self, image: Image, layer: LayerNodeIdentifier) { + pub fn insert_image_data(&mut self, resource_id: ResourceId, layer: LayerNodeIdentifier) { let transform = resolve_proto_node_type(graphene_std::transform_nodes::transform::IDENTIFIER) .expect("Transform node does not exist") .default_node_template(); - let resource_id = ResourceId::new(); - self.responses.add(ResourceMessage::StoreEmbedded { - resource_id, - data: image.to_png().into(), - }); - let image_node = resolve_proto_node_type(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER) .expect("Image node does not exist") - .node_template_input_override([Some(NodeInput::value(TaggedValue::Resource(resource_id), false))]); + .node_template_input_override([None, Some(NodeInput::value(TaggedValue::Resource(resource_id), false))]); let image_node_id = NodeId::new(); self.network_interface.insert_node(image_node_id, image_node, &[]); diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index a5f3ba0178..c6c2d25f6c 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -7,7 +7,8 @@ use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::utility_types::network_interface::{ InputMetadata, NodeNetworkInterface, NodeNetworkTemplate, NodeTemplate, NodeTemplateImplementation, NodeTypePersistentMetadata, Vec2InputSettings, WidgetOverride, }; -use crate::messages::prelude::{FontsMessage, FontsMessageHandler, Message, ResourceMessageHandler, Responses}; +use crate::messages::portfolio::resource_upload::utility_types::ResourceFileKind; +use crate::messages::prelude::{DocumentId, FontsMessage, FontsMessageHandler, Message, ResourceMessageHandler, Responses}; use crate::node_graph_executor::NodeGraphExecutor; use glam::DVec2; use graph_craft::ProtoNodeIdentifier; @@ -26,6 +27,7 @@ use std::collections::{HashMap, VecDeque}; pub struct NodePropertiesContext<'a> { pub responses: &'a mut VecDeque, pub executor: &'a mut NodeGraphExecutor, + pub document_id: DocumentId, pub network_interface: &'a mut NodeNetworkInterface, pub resources: &'a ResourceMessageHandler, pub fonts: &'a FontsMessageHandler, @@ -1461,6 +1463,13 @@ fn static_input_properties() -> InputProperties { Ok(result) }), ); + map.insert( + "image_file".to_string(), + Box::new(|node_id, index, context| { + let widgets = node_properties::resource_widget(ParameterWidgetsInfo::at_index(node_id, index, true, context), ResourceFileKind::RasterImage); + Ok(vec![LayoutGroup::row(widgets)]) + }), + ); map.insert( "artboard_background".to_string(), Box::new(|node_id, index, context| { diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 0b8f4ce79c..3a4314658f 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -7,15 +7,16 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions: use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface}; use crate::messages::portfolio::fonts::utility_types::FontCatalogStyle; +use crate::messages::portfolio::resource_upload::utility_types::{ResourceFileKind, UploadTarget}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils; use choice::enum_choice; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; -use graph_craft::application_io::resource::ResourceId; +use graph_craft::application_io::resource::{DataSource, Resource, ResourceId}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput}; -use graph_craft::{Type, concrete}; +use graph_craft::{Type, concrete, item}; use graphene_std::animation::RealTimeMode; use graphene_std::color::SRGBA8; use graphene_std::extract_xy::XY; @@ -341,6 +342,7 @@ pub(crate) fn property_from_type( Some(x) if id_is::(x) => footprint_widget(default_info, &mut extra_widgets), Some(x) if id_is::>(x) => vector_modification_widget(default_info).into(), Some(x) if id_is::>(x) => image_data_widget(default_info).into(), + Some(x) if id_is::(x) => resource_widget(default_info, ResourceFileKind::Any).into(), // =============================== // MANUALLY IMPLEMENTED ENUM TYPES // =============================== @@ -1288,6 +1290,107 @@ pub fn font_widget(parameter_widgets_info: ParameterWidgetsInfo) -> LayoutGroup font_widgets.into_iter().chain(style_widgets.unwrap_or_default()).collect::>().into() } +/// A dropdown of the document's uploaded files, led by "None" and a "Browse…" entry that uploads another file of the given kind. +pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, kind: ResourceFileKind) -> Vec { + let mut widgets = start_widgets(¶meter_widgets_info); + + let Some(input) = parameter_widgets_info.input() else { + log::warn!("A widget failed to be built because its node's input index is invalid."); + return vec![]; + }; + let selected = match input.as_non_exposed_value() { + Some(TaggedValue::Resource(resource_id)) => Some(*resource_id), + Some(TaggedValue::TypeDefault(_)) => None, + _ => return widgets, + }; + + // Fonts have their own picker, so only uploaded files are listed, labeled by hash and user count until resources carry names + let ParameterWidgetsInfo { + document_id, + node_id, + index, + resources, + network_interface, + .. + } = parameter_widgets_info; + let user_counts = network_interface.resource_user_counts(); + let mut files: Vec<(ResourceId, String, String)> = resources + .registry + .resolved() + .filter(|info| !info.sources.iter().any(|source| matches!(source, DataSource::Font { .. }))) + .map(|info| { + let hash = info.hash.map(|hash| hash.to_string()[..8].to_string()).unwrap_or_default(); + let users = user_counts.get(&info.id).copied().unwrap_or(0); + let tooltip_description = match users { + 0 => "Not used by any node input. This resource will be dropped upon document reload.".to_string(), + users => format!("Used by {users} node input{}.", if users == 1 { "" } else { "s" }), + }; + let uses = match users { + 0 => "unused".to_string(), + 1 => "1 use".to_string(), + users => format!("{users} uses"), + }; + (info.id, format!("{hash} · {uses}"), tooltip_description) + }) + .collect(); + files.sort(); + + // Entries assign only on click, since a hover preview leaves the replaced file unreferenced and garbage collected + let assign_on_click = |value: TaggedValue| { + move |_: &()| Message::Batched { + messages: Box::new([ + DocumentMessage::AddTransaction.into(), + NodeGraphMessage::SetInputValue { + node_id, + input_index: index, + value: value.clone().into(), + } + .into(), + ]), + } + }; + let none = MenuListEntry::new("none") + .label("None") + .tooltip_description("No resource assigned to this input.") + .on_update(|_| Message::NoOp) + .on_commit(assign_on_click(TaggedValue::TypeDefault(item!(Resource)))); + let browse = MenuListEntry::new("browse") + .label("Browse…") + .tooltip_description("Pick a file from disk to use for this input.") + .on_update(|_| Message::NoOp) + .on_commit(move |_| { + ResourceUploadMessage::RequestUpload { + target: UploadTarget::NodeInput { + document_id, + node_id, + input_index: index, + kind, + }, + } + .into() + }); + let file_entries = files + .iter() + .map(|(resource_id, label, tooltip_description)| { + MenuListEntry::new(format!("{resource_id:?}")) + .label(label.clone()) + .tooltip_description(tooltip_description.clone()) + .on_update(|_| Message::NoOp) + .on_commit(assign_on_click(TaggedValue::Resource(*resource_id))) + }) + .collect(); + let selected_index = match selected { + None => Some(0), + Some(selected) => files.iter().position(|(resource_id, ..)| *resource_id == selected).map(|position| position as u32 + 2), + }; + + widgets.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + DropdownInput::new(vec![vec![none, browse], file_entries]).selected_index(selected_index).widget_instance(), + ]); + widgets +} + pub fn get_document_node<'a>(node_id: NodeId, context: &'a NodePropertiesContext<'a>) -> Result<&'a DocumentNode, String> { let network = context .network_interface @@ -3343,6 +3446,7 @@ pub fn math_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> } pub struct ParameterWidgetsInfo<'a> { + document_id: DocumentId, network_interface: &'a NodeNetworkInterface, resources: &'a ResourceMessageHandler, selection_network_path: &'a [NodeId], @@ -3388,6 +3492,7 @@ impl<'a> ParameterWidgetsInfo<'a> { let document_node = context.network_interface.document_node(&node_id, context.selection_network_path); ParameterWidgetsInfo { + document_id: context.document_id, network_interface: context.network_interface, resources: context.resources, selection_network_path: context.selection_network_path, diff --git a/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs b/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs index 77e7742fc1..3d3b6954fe 100644 --- a/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs @@ -9,6 +9,7 @@ use crate::node_graph_executor::NodeGraphExecutor; #[derive(ExtractField)] pub struct PropertiesPanelMessageContext<'a> { pub executor: &'a mut NodeGraphExecutor, + pub document_id: DocumentId, pub network_interface: &'a mut NodeNetworkInterface, pub resources: &'a ResourceMessageHandler, pub selection_network_path: &'a [NodeId], @@ -26,6 +27,7 @@ impl MessageHandler> f fn process_message(&mut self, message: PropertiesPanelMessage, responses: &mut VecDeque, context: PropertiesPanelMessageContext) { let PropertiesPanelMessageContext { executor, + document_id, network_interface, resources, selection_network_path, @@ -51,6 +53,7 @@ impl MessageHandler> f let mut node_properties_context = NodePropertiesContext { responses, executor, + document_id, network_interface, resources, selection_network_path, diff --git a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs index 0a7cc9deb9..70d4e5482c 100644 --- a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs +++ b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs @@ -15,6 +15,7 @@ use crate::messages::portfolio::document::document_message_handler::DocumentMess use crate::messages::portfolio::document::utility_types::misc::GroupFolderType; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{StorageMetadataView, build_interface_from_storage}; +use crate::messages::portfolio::resource_upload::utility_types::UploadTarget; use crate::test_utils::test_prelude::*; use graphene_std::NodeParameter; use graphene_std::vector::style::RenderMode; @@ -494,6 +495,18 @@ async fn live_undo_new_document_draw_rect() { /// name set, reparent, and transform in a single transaction. Were the name set to open its own nested /// transaction (a historical wart), the first undo would revert only the name and leave the layer behind, so /// this asserts the layer count returns to its pre-paste value after exactly one undo. +/// Pastes a 2x2 image as a named layer. +fn paste_named_image() -> ResourceUploadMessage { + ResourceUploadMessage::Upload { + name: Some("pasted".into()), + data: Image::new(2, 2, Color::WHITE).to_png().into(), + target: UploadTarget::Layer { + mouse: None, + parent_and_insert_index: None, + }, + } +} + #[tokio::test] async fn paste_image_with_name_is_one_undo_step() { let mut editor = EditorTestUtils::create(); @@ -503,15 +516,7 @@ async fn paste_image_with_name_is_one_undo_step() { // Paste with a name so the handler emits the `SetDisplayName` sub-step that historically opened its // own transaction. `create_raster_image` passes `name: None` and so wouldn't exercise this path. - let image = Image::new(2, 2, Color::WHITE); - editor - .handle_message(PortfolioMessage::InsertImage { - name: Some("pasted".into()), - image, - mouse: None, - parent_and_insert_index: None, - }) - .await; + editor.handle_message(paste_named_image()).await; assert_eq!( editor.active_document().metadata().all_layers().count(), layers_before + 1, @@ -526,6 +531,37 @@ async fn paste_image_with_name_is_one_undo_step() { ); } +/// Choosing "None" in the Image node's file picker leaves the empty-resource placeholder, which must still render. +#[tokio::test] +async fn image_node_with_no_file_still_evaluates() { + use graph_craft::document::DocumentNodeImplementation; + use graph_craft::document::value::TaggedValue; + use graph_craft::item; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.create_raster_image(Image::new(2, 2, Color::WHITE), None).await; + + let image_node_id = editor + .active_document() + .network_interface + .document_network() + .nodes + .iter() + .find(|(_, node)| matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::raster_nodes::std_nodes::image::IDENTIFIER)) + .map(|(id, _)| *id) + .expect("the pasted image should be an Image node"); + editor + .handle_message(NodeGraphMessage::SetInputValue { + node_id: image_node_id, + input_index: graphene_std::raster_nodes::std_nodes::image::ResourceInput::INDEX, + value: TaggedValue::TypeDefault(item!(graph_craft::application_io::resource::Resource)).into(), + }) + .await; + + editor.eval_graph().await.expect("an Image node with no file chosen should still evaluate"); +} + /// Undoing an image paste reverts the interaction's `AddResource` in the `Gdd` cursor while the runtime keeps /// the resource alive for legacy redo, so the cursor legitimately holds fewer resources than a fresh /// `from_runtime`. Drives the relaxed comparison: every resource the current network references must be in @@ -538,15 +574,7 @@ async fn undo_image_paste_resources_subset_of_runtime() { let byte_store = mount_in_memory_storage(&mut editor).await; editor.active_document_mut().commit_storage_snapshot(&byte_store, true); - let image = Image::new(2, 2, Color::WHITE); - editor - .handle_message(PortfolioMessage::InsertImage { - name: Some("pasted".into()), - image, - mouse: None, - parent_and_insert_index: None, - }) - .await; + editor.handle_message(paste_named_image()).await; editor.handle_message(DocumentMessage::Undo).await; @@ -582,15 +610,7 @@ async fn undo_twice_steps_cursor_two_interactions() { editor.draw_rect(0., 0., 100., 100.).await; let after_rect = editor.active_document().network_interface.document_network().clone(); - let image = Image::new(2, 2, Color::WHITE); - editor - .handle_message(PortfolioMessage::InsertImage { - name: Some("pasted".into()), - image, - mouse: None, - parent_and_insert_index: None, - }) - .await; + editor.handle_message(paste_named_image()).await; // First undo: removes the paste, back to the post-rectangle network. Cursor and legacy must agree. editor.handle_message(DocumentMessage::Undo).await; diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs index b4b577ed56..1545c58e5e 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs @@ -314,6 +314,13 @@ impl NodeNetworkInterface { collect_network_resources(self.document_network(), target); } + /// How many value inputs across the document reference each resource, with no entry for a resource nothing uses. + pub fn resource_user_counts(&self) -> HashMap { + let mut counts = HashMap::new(); + visit_network_resources(self.document_network(), &mut |id| *counts.entry(id).or_insert(0) += 1); + counts + } + pub fn frontend_imports(&self, network_path: &[NodeId]) -> Vec> { match network_path.split_last() { Some((node_id, encapsulating_network_path)) => { diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/types.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/types.rs index d3a53066b8..70efe327c2 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/types.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/types.rs @@ -866,30 +866,49 @@ pub(crate) enum SoleDependentStep { } pub(crate) fn collect_network_resources(network: &NodeNetwork, out: &mut HashSet) { - for export in &network.exports { - collect_input_resource(export, out); - } - for node in network.nodes.values() { - collect_node_resources(node, out); - } + visit_network_resources(network, &mut |id| { + out.insert(id); + }); } /// Collects resource IDs referenced by a node and its nested networks. pub fn collect_node_resources(node: &DocumentNode, out: &mut HashSet) { - for input in &node.inputs { - collect_input_resource(input, out); - } - if let DocumentNodeImplementation::Network(nested) = &node.implementation { - collect_network_resources(nested, out); - } + visit_node_resources(node, &mut |id| { + out.insert(id); + }); } /// Records the resource ID held by a value input, covering node inputs and export slots alike. pub(crate) fn collect_input_resource(input: &NodeInput, out: &mut HashSet) { + visit_input_resource(input, &mut |id| { + out.insert(id); + }); +} + +/// Calls `visit` once per value input holding a resource ID across a network and its nested networks. +pub(crate) fn visit_network_resources(network: &NodeNetwork, visit: &mut impl FnMut(ResourceId)) { + for export in &network.exports { + visit_input_resource(export, visit); + } + for node in network.nodes.values() { + visit_node_resources(node, visit); + } +} + +fn visit_node_resources(node: &DocumentNode, visit: &mut impl FnMut(ResourceId)) { + for input in &node.inputs { + visit_input_resource(input, visit); + } + if let DocumentNodeImplementation::Network(nested) = &node.implementation { + visit_network_resources(nested, visit); + } +} + +fn visit_input_resource(input: &NodeInput, visit: &mut impl FnMut(ResourceId)) { if let NodeInput::Value { tagged_value, .. } = input && let TaggedValue::Resource(id) = &**tagged_value { - out.insert(*id); + visit(*id); } } @@ -911,4 +930,31 @@ mod tests { assert_eq!(ports.clicked_output_port_from_point(center + DVec2::new(200., 0.)), Some(0)); assert_eq!(ports.clicked_output_port_from_point(center), None); } + + #[test] + fn resource_visits_count_every_referencing_input_including_nested_networks() { + let shared = ResourceId::from(1); + let other = ResourceId::from(2); + let uses = |id: ResourceId| NodeInput::value(TaggedValue::Resource(id), false); + let node = |inputs: Vec| DocumentNode { inputs, ..Default::default() }; + + let inner = NodeNetwork { + exports: vec![uses(shared)], + nodes: [(NodeId(2), node(vec![uses(shared)]))].into_iter().collect(), + ..Default::default() + }; + let nested = DocumentNode { + implementation: DocumentNodeImplementation::Network(inner), + ..Default::default() + }; + let outer = NodeNetwork { + nodes: [(NodeId(1), node(vec![uses(shared), uses(other)])), (NodeId(3), nested)].into_iter().collect(), + ..Default::default() + }; + + let mut counts = HashMap::new(); + visit_network_resources(&outer, &mut |id| *counts.entry(id).or_insert(0) += 1); + assert_eq!(counts.get(&shared), Some(&3), "every referencing input counts, nested networks included"); + assert_eq!(counts.get(&other), Some(&1)); + } } diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 4a58e2743e..e119095336 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -2140,10 +2140,21 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], let _ = document.network_interface.replace_inputs(node_id, network_path, &mut node_template); document .network_interface - .set_input(&InputConnector::node_at_index(*node_id, 0), NodeInput::value(TaggedValue::Resource(resource_id), false), network_path); + .set_input(&InputConnector::node_at_index(*node_id, 1), NodeInput::value(TaggedValue::Resource(resource_id), false), network_path); } } + // Move the Image node's resource, whether a stored value or a wire, from the primary input to the first secondary input + if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER) + && inputs_count == 1 + && !matches!(node.inputs.first().and_then(|input| input.as_value()), Some(TaggedValue::ImageData(_))) + { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[0].clone(), network_path); + } + // Convert text nodes from the old `editor-api` scope + `Font` input to a single font `Resource` input. // The chosen typeface is recorded as a `DataSource::Font` in the document's resource registry and loaded on open. if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode")) && inputs_count == 13 && matches!(node.inputs.first(), Some(NodeInput::Scope(_))) { @@ -3111,6 +3122,46 @@ mod tests { } } + #[test] + fn every_legacy_image_shape_stores_its_resource_as_secondary_input() { + use graphene_std::raster::Image; + + let resource_id = ResourceId::new(); + let upstream_id = NodeId(2); + let legacy_inputs = [ + NodeInput::value(TaggedValue::ImageData(Image::new(2, 2, Color::WHITE)), false), + NodeInput::value(TaggedValue::Resource(resource_id), false), + NodeInput::node(upstream_id, 0), + ]; + + for legacy_input in legacy_inputs { + let image_id = NodeId(1); + let mut document = DocumentMessageHandler::default(); + let image_template = |inputs| NodeTemplate { + implementation: NodeTemplateImplementation::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER), + inputs, + ..Default::default() + }; + document.network_interface.insert_node(upstream_id, image_template(vec![]), &[]); + document.network_interface.insert_node(image_id, image_template(vec![NodeInput::value(TaggedValue::None, false)]), &[]); + document.network_interface.set_input(&InputConnector::node_at_index(image_id, 0), legacy_input.clone(), &[]); + + document_migration_upgrades(&mut document, false); + + let image_node = &document.network_interface.document_network().nodes[&image_id]; + assert_eq!(image_node.inputs.len(), 2, "the image node should gain its placeholder primary input"); + match (&legacy_input, &image_node.inputs[1]) { + (NodeInput::Node { .. }, migrated) => assert_eq!(migrated.as_node(), Some(upstream_id), "the wire should sit at input 1"), + (_, migrated) => { + let Some(TaggedValue::Resource(stored)) = migrated.as_value() else { + panic!("the file should sit at input 1") + }; + assert!(document.resources.registry.contains(stored) || *stored == resource_id, "the stored file should be the legacy one"); + } + } + } + } + #[test] fn test_no_duplicate_node_replacements() { let mut hashmap = HashMap::::new(); diff --git a/editor/src/messages/portfolio/mod.rs b/editor/src/messages/portfolio/mod.rs index 1edbb898d3..47a928da8c 100644 --- a/editor/src/messages/portfolio/mod.rs +++ b/editor/src/messages/portfolio/mod.rs @@ -6,6 +6,7 @@ pub mod document_migration; pub mod document_storage_io; pub mod fonts; pub mod persistent_state; +pub mod resource_upload; pub mod utility_types; #[doc(inline)] @@ -16,3 +17,5 @@ pub use persistent_state::{PersistentStateMessage, PersistentStateMessageContext pub use portfolio_message::{PortfolioMessage, PortfolioMessageDiscriminant}; #[doc(inline)] pub use portfolio_message_handler::{PortfolioMessageContext, PortfolioMessageHandler}; +#[doc(inline)] +pub use resource_upload::{ResourceUploadMessage, ResourceUploadMessageContext, ResourceUploadMessageHandler}; diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 8edca5950d..4c8824b564 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -1,10 +1,9 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier; use super::persistent_state::PersistentStateMessage; +use super::resource_upload::ResourceUploadMessage; use super::utility_types::{DockingSplitDirection, PanelGroupId, PanelType}; use crate::messages::frontend::utility_types::{ExportBounds, FileType, PersistedState}; use crate::messages::prelude::*; -use graphene_std::Color; -use graphene_std::raster::Image; use std::path::PathBuf; #[impl_message(Message, Portfolio)] @@ -18,6 +17,8 @@ pub enum PortfolioMessage { Fonts(FontsMessage), #[child] PersistentState(PersistentStateMessage), + #[child] + ResourceUpload(ResourceUploadMessage), // Messages Init, @@ -138,20 +139,10 @@ pub enum PortfolioMessage { document_is_saved: bool, document_serialized_content: String, }, - OpenImage { - name: Option, - image: Image, - }, OpenSvg { name: Option, svg: String, }, - InsertImage { - name: Option, - image: Image, - mouse: Option<(f64, f64)>, - parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>, - }, InsertSvg { name: Option, svg: String, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 847662e4f0..35846672a6 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -15,6 +15,7 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions; use crate::messages::portfolio::document::utility_types::network_interface::OutputConnector; use crate::messages::portfolio::document_migration::*; use crate::messages::portfolio::document_storage_io::{build_or_open_working_copy, compare_storage_against_runtime, open_gdd_document}; +use crate::messages::portfolio::resource_upload::utility_types::{UploadTarget, image_file_filter}; use crate::messages::portfolio::utility_types::FileContent; use crate::messages::preferences::SelectionMode; use crate::messages::prelude::*; @@ -24,10 +25,8 @@ use crate::node_graph_executor::{ExportConfig, NodeGraphExecutor}; use glam::{DAffine2, DVec2}; use graph_craft::application_io::resource::{DataSource, ResourceHash}; use graph_craft::document::NodeId; -use graphene_std::Color; -use graphene_std::raster_types::Image; use graphene_std::renderer::Quad; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::vec; @@ -61,6 +60,7 @@ pub struct PortfolioMessageHandler { pub(crate) active_document_id: Option, persistent_state: PersistentStateMessageHandler, pub fonts: FontsMessageHandler, + resource_upload: ResourceUploadMessageHandler, pub executor: NodeGraphExecutor, pub selection_mode: SelectionMode, pub reset_node_definitions_on_open: bool, @@ -116,6 +116,12 @@ impl MessageHandler> for Portfolio let context = FontsMessageContext { resource_storage }; self.fonts.process_message(message, responses, context); } + PortfolioMessage::ResourceUpload(message) => { + let context = ResourceUploadMessageContext { + document_open: self.active_document().is_some(), + }; + self.resource_upload.process_message(message, responses, context); + } // Messages PortfolioMessage::Init => { @@ -666,22 +672,14 @@ impl MessageHandler> for Portfolio name: "Graphite Document".into(), extensions: vec![FILE_EXTENSION.into(), GDD_FILE_EXTENSION.into()], }, - FileFilter { - name: "Image".into(), - extensions: vec!["svg".into(), "png".into(), "jpg".into(), "jpeg".into(), "bmp".into(), "gif".into()], - }, + image_file_filter(), ], }); } PortfolioMessage::Import => { // This portfolio message wraps the frontend message so it can be listed as an action, which isn't possible for frontend messages // TODO: Also offer the Graphite document filter once importing Graphite documents as nodes is supported - responses.add(FrontendMessage::TriggerImport { - filters: vec![FileFilter { - name: "Image".into(), - extensions: vec!["svg".into(), "png".into(), "jpg".into(), "jpeg".into(), "bmp".into(), "gif".into()], - }], - }); + responses.add(FrontendMessage::TriggerImport { filters: vec![image_file_filter()] }); } PortfolioMessage::OpenFile { path, content } => { let name = path.file_stem().map(|n| n.to_string_lossy().to_string()); @@ -705,8 +703,12 @@ impl MessageHandler> for Portfolio FileContent::Svg(svg) => { responses.add(PortfolioMessage::OpenSvg { name, svg }); } - FileContent::Image(image) => { - responses.add(PortfolioMessage::OpenImage { name, image }); + FileContent::Image(data) => { + responses.add(ResourceUploadMessage::Upload { + name, + data: data.into(), + target: UploadTarget::Document, + }); } FileContent::Unsupported => { // TODO: Show a more thoughtfully designed error message to the user @@ -744,12 +746,14 @@ impl MessageHandler> for Portfolio parent_and_insert_index: None, }); } - FileContent::Image(image) => { - responses.add(PortfolioMessage::InsertImage { + FileContent::Image(data) => { + responses.add(ResourceUploadMessage::Upload { name, - image, - mouse: None, - parent_and_insert_index: None, + data: data.into(), + target: UploadTarget::Layer { + mouse: None, + parent_and_insert_index: None, + }, }); } FileContent::Unsupported => { @@ -993,34 +997,6 @@ impl MessageHandler> for Portfolio self.tick_autosave_load_progress(responses, false); } } - PortfolioMessage::OpenImage { name, image } => { - // `NewDocumentWithName`'s handler routes empty/None-equivalent names through `resolve_document_name` which assigns the next available "Untitled Document {N}". - responses.add(PortfolioMessage::NewDocumentWithName { - name: name.clone().unwrap_or_default(), - }); - - responses.add(DocumentMessage::InsertImage { - name, - image, - mouse: None, - parent_and_insert_index: None, - place_at_origin: true, - }); - - // Wait for the document to be rendered so the click targets can be calculated in order to determine the artboard size that will encompass the pasted image - responses.add(DeferMessage::AfterGraphRun { - messages: vec![ - DocumentMessage::WrapContentInArtboard { - place_artboard_at_origin: true, - artboard_canvas: None, - } - .into(), - ], - }); - responses.add(DeferMessage::AfterNavigationReady { - messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()], - }); - } PortfolioMessage::OpenSvg { name, svg } => { responses.add(PortfolioMessage::NewDocumentWithName { name: name.clone().unwrap_or_default(), @@ -1181,24 +1157,6 @@ impl MessageHandler> for Portfolio responses.add(NodeGraphMessage::RunDocumentGraph); } } - PortfolioMessage::InsertImage { - name, - image, - mouse, - parent_and_insert_index, - } => { - if self.document_ids.is_empty() { - responses.add(PortfolioMessage::OpenImage { name, image }); - } else { - responses.add(DocumentMessage::InsertImage { - name, - image, - mouse, - parent_and_insert_index, - place_at_origin: false, - }); - } - } PortfolioMessage::InsertSvg { name, svg, @@ -1824,7 +1782,7 @@ impl PortfolioMessageHandler { } } - fn read_file(path: &PathBuf, content: Vec) -> FileContent { + fn read_file(path: &Path, content: Vec) -> FileContent { let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default().to_lowercase(); match extension.as_str() { FILE_EXTENSION => match String::from_utf8(content) { @@ -1836,18 +1794,7 @@ impl PortfolioMessageHandler { Ok(content) => FileContent::Svg(content), Err(_) => FileContent::Unsupported, }, - _ => { - let format = image::guess_format(&content).unwrap_or_else(|_| image::ImageFormat::from_path(path).unwrap_or(image::ImageFormat::Png)); - match image::load_from_memory_with_format(&content, format) { - Ok(image) => { - // TODO: Handle Image formats with more than 8 bits per channel - let image_data = image.to_rgba8(); - let image = Image::::from_image_data(image_data.as_raw(), image.width(), image.height()); - FileContent::Image(image) - } - Err(_) => FileContent::Unsupported, - } - } + _ => FileContent::Image(content), } } diff --git a/editor/src/messages/portfolio/resource_upload/mod.rs b/editor/src/messages/portfolio/resource_upload/mod.rs new file mode 100644 index 0000000000..2537562440 --- /dev/null +++ b/editor/src/messages/portfolio/resource_upload/mod.rs @@ -0,0 +1,9 @@ +mod resource_upload_message; +mod resource_upload_message_handler; + +pub mod utility_types; + +#[doc(inline)] +pub use resource_upload_message::{ResourceUploadMessage, ResourceUploadMessageDiscriminant}; +#[doc(inline)] +pub use resource_upload_message_handler::{ResourceUploadMessageContext, ResourceUploadMessageHandler}; diff --git a/editor/src/messages/portfolio/resource_upload/resource_upload_message.rs b/editor/src/messages/portfolio/resource_upload/resource_upload_message.rs new file mode 100644 index 0000000000..8f0d290115 --- /dev/null +++ b/editor/src/messages/portfolio/resource_upload/resource_upload_message.rs @@ -0,0 +1,14 @@ +use super::utility_types::UploadTarget; +use crate::messages::prelude::*; +use std::sync::Arc; + +#[impl_message(Message, PortfolioMessage, ResourceUpload)] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum ResourceUploadMessage { + /// Opens a file dialog filtered to what the target accepts, whose pick arrives as [`ResourceUploadMessage::ReceiveUpload`]. + RequestUpload { target: UploadTarget }, + /// The file picked for the pending [`ResourceUploadMessage::RequestUpload`]. + ReceiveUpload { name: Option, data: Arc<[u8]> }, + /// Stores a file as an embedded resource of the active document and hands it to its target. + Upload { name: Option, data: Arc<[u8]>, target: UploadTarget }, +} diff --git a/editor/src/messages/portfolio/resource_upload/resource_upload_message_handler.rs b/editor/src/messages/portfolio/resource_upload/resource_upload_message_handler.rs new file mode 100644 index 0000000000..93a1872f79 --- /dev/null +++ b/editor/src/messages/portfolio/resource_upload/resource_upload_message_handler.rs @@ -0,0 +1,216 @@ +use super::utility_types::{ImageResource, UNSUPPORTED_IMAGE_FILE, UploadTarget, decoded_image_size}; +use crate::messages::prelude::*; +use graph_craft::application_io::resource::ResourceId; +use graph_craft::document::value::TaggedValue; + +#[derive(ExtractField)] +pub struct ResourceUploadMessageContext { + /// Whether a document is open to receive an image layer, since otherwise the image opens its own + pub document_open: bool, +} + +/// Runs the file dialog for resource uploads and stores every upload in the active document before handing it to its target. +#[derive(Debug, Default, ExtractField)] +pub struct ResourceUploadMessageHandler { + /// Where the file from the open dialog goes once it is picked + pending_target: Option, +} + +#[message_handler_data] +impl MessageHandler for ResourceUploadMessageHandler { + fn process_message(&mut self, message: ResourceUploadMessage, responses: &mut VecDeque, context: ResourceUploadMessageContext) { + match message { + ResourceUploadMessage::RequestUpload { target } => { + self.pending_target = Some(target); + responses.add(FrontendMessage::TriggerUploadResource { filters: target.filters() }); + } + ResourceUploadMessage::ReceiveUpload { name, data } => { + let Some(target) = self.pending_target.take() else { + log::warn!("A file was picked without a pending upload request"); + return; + }; + responses.add(ResourceUploadMessage::Upload { name, data, target }); + } + ResourceUploadMessage::Upload { name, data, target } => { + let reject = |responses: &mut VecDeque, description: &str| { + responses.add(DialogMessage::DisplayDialogError { + title: "Unsupported image format".into(), + description: description.into(), + }); + }; + + match target { + UploadTarget::NodeInput { + document_id, + node_id, + input_index, + kind, + } => { + if let Some(description) = kind.rejection(&data) { + return reject(responses, description); + } + + // The file goes to the document that asked for it, which may no longer be active or open once the dialog closes + let resource_id = ResourceId::new(); + let messages = [ + DocumentMessage::AddTransaction, + DocumentMessage::Resource(ResourceMessage::StoreEmbedded { resource_id, data }), + DocumentMessage::NodeGraph(NodeGraphMessage::SetInputValue { + node_id, + input_index, + value: Box::new(TaggedValue::Resource(resource_id)), + }), + ]; + for message in messages { + responses.add(PortfolioMessage::DocumentPassMessage { document_id, message }); + } + } + UploadTarget::Layer { .. } | UploadTarget::Document => { + let Some((width, height)) = decoded_image_size(&data) else { + return reject(responses, UNSUPPORTED_IMAGE_FILE); + }; + + // A layer needs a document to land in, so without one the image opens its own, wrapped in an artboard once rendered + let (mouse, parent_and_insert_index, place_at_origin) = match target { + UploadTarget::Layer { mouse, parent_and_insert_index } if context.document_open => (mouse, parent_and_insert_index, false), + _ => { + // An empty name becomes the next available "Untitled Document" + responses.add(PortfolioMessage::NewDocumentWithName { + name: name.clone().unwrap_or_default(), + }); + (None, None, true) + } + }; + + let resource_id = ResourceId::new(); + responses.add(ResourceMessage::StoreEmbedded { resource_id, data }); + responses.add(DocumentMessage::InsertImage { + name, + image: ImageResource { resource_id, width, height }, + mouse, + parent_and_insert_index, + place_at_origin, + }); + + if place_at_origin { + responses.add(DeferMessage::AfterGraphRun { + messages: vec![ + DocumentMessage::WrapContentInArtboard { + place_artboard_at_origin: true, + artboard_canvas: None, + } + .into(), + ], + }); + responses.add(DeferMessage::AfterNavigationReady { + messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()], + }); + } + } + } + } + } + } + + fn actions(&self) -> ActionList { + actions!(ResourceUploadMessageDiscriminant;) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::messages::portfolio::resource_upload::utility_types::ResourceFileKind; + use graph_craft::document::NodeId; + use graphene_std::Color; + use graphene_std::raster::Image; + + fn upload(data: &[u8], target: UploadTarget, document_open: bool) -> VecDeque { + let mut responses = VecDeque::new(); + let message = ResourceUploadMessage::Upload { + name: None, + data: data.into(), + target, + }; + ResourceUploadMessageHandler::default().process_message(message, &mut responses, ResourceUploadMessageContext { document_open }); + responses + } + + fn stores_a_resource(message: &Message) -> bool { + matches!( + message, + Message::Portfolio(PortfolioMessage::Document(DocumentMessage::Resource(ResourceMessage::StoreEmbedded { .. }))) + ) + } + + #[test] + fn a_file_that_is_not_an_image_is_rejected_before_it_is_stored() { + let target = UploadTarget::NodeInput { + document_id: DocumentId(3), + node_id: NodeId(7), + input_index: 1, + kind: ResourceFileKind::RasterImage, + }; + let responses = upload(b"not an image", target, true); + + assert!(!responses.iter().any(stores_a_resource), "a rejected file should not become a resource"); + assert!( + responses.iter().any(|message| matches!(message, Message::Dialog(DialogMessage::DisplayDialogError { .. }))), + "the user should be told why" + ); + } + + #[test] + fn a_picked_file_goes_to_the_target_of_the_pending_request() { + let target = UploadTarget::NodeInput { + document_id: DocumentId(3), + node_id: NodeId(7), + input_index: 1, + kind: ResourceFileKind::Any, + }; + let mut handler = ResourceUploadMessageHandler::default(); + let context = || ResourceUploadMessageContext { document_open: true }; + + let mut responses = VecDeque::new(); + handler.process_message(ResourceUploadMessage::RequestUpload { target }, &mut responses, context()); + assert!(responses.contains(&FrontendMessage::TriggerUploadResource { filters: Vec::new() }.into()), "the dialog should open"); + + let mut responses = VecDeque::new(); + let picked = ResourceUploadMessage::ReceiveUpload { + name: Some("file.bin".into()), + data: b"bytes".as_slice().into(), + }; + handler.process_message(picked, &mut responses, context()); + let expected = ResourceUploadMessage::Upload { + name: Some("file.bin".into()), + data: b"bytes".as_slice().into(), + target, + }; + assert!(responses.contains(&expected.into()), "the picked file should be uploaded to the requested target"); + } + + #[test] + fn an_image_layer_opens_a_document_when_none_is_open() { + let png = Image::new(1, 1, Color::WHITE).to_png(); + let target = UploadTarget::Layer { + mouse: None, + parent_and_insert_index: None, + }; + let opens_a_document = |message: &Message| matches!(message, Message::Portfolio(PortfolioMessage::NewDocumentWithName { .. })); + let inserts_at_origin = |message: &Message| matches!(message, Message::Portfolio(PortfolioMessage::Document(DocumentMessage::InsertImage { place_at_origin, .. })) if *place_at_origin); + + let responses = upload(&png, target, true); + assert!(responses.iter().any(stores_a_resource), "an image should be stored as a resource"); + assert!( + !responses.iter().any(opens_a_document) && !responses.iter().any(inserts_at_origin), + "an open document should receive the layer" + ); + + let responses = upload(&png, target, false); + assert!( + responses.iter().position(opens_a_document) < responses.iter().position(stores_a_resource), + "the document should exist before the resource is stored in it" + ); + assert!(responses.iter().any(inserts_at_origin), "the image should sit at the origin of its new document"); + } +} diff --git a/editor/src/messages/portfolio/resource_upload/utility_types.rs b/editor/src/messages/portfolio/resource_upload/utility_types.rs new file mode 100644 index 0000000000..c217e0c177 --- /dev/null +++ b/editor/src/messages/portfolio/resource_upload/utility_types.rs @@ -0,0 +1,96 @@ +use crate::messages::frontend::utility_types::FileFilter; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::prelude::DocumentId; +use graph_craft::application_io::resource::ResourceId; +use graph_craft::document::NodeId; + +/// The raster image formats the editor decodes, by file extension. +pub const RASTER_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "bmp", "gif"]; +pub const VECTOR_IMAGE_EXTENSIONS: &[&str] = &["svg"]; + +/// The dialog error shown for a file that does not decode as a raster image. +pub const UNSUPPORTED_IMAGE_FILE: &str = "The loaded file is not a supported bitmap image format."; + +/// The file dialog filter for the raster image formats the editor decodes. +pub fn raster_image_file_filter() -> FileFilter { + FileFilter { + name: "Image".into(), + extensions: RASTER_IMAGE_EXTENSIONS.iter().map(|extension| extension.to_string()).collect(), + } +} + +/// The file dialog filter for every image the editor opens or imports, vector as well as raster. +pub fn image_file_filter() -> FileFilter { + FileFilter { + name: "Image".into(), + extensions: RASTER_IMAGE_EXTENSIONS.iter().chain(VECTOR_IMAGE_EXTENSIONS.iter()).map(|ext| ext.to_string()).collect(), + } +} + +/// The pixel size of a file that fully decodes as a raster image. +pub fn decoded_image_size(data: &[u8]) -> Option<(u32, u32)> { + image::load_from_memory(data).ok().map(|image| (image.width(), image.height())) +} + +/// What a file must decode as before a node input accepts it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum ResourceFileKind { + #[default] + Any, + RasterImage, +} + +impl ResourceFileKind { + /// The file dialog filters offered for this kind. + pub fn filters(self) -> Vec { + match self { + Self::Any => Vec::new(), + Self::RasterImage => vec![raster_image_file_filter()], + } + } + + /// The dialog error to show when the file's contents do not decode as this kind. + pub fn rejection(self, data: &[u8]) -> Option<&'static str> { + match self { + Self::Any => None, + Self::RasterImage => decoded_image_size(data).is_none().then_some(UNSUPPORTED_IMAGE_FILE), + } + } +} + +/// Where an uploaded file goes once it is stored as a resource. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum UploadTarget { + /// A node input in the given document that accepts the given kind of file. + NodeInput { + document_id: DocumentId, + node_id: NodeId, + input_index: usize, + kind: ResourceFileKind, + }, + /// A new image layer in the active document, centered on the mouse or else the viewport. + Layer { + mouse: Option<(f64, f64)>, + parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>, + }, + /// A new document sized to the image. + Document, +} + +impl UploadTarget { + /// The file dialog filters for what this target accepts. + pub fn filters(self) -> Vec { + match self { + Self::NodeInput { kind, .. } => kind.filters(), + Self::Layer { .. } | Self::Document => vec![raster_image_file_filter()], + } + } +} + +/// An uploaded image by its stored resource and pixel size. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ImageResource { + pub resource_id: ResourceId, + pub width: u32, + pub height: u32, +} diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index a02ce1d435..0f5a1f0e02 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -1,6 +1,3 @@ -use graphene_std::Color; -use graphene_std::raster::Image; - /// Proportional share (0-1) for the document panel's side when splitting adjacent to non-document panels. const DOCUMENT_PANEL_SHARE: f64 = 0.8; /// Proportional share for each side when neither (or both) contain the document panel. @@ -793,8 +790,8 @@ pub enum FileContent { Document(String), /// A `.gdd` document container (archive bytes). GddDocument(Vec), - /// A bitmap image. - Image(Image), + /// Any other file, expected to be a bitmap image. + Image(Vec), /// An SVG file string. Svg(String), /// Any other unsupported/unrecognized file type. diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 3b99ad1afb..77bc3b5832 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -33,6 +33,7 @@ pub use crate::messages::portfolio::document::resource::{ResourceMessage, Resour pub use crate::messages::portfolio::document::{DocumentMessage, DocumentMessageContext, DocumentMessageDiscriminant, DocumentMessageHandler}; pub use crate::messages::portfolio::fonts::{FontsMessage, FontsMessageContext, FontsMessageDiscriminant, FontsMessageHandler}; pub use crate::messages::portfolio::persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageDiscriminant, PersistentStateMessageHandler}; +pub use crate::messages::portfolio::resource_upload::{ResourceUploadMessage, ResourceUploadMessageContext, ResourceUploadMessageDiscriminant, ResourceUploadMessageHandler}; pub use crate::messages::portfolio::{PortfolioMessage, PortfolioMessageContext, PortfolioMessageDiscriminant, PortfolioMessageHandler}; pub use crate::messages::preferences::{PreferencesMessage, PreferencesMessageDiscriminant, PreferencesMessageHandler}; pub use crate::messages::resource_storage::{ResourceStorageMessage, ResourceStorageMessageContext, ResourceStorageMessageDiscriminant, ResourceStorageMessageHandler}; diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index 67f5a0e6cb..7b9fe963ae 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -5,11 +5,11 @@ use crate::messages::portfolio::document::utility_types::network_interface::{Flo use crate::messages::prelude::*; use glam::{DAffine2, DVec2}; use graph_craft::ProtoNodeIdentifier; +use graph_craft::application_io::resource::ResourceId; use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNode, NodeId, NodeInput}; use graphene_std::Color; use graphene_std::raster::BlendMode; -use graphene_std::raster_types::Image; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::misc::ManipulatorPointId; use graphene_std::vector::style::{FillChoice, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, initial_gradient_transform_for_bounding_box}; @@ -206,10 +206,15 @@ pub fn merge_points(document: &DocumentMessageHandler, layer: LayerNodeIdentifie responses.add(GraphOperationMessage::Vector { layer, modification_type }); } -/// Create a new bitmap layer. -pub fn new_image_layer(image: Image, id: NodeId, parent: LayerNodeIdentifier, responses: &mut VecDeque) -> LayerNodeIdentifier { +/// Create a new bitmap layer showing a stored image resource. +pub fn new_image_layer(resource_id: ResourceId, id: NodeId, parent: LayerNodeIdentifier, responses: &mut VecDeque) -> LayerNodeIdentifier { let insert_index = 0; - responses.add(GraphOperationMessage::NewBitmapLayer { id, image, parent, insert_index }); + responses.add(GraphOperationMessage::NewBitmapLayer { + id, + resource_id, + parent, + insert_index, + }); LayerNodeIdentifier::new_unchecked(id) } diff --git a/editor/src/test_utils.rs b/editor/src/test_utils.rs index 57c567b82c..b7dcda9362 100644 --- a/editor/src/test_utils.rs +++ b/editor/src/test_utils.rs @@ -2,6 +2,7 @@ use crate::application::Editor; use crate::messages::input_mapper::utility_types::keyboard::ModifierKeys; use crate::messages::input_mapper::utility_types::pointer::{EditorPointerState, MouseKeys, ViewportPosition}; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; +use crate::messages::portfolio::resource_upload::utility_types::UploadTarget; use crate::messages::prelude::*; use crate::messages::tool::tool_messages::tool_prelude::Key; use crate::messages::tool::utility_types::ToolType; @@ -242,11 +243,10 @@ impl EditorTestUtils { } pub async fn create_raster_image(&mut self, image: graphene_std::raster::Image, mouse: Option<(f64, f64)>) { - self.handle_message(PortfolioMessage::InsertImage { + self.handle_message(ResourceUploadMessage::Upload { name: None, - image, - mouse, - parent_and_insert_index: None, + data: image.to_png().into(), + target: UploadTarget::Layer { mouse, parent_and_insert_index: None }, }) .await; } diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index a77f40c201..496cc0dd18 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -103,6 +103,11 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: editor.importFile(data.filename, data.content); }); + subscriptions.subscribeFrontendMessage("TriggerUploadResource", async ({ filters }) => { + const data = await upload(acceptStringFromFilters(filters), "data"); + editor.uploadResource(data.filename, data.content); + }); + subscriptions.subscribeFrontendMessage("TriggerSaveDocument", (data) => { downloadFile(data.name, data.content); }); @@ -192,6 +197,7 @@ export function destroyPortfolioStore() { subscriptions.unsubscribeFrontendMessage("TriggerFetchAndOpenDocument"); subscriptions.unsubscribeFrontendMessage("TriggerOpen"); subscriptions.unsubscribeFrontendMessage("TriggerImport"); + subscriptions.unsubscribeFrontendMessage("TriggerUploadResource"); subscriptions.unsubscribeFrontendMessage("TriggerSaveDocument"); subscriptions.unsubscribeFrontendMessage("TriggerSaveFile"); subscriptions.unsubscribeFrontendMessage("TriggerExportImage"); diff --git a/frontend/src/utility-functions/files.ts b/frontend/src/utility-functions/files.ts index 1ab3273cff..60b2a8d431 100644 --- a/frontend/src/utility-functions/files.ts +++ b/frontend/src/utility-functions/files.ts @@ -85,9 +85,13 @@ export async function pasteFile(item: DataTransferItem, editor: EditorWrapper, m const file = item.getAsFile(); if (!file) return; + const extension = file.name.split(".").pop()?.toLowerCase() ?? ""; if (file.type.startsWith("image/svg")) { const svg = await file.text(); editor.pasteSvg(file.name, svg, mouse?.[0], mouse?.[1], insertParentId, insertIndex); + } else if (editor.rasterImageExtensions().includes(extension)) { + // Formats the editor decodes itself keep their original bytes instead of being rasterized by the browser + editor.pasteImageFile(file.name, await file.bytes(), mouse?.[0], mouse?.[1], insertParentId, insertIndex); } else if (file.type.startsWith("image/")) { const imageData = await extractPixelData(file); editor.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, mouse?.[0], mouse?.[1], insertParentId, insertIndex); diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index 96f514dc0b..cb4cdf1f83 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -20,10 +20,12 @@ mod editor_commands { 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::resource_upload::utility_types::UploadTarget; 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::Image; use graphene_std::raster::color::Color; use graphene_std::vector::style::FillChoice; use std::path::PathBuf; @@ -613,7 +615,7 @@ mod editor_commands { .into() } - /// Pastes an image + /// Pastes decoded RGBA8 pixels as an image layer, encoded as PNG for storage fn paste_image( name: Option, image_data: Vec, @@ -625,7 +627,7 @@ mod editor_commands { 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 data = Image::from_image_data(&image_data, width, height).to_png(); 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); @@ -635,15 +637,39 @@ mod editor_commands { None }; - PortfolioMessage::InsertImage { + ResourceUploadMessage::Upload { name, - image, - mouse, - parent_and_insert_index, + data: data.into(), + target: UploadTarget::Layer { mouse, parent_and_insert_index }, } .into() } + /// Pastes an image file as an image layer, keeping its original encoding + fn paste_image_file(name: Option, data: Vec, 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 + }; + + ResourceUploadMessage::Upload { + name, + data: data.into(), + target: UploadTarget::Layer { mouse, parent_and_insert_index }, + } + .into() + } + + /// Hands the file picked for a requested resource upload to the editor + fn upload_resource(name: String, data: Vec) -> Message { + ResourceUploadMessage::ReceiveUpload { name: Some(name), data: data.into() }.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))); diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index c2d238a645..03a1877851 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -240,6 +240,22 @@ impl EditorWrapper { cfg!(debug_assertions) } + /// The file extensions of raster images the editor decodes itself (web only; on desktop, dropped files are imported natively and this is never called) + #[cfg(all(feature = "web", not(feature = "native")))] + #[wasm_bindgen(js_name = rasterImageExtensions)] + pub fn raster_image_extensions(&self) -> Vec { + editor::messages::portfolio::resource_upload::utility_types::RASTER_IMAGE_EXTENSIONS + .iter() + .map(|extension| extension.to_string()) + .collect() + } + #[cfg(feature = "native")] + #[wasm_bindgen(js_name = rasterImageExtensions)] + pub fn raster_image_extensions(&self) -> Vec { + log::error!("rasterImageExtensions is unavailable on desktop, where dropped files are imported natively"); + Vec::new() + } + /// 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)] diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index f7db948073..89a32fb0ce 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -42,6 +42,7 @@ macro_rules! for_each_item_type_default { $action!(Gradient); $action!(Artboard); $action!(String); + $action!(Resource); }; } @@ -63,7 +64,6 @@ macro_rules! for_each_list_type_default { macro_rules! for_each_bare_type_default { ($action:ident) => { $action!(DocumentNode); - $action!(Resource); }; } diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index d15a50a12c..23b248ab2e 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -251,13 +251,17 @@ pub fn empty_image(_: impl Ctx, transform: Item, color: Item) - } #[node_macro::node(category(""))] -pub fn image<'a: 'n>(_: impl Ctx, resource: Item) -> Item> { +pub fn image<'a: 'n>( + _: impl Ctx, + _primary: (), + /// The image file to display. + #[widget(ParsedWidgetOverride::Custom = "image_file")] + resource: Item, +) -> Item> { let resource = resource.into_element(); let image_data = resource.as_ref(); - let Some(image) = ::image::load_from_memory(image_data).ok() else { - return Item::default(); - }; + let Some(image) = ::image::load_from_memory(image_data).ok() else { return Item::default() }; let image = image.to_rgba32f(); let image = Image { data: image.chunks(4).map(|pixel| Color::from_gamma_srgb_channels(pixel[0], pixel[1], pixel[2], pixel[3])).collect(),