diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 803a2f0a3a..30d0286f57 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -79,7 +79,8 @@ impl WinitApp { String::new() }); let message = PortfolioMessage::OpenDocumentFile { - document_name: path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string(), + document_name: None, + document_path: Some(path), document_serialized_content: content, }; let _ = event_loop_proxy.send_event(CustomEvent::DispatchMessage(message.into())); @@ -294,7 +295,8 @@ impl ApplicationHandler for WinitApp { let Some(content) = load_string(&path) else { return }; let message = PortfolioMessage::OpenDocumentFile { - document_name: name.unwrap_or(DEFAULT_DOCUMENT_NAME.to_string()), + document_name: None, + document_path: Some(path), document_serialized_content: content, }; self.dispatch_message(message.into()); diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 44a5b1d210..48bdea853f 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -151,6 +151,7 @@ pub const COLOR_OVERLAY_BLACK_75: &str = "#000000bf"; // DOCUMENT pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document"; +pub const FILE_EXTENSION: &str = "graphite"; pub const FILE_SAVE_SUFFIX: &str = ".graphite"; pub const MAX_UNDO_HISTORY_LEN: usize = 100; // TODO: Add this to user preferences pub const AUTO_SAVE_TIMEOUT_SECONDS: u64 = 1; diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index a80c94045e..e0e2b52a67 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -497,7 +497,8 @@ mod test { ); let responses = editor.editor.handle_message(PortfolioMessage::OpenDocumentFile { - document_name: document_name.into(), + document_name: Some(document_name.to_string()), + document_path: None, document_serialized_content, }); diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 255193a430..e5239f152c 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -340,6 +340,7 @@ pub fn input_mappings() -> Mapping { entry!(KeyDown(KeyA); modifiers=[Accel, Shift], canonical, action_dispatch=DocumentMessage::DeselectAllLayers), entry!(KeyDown(KeyA); modifiers=[Alt], action_dispatch=DocumentMessage::DeselectAllLayers), entry!(KeyDown(KeyS); modifiers=[Accel], action_dispatch=DocumentMessage::SaveDocument), + entry!(KeyDown(KeyS); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::SaveDocumentAs), entry!(KeyDown(KeyD); modifiers=[Accel], canonical, action_dispatch=DocumentMessage::DuplicateSelectedLayers), entry!(KeyDown(KeyJ); modifiers=[Accel], action_dispatch=DocumentMessage::DuplicateSelectedLayers), entry!(KeyDown(KeyG); modifiers=[Accel], action_dispatch=DocumentMessage::GroupSelectedLayers { group_folder_type: GroupFolderType::Layer }), diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index 9a19526170..4295dd02e6 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -110,6 +110,7 @@ pub enum DocumentMessage { RenderRulers, RenderScrollbars, SaveDocument, + SaveDocumentAs, SavedDocument { path: Option, }, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index c36d29da39..92a50fb562 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -6,7 +6,7 @@ use super::utility_types::misc::{GroupFolderType, SNAP_FUNCTIONS_FOR_BOUNDING_BO use super::utility_types::network_interface::{self, NodeNetworkInterface, TransactionStatus}; use super::utility_types::nodes::{CollapsedLayers, SelectedNodes}; use crate::application::{GRAPHITE_GIT_COMMIT_HASH, generate_uuid}; -use crate::consts::{ASYMPTOTIC_EFFECT, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_SAVE_SUFFIX, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ROTATE_SNAP_INTERVAL}; +use crate::consts::{ASYMPTOTIC_EFFECT, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, FILE_SAVE_SUFFIX, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ROTATE_SNAP_INTERVAL}; use crate::messages::input_mapper::utility_types::macros::action_keys; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::data_panel::{DataPanelMessageContext, DataPanelMessageHandler}; @@ -83,8 +83,6 @@ pub struct DocumentMessageHandler { /// List of the [`LayerNodeIdentifier`]s that are currently collapsed by the user in the Layers panel. /// Collapsed means that the expansion arrow isn't set to show the children of these layers. pub collapsed: CollapsedLayers, - /// The name of the document, which is displayed in the tab and title bar of the editor. - pub name: String, /// The full Git commit hash of the Graphite repository that was used to build the editor. /// We save this to provide a hint about which version of the editor was used to create the document. pub commit_hash: String, @@ -111,6 +109,12 @@ pub struct DocumentMessageHandler { // Fields omitted from the saved document format // ============================================= // + /// The name of the document, which is displayed in the tab and title bar of the editor. + #[serde(skip)] + pub name: String, + /// The path of the to the document file. + #[serde(skip)] + pub(crate) path: Option, /// Path to network currently viewed in the node graph overlay. This will eventually be stored in each panel, so that multiple panels can refer to different networks #[serde(skip)] breadcrumb_network_path: Vec, @@ -123,9 +127,6 @@ pub struct DocumentMessageHandler { /// Stack of document network snapshots for future history states. #[serde(skip)] document_redo_history: VecDeque, - /// The path of the to the document file. - #[serde(skip)] - path: Option, /// Hash of the document snapshot that was most recently saved to disk by the user. #[serde(skip)] saved_hash: Option, @@ -157,7 +158,6 @@ impl Default for DocumentMessageHandler { // ============================================ network_interface: default_document_network_interface(), collapsed: CollapsedLayers::default(), - name: DEFAULT_DOCUMENT_NAME.to_string(), commit_hash: GRAPHITE_GIT_COMMIT_HASH.to_string(), document_ptz: PTZ::default(), document_mode: DocumentMode::DesignMode, @@ -170,11 +170,12 @@ impl Default for DocumentMessageHandler { // ============================================= // Fields omitted from the saved document format // ============================================= + name: DEFAULT_DOCUMENT_NAME.to_string(), + path: None, breadcrumb_network_path: Vec::new(), selection_network_path: Vec::new(), document_undo_history: VecDeque::new(), document_redo_history: VecDeque::new(), - path: None, saved_hash: None, auto_saved_hash: None, layer_range_selection_reference: None, @@ -944,7 +945,20 @@ impl MessageHandler> for DocumentMes responses.add(OverlaysMessage::Draw); } DocumentMessage::RenameDocument { new_name } => { - self.name = new_name; + self.name = new_name.clone(); + + // If the new document name does not match the current path, clear the path. + if let Some(path) = self.path.as_ref() { + let document_name_from_path = if path.extension().is_some_and(|e| e == FILE_EXTENSION) { + path.file_stem().map(|n| n.to_string_lossy().to_string()) + } else { + None + }; + if Some(new_name) != document_name_from_path { + self.path = None; + } + } + responses.add(PortfolioMessage::UpdateOpenDocumentsList); responses.add(NodeGraphMessage::UpdateNewNodeGraph); } @@ -1017,25 +1031,40 @@ impl MessageHandler> for DocumentMes multiplier: scrollbar_multiplier.into(), }); } - DocumentMessage::SaveDocument => { + DocumentMessage::SaveDocument | DocumentMessage::SaveDocumentAs => { + if let DocumentMessage::SaveDocumentAs = message { + self.path = None; + } + self.set_save_state(true); responses.add(PortfolioMessage::AutoSaveActiveDocument); // Update the save status of the just saved document responses.add(PortfolioMessage::UpdateOpenDocumentsList); - let name = match self.name.ends_with(FILE_SAVE_SUFFIX) { - true => self.name.clone(), - false => self.name.clone() + FILE_SAVE_SUFFIX, - }; responses.add(FrontendMessage::TriggerSaveDocument { document_id, - name, + name: self.name.clone() + FILE_SAVE_SUFFIX, path: self.path.clone(), content: self.serialize_document().into_bytes(), }) } DocumentMessage::SavedDocument { path } => { self.path = path; + + // Update the name to match the file stem + let document_name_from_path = self.path.as_ref().and_then(|path| { + if path.extension().is_some_and(|e| e == FILE_EXTENSION) { + path.file_stem().map(|n| n.to_string_lossy().to_string()) + } else { + None + } + }); + if let Some(name) = document_name_from_path { + self.name = name; + + responses.add(PortfolioMessage::UpdateOpenDocumentsList); + responses.add(NodeGraphMessage::UpdateNewNodeGraph); + } } DocumentMessage::SelectParentLayer => { let selected_nodes = self.network_interface.selected_nodes(); @@ -1552,6 +1581,7 @@ impl MessageHandler> for DocumentMes Noop, Redo, SaveDocument, + SaveDocumentAs, SelectAllLayers, SetSnapping, ToggleGridVisibility, diff --git a/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs index 025b4b6b8f..f962bbf10e 100644 --- a/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs @@ -101,14 +101,24 @@ impl LayoutHolder for MenuBarMessageHandler { ..MenuBarEntry::default() }, ], - vec![MenuBarEntry { - label: "Save".into(), - icon: Some("Save".into()), - shortcut: action_keys!(DocumentMessageDiscriminant::SaveDocument), - action: MenuBarEntry::create_action(|_| DocumentMessage::SaveDocument.into()), - disabled: no_active_document, - ..MenuBarEntry::default() - }], + vec![ + MenuBarEntry { + label: "Save".into(), + icon: Some("Save".into()), + shortcut: action_keys!(DocumentMessageDiscriminant::SaveDocument), + action: MenuBarEntry::create_action(|_| DocumentMessage::SaveDocument.into()), + disabled: no_active_document, + ..MenuBarEntry::default() + }, + MenuBarEntry { + label: "Save as".into(), + icon: Some("Save".into()), + shortcut: action_keys!(DocumentMessageDiscriminant::SaveDocumentAs), + action: MenuBarEntry::create_action(|_| DocumentMessage::SaveDocumentAs.into()), + disabled: no_active_document, + ..MenuBarEntry::default() + }, + ], vec![ MenuBarEntry { label: "Import…".into(), diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index ac12eafb93..0e47d254a4 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -6,6 +6,7 @@ use crate::messages::prelude::*; use graphene_std::Color; use graphene_std::raster::Image; use graphene_std::text::Font; +use std::path::PathBuf; #[impl_message(Message, Portfolio)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -66,18 +67,20 @@ pub enum PortfolioMessage { NextDocument, OpenDocument, OpenDocumentFile { - document_name: String, + document_name: Option, + document_path: Option, document_serialized_content: String, }, - ToggleResetNodesToDefinitionsOnOpen, OpenDocumentFileWithId { document_id: DocumentId, - document_name: String, + document_name: Option, + document_path: Option, document_is_auto_saved: bool, document_is_saved: bool, document_serialized_content: String, to_front: bool, }, + ToggleResetNodesToDefinitionsOnOpen, PasteIntoFolder { clipboard: Clipboard, parent: LayerNodeIdentifier, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index ab1b86dad0..aef9738a30 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -2,7 +2,7 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier; use super::document::utility_types::network_interface; use super::utility_types::{PanelType, PersistentData}; use crate::application::generate_uuid; -use crate::consts::{DEFAULT_DOCUMENT_NAME, DEFAULT_STROKE_WIDTH}; +use crate::consts::{DEFAULT_DOCUMENT_NAME, DEFAULT_STROKE_WIDTH, FILE_EXTENSION}; use crate::messages::animation::TimingInformation; use crate::messages::debug::utility_types::MessageLoggingVerbosity; use crate::messages::dialog::simple_dialogs; @@ -419,12 +419,14 @@ impl MessageHandler> for Portfolio } PortfolioMessage::OpenDocumentFile { document_name, + document_path, document_serialized_content, } => { let document_id = DocumentId(generate_uuid()); responses.add(PortfolioMessage::OpenDocumentFileWithId { document_id, document_name, + document_path, document_is_auto_saved: false, document_is_saved: true, document_serialized_content, @@ -439,6 +441,7 @@ impl MessageHandler> for Portfolio PortfolioMessage::OpenDocumentFileWithId { document_id, document_name, + document_path, document_is_auto_saved, document_is_saved, document_serialized_content, @@ -450,10 +453,7 @@ impl MessageHandler> for Portfolio let document_serialized_content = document_migration_string_preprocessing(document_serialized_content); // Deserialize the document - let document = DocumentMessageHandler::deserialize_document(&document_serialized_content).map(|mut document| { - document.name.clone_from(&document_name); - document - }); + let document = DocumentMessageHandler::deserialize_document(&document_serialized_content); // Display an error to the user if the document could not be opened let mut document = match document { @@ -514,6 +514,30 @@ impl MessageHandler> for Portfolio document.set_auto_save_state(document_is_auto_saved); document.set_save_state(document_is_saved); + let document_name_from_path = document_path.as_ref().and_then(|path| { + if path.extension().is_some_and(|e| e == FILE_EXTENSION) { + path.file_stem().map(|n| n.to_string_lossy().to_string()) + } else { + None + } + }); + + match (document_name, document_path, document_name_from_path) { + (Some(name), _, None) => { + document.name = name; + } + (_, Some(path), Some(name)) => { + document.name = name; + document.path = Some(path); + } + (_, _, Some(name)) => { + document.name = name; + } + _ => { + document.name = DEFAULT_DOCUMENT_NAME.to_string(); + } + } + // Load the document into the portfolio so it opens in the editor self.load_document(document, document_id, self.layers_panel_open, responses, to_front); } diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index 80490c8b88..501e96cde0 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -421,7 +421,8 @@ impl EditorHandle { #[wasm_bindgen(js_name = openDocumentFile)] pub fn open_document_file(&self, document_name: String, document_serialized_content: String) { let message = PortfolioMessage::OpenDocumentFile { - document_name, + document_name: Some(document_name), + document_path: None, document_serialized_content, }; self.dispatch(message); @@ -432,7 +433,8 @@ impl EditorHandle { let document_id = DocumentId(document_id); let message = PortfolioMessage::OpenDocumentFileWithId { document_id, - document_name, + document_name: Some(document_name), + document_path: None, document_is_auto_saved: true, document_is_saved, document_serialized_content,