Desktop: Add File > Save As… (#3034)

* Make file name and document name identical

* Add save as action

* Fix test errors

* Add missing save as action

* Desktop fix drop file open document file message

* Address review comments

* Replace file save suffix with file extension

* Add comment specifying that the upload function takes a html input accept string

* Fix remove file extension in web

* Use let

* Don't show save as menu entry in web

* Don't add SaveDocumentAs in web

* Remove file extension on all open document file calls

---------

Co-authored-by: Dennis Kobert <dennis@kobert.dev>
This commit is contained in:
Timon 2025-08-20 10:09:01 +00:00 committed by GitHub
parent 7c30f6168b
commit e70862b399
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 152 additions and 73 deletions

View File

@ -10,7 +10,6 @@ use graph_craft::wasm_application_io::WasmApplicationIo;
use graphene_std::Color;
use graphene_std::raster::Image;
use graphite_editor::application::Editor;
use graphite_editor::consts::DEFAULT_DOCUMENT_NAME;
use graphite_editor::messages::prelude::*;
use std::fs;
use std::sync::Arc;
@ -79,7 +78,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 +294,8 @@ impl ApplicationHandler<CustomEvent> 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());

View File

@ -150,8 +150,8 @@ pub const COLOR_OVERLAY_WHITE: &str = "#ffffff";
pub const COLOR_OVERLAY_BLACK_75: &str = "#000000bf";
// DOCUMENT
pub const FILE_EXTENSION: &str = "graphite";
pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document";
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;

View File

@ -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,
});

View File

@ -44,7 +44,7 @@ impl MessageHandler<ExportDialogMessage, ExportDialogMessageContext<'_>> for Exp
ExportDialogMessage::ExportBounds { bounds } => self.bounds = bounds,
ExportDialogMessage::Submit => responses.add_front(PortfolioMessage::SubmitDocumentExport {
file_name: portfolio.active_document().map(|document| document.name.clone()).unwrap_or_default(),
name: portfolio.active_document().map(|document| document.name.clone()).unwrap_or_default(),
file_type: self.file_type,
scale_factor: self.scale_factor,
bounds: self.bounds,

View File

@ -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 }),

View File

@ -118,6 +118,7 @@ pub enum DocumentMessage {
RenderRulers,
RenderScrollbars,
SaveDocument,
SaveDocumentAs,
SavedDocument {
path: Option<PathBuf>,
},

View File

@ -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, 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};
@ -85,8 +85,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,
@ -113,6 +111,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<PathBuf>,
/// 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<NodeId>,
@ -125,9 +129,6 @@ pub struct DocumentMessageHandler {
/// Stack of document network snapshots for future history states.
#[serde(skip)]
document_redo_history: VecDeque<NodeNetworkInterface>,
/// The path of the to the document file.
#[serde(skip)]
path: Option<PathBuf>,
/// Hash of the document snapshot that was most recently saved to disk by the user.
#[serde(skip)]
saved_hash: Option<u64>,
@ -159,7 +160,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,
@ -172,11 +172,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,
@ -947,7 +948,11 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
responses.add(OverlaysMessage::Draw);
}
DocumentMessage::RenameDocument { new_name } => {
self.name = new_name;
self.name = new_name.clone();
self.path = None;
self.set_save_state(false);
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
responses.add(NodeGraphMessage::UpdateNewNodeGraph);
}
@ -1020,25 +1025,40 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> 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: format!("{}.{}", self.name.clone(), FILE_EXTENSION),
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();
@ -1571,6 +1591,10 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
ZoomCanvasToFitAll,
);
// Additional actions available on desktop
#[cfg(not(target_family = "wasm"))]
common.extend(actions!(DocumentMessageDiscriminant::SaveDocumentAs));
// Additional actions if there are any selected layers
if self.network_interface.selected_nodes().selected_layers(self.metadata()).next().is_some() {
let mut select = actions!(DocumentMessageDiscriminant;

View File

@ -101,14 +101,25 @@ impl LayoutHolder for MenuBarMessageHandler {
..MenuBarEntry::default()
},
],
vec![MenuBarEntry {
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()
}],
},
#[cfg(not(target_family = "wasm"))]
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(),

View File

@ -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<String>,
document_path: Option<PathBuf>,
document_serialized_content: String,
},
ToggleResetNodesToDefinitionsOnOpen,
OpenDocumentFileWithId {
document_id: DocumentId,
document_name: String,
document_name: Option<String>,
document_path: Option<PathBuf>,
document_is_auto_saved: bool,
document_is_saved: bool,
document_serialized_content: String,
to_front: bool,
},
ToggleResetNodesToDefinitionsOnOpen,
PasteIntoFolder {
clipboard: Clipboard,
parent: LayerNodeIdentifier,
@ -115,7 +118,7 @@ pub enum PortfolioMessage {
document_id: DocumentId,
},
SubmitDocumentExport {
file_name: String,
name: String,
file_type: FileType,
scale_factor: f64,
bounds: ExportBounds,

View File

@ -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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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);
}
@ -899,7 +923,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
}
}
PortfolioMessage::SubmitDocumentExport {
file_name,
name,
file_type,
scale_factor,
bounds,
@ -907,7 +931,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
} => {
let document = self.active_document_id.and_then(|id| self.documents.get_mut(&id)).expect("Tried to render non-existent document");
let export_config = ExportConfig {
file_name,
name,
file_type,
scale_factor,
bounds,

View File

@ -1,4 +1,3 @@
use crate::consts::FILE_SAVE_SUFFIX;
use crate::messages::frontend::utility_types::{ExportBounds, FileType};
use crate::messages::prelude::*;
use glam::{DAffine2, DVec2, UVec2};
@ -229,18 +228,11 @@ impl NodeGraphExecutor {
};
let ExportConfig {
file_type,
file_name,
size,
scale_factor,
..
file_type, name, size, scale_factor, ..
} = export_config;
let file_suffix = &format!(".{file_type:?}").to_lowercase();
let name = match file_name.ends_with(FILE_SAVE_SUFFIX) {
true => file_name.replace(FILE_SAVE_SUFFIX, file_suffix),
false => file_name + file_suffix,
};
let name = name + file_suffix;
if file_type == FileType::Svg {
responses.add(FrontendMessage::TriggerSaveFile { name, content: svg.into_bytes() });

View File

@ -73,7 +73,7 @@ pub struct GraphUpdate {
#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExportConfig {
pub file_name: String,
pub name: String,
pub file_type: FileType,
pub scale_factor: f64,
pub bounds: ExportBounds,

View File

@ -151,9 +151,11 @@
return;
}
if (file.name.endsWith(".graphite")) {
const graphiteFileSuffix = "." + editor.handle.fileExtension();
if (file.name.endsWith(graphiteFileSuffix)) {
const content = await file.text();
editor.handle.openDocumentFile(file.name, content);
const documentName = file.name.slice(0, -graphiteFileSuffix.length);
editor.handle.openDocumentFile(documentName, content);
return;
}
});

View File

@ -438,9 +438,11 @@
}
// When we eventually have sub-documents, this should be changed to import the document instead of opening it in a separate tab
if (file.name.endsWith(".graphite")) {
const graphiteFileSuffix = "." + editor.handle.fileExtension();
if (file.name.endsWith(graphiteFileSuffix)) {
const content = await file.text();
editor.handle.openDocumentFile(file.name, content);
const documentName = file.name.slice(0, -graphiteFileSuffix.length);
editor.handle.openDocumentFile(documentName, content);
return;
}
});

View File

@ -83,9 +83,11 @@
return;
}
if (file.name.endsWith(".graphite")) {
const graphiteFileSuffix = "." + editor.handle.fileExtension();
if (file.name.endsWith(graphiteFileSuffix)) {
const content = await file.text();
editor.handle.openDocumentFile(file.name, content);
const documentName = file.name.slice(0, -graphiteFileSuffix.length);
editor.handle.openDocumentFile(documentName, content);
return;
}
});

View File

@ -334,8 +334,11 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height);
}
if (file.name.endsWith(".graphite")) {
editor.handle.openDocumentFile(file.name, await file.text());
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);
}
});
}

View File

@ -62,9 +62,16 @@ export function createPortfolioState(editor: Editor) {
}
});
editor.subscriptions.subscribeJsMessage(TriggerOpenDocument, async () => {
const extension = editor.handle.fileSaveSuffix();
const data = await upload(extension, "text");
editor.handle.openDocumentFile(data.filename, data.content);
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(TriggerImport, async () => {
const data = await upload("image/*", "both");
@ -76,8 +83,10 @@ export function createPortfolioState(editor: Editor) {
}
// In case the user accidentally uploads a Graphite file, open it instead of failing to import it
if (data.filename.endsWith(".graphite")) {
editor.handle.openDocumentFile(data.filename, data.content.text);
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;
}

View File

@ -22,11 +22,12 @@ export function downloadFile(filename: string, content: ArrayBuffer) {
downloadFileBlob(filename, blob);
}
export async function upload<T extends "text" | "data" | "both">(acceptedExtensions: string, textOrData: T): Promise<UploadResult<T>> {
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#accept for the `accept` string format
export async function upload<T extends "text" | "data" | "both">(accept: string, textOrData: T): Promise<UploadResult<T>> {
return new Promise<UploadResult<T>>((resolve, _) => {
const element = document.createElement("input");
element.type = "file";
element.accept = acceptedExtensions;
element.accept = accept;
element.addEventListener(
"change",

View File

@ -6,7 +6,7 @@
//
use crate::helpers::translate_key;
use crate::{EDITOR_HANDLE, EDITOR_HAS_CRASHED, Error, MESSAGE_BUFFER};
use editor::consts::FILE_SAVE_SUFFIX;
use editor::consts::FILE_EXTENSION;
use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys;
use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta, ViewportBounds};
use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
@ -344,10 +344,10 @@ impl EditorHandle {
cfg!(debug_assertions)
}
/// Get the constant `FILE_SAVE_SUFFIX`
#[wasm_bindgen(js_name = fileSaveSuffix)]
pub fn file_save_suffix(&self) -> String {
FILE_SAVE_SUFFIX.into()
/// Get the constant `FILE_EXTENSION`
#[wasm_bindgen(js_name = fileExtension)]
pub fn file_extension(&self) -> String {
FILE_EXTENSION.into()
}
/// Update the value of a given UI widget, but don't commit it to the history (unless `commit_layout()` is called, which handles that)
@ -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,