Skip to content

Commit d4674e5

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 8551121 commit d4674e5

File tree

18 files changed

+280
-80
lines changed

18 files changed

+280
-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: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
pub use super::layer_panel::*;
2-
use crate::{frontend::layer_panel::*, EditorError};
2+
use crate::{
3+
consts::{FILE_EXPORT_SUFFIX, FILE_SAVE_SUFFIX},
4+
frontend::layer_panel::*,
5+
EditorError,
6+
};
37
use glam::{DAffine2, DVec2};
4-
use graphene::{document::Document as InternalDocument, LayerId};
8+
use graphene::{document::Document as InternalDocument, DocumentError, LayerId};
59
use serde::{Deserialize, Serialize};
610
use std::collections::HashMap;
711

@@ -81,6 +85,7 @@ pub enum DocumentMessage {
8185
AbortTransaction,
8286
CommitTransaction,
8387
ExportDocument,
88+
SaveDocument,
8489
RenderDocument,
8590
Undo,
8691
NudgeSelectedLayers(f64, f64),
@@ -115,7 +120,7 @@ impl DocumentMessageHandler {
115120
document_responses.retain(|response| !matches!(response, DocumentResponse::DocumentChanged));
116121
document_responses.len() != len
117122
}
118-
fn handle_folder_changed(&mut self, path: Vec<LayerId>) -> Option<Message> {
123+
pub fn handle_folder_changed(&mut self, path: Vec<LayerId>) -> Option<Message> {
119124
let _ = self.document.render_root();
120125
self.layer_data(&path).expanded.then(|| {
121126
let children = self.layer_panel(path.as_slice()).expect("The provided Path was not valid");
@@ -192,6 +197,18 @@ impl DocumentMessageHandler {
192197
movement_handler: MovementMessageHandler::default(),
193198
}
194199
}
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+
}
195212

196213
pub fn layer_data(&mut self, path: &[LayerId]) -> &mut LayerData {
197214
layer_data(&mut self.layer_data, path)
@@ -269,6 +286,10 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
269286
ExportDocument => {
270287
let bbox = self.document.visible_layers_bounding_box().unwrap_or([DVec2::ZERO, ipp.viewport_size.as_f64()]);
271288
let size = bbox[1] - bbox[0];
289+
let name = match self.name.ends_with(FILE_SAVE_SUFFIX) {
290+
true => self.name.clone().replace(FILE_SAVE_SUFFIX, FILE_EXPORT_SUFFIX),
291+
false => self.name.clone() + FILE_EXPORT_SUFFIX,
292+
};
272293
responses.push_back(
273294
FrontendMessage::ExportDocument {
274295
document: format!(
@@ -280,6 +301,20 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
280301
"\n",
281302
self.document.render_root()
282303
),
304+
name,
305+
}
306+
.into(),
307+
)
308+
}
309+
SaveDocument => {
310+
let name = match self.name.ends_with(FILE_SAVE_SUFFIX) {
311+
true => self.name.clone(),
312+
false => self.name.clone() + FILE_SAVE_SUFFIX,
313+
};
314+
responses.push_back(
315+
FrontendMessage::SaveDocument {
316+
document: self.document.serialize_document(),
317+
name,
283318
}
284319
.into(),
285320
)
@@ -484,6 +519,7 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
484519
DeselectAllLayers,
485520
RenderDocument,
486521
ExportDocument,
522+
SaveDocument,
487523
);
488524

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

4890
impl Default for DocumentsMessageHandler {
@@ -71,6 +113,7 @@ impl MessageHandler<DocumentsMessage, &InputPreprocessor> for DocumentsMessageHa
71113
.into(),
72114
);
73115
responses.push_back(RenderDocument.into());
116+
responses.extend(self.active_document_mut().handle_folder_changed(vec![]));
74117
}
75118
CloseActiveDocumentWithConfirmation => {
76119
responses.push_back(
@@ -138,48 +181,21 @@ impl MessageHandler<DocumentsMessage, &InputPreprocessor> for DocumentsMessageHa
138181
}
139182
}
140183
NewDocument => {
141-
let digits = ('0'..='9').collect::<Vec<char>>();
142-
let mut doc_title_numbers = self
143-
.documents
144-
.iter()
145-
.map(|d| {
146-
if d.name.ends_with(digits.as_slice()) {
147-
let (_, number) = d.name.split_at(17);
148-
number.trim().parse::<usize>().unwrap()
149-
} else {
150-
1
151-
}
152-
})
153-
.collect::<Vec<usize>>();
154-
doc_title_numbers.sort_unstable();
155-
let mut new_doc_title_num = 1;
156-
while new_doc_title_num <= self.documents.len() {
157-
if new_doc_title_num != doc_title_numbers[new_doc_title_num - 1] {
158-
break;
159-
}
160-
new_doc_title_num += 1;
161-
}
162-
let name = match new_doc_title_num {
163-
1 => "Untitled Document".to_string(),
164-
_ => format!("Untitled Document {}", new_doc_title_num),
165-
};
166-
167-
self.active_document_index = self.documents.len();
184+
let name = self.generate_new_document_name();
168185
let new_document = DocumentMessageHandler::with_name(name);
169-
self.documents.push(new_document);
170-
171-
// Send the new list of document tab names
172-
let open_documents = self.documents.iter().map(|doc| doc.name.clone()).collect();
173-
responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into());
174-
175-
responses.push_back(
176-
FrontendMessage::ExpandFolder {
177-
path: Vec::new(),
178-
children: Vec::new(),
186+
self.load_document(new_document, responses);
187+
}
188+
OpenDocument => {
189+
responses.push_back(FrontendMessage::OpenDocumentBrowse.into());
190+
}
191+
OpenDocumentFile(name, serialized_contents) => {
192+
let document = DocumentMessageHandler::with_name_and_content(name, serialized_contents);
193+
match document {
194+
Ok(document) => {
195+
self.load_document(document, responses);
179196
}
180-
.into(),
181-
);
182-
responses.push_back(SelectDocument(self.active_document_index).into());
197+
Err(e) => responses.push_back(FrontendMessage::DisplayError { description: e.to_string() }.into()),
198+
}
183199
}
184200
GetOpenDocumentsList => {
185201
// 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: Vec<LayerId>, 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
@@ -180,6 +180,8 @@ impl Default for Mapping {
180180
entry! {action=ToolMessage::SelectTool(ToolType::Eyedropper), key_down=KeyI},
181181
entry! {action=ToolMessage::ResetColors, key_down=KeyX, modifiers=[KeyShift, KeyControl]},
182182
entry! {action=ToolMessage::SwapColors, key_down=KeyX, modifiers=[KeyShift]},
183+
// Editor Actions
184+
entry! {action=FrontendMessage::OpenDocumentBrowse, key_down=KeyO, modifiers=[KeyControl]},
183185
// Document Actions
184186
entry! {action=DocumentMessage::Undo, key_down=KeyZ, modifiers=[KeyControl]},
185187
entry! {action=DocumentMessage::DeselectAllLayers, key_down=KeyA, modifiers=[KeyControl, KeyAlt]},
@@ -188,6 +190,8 @@ impl Default for Mapping {
188190
entry! {action=DocumentMessage::DeleteSelectedLayers, key_down=KeyX},
189191
entry! {action=DocumentMessage::DeleteSelectedLayers, key_down=KeyBackspace},
190192
entry! {action=DocumentMessage::ExportDocument, key_down=KeyE, modifiers=[KeyControl]},
193+
entry! {action=DocumentMessage::SaveDocument, key_down=KeyS, modifiers=[KeyControl]},
194+
entry! {action=DocumentMessage::SaveDocument, key_down=KeyS, modifiers=[KeyControl, KeyShift]},
191195
entry! {action=MovementMessage::MouseMove, message=InputMapperMessage::PointerMove},
192196
entry! {action=MovementMessage::RotateCanvasBegin{snap:false}, key_down=Mmb, modifiers=[KeyControl]},
193197
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)