diff --git a/desktop/src/app.rs b/desktop/src/app.rs index bafec9c1d2..251bac1111 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -180,7 +180,7 @@ impl App { if let Some(path) = futures::executor::block_on(show_dialog) && let Ok(content) = fs::read(&path) { - let message = DesktopWrapperMessage::OpenFileDialogResult { path, content, context }; + let message = DesktopWrapperMessage::FileDialogResult { path, content, context }; app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); } }); @@ -550,7 +550,7 @@ impl ApplicationHandler for App { for path in paths { match fs::read(&path) { Ok(content) => { - let message = DesktopWrapperMessage::OpenFile { path, content }; + let message = DesktopWrapperMessage::ImportFile { path, content }; self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); } Err(e) => { diff --git a/desktop/src/persist.rs b/desktop/src/persist.rs index a5baa211b1..97d329f66e 100644 --- a/desktop/src/persist.rs +++ b/desktop/src/persist.rs @@ -190,7 +190,7 @@ impl DocumentStore { fn document_path(id: &DocumentId) -> std::path::PathBuf { let mut path = crate::dirs::app_autosave_documents_dir(); - path.push(format!("{:x}.graphite", id.0)); + path.push(format!("{:x}.{}", id.0, graphite_desktop_wrapper::FILE_EXTENSION)); path } } diff --git a/desktop/wrapper/src/handle_desktop_wrapper_message.rs b/desktop/wrapper/src/handle_desktop_wrapper_message.rs index 5330e67977..e4efc915c2 100644 --- a/desktop/wrapper/src/handle_desktop_wrapper_message.rs +++ b/desktop/wrapper/src/handle_desktop_wrapper_message.rs @@ -1,5 +1,3 @@ -use graphene_std::Color; -use graphene_std::raster::Image; use graphite_editor::messages::clipboard::utility_types::ClipboardContentRaw; use graphite_editor::messages::prelude::*; @@ -14,9 +12,9 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess DesktopWrapperMessage::Input(message) => { dispatcher.queue_editor_message(EditorMessage::InputPreprocessor(message)); } - DesktopWrapperMessage::OpenFileDialogResult { path, content, context } => match context { - OpenFileDialogContext::Document => { - dispatcher.queue_desktop_wrapper_message(DesktopWrapperMessage::OpenDocument { path, content }); + 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 }); @@ -35,78 +33,11 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess } }, DesktopWrapperMessage::OpenFile { path, content } => { - let extension = path.extension().and_then(|s| s.to_str()).unwrap_or_default().to_lowercase(); - match extension.as_str() { - "graphite" => { - dispatcher.queue_desktop_wrapper_message(DesktopWrapperMessage::OpenDocument { path, content }); - } - _ => { - dispatcher.queue_desktop_wrapper_message(DesktopWrapperMessage::ImportFile { path, content }); - } - } - } - DesktopWrapperMessage::OpenDocument { path, content } => { - let Ok(content) = String::from_utf8(content) else { - tracing::warn!("Document file is invalid: {}", path.display()); - return; - }; - - let message = PortfolioMessage::OpenDocumentFile { - document_name: None, - document_path: Some(path), - document_serialized_content: content, - }; + let message = PortfolioMessage::OpenFile { path, content }; dispatcher.queue_editor_message(message); } DesktopWrapperMessage::ImportFile { path, content } => { - let extension = path.extension().and_then(|s| s.to_str()).unwrap_or_default().to_lowercase(); - match extension.as_str() { - "svg" => { - dispatcher.queue_desktop_wrapper_message(DesktopWrapperMessage::ImportSvg { path, content }); - } - _ => { - dispatcher.queue_desktop_wrapper_message(DesktopWrapperMessage::ImportImage { path, content }); - } - } - } - DesktopWrapperMessage::ImportSvg { path, content } => { - let Ok(content) = String::from_utf8(content) else { - tracing::warn!("Svg file is invalid: {}", path.display()); - return; - }; - - let message = PortfolioMessage::PasteSvg { - name: path.file_stem().map(|s| s.to_string_lossy().to_string()), - svg: content, - mouse: None, - parent_and_insert_index: None, - }; - dispatcher.queue_editor_message(message); - } - DesktopWrapperMessage::ImportImage { path, content } => { - let name = path.file_stem().and_then(|s| s.to_str()).map(|s| s.to_string()); - let extension = path.extension().and_then(|s| s.to_str()).unwrap_or_default().to_lowercase(); - let Some(image_format) = image::ImageFormat::from_extension(&extension) else { - tracing::warn!("Unsupported file type: {}", path.display()); - return; - }; - let reader = image::ImageReader::with_format(std::io::Cursor::new(content), image_format); - let Ok(image) = reader.decode() else { - tracing::error!("Failed to decode image: {}", path.display()); - return; - }; - let width = image.width(); - let height = image.height(); - - // TODO: Handle Image formats with more than 8 bits per channel - let image_data = image.to_rgba8(); - let image = Image::::from_image_data(image_data.as_raw(), width, height); - let message = PortfolioMessage::PasteImage { - name, - image, - mouse: None, - parent_and_insert_index: None, - }; + let message = PortfolioMessage::ImportFile { path, content }; dispatcher.queue_editor_message(message); } DesktopWrapperMessage::PollNodeGraphEvaluation => dispatcher.poll_node_graph_evaluation(), diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 9b64020eb5..e4ab2aeb2b 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -10,29 +10,17 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::RenderOverlays { context } => { dispatcher.respond(DesktopFrontendMessage::UpdateOverlays(context.take_scene())); } - FrontendMessage::TriggerOpenDocument => { + FrontendMessage::TriggerOpen => { dispatcher.respond(DesktopFrontendMessage::OpenFileDialog { title: "Open Document".to_string(), - filters: vec![FileFilter { - name: "Graphite".to_string(), - extensions: vec!["graphite".to_string()], - }], - context: OpenFileDialogContext::Document, + filters: vec![], + context: OpenFileDialogContext::Open, }); } FrontendMessage::TriggerImport => { dispatcher.respond(DesktopFrontendMessage::OpenFileDialog { title: "Import File".to_string(), - filters: vec![ - FileFilter { - name: "Svg".to_string(), - extensions: vec!["svg".to_string()], - }, - FileFilter { - name: "Image".to_string(), - extensions: vec!["png".to_string(), "jpg".to_string(), "jpeg".to_string(), "bmp".to_string()], - }, - ], + filters: vec![], context: OpenFileDialogContext::Import, }); } diff --git a/desktop/wrapper/src/lib.rs b/desktop/wrapper/src/lib.rs index 1080b83df6..5c2a096681 100644 --- a/desktop/wrapper/src/lib.rs +++ b/desktop/wrapper/src/lib.rs @@ -2,6 +2,7 @@ use graph_craft::wasm_application_io::WasmApplicationIo; use graphite_editor::application::{Editor, Environment, Host, Platform}; use graphite_editor::messages::prelude::{FrontendMessage, Message}; +pub use graphite_editor::consts::FILE_EXTENSION; // TODO: Remove usage of this reexport in desktop create and remove this line pub use graphene_std::Color; diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 23a9247547..84aab2b2aa 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -79,7 +79,7 @@ pub enum DesktopFrontendMessage { pub enum DesktopWrapperMessage { FromWeb(Box), Input(InputMessage), - OpenFileDialogResult { + FileDialogResult { path: PathBuf, content: Vec, context: OpenFileDialogContext, @@ -88,10 +88,6 @@ pub enum DesktopWrapperMessage { path: PathBuf, context: SaveFileDialogContext, }, - OpenDocument { - path: PathBuf, - content: Vec, - }, OpenFile { path: PathBuf, content: Vec, @@ -100,14 +96,6 @@ pub enum DesktopWrapperMessage { path: PathBuf, content: Vec, }, - ImportSvg { - path: PathBuf, - content: Vec, - }, - ImportImage { - path: PathBuf, - content: Vec, - }, PollNodeGraphEvaluation, UpdateMaximized { maximized: bool, @@ -153,7 +141,7 @@ pub struct FileFilter { } pub enum OpenFileDialogContext { - Document, + Open, Import, } diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 296084c835..dedd70a044 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -571,10 +571,9 @@ 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::OpenDocumentFile { - document_name: Some(document_name.to_string()), - document_path: None, - document_serialized_content, + let responses = editor.editor.handle_message(PortfolioMessage::OpenFile { + path: file_name.into(), + content: document_serialized_content.bytes().collect(), }); // Check if the graph renders diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index e5e64720ed..188456d5b2 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -107,7 +107,6 @@ pub enum FrontendMessage { font: Font, url: String, }, - TriggerImport, TriggerPersistenceRemoveDocument { #[serde(rename = "documentId")] document_id: DocumentId, @@ -122,7 +121,8 @@ pub enum FrontendMessage { TriggerLoadRestAutoSaveDocuments, TriggerOpenLaunchDocuments, TriggerLoadPreferences, - TriggerOpenDocument, + TriggerOpen, + TriggerImport, TriggerSavePreferences { preferences: PreferencesMessageHandler, }, diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 8daf93ca9f..cc94c276d2 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -442,7 +442,7 @@ 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::OpenDocument), + entry!(KeyDown(KeyO); modifiers=[Accel], action_dispatch=PortfolioMessage::Open), entry!(KeyDown(KeyI); modifiers=[Accel], action_dispatch=PortfolioMessage::Import), entry!(KeyDown(KeyX); modifiers=[Accel], action_dispatch=PortfolioMessage::Cut { clipboard: Clipboard::Device }), entry!(KeyDown(KeyC); modifiers=[Accel], action_dispatch=PortfolioMessage::Copy { clipboard: Clipboard::Device }), 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 ec785db165..c867b509fe 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -120,8 +120,8 @@ impl LayoutHolder for MenuBarMessageHandler { MenuListEntry::new("Open…") .label("Open…") .icon("Folder") - .tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::OpenDocument)) - .on_commit(|_| PortfolioMessage::OpenDocument.into()), + .tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::Open)) + .on_commit(|_| PortfolioMessage::Open.into()), MenuListEntry::new("Open Demo Artwork…") .label("Open Demo Artwork…") .icon("Image") @@ -161,7 +161,8 @@ impl LayoutHolder for MenuBarMessageHandler { .label("Import…") .icon("FileImport") .tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::Import)) - .on_commit(|_| PortfolioMessage::Import.into()), + .on_commit(|_| PortfolioMessage::Import.into()) + .disabled(no_active_document), MenuListEntry::new("Export…") .label("Export…") .icon("FileExport") diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index de11acd363..30edbd6c3b 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -58,7 +58,6 @@ pub enum PortfolioMessage { font_style: String, data: Vec, }, - Import, LoadDocumentResources { document_id: DocumentId, }, @@ -66,7 +65,16 @@ pub enum PortfolioMessage { name: String, }, NextDocument, - OpenDocument, + Open, + Import, + OpenFile { + path: PathBuf, + content: Vec, + }, + ImportFile { + path: PathBuf, + content: Vec, + }, OpenDocumentFile { document_name: Option, document_path: Option, @@ -82,11 +90,13 @@ pub enum PortfolioMessage { to_front: bool, select_after_open: bool, }, - ToggleResetNodesToDefinitionsOnOpen, - PasteIntoFolder { - clipboard: Clipboard, - parent: LayerNodeIdentifier, - insert_index: usize, + OpenImage { + name: Option, + image: Image, + }, + OpenSvg { + name: Option, + svg: String, }, PasteSerializedData { data: String, @@ -94,9 +104,6 @@ pub enum PortfolioMessage { PasteSerializedVector { data: String, }, - CenterPastedLayers { - layers: Vec, - }, PasteImage { name: Option, image: Image, @@ -109,6 +116,15 @@ pub enum PortfolioMessage { mouse: Option<(f64, f64)>, parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>, }, + // TODO: Unused except by tests, remove? + PasteIntoFolder { + clipboard: Clipboard, + parent: LayerNodeIdentifier, + insert_index: usize, + }, + CenterPastedLayers { + layers: Vec, + }, PrevDocument, RequestWelcomeScreenButtonsLayout, RequestStatusBarInfoLayout, @@ -132,6 +148,7 @@ pub enum PortfolioMessage { document_id: DocumentId, ignore_hash: bool, }, + ToggleResetNodesToDefinitionsOnOpen, ToggleDataPanelOpen, TogglePropertiesPanelOpen, ToggleLayersPanelOpen, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 6e8ba98842..a43dcb7ffd 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -17,6 +17,7 @@ use crate::messages::portfolio::document::utility_types::clipboards::{Clipboard, use crate::messages::portfolio::document::utility_types::network_interface::OutputConnector; use crate::messages::portfolio::document::utility_types::nodes::SelectedNodes; use crate::messages::portfolio::document_migration::*; +use crate::messages::portfolio::utility_types::FileContent; use crate::messages::preferences::SelectionMode; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils; @@ -27,11 +28,13 @@ use derivative::*; use glam::{DAffine2, DVec2}; use graph_craft::document::NodeId; use graphene_std::Color; +use graphene_std::raster_types::Image; use graphene_std::renderer::Quad; use graphene_std::subpath::BezierHandles; use graphene_std::text::Font; use graphene_std::vector::misc::HandleId; use graphene_std::vector::{PointId, SegmentId, Vector, VectorModificationType}; +use std::path::PathBuf; use std::vec; #[derive(ExtractField)] @@ -426,10 +429,6 @@ impl MessageHandler> for Portfolio } } PortfolioMessage::EditorPreferences => self.executor.update_editor_preferences(preferences.editor_preferences()), - PortfolioMessage::Import => { - // 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::TriggerImport); - } PortfolioMessage::LoadDocumentResources { document_id } => { let catalog = &self.persistent_data.font_catalog; @@ -465,9 +464,75 @@ impl MessageHandler> for Portfolio responses.add(PortfolioMessage::SelectDocument { document_id: next_id }); } } - PortfolioMessage::OpenDocument => { + 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); + } + PortfolioMessage::Import => { // 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::TriggerOpenDocument); + responses.add(FrontendMessage::TriggerImport); + } + 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) => { + responses.add(PortfolioMessage::OpenDocumentFile { + document_name: name, + document_path: Some(path), + document_serialized_content: content, + }); + } + FileContent::Svg(svg) => { + responses.add(PortfolioMessage::OpenSvg { name, svg }); + } + FileContent::Image(image) => { + responses.add(PortfolioMessage::OpenImage { name, image }); + } + 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::Svg(svg) => { + responses.add(PortfolioMessage::PasteSvg { + name, + svg, + mouse: None, + parent_and_insert_index: None, + }); + } + FileContent::Image(image) => { + responses.add(PortfolioMessage::PasteImage { + name, + image, + 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, @@ -596,6 +661,47 @@ impl MessageHandler> for Portfolio responses.add(PortfolioMessage::SelectDocument { document_id }); } } + PortfolioMessage::OpenImage { name, image } => { + responses.add(PortfolioMessage::NewDocumentWithName { + name: name.clone().unwrap_or(DEFAULT_DOCUMENT_NAME.into()), + }); + + responses.add(DocumentMessage::PasteImage { + name, + image, + mouse: None, + parent_and_insert_index: None, + }); + + // Wait for the document to be rendered so the click targets can be calculated in order to determine the artboard size that will encompass the pasted image + responses.add(DeferMessage::AfterGraphRun { + messages: vec![DocumentMessage::WrapContentInArtboard { place_artboard_at_origin: true }.into()], + }); + responses.add(DeferMessage::AfterNavigationReady { + messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()], + }); + } + PortfolioMessage::OpenSvg { name, svg } => { + responses.add(PortfolioMessage::NewDocumentWithName { + name: name.clone().unwrap_or(DEFAULT_DOCUMENT_NAME.into()), + }); + + responses.add(DocumentMessage::PasteSvg { + name, + svg, + mouse: None, + parent_and_insert_index: None, + }); + + // 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 }.into()], + }); + responses.add(DeferMessage::AfterNavigationReady { + messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()], + }); + } + // TODO: Unused except by tests, remove? PortfolioMessage::PasteIntoFolder { clipboard, parent, insert_index } => { let mut all_new_ids = Vec::new(); let paste = |entry: &CopyBufferEntry, responses: &mut VecDeque<_>, all_new_ids: &mut Vec| { @@ -856,28 +962,14 @@ impl MessageHandler> for Portfolio mouse, parent_and_insert_index, } => { - let create_document = self.documents.is_empty(); - - if create_document { - responses.add(PortfolioMessage::NewDocumentWithName { - name: name.clone().unwrap_or(DEFAULT_DOCUMENT_NAME.into()), - }); - } - - responses.add(DocumentMessage::PasteImage { - name, - image, - mouse, - parent_and_insert_index, - }); - - if create_document { - // Wait for the document to be rendered so the click targets can be calculated in order to determine the artboard size that will encompass the pasted image - responses.add(DeferMessage::AfterGraphRun { - messages: vec![DocumentMessage::WrapContentInArtboard { place_artboard_at_origin: true }.into()], - }); - responses.add(DeferMessage::AfterNavigationReady { - messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()], + if self.documents.is_empty() { + responses.add(PortfolioMessage::OpenImage { name, image }); + } else { + responses.add(DocumentMessage::PasteImage { + name, + image, + mouse, + parent_and_insert_index, }); } } @@ -887,29 +979,14 @@ impl MessageHandler> for Portfolio mouse, parent_and_insert_index, } => { - let create_document = self.documents.is_empty(); - - if create_document { - responses.add(PortfolioMessage::NewDocumentWithName { - name: name.clone().unwrap_or(DEFAULT_DOCUMENT_NAME.into()), - }); - } - - responses.add(DocumentMessage::PasteSvg { - name, - svg, - mouse, - parent_and_insert_index, - }); - - if create_document { - // Wait for the document to be rendered so the click targets can be calculated in order to determine the artboard size that will encompass the pasted image - responses.add(DeferMessage::AfterGraphRun { - messages: vec![DocumentMessage::WrapContentInArtboard { place_artboard_at_origin: true }.into()], - }); - - responses.add(DeferMessage::AfterNavigationReady { - messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()], + if self.documents.is_empty() { + responses.add(PortfolioMessage::OpenSvg { name, svg }); + } else { + responses.add(DocumentMessage::PasteSvg { + name, + svg, + mouse, + parent_and_insert_index, }); } } @@ -940,9 +1017,9 @@ impl MessageHandler> for Portfolio TextButton::new("Open Document") .icon(Some("Folder".into())) .flush(true) - .on_commit(|_| PortfolioMessage::OpenDocument.into()) + .on_commit(|_| PortfolioMessage::Open.into()) .widget_instance(), - ShortcutLabel::new(action_shortcut!(PortfolioMessageDiscriminant::OpenDocument)).widget_instance(), + ShortcutLabel::new(action_shortcut!(PortfolioMessageDiscriminant::Open)).widget_instance(), ], vec![ TextButton::new("Open Demo Artwork") @@ -1202,21 +1279,22 @@ impl MessageHandler> for Portfolio fn actions(&self) -> ActionList { let mut common = actions!(PortfolioMessageDiscriminant; - CloseActiveDocumentWithConfirmation, - CloseAllDocuments, - CloseAllDocumentsWithConfirmation, - Import, - NextDocument, - OpenDocument, - PasteIntoFolder, - PrevDocument, - ToggleRulers, + Open, ToggleDataPanelOpen, ); // Extend with actions that require an active document if let Some(document) = self.active_document() { common.extend(document.actions()); + common.extend(actions!(PortfolioMessageDiscriminant; + CloseActiveDocumentWithConfirmation, + CloseAllDocuments, + CloseAllDocumentsWithConfirmation, + ToggleRulers, + NextDocument, + PrevDocument, + Import, + )); // Extend with actions that must have a selected layer if document.network_interface.selected_nodes().selected_layers(document.metadata()).next().is_some() { @@ -1281,6 +1359,32 @@ impl PortfolioMessageHandler { } } + fn read_file(path: &PathBuf, 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, + }, + "svg" => match String::from_utf8(content) { + Ok(content) => FileContent::Svg(content), + Err(_) => FileContent::Unsupported, + }, + _ => { + let format = image::guess_format(&content).unwrap_or_else(|_| image::ImageFormat::from_path(path).unwrap_or(image::ImageFormat::Png)); + match image::load_from_memory_with_format(&content, format) { + Ok(image) => { + // TODO: Handle Image formats with more than 8 bits per channel + let image_data = image.to_rgba8(); + let image = Image::::from_image_data(image_data.as_raw(), image.width(), image.height()); + FileContent::Image(image) + } + Err(_) => FileContent::Unsupported, + } + } + } + } + fn load_document(&mut self, mut new_document: DocumentMessageHandler, document_id: DocumentId, layers_panel_open: bool, responses: &mut VecDeque, to_front: bool) { if to_front { self.document_ids.push_front(document_id); diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index a43fced4c1..c58e762ef1 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -1,3 +1,5 @@ +use graphene_std::Color; +use graphene_std::raster::Image; use graphene_std::text::{Font, FontCache}; #[derive(Debug, Default)] @@ -104,3 +106,14 @@ impl From for PanelType { } } } + +pub enum FileContent { + /// A Graphite document. + Document(String), + /// A bitmap image. + Image(Image), + /// An SVG file string. + Svg(String), + /// Any other unsupported/unrecognized file type. + Unsupported, +} diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 79a2d6739b..2ad4679769 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -19,8 +19,9 @@ } from "@graphite/messages"; import type { AppWindowState } from "@graphite/state-providers/app-window"; import type { DocumentState } from "@graphite/state-providers/document"; + import { pasteFile } from "@graphite/utility-functions/files"; import { textInputCleanup } from "@graphite/utility-functions/keyboard-entry"; - import { extractPixelData, rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization"; + import { rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization"; import { setupViewportResizeObserver, cleanupViewportResizeObserver } from "@graphite/utility-functions/viewports"; import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@graphite/components/floating-menus/EyedropperPreview.svelte"; @@ -130,36 +131,14 @@ })($document.toolShelfLayout[0]); function dropFile(e: DragEvent) { - const { dataTransfer } = e; - const [x, y] = e.target instanceof Element && e.target.closest("[data-viewport]") ? [e.clientX, e.clientY] : [undefined, undefined]; - if (!dataTransfer) return; + if (!e.dataTransfer) return; - e.preventDefault(); - - Array.from(dataTransfer.items).forEach(async (item) => { - const file = item.getAsFile(); - if (!file) return; - - if (file.type.includes("svg")) { - const svgData = await file.text(); - editor.handle.pasteSvg(file.name, svgData, x, y); - return; - } + let mouse: [number, number] | undefined = undefined; + if (e.target instanceof Element && e.target.closest("[data-viewport]")) mouse = [e.clientX, e.clientY]; - if (file.type.startsWith("image")) { - const imageData = await extractPixelData(file); - editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, x, y); - return; - } + e.preventDefault(); - const graphiteFileSuffix = "." + editor.handle.fileExtension(); - if (file.name.endsWith(graphiteFileSuffix)) { - const content = await file.text(); - const documentName = file.name.slice(0, -graphiteFileSuffix.length); - editor.handle.openDocumentFile(documentName, content); - return; - } - }); + Array.from(e.dataTransfer.items).forEach(async (item) => await pasteFile(item, editor, mouse)); } function panCanvasX(newValue: number) { diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index c65691fd67..aba662ef31 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -14,8 +14,8 @@ import type { DataBuffer, LayerPanelEntry, Layout } from "@graphite/messages"; import type { NodeGraphState } from "@graphite/state-providers/node-graph"; import type { TooltipState } from "@graphite/state-providers/tooltip"; + import { pasteFile } from "@graphite/utility-functions/files"; import { operatingSystem } from "@graphite/utility-functions/platform"; - import { extractPixelData } from "@graphite/utility-functions/rasterization"; import LayoutCol from "@graphite/components/layout/LayoutCol.svelte"; import LayoutRow from "@graphite/components/layout/LayoutRow.svelte"; @@ -508,31 +508,7 @@ e.preventDefault(); - Array.from(e.dataTransfer.items).forEach(async (item) => { - const file = item.getAsFile(); - if (!file) return; - - if (file.type.includes("svg")) { - const svgData = await file.text(); - editor.handle.pasteSvg(file.name, svgData, undefined, undefined, insertParentId, insertIndex); - return; - } - - if (file.type.startsWith("image")) { - const imageData = await extractPixelData(file); - editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, undefined, undefined, insertParentId, insertIndex); - return; - } - - // When we eventually have sub-documents, this should be changed to import the document instead of opening it in a separate tab - const graphiteFileSuffix = "." + editor.handle.fileExtension(); - if (file.name.endsWith(graphiteFileSuffix)) { - const content = await file.text(); - const documentName = file.name.slice(0, -graphiteFileSuffix.length); - editor.handle.openDocumentFile(documentName, content); - return; - } - }); + Array.from(e.dataTransfer.items).forEach(async (item) => await pasteFile(item, editor, undefined, insertParentId, insertIndex)); draggingData = undefined; fakeHighlightOfNotYetSelectedLayerBeingDragged = undefined; diff --git a/frontend/src/components/panels/Welcome.svelte b/frontend/src/components/panels/Welcome.svelte index f1cfec862e..0c9ae65d48 100644 --- a/frontend/src/components/panels/Welcome.svelte +++ b/frontend/src/components/panels/Welcome.svelte @@ -4,8 +4,8 @@ import type { Editor } from "@graphite/editor"; import type { Layout } from "@graphite/messages"; import { patchLayout, UpdateWelcomeScreenButtonsLayout } from "@graphite/messages"; + import { pasteFile } from "@graphite/utility-functions/files"; import { isDesktop } from "@graphite/utility-functions/platform"; - import { extractPixelData } from "@graphite/utility-functions/rasterization"; import LayoutCol from "@graphite/components/layout/LayoutCol.svelte"; import LayoutRow from "@graphite/components/layout/LayoutRow.svelte"; @@ -33,30 +33,7 @@ e.preventDefault(); - Array.from(e.dataTransfer.items).forEach(async (item) => { - const file = item.getAsFile(); - if (!file) return; - - if (file.type.includes("svg")) { - const svgData = await file.text(); - editor.handle.pasteSvg(file.name, svgData); - return; - } - - if (file.type.startsWith("image")) { - const imageData = await extractPixelData(file); - editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height); - return; - } - - const graphiteFileSuffix = "." + editor.handle.fileExtension(); - if (file.name.endsWith(graphiteFileSuffix)) { - const content = await file.text(); - const documentName = file.name.slice(0, -graphiteFileSuffix.length); - editor.handle.openDocumentFile(documentName, content); - return; - } - }); + Array.from(e.dataTransfer.items).forEach(async (item) => await pasteFile(item, editor)); } diff --git a/frontend/src/components/window/MainWindow.svelte b/frontend/src/components/window/MainWindow.svelte index c316909fe0..bb056d74f7 100644 --- a/frontend/src/components/window/MainWindow.svelte +++ b/frontend/src/components/window/MainWindow.svelte @@ -31,7 +31,7 @@ {#if $tooltip.visible} {/if} - {#if isDesktop() && new Date() > new Date("2026-01-31")} + {#if isDesktop() && new Date() > new Date("2026-03-15")}

diff --git a/frontend/src/editor.ts b/frontend/src/editor.ts index 1661b78920..d8c018102e 100644 --- a/frontend/src/editor.ts +++ b/frontend/src/editor.ts @@ -60,13 +60,13 @@ export function createEditor(): Editor { if (!demoArtwork) return; try { - const url = new URL(`/demo-artwork/${demoArtwork}.graphite`, document.location.href); + const url = new URL(`/demo-artwork/${demoArtwork}.${handle.fileExtension()}`, document.location.href); const data = await fetch(url); if (!data.ok) throw new Error(); const filename = url.pathname.split("/").pop() || "Untitled"; - const content = await data.text(); - handle.openDocumentFile(filename, content); + const content = await data.bytes(); + handle.openFile(`${filename}.${handle.fileExtension()}`, content); // Remove the hash fragment from the URL history.replaceState("", "", `${window.location.pathname}${window.location.search}`); diff --git a/frontend/src/io-managers/input.ts b/frontend/src/io-managers/input.ts index c606bbe998..b2a73c0b26 100644 --- a/frontend/src/io-managers/input.ts +++ b/frontend/src/io-managers/input.ts @@ -6,6 +6,7 @@ import { type DialogState } from "@graphite/state-providers/dialog"; import { type DocumentState } from "@graphite/state-providers/document"; import { type FullscreenState } from "@graphite/state-providers/fullscreen"; import { type PortfolioState } from "@graphite/state-providers/portfolio"; +import { pasteFile } from "@graphite/utility-functions/files"; import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "@graphite/utility-functions/keyboard-entry"; import { isDesktop, operatingSystem } from "@graphite/utility-functions/platform"; import { extractPixelData } from "@graphite/utility-functions/rasterization"; @@ -312,32 +313,8 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli e.preventDefault(); Array.from(dataTransfer.items).forEach(async (item) => { - if (item.type === "text/plain") { - item.getAsString((text) => { - editor.handle.pasteText(text); - }); - } - - const file = item.getAsFile(); - if (!file) return; - - if (file.type.includes("svg")) { - const text = await file.text(); - editor.handle.pasteSvg(file.name, text); - return; - } - - if (file.type.startsWith("image")) { - const imageData = await extractPixelData(file); - editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height); - } - - const graphiteFileSuffix = "." + editor.handle.fileExtension(); - if (file.name.endsWith(graphiteFileSuffix)) { - const content = await file.text(); - const documentName = file.name.slice(0, -graphiteFileSuffix.length); - editor.handle.openDocumentFile(documentName, content); - } + if (item.type === "text/plain") item.getAsString((text) => editor.handle.pasteText(text)); + await pasteFile(item, editor); }); } diff --git a/frontend/src/messages.ts b/frontend/src/messages.ts index 5be4a1eea7..a137b4755c 100644 --- a/frontend/src/messages.ts +++ b/frontend/src/messages.ts @@ -745,7 +745,7 @@ export class TriggerFetchAndOpenDocument extends JsMessage { readonly filename!: string; } -export class TriggerOpenDocument extends JsMessage {} +export class TriggerOpen extends JsMessage {} export class TriggerImport extends JsMessage {} @@ -1666,14 +1666,16 @@ export const messageMakers: Record = { DisplayDialogDismiss, DisplayDialogPanic, DisplayEditableTextbox, - DisplayEditableTextboxUpdateFontData, DisplayEditableTextboxTransform, + DisplayEditableTextboxUpdateFontData, DisplayRemoveEditableTextbox, - SendUIMetadata, - SendShortcutFullscreen, SendShortcutAltClick, + SendShortcutFullscreen, SendShortcutShiftClick, + SendUIMetadata, TriggerAboutGraphiteLocalizedCommitDate, + TriggerClipboardRead, + TriggerClipboardWrite, TriggerDisplayThirdPartyLicensesDialog, TriggerExportImage, TriggerFetchAndOpenDocument, @@ -1683,7 +1685,7 @@ export const messageMakers: Record = { TriggerLoadFirstAutoSaveDocument, TriggerLoadPreferences, TriggerLoadRestAutoSaveDocuments, - TriggerOpenDocument, + TriggerOpen, TriggerOpenLaunchDocuments, TriggerPersistenceRemoveDocument, TriggerPersistenceWriteDocument, @@ -1691,11 +1693,9 @@ export const messageMakers: Record = { TriggerSaveDocument, TriggerSaveFile, TriggerSavePreferences, - TriggerTextCommit, - TriggerClipboardRead, - TriggerClipboardWrite, TriggerSelectionRead, TriggerSelectionWrite, + TriggerTextCommit, TriggerVisitLink, UpdateActiveDocument, UpdateBox, @@ -1714,6 +1714,7 @@ export const messageMakers: Record = { UpdateDocumentScrollbars, UpdateExportReorderIndex, UpdateEyedropperSamplingState, + UpdateFullscreen, UpdateGraphFadeArtwork, UpdateGraphViewOverlay, UpdateImportReorderIndex, @@ -1724,6 +1725,7 @@ export const messageMakers: Record = { UpdateLayersPanelControlBarRightLayout, UpdateLayersPanelState, UpdateLayerWidths, + UpdateMaximized, UpdateMenuBarLayout, UpdateMouseCursor, UpdateNodeGraphControlBarLayout, @@ -1735,22 +1737,20 @@ export const messageMakers: Record = { UpdateNodeThumbnail, UpdateOpenDocumentsList, UpdatePlatform, - UpdateMaximized, - UpdateFullscreen, - WindowPointerLockMove, - WindowFullscreen, UpdatePropertiesPanelLayout, UpdatePropertiesPanelState, UpdateStatusBarHintsLayout, UpdateStatusBarInfoLayout, UpdateToolOptionsLayout, UpdateToolShelfLayout, + UpdateUIScale, UpdateViewportHolePunch, UpdateViewportPhysicalBounds, - UpdateUIScale, UpdateVisibleNodes, UpdateWelcomeScreenButtonsLayout, UpdateWirePathInProgress, UpdateWorkingColorsLayout, + WindowFullscreen, + WindowPointerLockMove, } as const; export type JsMessageType = keyof typeof messageMakers; diff --git a/frontend/src/state-providers/portfolio.ts b/frontend/src/state-providers/portfolio.ts index e7573cbdca..cf6487d0e4 100644 --- a/frontend/src/state-providers/portfolio.ts +++ b/frontend/src/state-providers/portfolio.ts @@ -8,7 +8,7 @@ import { TriggerExportImage, TriggerSaveFile, TriggerImport, - TriggerOpenDocument, + TriggerOpen, UpdateActiveDocument, UpdateOpenDocumentsList, UpdateDataPanelState, @@ -16,7 +16,7 @@ import { UpdateLayersPanelState, } from "@graphite/messages"; import { downloadFile, downloadFileBlob, upload } from "@graphite/utility-functions/files"; -import { extractPixelData, rasterizeSVG } from "@graphite/utility-functions/rasterization"; +import { rasterizeSVG } from "@graphite/utility-functions/rasterization"; export function createPortfolioState(editor: Editor) { const { subscribe, update } = writable({ @@ -45,12 +45,9 @@ export function createPortfolioState(editor: Editor) { }); editor.subscriptions.subscribeJsMessage(TriggerFetchAndOpenDocument, async (data) => { try { - const { name, filename } = data; - const url = new URL(`demo-artwork/${filename}`, document.location.href); + const url = new URL(`demo-artwork/${data.filename}`, document.location.href); const response = await fetch(url); - const content = await response.text(); - - editor.handle.openDocumentFile(name, content); + editor.handle.openFile(data.filename, await response.bytes()); } 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(() => { @@ -58,37 +55,14 @@ export function createPortfolioState(editor: Editor) { }, 0); } }); - editor.subscriptions.subscribeJsMessage(TriggerOpenDocument, async () => { - const suffix = "." + editor.handle.fileExtension(); - const data = await upload(suffix, "text"); - - // Use filename as document name, removing the extension if it exists - let documentName = data.filename; - if (documentName.endsWith(suffix)) { - documentName = documentName.slice(0, -suffix.length); - } - - editor.handle.openDocumentFile(documentName, data.content); + editor.subscriptions.subscribeJsMessage(TriggerOpen, async () => { + const data = await upload(`image/*,.${editor.handle.fileExtension()}`, "data"); + editor.handle.openFile(data.filename, data.content); }); editor.subscriptions.subscribeJsMessage(TriggerImport, async () => { - const data = await upload("image/*", "both"); - - if (data.type.includes("svg")) { - const svg = new TextDecoder().decode(data.content.data); - editor.handle.pasteSvg(data.filename, svg); - return; - } - - // In case the user accidentally uploads a Graphite file, open it instead of failing to import it - const graphiteFileSuffix = "." + editor.handle.fileExtension(); - if (data.filename.endsWith(graphiteFileSuffix)) { - const documentName = data.filename.slice(0, -graphiteFileSuffix.length); - editor.handle.openDocumentFile(documentName, data.content.text); - return; - } - - const imageData = await extractPixelData(new Blob([new Uint8Array(data.content.data)], { type: data.type })); - editor.handle.pasteImage(data.filename, new Uint8Array(imageData.data), imageData.width, imageData.height); + // TODO: Use the same `accept` string as in the `TriggerOpen` handler once importing Graphite documents as nodes is supported + const data = await upload("image/*", "data"); + editor.handle.importFile(data.filename, data.content); }); editor.subscriptions.subscribeJsMessage(TriggerSaveDocument, (data) => { downloadFile(data.name, data.content); diff --git a/frontend/src/utility-functions/files.ts b/frontend/src/utility-functions/files.ts index 08be7ccdf2..2efb3366d1 100644 --- a/frontend/src/utility-functions/files.ts +++ b/frontend/src/utility-functions/files.ts @@ -1,3 +1,6 @@ +import { type Editor } from "@graphite/editor"; +import { extractPixelData } from "@graphite/utility-functions/rasterization"; + export function downloadFileURL(filename: string, url: string) { const element = document.createElement("a"); @@ -60,3 +63,19 @@ export async function upload(accept: string, } export type UploadResult = { filename: string; type: string; content: UploadResultType }; type UploadResultType = T extends "text" ? string : T extends "data" ? Uint8Array : T extends "both" ? { text: string; data: Uint8Array } : never; + +export async function pasteFile(item: DataTransferItem, editor: Editor, mouse?: [number, number], insertParentId?: bigint, insertIndex?: number) { + const file = item.getAsFile(); + if (!file) return; + + if (file.type.startsWith("image/svg")) { + const svg = await file.text(); + editor.handle.pasteSvg(file.name, svg, mouse?.[0], mouse?.[1], insertParentId, insertIndex); + } else if (file.type.startsWith("image/")) { + const imageData = await extractPixelData(file); + editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, mouse?.[0], mouse?.[1], insertParentId, insertIndex); + } else if (file.name.endsWith("." + editor.handle.fileExtension())) { + // 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.handle.openFile(file.name, await file.bytes()); + } +} diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index 192687feac..6877874007 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -22,6 +22,7 @@ use js_sys::{Object, Reflect}; use serde::Serialize; use serde_wasm_bindgen::{self, from_value}; use std::cell::RefCell; +use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use wasm_bindgen::JsCast; @@ -414,13 +415,15 @@ impl EditorHandle { self.dispatch(message); } - #[wasm_bindgen(js_name = openDocumentFile)] - pub fn open_document_file(&self, document_name: String, document_serialized_content: String) { - let message = PortfolioMessage::OpenDocumentFile { - document_name: Some(document_name), - document_path: None, - document_serialized_content, - }; + #[wasm_bindgen(js_name = openFile)] + pub fn open_file(&self, path: String, content: Vec) { + let message = PortfolioMessage::OpenFile { path: PathBuf::from(path), content }; + self.dispatch(message); + } + + #[wasm_bindgen(js_name = importFile)] + pub fn import_file(&self, path: String, content: Vec) { + let message = PortfolioMessage::ImportFile { path: PathBuf::from(path), content }; self.dispatch(message); }