Skip to content

Commit 78e0e20

Browse files
azeembaTrueDoctorKeavon
committed
Add support for saving and opening files (#325)
* Add support for saving a document This is similar to the "export" functionality, except that we store all metadata needed to open the file again. Currently we store the internal representation of the layer which is probably pretty fragile. Example document: ```json { "nodes": {}, "root": { "blend_mode": "Normal", "cache": "...", "cache_dirty": false, "data": { "Folder": { "layer_ids": [ 3902938778642561358 ], "layers": [ { "blend_mode": "Normal", "cache": "...", "cache_dirty": false, "data": { "Shape": { "path": [ { "MoveTo": { "x": 0.0, "y": 0.0 } }, { "LineTo": { "x": 1.0, "y": 0.0 } }, { "LineTo": { "x": 1.0, "y": 1.0 } }, { "LineTo": { "x": 0.0, "y": 1.0 } }, "ClosePath" ], "render_index": 1, "solid": true, "style": { "fill": { "color": { "alpha": 1.0, "blue": 0.0, "green": 0.0, "red": 0.0 } }, "stroke": null } } }, "name": null, "opacity": 1.0, "thumbnail_cache": "...", "transform": { "matrix2": [ 223.0, 0.0, -0.0, 348.0 ], "translation": [ -188.0, -334.0 ] }, "visible": true } ], "next_assignment_id": 3902938778642561359 } }, "name": null, "opacity": 1.0, "thumbnail_cache": "...", "transform": { "matrix2": [ 1.0, 0.0, 0.0, 1.0 ], "translation": [ 479.0, 563.0 ] }, "visible": true }, "version": 0 } ``` * Add support for opening a saved document User can select a file using the browser's file input selector. We parse it as JSON and load it into the internal representation. Concerns: - The file format is fragile - Loading data directly into internal data structures usually creates security vulnerabilities - Error handling: The user is not informed of errors * Serialize Document and skip "cache" fields in Layer Instead of serializing the root layer, we serialize the Document struct directly. Additionally, we mark the "cache" fields in layer as "skip" fields so they don't get serialized. * Opened files use the filename as the tab title * Split "new document" and "open document" handling Open document needs name and content to be provided so having a different interface is cleaner. Also did some refactoring to reuse code. * Show error to user when a file fails to open * Clean up code: better variable naming and structure * Use document name for saved and exported files We pass through the document name in the export and save messages. Additionally, we check if the appropriate file suffixes (.graphite and .svg) need to be added before passing it to the frontend. * Refactor document name generation * Don't assign a default of 1 to Documents that start with something other than DEFAULT_DOCUMENT_NAME * Improve runtime complexity by using binary instead of linear search * Update Layer panel upon document selection * Add File>Open/Ctrl+O; File>Save (As)/Ctrl+(Shift)+S; browse filters extension; split out download()/upload() into files.ts; change unsaved close dialog text Co-authored-by: Dennis Kobert <[email protected]> Co-authored-by: Keavon Chambers <[email protected]>
1 parent 3763bcc commit 78e0e20

File tree

18 files changed

+279
-80
lines changed

18 files changed

+279
-80
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

editor/src/consts.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ pub const LINE_ROTATE_SNAP_ANGLE: f64 = 15.;
1818

1919
// SELECT TOOL
2020
pub const SELECTION_TOLERANCE: f64 = 1.0;
21+
22+
pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document";
23+
pub const FILE_SAVE_SUFFIX: &str = ".graphite";
24+
pub const FILE_EXPORT_SUFFIX: &str = ".svg";

editor/src/document/document_file.rs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
pub use super::layer_panel::*;
2-
use crate::EditorError;
2+
use crate::{
3+
consts::{FILE_EXPORT_SUFFIX, FILE_SAVE_SUFFIX},
4+
EditorError,
5+
};
36
use glam::{DAffine2, DVec2};
4-
use graphene::{document::Document as InternalDocument, LayerId};
7+
use graphene::{document::Document as InternalDocument, DocumentError, LayerId};
58
use serde::{Deserialize, Serialize};
69
use std::collections::HashMap;
710

@@ -82,6 +85,7 @@ pub enum DocumentMessage {
8285
AbortTransaction,
8386
CommitTransaction,
8487
ExportDocument,
88+
SaveDocument,
8589
RenderDocument,
8690
Undo,
8791
NudgeSelectedLayers(f64, f64),
@@ -116,7 +120,7 @@ impl DocumentMessageHandler {
116120
document_responses.retain(|response| !matches!(response, DocumentResponse::DocumentChanged));
117121
document_responses.len() != len
118122
}
119-
fn handle_folder_changed(&mut self, path: Vec<LayerId>) -> Option<Message> {
123+
pub fn handle_folder_changed(&mut self, path: Vec<LayerId>) -> Option<Message> {
120124
let _ = self.document.render_root();
121125
self.layer_data(&path).expanded.then(|| {
122126
let children = self.layer_panel(path.as_slice()).expect("The provided Path was not valid");
@@ -193,6 +197,18 @@ impl DocumentMessageHandler {
193197
movement_handler: MovementMessageHandler::default(),
194198
}
195199
}
200+
pub fn with_name_and_content(name: String, serialized_content: String) -> Result<Self, EditorError> {
201+
let mut document = Self::with_name(name);
202+
let internal_document = InternalDocument::with_content(&serialized_content);
203+
match internal_document {
204+
Ok(handle) => {
205+
document.document = handle;
206+
Ok(document)
207+
}
208+
Err(DocumentError::InvalidFile(msg)) => Err(EditorError::Document(msg)),
209+
_ => Err(EditorError::Document(String::from("Failed to open file"))),
210+
}
211+
}
196212

197213
pub fn layer_data(&mut self, path: &[LayerId]) -> &mut LayerData {
198214
layer_data(&mut self.layer_data, path)
@@ -269,6 +285,10 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
269285
ExportDocument => {
270286
let bbox = self.document.visible_layers_bounding_box().unwrap_or([DVec2::ZERO, ipp.viewport_size.as_f64()]);
271287
let size = bbox[1] - bbox[0];
288+
let name = match self.name.ends_with(FILE_SAVE_SUFFIX) {
289+
true => self.name.clone().replace(FILE_SAVE_SUFFIX, FILE_EXPORT_SUFFIX),
290+
false => self.name.clone() + FILE_EXPORT_SUFFIX,
291+
};
272292
responses.push_back(
273293
FrontendMessage::ExportDocument {
274294
document: format!(
@@ -280,6 +300,20 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
280300
"\n",
281301
self.document.render_root()
282302
),
303+
name,
304+
}
305+
.into(),
306+
)
307+
}
308+
SaveDocument => {
309+
let name = match self.name.ends_with(FILE_SAVE_SUFFIX) {
310+
true => self.name.clone(),
311+
false => self.name.clone() + FILE_SAVE_SUFFIX,
312+
};
313+
responses.push_back(
314+
FrontendMessage::SaveDocument {
315+
document: self.document.serialize_document(),
316+
name,
283317
}
284318
.into(),
285319
)
@@ -493,6 +527,7 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
493527
DeselectAllLayers,
494528
RenderDocument,
495529
ExportDocument,
530+
SaveDocument,
496531
);
497532

498533
if self.layer_data.values().any(|data| data.selected) {

editor/src/document/document_message_handler.rs

Lines changed: 56 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use log::warn;
77
use std::collections::VecDeque;
88

99
use super::DocumentMessageHandler;
10+
use crate::consts::DEFAULT_DOCUMENT_NAME;
1011

1112
#[impl_message(Message, Documents)]
1213
#[derive(PartialEq, Clone, Debug)]
@@ -25,6 +26,8 @@ pub enum DocumentsMessage {
2526
CloseAllDocumentsWithConfirmation,
2627
CloseAllDocuments,
2728
NewDocument,
29+
OpenDocument,
30+
OpenDocumentFile(String, String),
2831
GetOpenDocumentsList,
2932
NextDocument,
3033
PrevDocument,
@@ -44,6 +47,45 @@ impl DocumentsMessageHandler {
4447
pub fn active_document_mut(&mut self) -> &mut DocumentMessageHandler {
4548
&mut self.documents[self.active_document_index]
4649
}
50+
fn generate_new_document_name(&self) -> String {
51+
let mut doc_title_numbers = self
52+
.documents
53+
.iter()
54+
.filter_map(|d| {
55+
d.name
56+
.rsplit_once(DEFAULT_DOCUMENT_NAME)
57+
.map(|(prefix, number)| (prefix.is_empty()).then(|| number.trim().parse::<isize>().ok()).flatten().unwrap_or(1))
58+
})
59+
.collect::<Vec<isize>>();
60+
doc_title_numbers.sort_unstable();
61+
doc_title_numbers.iter_mut().enumerate().for_each(|(i, number)| *number = *number - i as isize - 2);
62+
// Uses binary search to find the index of the element where number is bigger than i
63+
let new_doc_title_num = doc_title_numbers.binary_search(&0).map_or_else(|e| e, |v| v) + 1;
64+
65+
let name = match new_doc_title_num {
66+
1 => DEFAULT_DOCUMENT_NAME.to_string(),
67+
_ => format!("{} {}", DEFAULT_DOCUMENT_NAME, new_doc_title_num),
68+
};
69+
name
70+
}
71+
72+
fn load_document(&mut self, new_document: DocumentMessageHandler, responses: &mut VecDeque<Message>) {
73+
self.active_document_index = self.documents.len();
74+
self.documents.push(new_document);
75+
76+
// Send the new list of document tab names
77+
let open_documents = self.documents.iter().map(|doc| doc.name.clone()).collect();
78+
responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into());
79+
80+
responses.push_back(
81+
FrontendMessage::ExpandFolder {
82+
path: Vec::new().into(),
83+
children: Vec::new(),
84+
}
85+
.into(),
86+
);
87+
responses.push_back(DocumentsMessage::SelectDocument(self.active_document_index).into());
88+
}
4789
}
4890

4991
impl Default for DocumentsMessageHandler {
@@ -72,6 +114,7 @@ impl MessageHandler<DocumentsMessage, &InputPreprocessor> for DocumentsMessageHa
72114
.into(),
73115
);
74116
responses.push_back(RenderDocument.into());
117+
responses.extend(self.active_document_mut().handle_folder_changed(vec![]));
75118
}
76119
CloseActiveDocumentWithConfirmation => {
77120
responses.push_back(
@@ -145,48 +188,21 @@ impl MessageHandler<DocumentsMessage, &InputPreprocessor> for DocumentsMessageHa
145188
}
146189
}
147190
NewDocument => {
148-
let digits = ('0'..='9').collect::<Vec<char>>();
149-
let mut doc_title_numbers = self
150-
.documents
151-
.iter()
152-
.map(|d| {
153-
if d.name.ends_with(digits.as_slice()) {
154-
let (_, number) = d.name.split_at(17);
155-
number.trim().parse::<usize>().unwrap()
156-
} else {
157-
1
158-
}
159-
})
160-
.collect::<Vec<usize>>();
161-
doc_title_numbers.sort_unstable();
162-
let mut new_doc_title_num = 1;
163-
while new_doc_title_num <= self.documents.len() {
164-
if new_doc_title_num != doc_title_numbers[new_doc_title_num - 1] {
165-
break;
166-
}
167-
new_doc_title_num += 1;
168-
}
169-
let name = match new_doc_title_num {
170-
1 => "Untitled Document".to_string(),
171-
_ => format!("Untitled Document {}", new_doc_title_num),
172-
};
173-
174-
self.active_document_index = self.documents.len();
191+
let name = self.generate_new_document_name();
175192
let new_document = DocumentMessageHandler::with_name(name);
176-
self.documents.push(new_document);
177-
178-
// Send the new list of document tab names
179-
let open_documents = self.documents.iter().map(|doc| doc.name.clone()).collect();
180-
responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into());
181-
182-
responses.push_back(
183-
FrontendMessage::ExpandFolder {
184-
path: Vec::new().into(),
185-
children: Vec::new(),
193+
self.load_document(new_document, responses);
194+
}
195+
OpenDocument => {
196+
responses.push_back(FrontendMessage::OpenDocumentBrowse.into());
197+
}
198+
OpenDocumentFile(name, serialized_contents) => {
199+
let document = DocumentMessageHandler::with_name_and_content(name, serialized_contents);
200+
match document {
201+
Ok(document) => {
202+
self.load_document(document, responses);
186203
}
187-
.into(),
188-
);
189-
responses.push_back(SelectDocument(self.active_document_index).into());
204+
Err(e) => responses.push_back(FrontendMessage::DisplayError { description: e.to_string() }.into()),
205+
}
190206
}
191207
GetOpenDocumentsList => {
192208
// Send the list of document tab names

editor/src/frontend/frontend_message_handler.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ pub enum FrontendMessage {
1818
DisplayConfirmationToCloseAllDocuments,
1919
UpdateCanvas { document: String },
2020
UpdateLayer { path: Path, data: LayerPanelEntry },
21-
ExportDocument { document: String },
21+
ExportDocument { document: String, name: String },
22+
SaveDocument { document: String, name: String },
23+
OpenDocumentBrowse,
2224
EnableTextInput,
2325
DisableTextInput,
2426
UpdateWorkingColors { primary: Color, secondary: Color },
@@ -52,5 +54,6 @@ impl MessageHandler<FrontendMessage, ()> for FrontendMessageHandler {
5254
DisableTextInput,
5355
SetCanvasZoom,
5456
SetCanvasRotation,
57+
OpenDocumentBrowse,
5558
);
5659
}

editor/src/input/input_mapper.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ impl Default for Mapping {
184184
entry! {action=ToolMessage::SelectTool(ToolType::Eyedropper), key_down=KeyI},
185185
entry! {action=ToolMessage::ResetColors, key_down=KeyX, modifiers=[KeyShift, KeyControl]},
186186
entry! {action=ToolMessage::SwapColors, key_down=KeyX, modifiers=[KeyShift]},
187+
// Editor Actions
188+
entry! {action=FrontendMessage::OpenDocumentBrowse, key_down=KeyO, modifiers=[KeyControl]},
187189
// Document Actions
188190
entry! {action=DocumentsMessage::PasteIntoSelectedFolder, key_down=KeyV, modifiers=[KeyControl]},
189191
entry! {action=DocumentMessage::Undo, key_down=KeyZ, modifiers=[KeyControl]},
@@ -200,6 +202,8 @@ impl Default for Mapping {
200202
entry! {action=DocumentMessage::DeleteSelectedLayers, key_down=KeyX},
201203
entry! {action=DocumentMessage::DeleteSelectedLayers, key_down=KeyBackspace},
202204
entry! {action=DocumentMessage::ExportDocument, key_down=KeyE, modifiers=[KeyControl]},
205+
entry! {action=DocumentMessage::SaveDocument, key_down=KeyS, modifiers=[KeyControl]},
206+
entry! {action=DocumentMessage::SaveDocument, key_down=KeyS, modifiers=[KeyControl, KeyShift]},
203207
entry! {action=MovementMessage::MouseMove, message=InputMapperMessage::PointerMove},
204208
entry! {action=MovementMessage::RotateCanvasBegin{snap:false}, key_down=Mmb, modifiers=[KeyControl]},
205209
entry! {action=MovementMessage::RotateCanvasBegin{snap:true}, key_down=Mmb, modifiers=[KeyControl, KeyShift]},

editor/src/misc/error.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,23 @@ use thiserror::Error;
55
/// The error type used by the Graphite editor.
66
#[derive(Clone, Debug, Error)]
77
pub enum EditorError {
8-
#[error("Failed to execute operation: {0}")]
8+
#[error("Failed to execute operation:\n{0}")]
99
InvalidOperation(String),
10-
#[error("{0}")]
11-
Misc(String),
12-
#[error("Tried to construct an invalid color {0:?}")]
10+
11+
#[error("Tried to construct an invalid color:\n{0:?}")]
1312
Color(String),
13+
1414
#[error("The requested tool does not exist")]
1515
UnknownTool,
16-
#[error("The operation caused a document error {0:?}")]
16+
17+
#[error("The operation caused a document error:\n{0:?}")]
1718
Document(String),
18-
#[error("A Rollback was initated but no transaction was in progress")]
19+
20+
#[error("A rollback was initiated but no transaction was in progress")]
1921
NoTransactionInProgress,
22+
23+
#[error("{0}")]
24+
Misc(String),
2025
}
2126

2227
macro_rules! derive_from {

frontend/src/components/panels/Document.vue

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@
211211
import { defineComponent } from "vue";
212212
213213
import { makeModifiersBitfield } from "@/utilities/input";
214-
import { ResponseType, registerResponseHandler, Response, UpdateCanvas, SetActiveTool, ExportDocument, SetCanvasZoom, SetCanvasRotation } from "@/utilities/response-handler";
214+
import { ResponseType, registerResponseHandler, Response, UpdateCanvas, SetActiveTool, SetCanvasZoom, SetCanvasRotation } from "@/utilities/response-handler";
215215
import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets";
216216
import { comingSoon } from "@/utilities/errors";
217217
@@ -300,37 +300,25 @@ export default defineComponent({
300300
async resetWorkingColors() {
301301
(await wasm).reset_colors();
302302
},
303-
download(filename: string, fileData: string) {
304-
const svgBlob = new Blob([fileData], { type: "image/svg+xml;charset=utf-8" });
305-
const svgUrl = URL.createObjectURL(svgBlob);
306-
const element = document.createElement("a");
307-
308-
element.href = svgUrl;
309-
element.setAttribute("download", filename);
310-
element.style.display = "none";
311-
312-
element.click();
313-
},
314303
},
315304
mounted() {
316305
registerResponseHandler(ResponseType.UpdateCanvas, (responseData: Response) => {
317306
const updateData = responseData as UpdateCanvas;
318307
if (updateData) this.viewportSvg = updateData.document;
319308
});
320-
registerResponseHandler(ResponseType.ExportDocument, (responseData: Response) => {
321-
const updateData = responseData as ExportDocument;
322-
if (updateData) this.download("canvas.svg", updateData.document);
323-
});
309+
324310
registerResponseHandler(ResponseType.SetActiveTool, (responseData: Response) => {
325311
const toolData = responseData as SetActiveTool;
326312
if (toolData) this.activeTool = toolData.tool_name;
327313
});
314+
328315
registerResponseHandler(ResponseType.SetCanvasZoom, (responseData: Response) => {
329316
const updateData = responseData as SetCanvasZoom;
330317
if (updateData) {
331318
this.documentZoom = updateData.new_zoom * 100;
332319
}
333320
});
321+
334322
registerResponseHandler(ResponseType.SetCanvasRotation, (responseData: Response) => {
335323
const updateData = responseData as SetCanvasRotation;
336324
if (updateData) {

frontend/src/components/widgets/inputs/MenuBarInput.vue

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ const menuEntries: MenuListEntries = [
6969
children: [
7070
[
7171
{ label: "New", icon: "File", shortcut: ["Ctrl", "N"], shortcutRequiresLock: true, action: async () => (await wasm).new_document() },
72-
{ label: "Open…", shortcut: ["Ctrl", "O"] },
72+
{ label: "Open…", shortcut: ["Ctrl", "O"], action: async () => (await wasm).open_document() },
7373
{
7474
label: "Open Recent",
7575
shortcut: ["Ctrl", "", "O"],
@@ -90,8 +90,8 @@ const menuEntries: MenuListEntries = [
9090
{ label: "Close All", shortcut: ["Ctrl", "Alt", "W"], action: async () => (await wasm).close_all_documents_with_confirmation() },
9191
],
9292
[
93-
{ label: "Save", shortcut: ["Ctrl", "S"] },
94-
{ label: "Save As…", shortcut: ["Ctrl", "", "S"] },
93+
{ label: "Save", shortcut: ["Ctrl", "S"], action: async () => (await wasm).save_document() },
94+
{ label: "Save As…", shortcut: ["Ctrl", "", "S"], action: async () => (await wasm).save_document() },
9595
{ label: "Save All", shortcut: ["Ctrl", "Alt", "S"] },
9696
{ label: "Auto-Save", checkbox: true, checked: true },
9797
],
@@ -128,10 +128,10 @@ const menuEntries: MenuListEntries = [
128128
label: "Order",
129129
children: [
130130
[
131-
{ label: "Raise To Front", shortcut: ["Ctrl", "Shift", "]"], action: async () => (await wasm).reorder_selected_layers(2147483647) },
131+
{ label: "Raise To Front", shortcut: ["Ctrl", "Shift", "]"], action: async () => (await wasm).reorder_selected_layers((await wasm).i32_max()) },
132132
{ label: "Raise", shortcut: ["Ctrl", "]"], action: async () => (await wasm).reorder_selected_layers(1) },
133133
{ label: "Lower", shortcut: ["Ctrl", "["], action: async () => (await wasm).reorder_selected_layers(-1) },
134-
{ label: "Lower to Back", shortcut: ["Ctrl", "Shift", "["], action: async () => (await wasm).reorder_selected_layers(-2147483648) },
134+
{ label: "Lower to Back", shortcut: ["Ctrl", "Shift", "["], action: async () => (await wasm).reorder_selected_layers((await wasm).i32_min()) },
135135
],
136136
],
137137
},

0 commit comments

Comments
 (0)