Skip to content

Add support for saving and opening files #325

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Aug 14, 2021
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions editor/src/consts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ pub const LINE_ROTATE_SNAP_ANGLE: f64 = 15.;

// SELECT TOOL
pub const SELECTION_TOLERANCE: f64 = 1.0;

pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document";
pub const FILE_SAVE_SUFFIX: &str = ".graphite";
pub const FILE_EXPORT_SUFFIX: &str = ".svg";
42 changes: 39 additions & 3 deletions editor/src/document/document_file.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
pub use super::layer_panel::*;
use crate::{frontend::layer_panel::*, EditorError};
use crate::{
consts::{FILE_EXPORT_SUFFIX, FILE_SAVE_SUFFIX},
frontend::layer_panel::*,
EditorError,
};
use glam::{DAffine2, DVec2};
use graphene::{document::Document as InternalDocument, LayerId};
use graphene::{document::Document as InternalDocument, DocumentError, LayerId};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

Expand Down Expand Up @@ -81,6 +85,7 @@ pub enum DocumentMessage {
AbortTransaction,
CommitTransaction,
ExportDocument,
SaveDocument,
RenderDocument,
Undo,
NudgeSelectedLayers(f64, f64),
Expand Down Expand Up @@ -115,7 +120,7 @@ impl DocumentMessageHandler {
document_responses.retain(|response| !matches!(response, DocumentResponse::DocumentChanged));
document_responses.len() != len
}
fn handle_folder_changed(&mut self, path: Vec<LayerId>) -> Option<Message> {
pub fn handle_folder_changed(&mut self, path: Vec<LayerId>) -> Option<Message> {
let _ = self.document.render_root();
self.layer_data(&path).expanded.then(|| {
let children = self.layer_panel(path.as_slice()).expect("The provided Path was not valid");
Expand Down Expand Up @@ -192,6 +197,18 @@ impl DocumentMessageHandler {
movement_handler: MovementMessageHandler::default(),
}
}
pub fn with_name_and_content(name: String, serialized_content: String) -> Result<Self, EditorError> {
let mut document = Self::with_name(name);
let internal_document = InternalDocument::with_content(&serialized_content);
match internal_document {
Ok(handle) => {
document.document = handle;
Ok(document)
}
Err(DocumentError::InvalidFile(msg)) => Err(EditorError::Document(msg)),
_ => Err(EditorError::Document(String::from("Failed to open file"))),
}
}

pub fn layer_data(&mut self, path: &[LayerId]) -> &mut LayerData {
layer_data(&mut self.layer_data, path)
Expand Down Expand Up @@ -269,6 +286,10 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
ExportDocument => {
let bbox = self.document.visible_layers_bounding_box().unwrap_or([DVec2::ZERO, ipp.viewport_size.as_f64()]);
let size = bbox[1] - bbox[0];
let name = match self.name.ends_with(FILE_SAVE_SUFFIX) {
true => self.name.clone().replace(FILE_SAVE_SUFFIX, FILE_EXPORT_SUFFIX),
false => self.name.clone() + FILE_EXPORT_SUFFIX,
};
responses.push_back(
FrontendMessage::ExportDocument {
document: format!(
Expand All @@ -280,6 +301,20 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
"\n",
self.document.render_root()
),
name,
}
.into(),
)
}
SaveDocument => {
let name = match self.name.ends_with(FILE_SAVE_SUFFIX) {
true => self.name.clone(),
false => self.name.clone() + FILE_SAVE_SUFFIX,
};
responses.push_back(
FrontendMessage::SaveDocument {
document: self.document.serialize_document(),
name,
}
.into(),
)
Expand Down Expand Up @@ -484,6 +519,7 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
DeselectAllLayers,
RenderDocument,
ExportDocument,
SaveDocument,
);

if self.layer_data.values().any(|data| data.selected) {
Expand Down
96 changes: 56 additions & 40 deletions editor/src/document/document_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use log::warn;
use std::collections::VecDeque;

use super::DocumentMessageHandler;
use crate::consts::DEFAULT_DOCUMENT_NAME;

#[impl_message(Message, Documents)]
#[derive(PartialEq, Clone, Debug)]
Expand All @@ -24,6 +25,8 @@ pub enum DocumentsMessage {
CloseAllDocumentsWithConfirmation,
CloseAllDocuments,
NewDocument,
OpenDocument,
OpenDocumentFile(String, String),
GetOpenDocumentsList,
NextDocument,
PrevDocument,
Expand All @@ -43,6 +46,45 @@ impl DocumentsMessageHandler {
pub fn active_document_mut(&mut self) -> &mut DocumentMessageHandler {
&mut self.documents[self.active_document_index]
}
fn generate_new_document_name(&self) -> String {
let mut doc_title_numbers = self
.documents
.iter()
.filter_map(|d| {
d.name
.rsplit_once(DEFAULT_DOCUMENT_NAME)
.map(|(prefix, number)| (prefix.is_empty()).then(|| number.trim().parse::<isize>().ok()).flatten().unwrap_or(1))
})
.collect::<Vec<isize>>();
doc_title_numbers.sort_unstable();
doc_title_numbers.iter_mut().enumerate().for_each(|(i, number)| *number = *number - i as isize - 2);
// Uses binary search to find the index of the element where number is bigger than i
let new_doc_title_num = doc_title_numbers.binary_search(&0).map_or_else(|e| e, |v| v) + 1;

let name = match new_doc_title_num {
1 => DEFAULT_DOCUMENT_NAME.to_string(),
_ => format!("{} {}", DEFAULT_DOCUMENT_NAME, new_doc_title_num),
};
name
}

fn load_document(&mut self, new_document: DocumentMessageHandler, responses: &mut VecDeque<Message>) {
self.active_document_index = self.documents.len();
self.documents.push(new_document);

// Send the new list of document tab names
let open_documents = self.documents.iter().map(|doc| doc.name.clone()).collect();
responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into());

responses.push_back(
FrontendMessage::ExpandFolder {
path: Vec::new(),
children: Vec::new(),
}
.into(),
);
responses.push_back(DocumentsMessage::SelectDocument(self.active_document_index).into());
}
}

impl Default for DocumentsMessageHandler {
Expand Down Expand Up @@ -71,6 +113,7 @@ impl MessageHandler<DocumentsMessage, &InputPreprocessor> for DocumentsMessageHa
.into(),
);
responses.push_back(RenderDocument.into());
responses.extend(self.active_document_mut().handle_folder_changed(vec![]));
}
CloseActiveDocumentWithConfirmation => {
responses.push_back(
Expand Down Expand Up @@ -138,48 +181,21 @@ impl MessageHandler<DocumentsMessage, &InputPreprocessor> for DocumentsMessageHa
}
}
NewDocument => {
let digits = ('0'..='9').collect::<Vec<char>>();
let mut doc_title_numbers = self
.documents
.iter()
.map(|d| {
if d.name.ends_with(digits.as_slice()) {
let (_, number) = d.name.split_at(17);
number.trim().parse::<usize>().unwrap()
} else {
1
}
})
.collect::<Vec<usize>>();
doc_title_numbers.sort_unstable();
let mut new_doc_title_num = 1;
while new_doc_title_num <= self.documents.len() {
if new_doc_title_num != doc_title_numbers[new_doc_title_num - 1] {
break;
}
new_doc_title_num += 1;
}
let name = match new_doc_title_num {
1 => "Untitled Document".to_string(),
_ => format!("Untitled Document {}", new_doc_title_num),
};

self.active_document_index = self.documents.len();
let name = self.generate_new_document_name();
let new_document = DocumentMessageHandler::with_name(name);
self.documents.push(new_document);

// Send the new list of document tab names
let open_documents = self.documents.iter().map(|doc| doc.name.clone()).collect();
responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into());

responses.push_back(
FrontendMessage::ExpandFolder {
path: Vec::new(),
children: Vec::new(),
self.load_document(new_document, responses);
}
OpenDocument => {
responses.push_back(FrontendMessage::OpenDocumentBrowse.into());
}
OpenDocumentFile(name, serialized_contents) => {
let document = DocumentMessageHandler::with_name_and_content(name, serialized_contents);
match document {
Ok(document) => {
self.load_document(document, responses);
}
.into(),
);
responses.push_back(SelectDocument(self.active_document_index).into());
Err(e) => responses.push_back(FrontendMessage::DisplayError { description: e.to_string() }.into()),
}
}
GetOpenDocumentsList => {
// Send the list of document tab names
Expand Down
5 changes: 4 additions & 1 deletion editor/src/frontend/frontend_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ pub enum FrontendMessage {
DisplayConfirmationToCloseAllDocuments,
UpdateCanvas { document: String },
UpdateLayer { path: Vec<LayerId>, data: LayerPanelEntry },
ExportDocument { document: String },
ExportDocument { document: String, name: String },
SaveDocument { document: String, name: String },
OpenDocumentBrowse,
EnableTextInput,
DisableTextInput,
UpdateWorkingColors { primary: Color, secondary: Color },
Expand Down Expand Up @@ -52,5 +54,6 @@ impl MessageHandler<FrontendMessage, ()> for FrontendMessageHandler {
DisableTextInput,
SetCanvasZoom,
SetCanvasRotation,
OpenDocumentBrowse,
);
}
4 changes: 4 additions & 0 deletions editor/src/input/input_mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ impl Default for Mapping {
entry! {action=ToolMessage::SelectTool(ToolType::Eyedropper), key_down=KeyI},
entry! {action=ToolMessage::ResetColors, key_down=KeyX, modifiers=[KeyShift, KeyControl]},
entry! {action=ToolMessage::SwapColors, key_down=KeyX, modifiers=[KeyShift]},
// Editor Actions
entry! {action=FrontendMessage::OpenDocumentBrowse, key_down=KeyO, modifiers=[KeyControl]},
// Document Actions
entry! {action=DocumentMessage::Undo, key_down=KeyZ, modifiers=[KeyControl]},
entry! {action=DocumentMessage::DeselectAllLayers, key_down=KeyA, modifiers=[KeyControl, KeyAlt]},
Expand All @@ -188,6 +190,8 @@ impl Default for Mapping {
entry! {action=DocumentMessage::DeleteSelectedLayers, key_down=KeyX},
entry! {action=DocumentMessage::DeleteSelectedLayers, key_down=KeyBackspace},
entry! {action=DocumentMessage::ExportDocument, key_down=KeyE, modifiers=[KeyControl]},
entry! {action=DocumentMessage::SaveDocument, key_down=KeyS, modifiers=[KeyControl]},
entry! {action=DocumentMessage::SaveDocument, key_down=KeyS, modifiers=[KeyControl, KeyShift]},
entry! {action=MovementMessage::MouseMove, message=InputMapperMessage::PointerMove},
entry! {action=MovementMessage::RotateCanvasBegin{snap:false}, key_down=Mmb, modifiers=[KeyControl]},
entry! {action=MovementMessage::RotateCanvasBegin{snap:true}, key_down=Mmb, modifiers=[KeyControl, KeyShift]},
Expand Down
17 changes: 11 additions & 6 deletions editor/src/misc/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,23 @@ use thiserror::Error;
/// The error type used by the Graphite editor.
#[derive(Clone, Debug, Error)]
pub enum EditorError {
#[error("Failed to execute operation: {0}")]
#[error("Failed to execute operation:\n{0}")]
InvalidOperation(String),
#[error("{0}")]
Misc(String),
#[error("Tried to construct an invalid color {0:?}")]

#[error("Tried to construct an invalid color:\n{0:?}")]
Color(String),

#[error("The requested tool does not exist")]
UnknownTool,
#[error("The operation caused a document error {0:?}")]

#[error("The operation caused a document error:\n{0:?}")]
Document(String),
#[error("A Rollback was initated but no transaction was in progress")]

#[error("A rollback was initiated but no transaction was in progress")]
NoTransactionInProgress,

#[error("{0}")]
Misc(String),
}

macro_rules! derive_from {
Expand Down
20 changes: 4 additions & 16 deletions frontend/src/components/panels/Document.vue
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@
import { defineComponent } from "vue";

import { makeModifiersBitfield } from "@/utilities/input";
import { ResponseType, registerResponseHandler, Response, UpdateCanvas, SetActiveTool, ExportDocument, SetCanvasZoom, SetCanvasRotation } from "@/utilities/response-handler";
import { ResponseType, registerResponseHandler, Response, UpdateCanvas, SetActiveTool, SetCanvasZoom, SetCanvasRotation } from "@/utilities/response-handler";
import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets";
import { comingSoon } from "@/utilities/errors";

Expand Down Expand Up @@ -300,37 +300,25 @@ export default defineComponent({
async resetWorkingColors() {
(await wasm).reset_colors();
},
download(filename: string, fileData: string) {
const svgBlob = new Blob([fileData], { type: "image/svg+xml;charset=utf-8" });
const svgUrl = URL.createObjectURL(svgBlob);
const element = document.createElement("a");

element.href = svgUrl;
element.setAttribute("download", filename);
element.style.display = "none";

element.click();
},
},
mounted() {
registerResponseHandler(ResponseType.UpdateCanvas, (responseData: Response) => {
const updateData = responseData as UpdateCanvas;
if (updateData) this.viewportSvg = updateData.document;
});
registerResponseHandler(ResponseType.ExportDocument, (responseData: Response) => {
const updateData = responseData as ExportDocument;
if (updateData) this.download("canvas.svg", updateData.document);
});

registerResponseHandler(ResponseType.SetActiveTool, (responseData: Response) => {
const toolData = responseData as SetActiveTool;
if (toolData) this.activeTool = toolData.tool_name;
});

registerResponseHandler(ResponseType.SetCanvasZoom, (responseData: Response) => {
const updateData = responseData as SetCanvasZoom;
if (updateData) {
this.documentZoom = updateData.new_zoom * 100;
}
});

registerResponseHandler(ResponseType.SetCanvasRotation, (responseData: Response) => {
const updateData = responseData as SetCanvasRotation;
if (updateData) {
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/components/widgets/inputs/MenuBarInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const menuEntries: MenuListEntries = [
children: [
[
{ label: "New", icon: "File", shortcut: ["Ctrl", "N"], shortcutRequiresLock: true, action: async () => (await wasm).new_document() },
{ label: "Open…", shortcut: ["Ctrl", "O"] },
{ label: "Open…", shortcut: ["Ctrl", "O"], action: async () => (await wasm).open_document() },
{
label: "Open Recent",
shortcut: ["Ctrl", "⇧", "O"],
Expand All @@ -90,8 +90,8 @@ const menuEntries: MenuListEntries = [
{ label: "Close All", shortcut: ["Ctrl", "Alt", "W"], action: async () => (await wasm).close_all_documents_with_confirmation() },
],
[
{ label: "Save", shortcut: ["Ctrl", "S"] },
{ label: "Save As…", shortcut: ["Ctrl", "⇧", "S"] },
{ label: "Save", shortcut: ["Ctrl", "S"], action: async () => (await wasm).save_document() },
{ label: "Save As…", shortcut: ["Ctrl", "⇧", "S"], action: async () => (await wasm).save_document() },
{ label: "Save All", shortcut: ["Ctrl", "Alt", "S"] },
{ label: "Auto-Save", checkbox: true, checked: true },
],
Expand Down Expand Up @@ -128,10 +128,10 @@ const menuEntries: MenuListEntries = [
label: "Order",
children: [
[
{ label: "Raise To Front", shortcut: ["Ctrl", "Shift", "]"], action: async () => (await wasm).reorder_selected_layers(2147483647) },
{ label: "Raise To Front", shortcut: ["Ctrl", "Shift", "]"], action: async () => (await wasm).reorder_selected_layers((await wasm).i32_max()) },
{ label: "Raise", shortcut: ["Ctrl", "]"], action: async () => (await wasm).reorder_selected_layers(1) },
{ label: "Lower", shortcut: ["Ctrl", "["], action: async () => (await wasm).reorder_selected_layers(-1) },
{ label: "Lower to Back", shortcut: ["Ctrl", "Shift", "["], action: async () => (await wasm).reorder_selected_layers(-2147483648) },
{ label: "Lower to Back", shortcut: ["Ctrl", "Shift", "["], action: async () => (await wasm).reorder_selected_layers((await wasm).i32_min()) },
],
],
},
Expand Down
Loading