From 7d1303f69539b0183bbc0f69f49593ab8dfc01e7 Mon Sep 17 00:00:00 2001 From: Timon Date: Sat, 19 Sep 2026 05:47:02 +0000 Subject: [PATCH] Unify file opening, importing, and pasting into a file ingest handler (#4548) * Unify file and data ingest * Code review --------- Co-authored-by: Keavon Chambers --- Cargo.lock | 1 + Cargo.toml | 1 + desktop/src/app.rs | 23 +- .../src/handle_desktop_wrapper_message.rs | 31 +- .../wrapper/src/intercept_frontend_message.rs | 32 +- desktop/wrapper/src/messages.rs | 19 +- editor/src/dispatcher.rs | 9 +- .../clipboard/clipboard_message_handler.rs | 20 -- .../src/messages/clipboard/utility_types.rs | 2 - .../src/messages/frontend/frontend_message.rs | 14 +- editor/src/messages/frontend/utility_types.rs | 10 + .../messages/input_mapper/input_mappings.rs | 4 +- .../menu_bar/menu_bar_message_handler.rs | 8 +- .../portfolio/document/document_message.rs | 6 +- .../document/document_message_handler.rs | 8 +- .../graph_operation_message.rs | 4 +- .../graph_operation_message_handler.rs | 9 +- .../document/graph_operation/utility_types.rs | 6 +- .../node_graph/document_node_definitions.rs | 4 +- .../document/node_graph/node_properties.rs | 28 +- .../storage_tests/round_trip_tests.rs | 34 +- .../network_interface/queries.rs | 7 +- .../network_interface/template.rs | 8 +- .../utility_types/network_interface/types.rs | 84 ++--- .../messages/portfolio/document_migration.rs | 58 +++- .../portfolio/ingest/ingest_message.rs | 28 ++ .../ingest/ingest_message_handler.rs | 317 ++++++++++++++++++ editor/src/messages/portfolio/ingest/mod.rs | 9 + .../portfolio/ingest/utility_types.rs | 221 ++++++++++++ editor/src/messages/portfolio/mod.rs | 6 +- .../messages/portfolio/portfolio_message.rs | 25 +- .../portfolio/portfolio_message_handler.rs | 216 +----------- .../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 | 13 - editor/src/messages/prelude.rs | 2 +- .../graph_modification_utils.rs | 13 +- editor/src/test_utils.rs | 11 +- frontend/src/stores/portfolio.ts | 23 +- frontend/src/utility-functions/clipboard.ts | 22 +- frontend/src/utility-functions/files.ts | 20 +- frontend/src/utility-functions/network.ts | 2 +- .../src/utility-functions/rasterization.ts | 9 - frontend/wrapper/src/editor_commands.rs | 117 ++----- frontend/wrapper/src/editor_wrapper.rs | 16 - 47 files changed, 855 insertions(+), 980 deletions(-) create mode 100644 editor/src/messages/portfolio/ingest/ingest_message.rs create mode 100644 editor/src/messages/portfolio/ingest/ingest_message_handler.rs create mode 100644 editor/src/messages/portfolio/ingest/mod.rs create mode 100644 editor/src/messages/portfolio/ingest/utility_types.rs delete mode 100644 editor/src/messages/portfolio/resource_upload/mod.rs delete mode 100644 editor/src/messages/portfolio/resource_upload/resource_upload_message.rs delete mode 100644 editor/src/messages/portfolio/resource_upload/resource_upload_message_handler.rs delete mode 100644 editor/src/messages/portfolio/resource_upload/utility_types.rs diff --git a/Cargo.lock b/Cargo.lock index 07408dfef6..3b630b41aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2834,6 +2834,7 @@ dependencies = [ "gif", "num-traits", "png 0.17.16", + "serde", "zune-core", "zune-jpeg", ] diff --git a/Cargo.toml b/Cargo.toml index f7d242e817..0acbca2967 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -192,6 +192,7 @@ image = { version = "0.25", default-features = false, features = [ "jpeg", "bmp", "gif", + "serde", ] } pretty_assertions = "1.4" fern = { version = "0.7", features = ["colored"] } diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 1f48b438b0..a80409e0db 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -24,7 +24,7 @@ use crate::preferences; use crate::render::{RenderError, RenderState}; use crate::ui::{InputEvent, UiCommand, UiInstance}; use crate::window::Window; -use crate::wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, Preferences}; +use crate::wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, IngestAction, Preferences}; use crate::wrapper::{DesktopWrapper, MmapResourceStorage, NodeGraphExecutionResult, WgpuContext, serialize_frontend_messages}; pub(crate) struct App { @@ -205,15 +205,15 @@ impl App { }; self.send_or_queue_web_message(bytes); } - DesktopFrontendMessage::OpenFileDialog { title, filters, multiple, context } => { + DesktopFrontendMessage::OpenFileDialog { title, options, action } => { let app_event_scheduler = self.app_event_scheduler.clone(); let _ = thread::spawn(move || { let mut dialog = AsyncFileDialog::new().set_title(title); - for filter in filters { + for filter in options.filters { dialog = dialog.add_filter(filter.name, &filter.extensions); } - let handles = if multiple { + let handles = if options.multiple { futures::executor::block_on(dialog.pick_files()).unwrap_or_default() } else { futures::executor::block_on(dialog.pick_file()).into_iter().collect() @@ -223,7 +223,8 @@ impl App { let path = handle.path().to_path_buf(); match fs::read(&path) { Ok(content) => { - let message = DesktopWrapperMessage::FileDialogResult { path, content, context }; + let action = action.clone(); + let message = DesktopWrapperMessage::IngestFile { path, content, action }; app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); } Err(e) => tracing::error!("Failed to read file {}: {}", path.display(), e), @@ -520,7 +521,11 @@ impl App { for path in paths { tracing::info!("Opening file: {}", path.display()); if let Ok(content) = fs::read(&path) { - let message = DesktopWrapperMessage::OpenFile { path, content }; + let message = DesktopWrapperMessage::IngestFile { + path, + content, + action: IngestAction::Open, + }; app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); } else { tracing::error!("Failed to read file: {}", path.display()); @@ -657,7 +662,11 @@ impl ApplicationHandler for App { for path in paths { match fs::read(&path) { Ok(content) => { - let message = DesktopWrapperMessage::ImportFile { path, content }; + let message = DesktopWrapperMessage::IngestFile { + path, + content, + action: IngestAction::Import, + }; self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); } Err(e) => { diff --git a/desktop/wrapper/src/handle_desktop_wrapper_message.rs b/desktop/wrapper/src/handle_desktop_wrapper_message.rs index 3ffeef87f9..7a4dafbe0a 100644 --- a/desktop/wrapper/src/handle_desktop_wrapper_message.rs +++ b/desktop/wrapper/src/handle_desktop_wrapper_message.rs @@ -2,7 +2,7 @@ use graphite_editor::messages::clipboard::utility_types::ClipboardContentRaw; use graphite_editor::messages::prelude::*; use super::DesktopWrapperMessageDispatcher; -use super::messages::{DesktopFrontendMessage, DesktopWrapperMessage, EditorMessage, OpenFileDialogContext, SaveFileDialogContext}; +use super::messages::{DesktopFrontendMessage, DesktopWrapperMessage, EditorMessage, SaveFileDialogContext}; pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMessageDispatcher, message: DesktopWrapperMessage) { match message { @@ -15,19 +15,14 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess DesktopWrapperMessage::Input(message) => { dispatcher.queue_editor_message(EditorMessage::InputPreprocessor(message)); } - DesktopWrapperMessage::FileDialogResult { path, content, context } => match context { - OpenFileDialogContext::Open => { - dispatcher.queue_desktop_wrapper_message(DesktopWrapperMessage::OpenFile { path, content }); - } - 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::IngestFile { path, content, action } => { + dispatcher.queue_editor_message(IngestMessage::Ingest { + data: content, + action, + mime_type: String::new(), + path: Some(path), + }); + } DesktopWrapperMessage::SaveFileDialogResult { path, context } => match context { SaveFileDialogContext::Document { document_id, content } => { dispatcher.respond(DesktopFrontendMessage::WriteFile { path: path.clone(), content }); @@ -40,14 +35,6 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess dispatcher.respond(DesktopFrontendMessage::WriteFile { path, content }); } }, - DesktopWrapperMessage::OpenFile { path, content } => { - let message = PortfolioMessage::OpenFile { path, content }; - dispatcher.queue_editor_message(message); - } - DesktopWrapperMessage::ImportFile { path, content } => { - let message = PortfolioMessage::ImportFile { path, content }; - dispatcher.queue_editor_message(message); - } DesktopWrapperMessage::PollNodeGraphEvaluation => dispatcher.poll_node_graph_evaluation(), DesktopWrapperMessage::UpdateMaximized { maximized } => { let message = FrontendMessage::UpdateMaximized { maximized }; diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 736c8fda16..f5c71f8bc4 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -3,35 +3,23 @@ use graphite_editor::messages::layout::utility_types::layout_widget::LayoutTarge use graphite_editor::messages::prelude::FrontendMessage; use super::DesktopWrapperMessageDispatcher; -use super::messages::{DesktopFrontendMessage, OpenFileDialogContext, SaveFileDialogContext}; +use super::messages::{DesktopFrontendMessage, IngestAction, SaveFileDialogContext}; pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageDispatcher, message: FrontendMessage) -> Option { match message { FrontendMessage::RenderOverlays { context } => { dispatcher.respond(DesktopFrontendMessage::UpdateOverlays(context.take_scene())); } - FrontendMessage::TriggerOpen { filters } => { + FrontendMessage::TriggerBrowse { options, action } => { + let title = match action { + IngestAction::Open => "Open Document", + IngestAction::Import => "Import File", + _ => "Select File", + }; dispatcher.respond(DesktopFrontendMessage::OpenFileDialog { - title: "Open Document".to_string(), - filters, - multiple: true, - context: OpenFileDialogContext::Open, - }); - } - FrontendMessage::TriggerImport { filters } => { - dispatcher.respond(DesktopFrontendMessage::OpenFileDialog { - title: "Import File".to_string(), - filters, - multiple: false, - context: OpenFileDialogContext::Import, - }); - } - FrontendMessage::TriggerUploadResource { filters } => { - dispatcher.respond(DesktopFrontendMessage::OpenFileDialog { - title: "Select File".to_string(), - filters, - multiple: false, - context: OpenFileDialogContext::UploadResource, + title: title.to_string(), + options, + action, }); } FrontendMessage::TriggerSaveDocument { diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index cb90c4c262..2bce1100e8 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -3,9 +3,10 @@ use std::path::PathBuf; pub(crate) use graphite_editor::messages::prelude::Message as EditorMessage; -pub use graphite_editor::messages::frontend::utility_types::{DocumentInfo, FileFilter, PersistedState}; +pub use graphite_editor::messages::frontend::utility_types::{DocumentInfo, FileDialogOptions, FileFilter, PersistedState}; pub use graphite_editor::messages::input_mapper::utility_types::keyboard::{Key, ModifierKeys}; pub use graphite_editor::messages::input_mapper::utility_types::pointer::{EditorPointerState, MouseKeys, ScrollDelta}; +pub use graphite_editor::messages::portfolio::ingest::utility_types::IngestAction; pub use graphite_editor::messages::prelude::DocumentId; pub use graphite_editor::messages::prelude::InputPreprocessorMessage as InputMessage; pub use graphite_editor::messages::prelude::PreferencesMessageHandler as Preferences; @@ -15,9 +16,8 @@ pub enum DesktopFrontendMessage { OpenLaunchDocuments, OpenFileDialog { title: String, - filters: Vec, - multiple: bool, - context: OpenFileDialogContext, + options: FileDialogOptions, + action: IngestAction, }, SaveFileDialog { title: String, @@ -87,10 +87,8 @@ pub enum DesktopWrapperMessage { FromWeb(Box), Wake, Input(InputMessage), - FileDialogResult { path: PathBuf, content: Vec, context: OpenFileDialogContext }, + IngestFile { path: PathBuf, content: Vec, action: IngestAction }, SaveFileDialogResult { path: PathBuf, context: SaveFileDialogContext }, - OpenFile { path: PathBuf, content: Vec }, - ImportFile { path: PathBuf, content: Vec }, PollNodeGraphEvaluation, UpdateMaximized { maximized: bool }, UpdateFullscreen { fullscreen: bool }, @@ -103,13 +101,6 @@ pub enum DesktopWrapperMessage { LoadThirdPartyLicenses { text: String }, } -#[derive(Clone, Copy)] -pub enum OpenFileDialogContext { - Open, - Import, - UploadResource, -} - pub enum SaveFileDialogContext { Document { document_id: DocumentId, content: Vec }, File { content: Vec }, diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 1dec01ac61..47fee51f5f 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -427,6 +427,7 @@ impl Dispatcher { #[cfg(test)] mod test { + use crate::messages::portfolio::ingest::utility_types::IngestAction; pub use crate::test_utils::test_prelude::*; #[tokio::test] @@ -478,9 +479,11 @@ mod test { "Demo artwork '{document_name}' has more than 1 line (remember to open and re-save it in Graphite)", ); - let responses = editor.editor.handle_message(PortfolioMessage::OpenFile { - path: file_name.into(), - content: document_serialized_content.bytes().collect(), + let responses = editor.editor.handle_message(IngestMessage::Ingest { + data: document_serialized_content.into_bytes(), + action: IngestAction::Open, + mime_type: String::new(), + path: Some(file_name.into()), }); // Check if the graph renders diff --git a/editor/src/messages/clipboard/clipboard_message_handler.rs b/editor/src/messages/clipboard/clipboard_message_handler.rs index f62b1cdaff..d53c81c69f 100644 --- a/editor/src/messages/clipboard/clipboard_message_handler.rs +++ b/editor/src/messages/clipboard/clipboard_message_handler.rs @@ -5,14 +5,12 @@ 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; use graph_craft::application_io::resource::{DataSource, ResourceHash}; use graph_craft::document::NodeId; use graphene_std::Color; -use graphene_std::raster::Image; use graphene_std::vector::misc::{BezierHandles, HandleId, point_to_dvec2, segment_to_handles}; use graphene_std::vector::{PointId, SegmentId, VectorModificationType}; use graphite_proc_macros::{ExtractField, message_handler_data}; @@ -52,24 +50,6 @@ impl MessageHandler> for Clipboard responses.add(FrontendMessage::TriggerSelectionWrite { content: text }); } } - ClipboardContentRaw::Svg(svg) => { - responses.add(PortfolioMessage::InsertSvg { - svg, - name: None, - mouse: None, - parent_and_insert_index: None, - }); - } - ClipboardContentRaw::Image { data, width, height } => { - responses.add(ResourceUploadMessage::Upload { - name: None, - data: Image::from_image_data(&data, width, height).to_png().into(), - target: UploadTarget::Layer { - mouse: None, - parent_and_insert_index: None, - }, - }); - } }, ClipboardMessage::ReadSelection { content, cut } => { if let Some(text) = content { diff --git a/editor/src/messages/clipboard/utility_types.rs b/editor/src/messages/clipboard/utility_types.rs index a4298222ca..ddd4d4dd23 100644 --- a/editor/src/messages/clipboard/utility_types.rs +++ b/editor/src/messages/clipboard/utility_types.rs @@ -10,8 +10,6 @@ use graphene_std::vector::Vector; #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum ClipboardContentRaw { Text(String), - Svg(String), - Image { data: Vec, width: u32, height: u32 }, } #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index fb4dc88ab0..7db225d633 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -1,7 +1,7 @@ use super::IconName; use super::utility_types::{MouseCursorIcon, PersistedState}; use crate::messages::app_window::app_window_message_handler::AppWindowPlatform; -use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FileFilter, RasterizedImage}; +use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FileDialogOptions, FileFilter, RasterizedImage}; use crate::messages::input_mapper::utility_types::misc::ActionShortcut; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::node_graph::utility_types::{ @@ -9,6 +9,7 @@ use crate::messages::portfolio::document::node_graph::utility_types::{ }; use crate::messages::portfolio::document::utility_types::nodes::{LayerPanelEntry, LayerStructureEntry}; use crate::messages::portfolio::document::utility_types::wires::{WirePath, WirePathUpdate}; +use crate::messages::portfolio::ingest::utility_types::IngestAction; use crate::messages::portfolio::utility_types::WorkspacePanelLayout; use crate::messages::prelude::*; use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary; @@ -87,14 +88,9 @@ pub enum FrontendMessage { commit_date: String, }, TriggerDisplayThirdPartyLicensesDialog, - TriggerOpen { - filters: Vec, - }, - TriggerImport { - filters: Vec, - }, - TriggerUploadResource { - filters: Vec, + TriggerBrowse { + options: FileDialogOptions, + action: IngestAction, }, TriggerSaveDocument { document_id: DocumentId, diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index f5d31dcfd8..4288edda2f 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -82,6 +82,7 @@ impl FileType { FileFilter { name: name.into(), extensions: vec![self.extension().into()], + mime_types: Vec::new(), } } } @@ -117,4 +118,13 @@ pub struct RasterizedImage { pub struct FileFilter { pub name: String, pub extensions: Vec, + #[serde(rename = "mimeTypes")] + pub mime_types: Vec, +} + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] +pub struct FileDialogOptions { + pub filters: Vec, + pub multiple: bool, } diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index c1b4309649..f9442a06d4 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -444,8 +444,8 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { entry!(KeyDown(Tab); modifiers=[Control, Shift], action_dispatch=PortfolioMessage::PrevDocument), entry!(KeyDown(KeyW); modifiers=[Accel], action_dispatch=PortfolioMessage::CloseActiveDocumentWithConfirmation), entry!(KeyDown(KeyW); modifiers=[Accel, Alt], action_dispatch=PortfolioMessage::CloseAllDocumentsWithConfirmation), - entry!(KeyDown(KeyO); modifiers=[Accel], action_dispatch=PortfolioMessage::Open), - entry!(KeyDown(KeyI); modifiers=[Accel], action_dispatch=PortfolioMessage::Import), + entry!(KeyDown(KeyO); modifiers=[Accel], action_dispatch=IngestMessage::Open), + entry!(KeyDown(KeyI); modifiers=[Accel], action_dispatch=IngestMessage::Import), entry!(KeyDown(KeyR); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleRulers), entry!(KeyDown(KeyD); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleDataPanelOpen), entry!(KeyDown(Enter); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleFocusDocument), diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index 43946b2b7d..be05619836 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -123,8 +123,8 @@ impl LayoutHolder for MenuBarMessageHandler { MenuListEntry::new("Open…") .label("Open…") .icon("Folder") - .tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::Open)) - .on_commit(|_| PortfolioMessage::Open.into()), + .tooltip_shortcut(action_shortcut!(IngestMessageDiscriminant::Open)) + .on_commit(|_| IngestMessage::Open.into()), MenuListEntry::new("Open Demo Artwork…") .label("Open Demo Artwork…") .icon("Image") @@ -163,8 +163,8 @@ impl LayoutHolder for MenuBarMessageHandler { MenuListEntry::new("Import…") .label("Import…") .icon("FileImport") - .tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::Import)) - .on_commit(|_| PortfolioMessage::Import.into()) + .tooltip_shortcut(action_shortcut!(IngestMessageDiscriminant::Import)) + .on_commit(|_| IngestMessage::Import.into()) .disabled(no_active_document), MenuListEntry::new("Export…") .label("Export…") diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index 9be7329b76..bc41b5e4ee 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -7,10 +7,9 @@ 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 glam::{DAffine2, IVec2, UVec2}; use graph_craft::document::NodeId; use graphene_std::Appearance; use graphene_std::raster::BlendMode; @@ -116,7 +115,8 @@ pub enum DocumentMessage { }, InsertImage { name: Option, - image: ImageResource, + data: Arc<[u8]>, + size: UVec2, 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 962beb8db9..0672a0c4c3 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -798,13 +798,14 @@ impl MessageHandler> for DocumentMes } DocumentMessage::InsertImage { name, - image, + data, + size, mouse, parent_and_insert_index, place_at_origin, } => { let layer_parent = self.new_layer_parent(true); - let image_size = DVec2::new(image.width as f64, image.height as f64); + let image_size = size.as_dvec2(); let mut transform = if place_at_origin { // File-open flow: place at document origin without centering so `WrapContentInArtboard` can wrap it @@ -827,7 +828,7 @@ impl MessageHandler> for DocumentMes responses.add(DocumentMessage::StartTransaction); - let layer = graph_modification_utils::new_image_layer(image.resource_id, layer_node_id, layer_parent, responses); + let layer = graph_modification_utils::new_image_layer(data, layer_node_id, layer_parent, responses); if let Some(name) = name { responses.add(NodeGraphMessage::SetDisplayName { @@ -1099,6 +1100,7 @@ impl MessageHandler> for DocumentMes filters: vec![FileFilter { name: "Graphite Document".into(), extensions: vec![extension.into()], + mime_types: Vec::new(), }], content: content.into(), }) 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 89d9fd5e0e..dbc166c365 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,13 +3,13 @@ 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::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, PaintOrder, Stroke}; use graphene_std::vector::{Gradient, VectorModificationType}; +use std::sync::Arc; #[impl_message(Message, DocumentMessage, GraphOperation)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -141,7 +141,7 @@ pub enum GraphOperationMessage { }, NewBitmapLayer { id: NodeId, - resource_id: ResourceId, + data: Arc<[u8]>, 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 93a415ea82..cc72a8457d 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,15 +267,10 @@ impl MessageHandler> for responses.add_front(NodeGraphMessage::SelectedNodesSet { nodes: vec![id] }); responses.add(NodeGraphMessage::RunDocumentGraph); } - GraphOperationMessage::NewBitmapLayer { - id, - resource_id, - parent, - insert_index, - } => { + GraphOperationMessage::NewBitmapLayer { id, data, parent, insert_index } => { let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); let layer = modify_inputs.create_layer(id); - modify_inputs.insert_image_data(resource_id, layer); + modify_inputs.insert_image_data(data, 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 8982daab52..385ab027e6 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -19,6 +19,7 @@ use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientIn use graphene_std::vector::{Gradient, GradientRamp, Vector, VectorModification, VectorModificationType}; use graphene_std::{Artboard, Color, Graphic}; use kurbo::BezPath; +use std::sync::Arc; #[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] pub enum TransformIn { @@ -269,11 +270,14 @@ impl<'a> ModifyInputsContext<'a> { self.network_interface.set_chain_position(node_id, &[]); } - pub fn insert_image_data(&mut self, resource_id: ResourceId, layer: LayerNodeIdentifier) { + pub fn insert_image_data(&mut self, data: Arc<[u8]>, 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 }); + 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([None, Some(NodeInput::value(TaggedValue::Resource(resource_id), false))]); 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 c6c2d25f6c..01ae577ee5 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,7 @@ 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::portfolio::resource_upload::utility_types::ResourceFileKind; +use crate::messages::portfolio::ingest::utility_types::TypeFilter; use crate::messages::prelude::{DocumentId, FontsMessage, FontsMessageHandler, Message, ResourceMessageHandler, Responses}; use crate::node_graph_executor::NodeGraphExecutor; use glam::DVec2; @@ -1466,7 +1466,7 @@ fn static_input_properties() -> InputProperties { 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); + let widgets = node_properties::resource_widget(ParameterWidgetsInfo::at_index(node_id, index, true, context), vec![TypeFilter::raster()]); Ok(vec![LayoutGroup::row(widgets)]) }), ); 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 3a4314658f..88e7b7512d 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -7,7 +7,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::{InputConnector, NodeNetworkInterface}; use crate::messages::portfolio::fonts::utility_types::FontCatalogStyle; -use crate::messages::portfolio::resource_upload::utility_types::{ResourceFileKind, UploadTarget}; +use crate::messages::portfolio::ingest::utility_types::TypeFilter; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils; use choice::enum_choice; @@ -342,7 +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(), + Some(x) if id_is::(x) => resource_widget(default_info, Vec::new()).into(), // =============================== // MANUALLY IMPLEMENTED ENUM TYPES // =============================== @@ -1290,8 +1290,8 @@ 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 { +/// A dropdown of the document's uploaded files, led by "None" and a "Browse…" entry that uploads another file matching the given filters. +pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, filters: Vec) -> Vec { let mut widgets = start_widgets(¶meter_widgets_info); let Some(input) = parameter_widgets_info.input() else { @@ -1304,7 +1304,6 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, kind: Resou _ => 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, @@ -1313,14 +1312,17 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, kind: Resou network_interface, .. } = parameter_widgets_info; - let user_counts = network_interface.resource_user_counts(); + let use_counts = network_interface.collect_resources_use_counts(); + + // This is a heuristic to filter for image resources and will break once other data types are loaded. + // TODO: Add a proper way to filter for image resources. 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 users = use_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" }), @@ -1359,13 +1361,11 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, kind: Resou .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, - }, + IngestMessage::SetResourceInput { + document_id, + node_id, + input_index: index, + filters: filters.clone(), } .into() }); 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 70d4e5482c..0f9255ea85 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,7 +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::messages::portfolio::ingest::utility_types::IngestAction; use crate::test_utils::test_prelude::*; use graphene_std::NodeParameter; use graphene_std::vector::style::RenderMode; @@ -491,22 +491,19 @@ async fn live_undo_new_document_draw_rect() { assert_eq!(editor.active_document().network_interface.document_network(), &before_rect, "undo should restore the pre-rect network"); } +fn paste_named_image() -> IngestMessage { + IngestMessage::Ingest { + data: Image::new(2, 2, Color::WHITE).to_png(), + action: IngestAction::Paste, + mime_type: String::new(), + path: Some("pasted.png".into()), + } +} + /// Pasting an image is one user action and must be one undo step: the paste handler brackets the layer add, /// 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(); @@ -534,6 +531,7 @@ 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::application_io::resource::Resource; use graph_craft::document::DocumentNodeImplementation; use graph_craft::document::value::TaggedValue; use graph_craft::item; @@ -555,7 +553,7 @@ async fn image_node_with_no_file_still_evaluates() { .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(), + value: TaggedValue::TypeDefault(item!(Resource)).into(), }) .await; @@ -695,9 +693,11 @@ async fn demo_artwork_edit_autosaves_and_round_trips() { // Open a real demo artwork through the normal open path and let it render. let content = std::fs::read_to_string("../demo-artwork/changing-seasons.graphite").expect("read demo artwork"); editor - .handle_message(PortfolioMessage::OpenFile { - path: "changing-seasons.graphite".into(), - content: content.bytes().collect(), + .handle_message(IngestMessage::Ingest { + data: content.into_bytes(), + action: IngestAction::Open, + mime_type: String::new(), + path: Some("changing-seasons.graphite".into()), }) .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 1545c58e5e..c2c0f4e216 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 @@ -311,11 +311,12 @@ impl NodeNetworkInterface { } pub fn collect_used_resources(&self, target: &mut HashSet) { - collect_network_resources(self.document_network(), target); + visit_network_resources(self.document_network(), &mut |id| { + target.insert(id); + }); } - /// 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 { + pub fn collect_resources_use_counts(&self) -> HashMap { let mut counts = HashMap::new(); visit_network_resources(self.document_network(), &mut |id| *counts.entry(id).or_insert(0) += 1); counts diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/template.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/template.rs index 3fccaba631..9c02b43051 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/template.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/template.rs @@ -283,12 +283,16 @@ impl NodeTemplateImplementation { /// Collects resource IDs referenced by a template and its nested networks. pub fn collect_template_resources(template: &NodeTemplate, out: &mut HashSet) { for input in &template.inputs { - collect_input_resource(input, out); + visit_input_resource(input, &mut |id| { + out.insert(id); + }); } if let NodeTemplateImplementation::Network(network_template) = &template.implementation { for export in &network_template.exports { - collect_input_resource(export, out); + visit_input_resource(export, &mut |id| { + out.insert(id); + }); } for nested_template in network_template.nodes.values() { collect_template_resources(nested_template, out); 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 70efe327c2..7f25139aac 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 @@ -865,27 +865,6 @@ pub(crate) enum SoleDependentStep { Escape, } -pub(crate) fn collect_network_resources(network: &NodeNetwork, out: &mut HashSet) { - 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) { - 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); @@ -895,7 +874,7 @@ pub(crate) fn visit_network_resources(network: &NodeNetwork, visit: &mut impl Fn } } -fn visit_node_resources(node: &DocumentNode, visit: &mut impl FnMut(ResourceId)) { +pub(crate) fn visit_node_resources(node: &DocumentNode, visit: &mut impl FnMut(ResourceId)) { for input in &node.inputs { visit_input_resource(input, visit); } @@ -904,7 +883,7 @@ fn visit_node_resources(node: &DocumentNode, visit: &mut impl FnMut(ResourceId)) } } -fn visit_input_resource(input: &NodeInput, visit: &mut impl FnMut(ResourceId)) { +pub(crate) fn visit_input_resource(input: &NodeInput, visit: &mut impl FnMut(ResourceId)) { if let NodeInput::Value { tagged_value, .. } = input && let TaggedValue::Resource(id) = &**tagged_value { @@ -916,6 +895,38 @@ fn visit_input_resource(input: &NodeInput, visit: &mut impl FnMut(ResourceId)) { mod tests { use super::*; + #[test] + fn resource_visits_reach_nested_networks_and_exports() { + let shared = ResourceId::from(1); + 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 outer = NodeNetwork { + nodes: [ + (NodeId(1), node(vec![uses(shared), uses(ResourceId::from(2))])), + ( + NodeId(3), + DocumentNode { + implementation: DocumentNodeImplementation::Network(inner), + ..Default::default() + }, + ), + ] + .into_iter() + .collect(), + ..Default::default() + }; + + let mut visits = Vec::new(); + visit_network_resources(&outer, &mut |id| visits.push(id)); + assert_eq!(visits.iter().filter(|id| **id == shared).count(), 3); + assert_eq!(visits.len(), 4); + } + #[test] fn port_click_targets_are_clickable_at_their_center() { let center = DVec2::new(100., 50.); @@ -930,31 +941,4 @@ 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 e119095336..a2d105c40e 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -10,7 +10,7 @@ use glam::{DVec2, IVec2}; use graph_craft::application_io::resource::{DataSource, Resource, ResourceHash, ResourceId}; use graph_craft::document::DocumentNode; use graph_craft::document::{DocumentNodeImplementation, NodeInput, value::TaggedValue}; -use graph_craft::{Type, item, list}; +use graph_craft::{Type, concrete, item, list}; use graphene_std::Color; use graphene_std::ParameterRef; use graphene_std::ProtoNodeIdentifier; @@ -2877,6 +2877,24 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], } } + // A `Resource` input disconnected before `Resource` became an item-ranked type default stored the bare `TypeDefault(Resource)`, which no longer unwraps to an `Item` default + if let Some(current_node) = document.network_interface.document_node(node_id, network_path) { + let bare_resource_inputs: Vec<(usize, bool)> = current_node + .inputs + .iter() + .enumerate() + .filter_map(|(index, input)| match input { + NodeInput::Value { tagged_value, exposed } if matches!(&**tagged_value, TaggedValue::TypeDefault(stored_type) if *stored_type == concrete!(Resource)) => Some((index, *exposed)), + _ => None, + }) + .collect(); + for (index, exposed) in bare_resource_inputs { + document + .network_interface + .set_input(&InputConnector::node_at_index(*node_id, index), NodeInput::type_default(item!(Resource), exposed), network_path); + } + } + // ================================== // PUT ALL MIGRATIONS ABOVE THIS LINE // ================================== @@ -3137,31 +3155,53 @@ mod tests { for legacy_input in legacy_inputs { let image_id = NodeId(1); let mut document = DocumentMessageHandler::default(); - let image_template = |inputs| NodeTemplate { + let image_template = NodeTemplate { implementation: NodeTemplateImplementation::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER), - inputs, + inputs: vec![NodeInput::value(TaggedValue::None, false)], ..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.insert_node(upstream_id, NodeTemplate::default(), &[]); + document.network_interface.insert_node(image_id, image_template, &[]); 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 migrated = &image_node.inputs[1]; + match legacy_input.as_value() { + None => assert_eq!(migrated.as_node(), Some(upstream_id), "the wire should sit at input 1"), + Some(legacy_value) => { 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"); + match legacy_value { + TaggedValue::Resource(legacy) => assert_eq!(stored, legacy, "the stored resource should be kept"), + _ => assert!(document.resources.registry.contains(stored), "the embedded pixels should be stored as a new resource"), + } } } } } + #[test] + fn bare_resource_type_defaults_become_item_defaults() { + let node_id = NodeId(1); + let mut document = DocumentMessageHandler::default(); + let image_template = NodeTemplate { + implementation: NodeTemplateImplementation::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER), + inputs: vec![NodeInput::value(TaggedValue::None, false), NodeInput::value(TaggedValue::TypeDefault(concrete!(Resource)), true)], + ..Default::default() + }; + document.network_interface.insert_node(node_id, image_template, &[]); + + document_migration_upgrades(&mut document, false); + + let migrated = &document.network_interface.document_network().nodes[&node_id].inputs[1]; + assert_eq!(migrated.as_value(), Some(&TaggedValue::TypeDefault(item!(Resource))), "the bare default should become the item default"); + assert!(matches!(migrated, NodeInput::Value { exposed: true, .. }), "the exposed flag should be kept"); + } + #[test] fn test_no_duplicate_node_replacements() { let mut hashmap = HashMap::::new(); diff --git a/editor/src/messages/portfolio/ingest/ingest_message.rs b/editor/src/messages/portfolio/ingest/ingest_message.rs new file mode 100644 index 0000000000..cb8bdaf6ea --- /dev/null +++ b/editor/src/messages/portfolio/ingest/ingest_message.rs @@ -0,0 +1,28 @@ +use super::utility_types::{IngestAction, TypeFilter}; +use crate::messages::prelude::*; +use graph_craft::document::NodeId; +use std::path::PathBuf; + +#[impl_message(Message, PortfolioMessage, Ingest)] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum IngestMessage { + Ingest { + data: Vec, + action: IngestAction, + mime_type: String, + path: Option, + }, + Browse { + filters: Vec, + multiple: bool, + action: IngestAction, + }, + Open, + Import, + SetResourceInput { + document_id: DocumentId, + node_id: NodeId, + input_index: usize, + filters: Vec, + }, +} diff --git a/editor/src/messages/portfolio/ingest/ingest_message_handler.rs b/editor/src/messages/portfolio/ingest/ingest_message_handler.rs new file mode 100644 index 0000000000..1ad47de07c --- /dev/null +++ b/editor/src/messages/portfolio/ingest/ingest_message_handler.rs @@ -0,0 +1,317 @@ +use super::utility_types::{DataType, IngestAction, TypeFilter, decoded_image_size}; +use crate::messages::frontend::utility_types::{FileDialogOptions, FileFilter}; +use crate::messages::prelude::*; +use glam::IVec2; +use graph_craft::application_io::resource::ResourceId; +use graph_craft::document::value::TaggedValue; + +#[derive(ExtractField)] +pub struct IngestMessageContext { + pub document_open: bool, +} + +#[derive(Debug, Default, ExtractField)] +pub struct IngestMessageHandler {} + +#[message_handler_data] +impl MessageHandler for IngestMessageHandler { + fn process_message(&mut self, message: IngestMessage, responses: &mut VecDeque, context: IngestMessageContext) { + match message { + IngestMessage::Open => responses.add(IngestMessage::Browse { + filters: vec![TypeFilter::documents(), TypeFilter::image()], + multiple: true, + action: IngestAction::Open, + }), + IngestMessage::Import => responses.add(IngestMessage::Browse { + filters: vec![TypeFilter::image()], + multiple: false, + action: IngestAction::Import, + }), + IngestMessage::SetResourceInput { + document_id, + node_id, + input_index, + filters, + } => { + let accepted_types = filters.iter().flat_map(|filter| filter.types.iter().copied()).collect(); + + responses.add(IngestMessage::Browse { + filters, + multiple: false, + action: IngestAction::ResourceInput { + document_id, + node_id, + input_index: input_index as u32, + accepted_types, + }, + }); + } + IngestMessage::Browse { filters, multiple, action } => responses.add(FrontendMessage::TriggerBrowse { + options: FileDialogOptions { + filters: filters.into_iter().map(FileFilter::from).collect(), + multiple, + }, + action, + }), + IngestMessage::Ingest { data, action, mime_type, path } => { + let data_type = DataType::detect(&data, &mime_type, path.as_deref()); + + let placement = match action { + IngestAction::ResourceInput { + document_id, + node_id, + input_index, + accepted_types, + } => { + if !is_accepted(&data, data_type, &accepted_types) { + responses.add(DialogMessage::DisplayDialogError { + title: "Unsupported file".into(), + description: "This file is not a type that this input accepts.".into(), + }); + return; + } + + // 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: data.into() }), + DocumentMessage::NodeGraph(NodeGraphMessage::SetInputValue { + node_id, + input_index: input_index as usize, + value: TaggedValue::Resource(resource_id).into(), + }), + ]; + for message in messages { + responses.add(PortfolioMessage::DocumentPassMessage { document_id, message }); + } + return; + } + IngestAction::Open => None, + IngestAction::Import | IngestAction::Paste => Some((None, None)), + IngestAction::DropOnCanvas { mouse } => Some((Some(mouse), None)), + IngestAction::DropOnLayers { parent, insert_index } => Some((None, Some((parent, insert_index as usize)))), + } + .filter(|_| context.document_open); + + let name = path.as_ref().and_then(|path| path.file_stem()).map(|stem| stem.to_string_lossy().into_owned()); + let document_path = path.filter(|path| path.is_absolute()); + + let (mouse, parent_and_insert_index) = placement.unwrap_or_default(); + let place_at_origin = placement.is_none(); + let (insert, artboard_canvas) = match data_type { + DataType::GraphiteLegacy => { + let Ok(document_serialized_content) = String::from_utf8(data) else { + return unsupported(responses); + }; + responses.add(PortfolioMessage::OpenDocumentFile { + document_name: name, + document_path, + document_serialized_content, + }); + return; + } + DataType::Gdd => { + responses.add(PortfolioMessage::OpenGddDocument { + document_name: name, + document_path, + content: data, + }); + return; + } + DataType::Svg => { + let Ok(svg) = String::from_utf8(data) else { return unsupported(responses) }; + let artboard_canvas = place_at_origin.then(|| svg_canvas(&svg)).flatten(); + let insert = DocumentMessage::InsertSvg { + name: name.clone(), + svg, + mouse, + parent_and_insert_index, + place_at_origin, + }; + (insert, artboard_canvas) + } + DataType::Raster(_) => { + let Some(size) = decoded_image_size(&data) else { return unsupported(responses) }; + let insert = DocumentMessage::InsertImage { + name: name.clone(), + data: data.into(), + size: size.into(), + mouse, + parent_and_insert_index, + place_at_origin, + }; + (insert, None) + } + DataType::Unknown => return unsupported(responses), + }; + + if !place_at_origin { + responses.add(insert); + return; + } + responses.add(PortfolioMessage::NewDocumentWithName { name: name.unwrap_or_default() }); + responses.add(insert); + responses.add(DeferMessage::AfterGraphRun { + messages: vec![ + DocumentMessage::WrapContentInArtboard { + place_artboard_at_origin: true, + artboard_canvas, + } + .into(), + ], + }); + responses.add(DeferMessage::AfterNavigationReady { + messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()], + }); + } + } + } + + fn actions(&self) -> ActionList { + actions!(IngestMessageDiscriminant;) + } +} + +fn unsupported(responses: &mut VecDeque) { + responses.add(DialogMessage::DisplayDialogError { + title: "Unsupported format".into(), + description: "This file is not a supported document or image format.".into(), + }); +} + +/// Whether a node input takes the file, where an image must also fully decode. +fn is_accepted(data: &[u8], data_type: DataType, accepted_types: &[DataType]) -> bool { + if accepted_types.is_empty() { + return true; + } + + accepted_types.contains(&data_type) && (!matches!(data_type, DataType::Raster(_)) || decoded_image_size(data).is_some()) +} + +// The viewBox preserves the full canvas rather than the tighter bounding box of the rendered content +fn svg_canvas(svg: &str) -> Option<(IVec2, IVec2)> { + usvg::roxmltree::Document::parse(svg) + .ok() + .and_then(|document| { + let numbers: Vec = document + .root_element() + .attribute("viewBox")? + .split(|character: char| character.is_ascii_whitespace() || character == ',') + .filter_map(|number| number.parse().ok()) + .collect(); + let [x, y, width, height, ..] = numbers[..] else { return None }; + Some((IVec2::new(x.round() as i32, y.round() as i32), IVec2::new(width.round() as i32, height.round() as i32))) + }) + .or_else(|| { + let size = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?.size(); + Some((IVec2::ZERO, IVec2::new(size.width().round() as i32, size.height().round() as i32))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use graph_craft::document::NodeId; + use graphene_std::Color; + use graphene_std::raster::Image; + + const REQUESTING_DOCUMENT: DocumentId = DocumentId(3); + + fn ingest(data: &[u8], action: IngestAction, document_open: bool) -> VecDeque { + let mut responses = VecDeque::new(); + let message = IngestMessage::Ingest { + data: data.into(), + action, + mime_type: String::new(), + path: None, + }; + IngestMessageHandler::default().process_message(message, &mut responses, IngestMessageContext { document_open }); + responses + } + + fn resource_input(accepted_types: Vec) -> IngestAction { + IngestAction::ResourceInput { + document_id: REQUESTING_DOCUMENT, + node_id: NodeId(7), + input_index: 1, + accepted_types, + } + } + + fn stored_resource(message: &Message) -> Option { + match message { + Message::Portfolio(PortfolioMessage::DocumentPassMessage { + document_id: REQUESTING_DOCUMENT, + message: DocumentMessage::Resource(ResourceMessage::StoreEmbedded { resource_id, .. }), + }) => Some(*resource_id), + _ => None, + } + } + + #[test] + fn resource_input_stores_the_file_and_assigns_it_in_the_requesting_document() { + let responses = ingest(b"any bytes", resource_input(Vec::new()), true); + let assigned = responses.iter().find_map(|message| match message { + Message::Portfolio(PortfolioMessage::DocumentPassMessage { + document_id: REQUESTING_DOCUMENT, + message: DocumentMessage::NodeGraph(NodeGraphMessage::SetInputValue { node_id, input_index, value }), + }) => Some((*node_id, *input_index, value.clone())), + _ => None, + }); + + let stored = responses.iter().find_map(stored_resource).expect("the file should be stored as a resource"); + assert_eq!(assigned, Some((NodeId(7), 1, TaggedValue::Resource(stored).into()))); + assert!( + responses.iter().all(|message| matches!(message, Message::Portfolio(PortfolioMessage::DocumentPassMessage { .. }))), + "nothing should reach whichever document happens to be active" + ); + } + + #[test] + fn resource_input_rejects_a_file_outside_its_types_before_storing_it() { + let png = Image::new(8, 8, Color::WHITE).to_png(); + let raster = TypeFilter::raster().types; + assert!(ingest(&png, resource_input(raster.clone()), true).iter().any(|message| stored_resource(message).is_some())); + + // Text, an SVG, and an image cut off after its intact header + for rejected in [b"not an image".as_slice(), b"".as_slice(), &png[..40]] { + let responses = ingest(rejected, resource_input(raster.clone()), true); + assert_eq!(responses.len(), 1, "a rejected file should not become a resource"); + assert!(matches!(responses[0], Message::Dialog(DialogMessage::DisplayDialogError { .. })), "the user should be told why"); + } + } + + #[test] + fn truncated_image_only_shows_a_dialog() { + let png = Image::new(8, 8, Color::WHITE).to_png(); + let responses = ingest(&png[..40], IngestAction::Paste, true); + assert_eq!(responses.len(), 1); + assert!(matches!(responses[0], Message::Dialog(DialogMessage::DisplayDialogError { .. }))); + } + + #[test] + fn unsupported_file_only_shows_a_dialog() { + let responses = ingest(b"just text", IngestAction::Open, true); + assert_eq!(responses.len(), 1); + assert!(matches!(responses[0], Message::Dialog(DialogMessage::DisplayDialogError { .. }))); + } + + #[test] + fn image_without_a_document_opens_one() { + let png = Image::new(1, 1, Color::WHITE).to_png(); + let responses = ingest(&png, IngestAction::Paste, false); + assert!(matches!(responses[0], Message::Portfolio(PortfolioMessage::NewDocumentWithName { .. }))); + assert!( + responses + .iter() + .any(|message| matches!(message, Message::Portfolio(PortfolioMessage::Document(DocumentMessage::InsertImage { place_at_origin: true, .. })))) + ); + + let responses = ingest(&png, IngestAction::Paste, true); + assert!(matches!( + responses[0], + Message::Portfolio(PortfolioMessage::Document(DocumentMessage::InsertImage { place_at_origin: false, .. })) + )); + } +} diff --git a/editor/src/messages/portfolio/ingest/mod.rs b/editor/src/messages/portfolio/ingest/mod.rs new file mode 100644 index 0000000000..81e82ab363 --- /dev/null +++ b/editor/src/messages/portfolio/ingest/mod.rs @@ -0,0 +1,9 @@ +mod ingest_message; +mod ingest_message_handler; + +pub mod utility_types; + +#[doc(inline)] +pub use ingest_message::{IngestMessage, IngestMessageDiscriminant}; +#[doc(inline)] +pub use ingest_message_handler::{IngestMessageContext, IngestMessageHandler}; diff --git a/editor/src/messages/portfolio/ingest/utility_types.rs b/editor/src/messages/portfolio/ingest/utility_types.rs new file mode 100644 index 0000000000..f2a896035e --- /dev/null +++ b/editor/src/messages/portfolio/ingest/utility_types.rs @@ -0,0 +1,221 @@ +use crate::consts::{FILE_EXTENSION, GDD_FILE_EXTENSION}; +use crate::messages::frontend::utility_types::FileFilter; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::prelude::DocumentId; +use document_container::archive::ArchiveFormat; +use graph_craft::document::NodeId; +use image::ImageFormat; +use std::ffi::OsStr; +use std::path::Path; + +/// How many leading bytes are inspected to recognize a text format. +const SNIFFED_TEXT_LENGTH: usize = 4096; + +/// 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())) +} + +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))] +pub enum IngestAction { + Open, + Import, + Paste, + DropOnCanvas { + mouse: (f64, f64), + }, + DropOnLayers { + parent: LayerNodeIdentifier, + insert_index: u32, + }, + ResourceInput { + document_id: DocumentId, + node_id: NodeId, + input_index: u32, + /// The types this input takes, where none means any file. + #[cfg_attr(feature = "wasm", tsify(type = "unknown"))] + accepted_types: Vec, + }, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum DataType { + GraphiteLegacy, + Gdd, + Svg, + Raster(ImageFormat), + #[default] + Unknown, +} + +impl DataType { + /// The content decides, and the MIME type then the file name only settle what it leaves unknown. + pub fn detect(data: &[u8], mime_type: &str, path: Option<&Path>) -> Self { + match Self::from_content(data) { + Self::Unknown => match Self::from_mime(mime_type) { + Self::Unknown => path.map_or(Self::Unknown, Self::from_path), + data_type => data_type, + }, + data_type => data_type, + } + } + + pub fn from_mime(mime: &str) -> Self { + match mime.to_ascii_lowercase().as_str() { + "application/graphite+json" => Self::GraphiteLegacy, + "application/vnd.graphite.document" => Self::Gdd, + "image/svg+xml" => Self::Svg, + mime => ImageFormat::from_mime_type(mime).map_or(Self::Unknown, Self::Raster), + } + } + + pub fn from_extension(extension: &str) -> Self { + match extension.trim_start_matches('.').to_ascii_lowercase().as_str() { + FILE_EXTENSION => Self::GraphiteLegacy, + GDD_FILE_EXTENSION => Self::Gdd, + "svg" => Self::Svg, + extension => ImageFormat::from_extension(extension).map_or(Self::Unknown, Self::Raster), + } + } + + pub fn from_path(path: impl AsRef) -> Self { + path.as_ref().extension().and_then(OsStr::to_str).map_or(Self::Unknown, Self::from_extension) + } + + pub fn from_content(data: &[u8]) -> Self { + if ArchiveFormat::detect(data).is_some() { + return Self::Gdd; + } + if let Ok(format) = image::guess_format(data) { + return Self::Raster(format); + } + + // Only the head is read as text, where a character split by the cut is the one invalid sequence tolerated + let head = &data[..data.len().min(SNIFFED_TEXT_LENGTH)]; + let text = match std::str::from_utf8(head) { + Ok(text) => text, + Err(error) if error.error_len().is_none() => std::str::from_utf8(&head[..error.valid_up_to()]).unwrap_or_default(), + Err(_) => return Self::Unknown, + }; + + let text = text.trim_start_matches('\u{feff}').trim_start(); + if text.starts_with('{') { + Self::GraphiteLegacy + } else if text.starts_with('<') && text.contains(" Option<&'static str> { + Some(match self { + Self::GraphiteLegacy => "application/graphite+json", + Self::Gdd => "application/vnd.graphite.document", + Self::Svg => "image/svg+xml", + Self::Raster(format) => format.to_mime_type(), + Self::Unknown => return None, + }) + } + + pub fn extensions(self) -> &'static [&'static str] { + match self { + Self::GraphiteLegacy => &[FILE_EXTENSION], + Self::Gdd => &[GDD_FILE_EXTENSION], + Self::Svg => &["svg"], + Self::Raster(format) => format.extensions_str(), + Self::Unknown => &[], + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct TypeFilter { + pub name: String, + pub types: Vec, +} + +impl TypeFilter { + pub fn documents() -> Self { + Self { + name: "Graphite Document".into(), + types: vec![DataType::Gdd, DataType::GraphiteLegacy], + } + } + + pub fn raster() -> Self { + Self { + name: "Image".into(), + types: ImageFormat::all().filter(ImageFormat::reading_enabled).map(DataType::Raster).collect(), + } + } + + pub fn image() -> Self { + let mut images = Self::raster(); + images.types.push(DataType::Svg); + images + } +} + +impl From for FileFilter { + fn from(filter: TypeFilter) -> Self { + Self { + name: filter.name, + extensions: filter.types.iter().flat_map(|data_type| data_type.extensions()).map(|extension| extension.to_string()).collect(), + mime_types: filter.types.iter().filter_map(|data_type| data_type.mime()).map(str::to_string).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use graphene_std::Color; + use graphene_std::raster::Image; + + #[test] + fn data_type_from_content() { + assert_eq!(DataType::from_content(&Image::new(1, 1, Color::WHITE).to_png()), DataType::Raster(ImageFormat::Png)); + assert_eq!(DataType::from_content(b"PK\x03\x04"), DataType::Gdd); + assert_eq!(DataType::from_content(b"{\"network_interface\":{}}"), DataType::GraphiteLegacy); + assert_eq!( + DataType::from_content("\u{feff}\n".as_bytes()), + DataType::Svg + ); + assert_eq!(DataType::from_content(b"just text"), DataType::Unknown); + assert_eq!(DataType::from_content(&[0xff, 0xfe, 0x00]), DataType::Unknown); + } + + #[test] + fn data_type_from_content_reads_only_the_head() { + let late_svg = format!("", " ".repeat(SNIFFED_TEXT_LENGTH)); + assert_eq!(DataType::from_content(late_svg.as_bytes()), DataType::Unknown); + + // The three-byte euro sign straddles the cut, which must not hide the text before it + let straddling = format!("{}€", " ".repeat(SNIFFED_TEXT_LENGTH - 6)); + assert_eq!(DataType::from_content(straddling.as_bytes()), DataType::Svg); + } + + #[test] + fn data_type_detect() { + let detect = |mime_type: &str, path: &str| DataType::detect(&[], mime_type, Some(Path::new(path))); + assert_eq!(detect("", "photo.JPEG"), DataType::Raster(ImageFormat::Jpeg)); + assert_eq!(detect("image/svg+xml", ""), DataType::Svg); + assert_eq!(detect("application/graphite+json", ""), DataType::GraphiteLegacy); + assert_eq!(detect("image/png", "document.gdd"), DataType::Raster(ImageFormat::Png)); + assert_eq!(detect("text/csv", "table.csv"), DataType::Unknown); + assert_eq!(DataType::detect(&[], "", None), DataType::Unknown); + + let png = Image::new(1, 1, Color::WHITE).to_png(); + assert_eq!(DataType::detect(&png, "image/svg+xml", Some(Path::new("drawing.svg"))), DataType::Raster(ImageFormat::Png)); + } + + #[test] + fn type_filter_to_file_filter() { + let filter = FileFilter::from(TypeFilter::image()); + assert!(filter.extensions.iter().any(|extension| extension == "jpeg") && filter.extensions.iter().any(|extension| extension == "png")); + assert!(filter.extensions.last().is_some_and(|extension| extension == "svg")); + assert!(filter.mime_types.contains(&"image/jpeg".to_string()) && filter.mime_types.contains(&"image/svg+xml".to_string())); + } +} diff --git a/editor/src/messages/portfolio/mod.rs b/editor/src/messages/portfolio/mod.rs index 47a928da8c..b385478a0f 100644 --- a/editor/src/messages/portfolio/mod.rs +++ b/editor/src/messages/portfolio/mod.rs @@ -5,17 +5,17 @@ pub mod document; pub mod document_migration; pub mod document_storage_io; pub mod fonts; +pub mod ingest; pub mod persistent_state; -pub mod resource_upload; pub mod utility_types; #[doc(inline)] pub use fonts::{FontsMessage, FontsMessageContext, FontsMessageHandler}; #[doc(inline)] +pub use ingest::{IngestMessage, IngestMessageContext, IngestMessageHandler}; +#[doc(inline)] pub use persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageHandler}; #[doc(inline)] 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 4c8824b564..4ad7cfcdfa 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -1,6 +1,5 @@ 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::*; @@ -16,9 +15,9 @@ pub enum PortfolioMessage { #[child] Fonts(FontsMessage), #[child] - PersistentState(PersistentStateMessage), + Ingest(IngestMessage), #[child] - ResourceUpload(ResourceUploadMessage), + PersistentState(PersistentStateMessage), // Messages Init, @@ -89,16 +88,6 @@ pub enum PortfolioMessage { name: String, }, NextDocument, - Open, - Import, - OpenFile { - path: PathBuf, - content: Vec, - }, - ImportFile { - path: PathBuf, - content: Vec, - }, OpenDocumentFile { document_name: Option, document_path: Option, @@ -139,16 +128,6 @@ pub enum PortfolioMessage { document_is_saved: bool, document_serialized_content: String, }, - OpenSvg { - name: Option, - svg: String, - }, - InsertSvg { - name: Option, - svg: String, - mouse: Option<(f64, f64)>, - parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>, - }, CenterLayers { layers: Vec, }, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 35846672a6..7ffff7c34c 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -15,8 +15,6 @@ 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::*; use crate::messages::tool::utility_types::{HintData, ToolType}; @@ -26,7 +24,7 @@ use glam::{DAffine2, DVec2}; use graph_craft::application_io::resource::{DataSource, ResourceHash}; use graph_craft::document::NodeId; use graphene_std::renderer::Quad; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use std::vec; @@ -60,7 +58,7 @@ pub struct PortfolioMessageHandler { pub(crate) active_document_id: Option, persistent_state: PersistentStateMessageHandler, pub fonts: FontsMessageHandler, - resource_upload: ResourceUploadMessageHandler, + ingest: IngestMessageHandler, pub executor: NodeGraphExecutor, pub selection_mode: SelectionMode, pub reset_node_definitions_on_open: bool, @@ -116,11 +114,11 @@ impl MessageHandler> for Portfolio let context = FontsMessageContext { resource_storage }; self.fonts.process_message(message, responses, context); } - PortfolioMessage::ResourceUpload(message) => { - let context = ResourceUploadMessageContext { + PortfolioMessage::Ingest(message) => { + let context = IngestMessageContext { document_open: self.active_document().is_some(), }; - self.resource_upload.process_message(message, responses, context); + self.ingest.process_message(message, responses, context); } // Messages @@ -510,6 +508,7 @@ impl MessageHandler> for Portfolio filters: vec![FileFilter { name: "Graphite Document".into(), extensions: vec![FILE_EXTENSION.into()], + mime_types: Vec::new(), }], content: serde_bytes::ByteBuf::from(content), }); @@ -521,6 +520,7 @@ impl MessageHandler> for Portfolio filters: vec![FileFilter { name: "Zip Archive".into(), extensions: vec!["zip".into()], + mime_types: Vec::new(), }], content: serde_bytes::ByteBuf::from(zip_bytes), }), @@ -664,107 +664,6 @@ impl MessageHandler> for Portfolio responses.add(PortfolioMessage::SelectDocument { document_id: next_id }); } } - PortfolioMessage::Open => { - // This portfolio message wraps the frontend message so it can be listed as an action, which isn't possible for frontend messages - responses.add(FrontendMessage::TriggerOpen { - filters: vec![ - FileFilter { - name: "Graphite Document".into(), - extensions: vec![FILE_EXTENSION.into(), GDD_FILE_EXTENSION.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![image_file_filter()] }); - } - PortfolioMessage::OpenFile { path, content } => { - let name = path.file_stem().map(|n| n.to_string_lossy().to_string()); - match Self::read_file(&path, content) { - FileContent::Document(content) => { - let document_path = if path.is_absolute() { Some(path) } else { None }; - responses.add(PortfolioMessage::OpenDocumentFile { - document_name: name, - document_path, - document_serialized_content: content, - }); - } - FileContent::GddDocument(content) => { - let document_path = if path.is_absolute() { Some(path) } else { None }; - responses.add(PortfolioMessage::OpenGddDocument { - document_name: name, - document_path, - content, - }); - } - FileContent::Svg(svg) => { - responses.add(PortfolioMessage::OpenSvg { name, svg }); - } - 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 - responses.add(DialogMessage::DisplayDialogError { - title: "Unsupported format".into(), - description: "This file cannot be opened because it is not a supported image file type.".into(), - }) - } - } - } - PortfolioMessage::ImportFile { path, content } => { - let name = path.file_stem().map(|n| n.to_string_lossy().to_string()); - match Self::read_file(&path, content) { - FileContent::Document(content) => { - // TODO: Consider importing a document as a node into the current document - // For now treat importing a document as opening it - responses.add(PortfolioMessage::OpenDocumentFile { - document_name: name, - document_path: Some(path), - document_serialized_content: content, - }); - } - FileContent::GddDocument(content) => { - responses.add(PortfolioMessage::OpenGddDocument { - document_name: name, - document_path: Some(path), - content, - }); - } - FileContent::Svg(svg) => { - responses.add(PortfolioMessage::InsertSvg { - name, - svg, - mouse: None, - parent_and_insert_index: None, - }); - } - FileContent::Image(data) => { - responses.add(ResourceUploadMessage::Upload { - name, - data: data.into(), - target: UploadTarget::Layer { - mouse: None, - parent_and_insert_index: None, - }, - }); - } - FileContent::Unsupported => { - // TODO: Show a more thoughtfully designed error message to the user - responses.add(DialogMessage::DisplayDialogError { - title: "Unsupported format".into(), - description: "This file cannot be imported because it is not a supported image file type.".into(), - }) - } - } - } PortfolioMessage::OpenDocumentFile { document_name, document_path, @@ -997,61 +896,6 @@ impl MessageHandler> for Portfolio self.tick_autosave_load_progress(responses, false); } } - PortfolioMessage::OpenSvg { name, svg } => { - responses.add(PortfolioMessage::NewDocumentWithName { - name: name.clone().unwrap_or_default(), - }); - - // Parse the SVG to extract its declared canvas origin and dimensions from the viewBox attribute. - // This preserves the full canvas rather than measuring only the tighter rendered content bounding box. - let artboard_canvas = usvg::roxmltree::Document::parse(&svg) - .ok() - .and_then(|doc| { - let vb = doc.root_element().attribute("viewBox")?; - let nums: Vec = vb - .split(|c: char| c.is_ascii_whitespace() || c == ',') - .filter(|s| !s.is_empty()) - .filter_map(|s| s.parse().ok()) - .collect(); - if nums.len() >= 4 { - Some(( - glam::IVec2::new(nums[0].round() as i32, nums[1].round() as i32), - glam::IVec2::new(nums[2].round() as i32, nums[3].round() as i32), - )) - } else { - None - } - }) - .or_else(|| { - // Fall back to the viewport size when there is no viewBox attribute - usvg::Tree::from_str(&svg, &usvg::Options::default()).ok().map(|tree| { - let size = tree.size(); - (glam::IVec2::ZERO, glam::IVec2::new(size.width().round() as i32, size.height().round() as i32)) - }) - }); - - responses.add(DocumentMessage::InsertSvg { - name, - svg, - 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 SVG - responses.add(DeferMessage::AfterGraphRun { - messages: vec![ - DocumentMessage::WrapContentInArtboard { - place_artboard_at_origin: true, - artboard_canvas, - } - .into(), - ], - }); - responses.add(DeferMessage::AfterNavigationReady { - messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()], - }); - } PortfolioMessage::CenterLayers { layers } => { if let Some(document) = self.active_document_mut() { let viewport_bounds_quad_pixels = Quad::from_box([DVec2::ZERO, viewport.size().into_dvec2()]); // In viewport pixel coordinates @@ -1157,24 +1001,6 @@ impl MessageHandler> for Portfolio responses.add(NodeGraphMessage::RunDocumentGraph); } } - PortfolioMessage::InsertSvg { - name, - svg, - mouse, - parent_and_insert_index, - } => { - if self.document_ids.is_empty() { - responses.add(PortfolioMessage::OpenSvg { name, svg }); - } else { - responses.add(DocumentMessage::InsertSvg { - name, - svg, - mouse, - parent_and_insert_index, - place_at_origin: false, - }); - } - } PortfolioMessage::PrevDocument => { if let Some(active_document_id) = self.active_document_id { let len = self.document_ids.len(); @@ -1235,12 +1061,8 @@ impl MessageHandler> for Portfolio ShortcutLabel::new(action_shortcut!(DialogMessageDiscriminant::RequestNewDocumentDialog)).widget_instance(), ], vec![ - TextButton::new("Open Document") - .icon("Folder") - .flush(true) - .on_commit(|_| PortfolioMessage::Open.into()) - .widget_instance(), - ShortcutLabel::new(action_shortcut!(PortfolioMessageDiscriminant::Open)).widget_instance(), + TextButton::new("Open Document").icon("Folder").flush(true).on_commit(|_| IngestMessage::Open.into()).widget_instance(), + ShortcutLabel::new(action_shortcut!(IngestMessageDiscriminant::Open)).widget_instance(), ], vec![ TextButton::new("Open Demo Artwork") @@ -1652,9 +1474,9 @@ impl MessageHandler> for Portfolio fn actions(&self) -> ActionList { let mut common = actions!(PortfolioMessageDiscriminant; - Open, ToggleFocusDocument, ); + common.extend(actions!(IngestMessageDiscriminant; Open)); // Extend with actions that require an active document if let Some(document) = self.active_document() { @@ -1666,8 +1488,8 @@ impl MessageHandler> for Portfolio ToggleRulers, NextDocument, PrevDocument, - Import, )); + common.extend(actions!(IngestMessageDiscriminant; Import)); } // Extend with actions that are disabled when focusing the document @@ -1782,22 +1604,6 @@ impl PortfolioMessageHandler { } } - 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) { - Ok(content) => FileContent::Document(content), - Err(_) => FileContent::Unsupported, - }, - GDD_FILE_EXTENSION => FileContent::GddDocument(content), - "svg" => match String::from_utf8(content) { - Ok(content) => FileContent::Svg(content), - Err(_) => FileContent::Unsupported, - }, - _ => FileContent::Image(content), - } - } - fn load_document( &mut self, mut new_document: DocumentMessageHandler, diff --git a/editor/src/messages/portfolio/resource_upload/mod.rs b/editor/src/messages/portfolio/resource_upload/mod.rs deleted file mode 100644 index 2537562440..0000000000 --- a/editor/src/messages/portfolio/resource_upload/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 8f0d290115..0000000000 --- a/editor/src/messages/portfolio/resource_upload/resource_upload_message.rs +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 93a1872f79..0000000000 --- a/editor/src/messages/portfolio/resource_upload/resource_upload_message_handler.rs +++ /dev/null @@ -1,216 +0,0 @@ -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 deleted file mode 100644 index c217e0c177..0000000000 --- a/editor/src/messages/portfolio/resource_upload/utility_types.rs +++ /dev/null @@ -1,96 +0,0 @@ -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 0f5a1f0e02..eaa1aedd73 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -784,16 +784,3 @@ impl PanelLayoutSubdivision { } } } - -pub enum FileContent { - /// A legacy `.graphite` document (serialized runtime JSON). - Document(String), - /// A `.gdd` document container (archive bytes). - GddDocument(Vec), - /// Any other file, expected to be a bitmap image. - Image(Vec), - /// An SVG file string. - Svg(String), - /// Any other unsupported/unrecognized file type. - Unsupported, -} diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 77bc3b5832..4b5bf9a816 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -32,8 +32,8 @@ pub use crate::messages::portfolio::document::properties_panel::{PropertiesPanel pub use crate::messages::portfolio::document::resource::{ResourceMessage, ResourceMessageContext, ResourceMessageDiscriminant, ResourceMessageHandler}; 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::ingest::{IngestMessage, IngestMessageContext, IngestMessageDiscriminant, IngestMessageHandler}; 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 7b9fe963ae..d28bb3a17b 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -5,7 +5,6 @@ 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; @@ -16,6 +15,7 @@ use graphene_std::vector::style::{FillChoice, PaintOrder, StrokeAlign, StrokeCap use graphene_std::vector::{Gradient, GradientForm, GradientRamp, GradientSettings, PointId, SegmentId, VectorModificationType}; use graphene_std::{NodeParameter, ParameterRef}; use std::collections::VecDeque; +use std::sync::Arc; /// Returns the ID of the first Spline node in the horizontal flow which is not followed by a `Path` node, or `None` if none exists. pub fn find_spline(document: &DocumentMessageHandler, layer: LayerNodeIdentifier) -> Option { @@ -206,15 +206,10 @@ pub fn merge_points(document: &DocumentMessageHandler, layer: LayerNodeIdentifie responses.add(GraphOperationMessage::Vector { layer, modification_type }); } -/// 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 { +/// Create a new bitmap layer. +pub fn new_image_layer(data: Arc<[u8]>, id: NodeId, parent: LayerNodeIdentifier, responses: &mut VecDeque) -> LayerNodeIdentifier { let insert_index = 0; - responses.add(GraphOperationMessage::NewBitmapLayer { - id, - resource_id, - parent, - insert_index, - }); + responses.add(GraphOperationMessage::NewBitmapLayer { id, data, parent, insert_index }); LayerNodeIdentifier::new_unchecked(id) } diff --git a/editor/src/test_utils.rs b/editor/src/test_utils.rs index b7dcda9362..f8888d659f 100644 --- a/editor/src/test_utils.rs +++ b/editor/src/test_utils.rs @@ -2,7 +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::portfolio::ingest::utility_types::IngestAction; use crate::messages::prelude::*; use crate::messages::tool::tool_messages::tool_prelude::Key; use crate::messages::tool::utility_types::ToolType; @@ -243,10 +243,11 @@ impl EditorTestUtils { } pub async fn create_raster_image(&mut self, image: graphene_std::raster::Image, mouse: Option<(f64, f64)>) { - self.handle_message(ResourceUploadMessage::Upload { - name: None, - data: image.to_png().into(), - target: UploadTarget::Layer { mouse, parent_and_insert_index: None }, + self.handle_message(IngestMessage::Ingest { + data: image.to_png(), + action: mouse.map_or(IngestAction::Paste, |mouse| IngestAction::DropOnCanvas { mouse }), + mime_type: String::new(), + path: None, }) .await; } diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 496cc0dd18..4dbee58f2b 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -84,7 +84,7 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: try { const url = new URL(`demo-artwork/${data.filename}`, document.location.href); const response = await fetch(url); - editor.openFile(data.filename, await response.bytes()); + editor.ingestPicked(data.filename, "", await response.bytes(), "Open"); } catch { // Needs to be delayed until the end of the current call stack so the existing demo artwork dialog can be closed first, otherwise this dialog won't show setTimeout(() => { @@ -93,19 +93,10 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: } }); - subscriptions.subscribeFrontendMessage("TriggerOpen", async ({ filters }) => { - const files = await upload(acceptStringFromFilters(filters), "data", true); - files.forEach((file) => editor.openFile(file.filename, file.content)); - }); - - subscriptions.subscribeFrontendMessage("TriggerImport", async ({ filters }) => { - const data = await upload(acceptStringFromFilters(filters), "data"); - 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("TriggerBrowse", async ({ options, action }) => { + const accept = acceptStringFromFilters(options.filters); + const files = options.multiple ? await upload(accept, "data", true) : [await upload(accept, "data")]; + files.forEach((file) => editor.ingestPicked(file.filename, file.type, file.content, action)); }); subscriptions.subscribeFrontendMessage("TriggerSaveDocument", (data) => { @@ -195,9 +186,7 @@ export function destroyPortfolioStore() { subscriptions.unsubscribeFrontendMessage("UpdateOpenDocumentsList"); subscriptions.unsubscribeFrontendMessage("UpdateActiveDocument"); subscriptions.unsubscribeFrontendMessage("TriggerFetchAndOpenDocument"); - subscriptions.unsubscribeFrontendMessage("TriggerOpen"); - subscriptions.unsubscribeFrontendMessage("TriggerImport"); - subscriptions.unsubscribeFrontendMessage("TriggerUploadResource"); + subscriptions.unsubscribeFrontendMessage("TriggerBrowse"); subscriptions.unsubscribeFrontendMessage("TriggerSaveDocument"); subscriptions.unsubscribeFrontendMessage("TriggerSaveFile"); subscriptions.unsubscribeFrontendMessage("TriggerExportImage"); diff --git a/frontend/src/utility-functions/clipboard.ts b/frontend/src/utility-functions/clipboard.ts index b972452491..a30808784e 100644 --- a/frontend/src/utility-functions/clipboard.ts +++ b/frontend/src/utility-functions/clipboard.ts @@ -1,4 +1,3 @@ -import { extractPixelData } from "/src/utility-functions/rasterization"; import { stripIndents } from "/src/utility-functions/strip-indents"; import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; @@ -114,28 +113,9 @@ export async function triggerClipboardRead(editor: EditorWrapper) { // Read an image from the clipboard and pass it to the editor to be loaded const imageType = item.types.find((type) => type.startsWith("image/")); - // Import the actual SVG content if it's an SVG - if (imageType?.includes("svg")) { - const blob = await item.getType("text/plain"); - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === "string") editor.pasteSvg(undefined, reader.result); - }; - reader.readAsText(blob); - return true; - } - - // Import the bitmap image if it's an image if (imageType) { const blob = await item.getType(imageType); - const reader = new FileReader(); - reader.onload = async () => { - if (reader.result instanceof ArrayBuffer) { - const imageData = await extractPixelData(new Blob([reader.result], { type: imageType })); - editor.pasteImage(undefined, new Uint8Array(imageData.data), imageData.width, imageData.height); - } - }; - reader.readAsArrayBuffer(blob); + editor.ingestFile(undefined, imageType, new Uint8Array(await blob.arrayBuffer())); return true; } diff --git a/frontend/src/utility-functions/files.ts b/frontend/src/utility-functions/files.ts index 60b2a8d431..b4690d930f 100644 --- a/frontend/src/utility-functions/files.ts +++ b/frontend/src/utility-functions/files.ts @@ -1,4 +1,3 @@ -import { extractPixelData } from "/src/utility-functions/rasterization"; import type { EditorWrapper, FileFilter } from "/wrapper/pkg/graphite_wasm_wrapper"; export function downloadFileURL(filename: string, url: string) { @@ -85,24 +84,9 @@ 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); - } else { - // TODO: When we eventually have sub-documents, this should be changed to import the document as a node instead of opening it in a separate tab - editor.openFile(file.name, await file.bytes()); - } + editor.ingestFile(file.name, file.type, await file.bytes(), mouse?.[0], mouse?.[1], insertParentId, insertIndex); } export function acceptStringFromFilters(filters: FileFilter[]): string { - const extensions = filters.flatMap((filter) => filter.extensions); - const imageMime = extensions.some((extension) => ["svg", "png", "jpg", "jpeg", "bmp", "gif", "webp", "avif", "tif", "tiff"].includes(extension)) ? ["image/*"] : []; - return [...imageMime, ...extensions.map((extension) => `.${extension}`)].join(","); + return filters.flatMap((filter) => [...filter.mimeTypes, ...filter.extensions.map((extension) => `.${extension}`)]).join(","); } diff --git a/frontend/src/utility-functions/network.ts b/frontend/src/utility-functions/network.ts index 9a3c1ce556..c0b1173657 100644 --- a/frontend/src/utility-functions/network.ts +++ b/frontend/src/utility-functions/network.ts @@ -46,7 +46,7 @@ export async function loadDemoArtwork(editor: EditorWrapper) { const filename = url.pathname.split("/").pop() || "Untitled.graphite"; const content = await response.bytes(); - editor.openFile(filename, content); + editor.ingestPicked(filename, "", content, "Open"); history.replaceState("", "", `${window.location.pathname}${window.location.search}`); } catch { diff --git a/frontend/src/utility-functions/rasterization.ts b/frontend/src/utility-functions/rasterization.ts index 8a0807ac2d..5eebbeddab 100644 --- a/frontend/src/utility-functions/rasterization.ts +++ b/frontend/src/utility-functions/rasterization.ts @@ -51,15 +51,6 @@ export async function rasterizeSVG(svg: string, width: number, height: number, m return blob; } -/// Convert an image source (e.g. PNG document) into pixel data, a width, and a height -export async function extractPixelData(imageData: ImageBitmapSource): Promise { - const canvasContext = await imageToCanvasContext(imageData); - const width = canvasContext.canvas.width; - const height = canvasContext.canvas.height; - - return canvasContext.getImageData(0, 0, width, height); -} - export async function imageToCanvasContext(imageData: ImageBitmapSource): Promise { // Special handling to rasterize an SVG file let svgImageData; diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index cb4cdf1f83..60d4883fb7 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -20,12 +20,10 @@ 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; @@ -213,14 +211,6 @@ mod editor_commands { DialogMessage::RequestNewDocumentDialog.into() } - fn open_file(path: String, content: Vec) -> Message { - PortfolioMessage::OpenFile { path: PathBuf::from(path), content }.into() - } - - fn import_file(path: String, content: Vec) -> Message { - PortfolioMessage::ImportFile { path: PathBuf::from(path), content }.into() - } - fn trigger_auto_save(document_id: u64) -> Message { PortfolioMessage::AutoSaveDocument { document_id: DocumentId(document_id) }.into() } @@ -607,6 +597,36 @@ mod editor_commands { ClipboardMessage::ReadSelection { content, cut }.into() } + /// A file picked in the dialog that `TriggerBrowse` opened + fn ingest_picked(name: String, mime_type: String, data: Vec, action: IngestAction) -> Message { + IngestMessage::Ingest { + data, + action, + mime_type, + path: Some(PathBuf::from(name)), + } + .into() + } + + /// A file dropped on a panel or pasted, placed by the drop position or the layer slot it landed in + fn ingest_file(name: Option, mime_type: String, data: Vec, mouse_x: Option, mouse_y: Option, insert_parent_id: Option, insert_index: Option) -> Message { + let action = match (insert_parent_id.zip(insert_index), mouse_x.zip(mouse_y)) { + (Some((parent, insert_index)), _) => IngestAction::DropOnLayers { + parent: LayerNodeIdentifier::new_unchecked(NodeId(parent)), + insert_index, + }, + (None, Some(mouse)) => IngestAction::DropOnCanvas { mouse }, + (None, None) => IngestAction::Paste, + }; + IngestMessage::Ingest { + data, + action, + mime_type, + path: name.map(PathBuf::from), + } + .into() + } + /// Paste from a serialized JSON representation fn paste_text(data: String) -> Message { ClipboardMessage::ReadClipboard { @@ -615,82 +635,6 @@ mod editor_commands { .into() } - /// Pastes decoded RGBA8 pixels as an image layer, encoded as PNG for storage - fn paste_image( - name: Option, - image_data: Vec, - width: u32, - height: u32, - mouse_x: Option, - mouse_y: Option, - insert_parent_id: Option, - insert_index: Option, - ) -> Message { - let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y))); - let 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); - 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() - } - - /// 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))); - - 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 { @@ -792,6 +736,7 @@ macro_rules! editor_proxy_types { } editor_proxy_types! { + IngestAction = editor::messages::portfolio::ingest::utility_types::IngestAction; LayoutTarget = editor::messages::layout::utility_types::layout_widget::LayoutTarget; DockingSplitDirection = editor::messages::portfolio::utility_types::DockingSplitDirection; PanelTypes = Vec; diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 03a1877851..c2d238a645 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -240,22 +240,6 @@ 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)]