diff --git a/Cargo.lock b/Cargo.lock index 1c56b9a347..da5175aa97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,6 +110,7 @@ dependencies = [ "kurbo", "log", "serde", + "serde_json", ] [[package]] diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 9dbdc7c384..9d85fee1fa 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -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"; diff --git a/editor/src/document/document_file.rs b/editor/src/document/document_file.rs index 8e613ca59f..5c64bae9be 100644 --- a/editor/src/document/document_file.rs +++ b/editor/src/document/document_file.rs @@ -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; @@ -81,6 +85,7 @@ pub enum DocumentMessage { AbortTransaction, CommitTransaction, ExportDocument, + SaveDocument, RenderDocument, Undo, NudgeSelectedLayers(f64, f64), @@ -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) -> Option { + pub fn handle_folder_changed(&mut self, path: Vec) -> Option { 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"); @@ -192,6 +197,18 @@ impl DocumentMessageHandler { movement_handler: MovementMessageHandler::default(), } } + pub fn with_name_and_content(name: String, serialized_content: String) -> Result { + 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) @@ -269,6 +286,10 @@ impl MessageHandler 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!( @@ -280,6 +301,20 @@ impl MessageHandler 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(), ) @@ -484,6 +519,7 @@ impl MessageHandler for DocumentMessageHand DeselectAllLayers, RenderDocument, ExportDocument, + SaveDocument, ); if self.layer_data.values().any(|data| data.selected) { diff --git a/editor/src/document/document_message_handler.rs b/editor/src/document/document_message_handler.rs index 335a431e28..51eb0725b1 100644 --- a/editor/src/document/document_message_handler.rs +++ b/editor/src/document/document_message_handler.rs @@ -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)] @@ -24,6 +25,8 @@ pub enum DocumentsMessage { CloseAllDocumentsWithConfirmation, CloseAllDocuments, NewDocument, + OpenDocument, + OpenDocumentFile(String, String), GetOpenDocumentsList, NextDocument, PrevDocument, @@ -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::().ok()).flatten().unwrap_or(1)) + }) + .collect::>(); + 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) { + 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 { @@ -71,6 +113,7 @@ impl MessageHandler for DocumentsMessageHa .into(), ); responses.push_back(RenderDocument.into()); + responses.extend(self.active_document_mut().handle_folder_changed(vec![])); } CloseActiveDocumentWithConfirmation => { responses.push_back( @@ -138,48 +181,21 @@ impl MessageHandler for DocumentsMessageHa } } NewDocument => { - let digits = ('0'..='9').collect::>(); - 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::().unwrap() - } else { - 1 - } - }) - .collect::>(); - 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 diff --git a/editor/src/frontend/frontend_message_handler.rs b/editor/src/frontend/frontend_message_handler.rs index 1147076ff3..19f51d0aca 100644 --- a/editor/src/frontend/frontend_message_handler.rs +++ b/editor/src/frontend/frontend_message_handler.rs @@ -18,7 +18,9 @@ pub enum FrontendMessage { DisplayConfirmationToCloseAllDocuments, UpdateCanvas { document: String }, UpdateLayer { path: Vec, data: LayerPanelEntry }, - ExportDocument { document: String }, + ExportDocument { document: String, name: String }, + SaveDocument { document: String, name: String }, + OpenDocumentBrowse, EnableTextInput, DisableTextInput, UpdateWorkingColors { primary: Color, secondary: Color }, @@ -52,5 +54,6 @@ impl MessageHandler for FrontendMessageHandler { DisableTextInput, SetCanvasZoom, SetCanvasRotation, + OpenDocumentBrowse, ); } diff --git a/editor/src/input/input_mapper.rs b/editor/src/input/input_mapper.rs index 58ad6e0632..98a3084340 100644 --- a/editor/src/input/input_mapper.rs +++ b/editor/src/input/input_mapper.rs @@ -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]}, @@ -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]}, diff --git a/editor/src/misc/error.rs b/editor/src/misc/error.rs index ad1cf505a7..08e4c40e00 100644 --- a/editor/src/misc/error.rs +++ b/editor/src/misc/error.rs @@ -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 { diff --git a/frontend/src/components/panels/Document.vue b/frontend/src/components/panels/Document.vue index 4093d4787b..f88fcc0080 100644 --- a/frontend/src/components/panels/Document.vue +++ b/frontend/src/components/panels/Document.vue @@ -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"; @@ -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) { diff --git a/frontend/src/components/widgets/inputs/MenuBarInput.vue b/frontend/src/components/widgets/inputs/MenuBarInput.vue index 8f8cebfc2b..f6a40959d4 100644 --- a/frontend/src/components/widgets/inputs/MenuBarInput.vue +++ b/frontend/src/components/widgets/inputs/MenuBarInput.vue @@ -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"], @@ -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 }, ], @@ -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()) }, ], ], }, diff --git a/frontend/src/utilities/documents.ts b/frontend/src/utilities/documents.ts index e493be3262..0094c4bc55 100644 --- a/frontend/src/utilities/documents.ts +++ b/frontend/src/utilities/documents.ts @@ -1,7 +1,17 @@ import { reactive, readonly } from "vue"; import { createDialog, dismissDialog } from "@/utilities/dialog"; -import { ResponseType, registerResponseHandler, Response, SetActiveDocument, UpdateOpenDocumentsList, DisplayConfirmationToCloseDocument } from "@/utilities/response-handler"; +import { + ResponseType, + registerResponseHandler, + Response, + SetActiveDocument, + UpdateOpenDocumentsList, + DisplayConfirmationToCloseDocument, + ExportDocument, + SaveDocument, +} from "@/utilities/response-handler"; +import { download, upload } from "./files"; const wasm = import("@/../wasm/pkg"); @@ -21,15 +31,14 @@ export async function closeDocumentWithConfirmation(tabIndex: number) { const tabLabel = state.documents[tabIndex]; - // TODO: Rename to "Save changes before closing?" when we can actually save documents somewhere, not just export SVGs - createDialog("File", "Close without exporting SVG?", tabLabel, [ + createDialog("File", "Save changes before closing?", tabLabel, [ { kind: "TextButton", callback: async () => { - (await wasm).export_document(); + (await wasm).save_document(); dismissDialog(); }, - props: { label: "Export", emphasized: true, minWidth: 96 }, + props: { label: "Save", emphasized: true, minWidth: 96 }, }, { kind: "TextButton", @@ -78,6 +87,7 @@ registerResponseHandler(ResponseType.UpdateOpenDocumentsList, (responseData: Res state.title = state.documents[state.activeDocumentIndex]; } }); + registerResponseHandler(ResponseType.SetActiveDocument, (responseData: Response) => { const documentData = responseData as SetActiveDocument; if (documentData) { @@ -85,12 +95,30 @@ registerResponseHandler(ResponseType.SetActiveDocument, (responseData: Response) state.title = state.documents[state.activeDocumentIndex]; } }); + registerResponseHandler(ResponseType.DisplayConfirmationToCloseDocument, (responseData: Response) => { const data = responseData as DisplayConfirmationToCloseDocument; closeDocumentWithConfirmation(data.document_index); }); -registerResponseHandler(ResponseType.DisplayConfirmationToCloseAllDocuments, (_responseData: Response) => { + +registerResponseHandler(ResponseType.DisplayConfirmationToCloseAllDocuments, (_: Response) => { closeAllDocumentsWithConfirmation(); }); +registerResponseHandler(ResponseType.OpenDocumentBrowse, async (_: Response) => { + const extension = (await wasm).file_save_suffix(); + const data = await upload(extension); + (await wasm).open_document_file(data.filename, data.content); +}); + +registerResponseHandler(ResponseType.ExportDocument, (responseData: Response) => { + const updateData = responseData as ExportDocument; + if (updateData) download(updateData.name, updateData.document); +}); + +registerResponseHandler(ResponseType.SaveDocument, (responseData: Response) => { + const saveData = responseData as SaveDocument; + if (saveData) download(saveData.name, saveData.document); +}); + (async () => (await wasm).get_open_documents_list())(); diff --git a/frontend/src/utilities/files.ts b/frontend/src/utilities/files.ts new file mode 100644 index 0000000000..4d474b264c --- /dev/null +++ b/frontend/src/utilities/files.ts @@ -0,0 +1,39 @@ +export function download(filename: string, fileData: string) { + let type = "text/plain;charset=utf-8"; + if (filename.endsWith(".svg")) { + type = "image/svg+xml;charset=utf-8"; + } + const blob = new Blob([fileData], { type }); + const url = URL.createObjectURL(blob); + const element = document.createElement("a"); + + element.href = url; + element.setAttribute("download", filename); + element.style.display = "none"; + + element.click(); +} + +export async function upload(acceptedEextensions: string) { + return new Promise<{ filename: string; content: string }>((resolve, _) => { + const element = document.createElement("input"); + element.type = "file"; + element.style.display = "none"; + element.accept = acceptedEextensions; + + element.addEventListener( + "change", + async () => { + if (!element.files || !element.files.length) return; + const file = element.files[0]; + const filename = file.name; + const content = await file.text(); + + resolve({ filename, content }); + }, + { capture: false, once: true } + ); + + element.click(); + }); +} diff --git a/frontend/src/utilities/response-handler.ts b/frontend/src/utilities/response-handler.ts index bf18407876..9c20032b0c 100644 --- a/frontend/src/utilities/response-handler.ts +++ b/frontend/src/utilities/response-handler.ts @@ -15,6 +15,8 @@ const state = reactive({ export enum ResponseType { UpdateCanvas = "UpdateCanvas", ExportDocument = "ExportDocument", + SaveDocument = "SaveDocument", + OpenDocumentBrowse = "OpenDocumentBrowse", ExpandFolder = "ExpandFolder", CollapseFolder = "CollapseFolder", UpdateLayer = "UpdateLayer", @@ -72,6 +74,10 @@ function parseResponse(responseType: string, data: any): Response { return newSetCanvasRotation(data.SetCanvasRotation); case "ExportDocument": return newExportDocument(data.ExportDocument); + case "SaveDocument": + return newSaveDocument(data.SaveDocument); + case "OpenDocumentBrowse": + return newOpenDocumentBrowse(data.OpenDocumentBrowse); case "UpdateWorkingColors": return newUpdateWorkingColors(data.UpdateWorkingColors); case "DisplayError": @@ -167,13 +173,31 @@ function newUpdateCanvas(input: any): UpdateCanvas { export interface ExportDocument { document: string; + name: string; +} +function newExportDocument(input: any): ExportDocument { + return { + document: input.document, + name: input.name, + }; +} + +export interface SaveDocument { + document: string; + name: string; } -function newExportDocument(input: any): UpdateCanvas { +function newSaveDocument(input: any): SaveDocument { return { document: input.document, + name: input.name, }; } +export type OpenDocumentBrowse = {}; +function newOpenDocumentBrowse(_: any): OpenDocumentBrowse { + return {}; +} + export type DocumentChanged = {}; function newDocumentChanged(_: any): DocumentChanged { return {}; diff --git a/frontend/wasm/src/document.rs b/frontend/wasm/src/document.rs index 7f2f2f13fe..e9d62668af 100644 --- a/frontend/wasm/src/document.rs +++ b/frontend/wasm/src/document.rs @@ -69,6 +69,21 @@ pub fn new_document() -> Result<(), JsValue> { EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentsMessage::NewDocument).map_err(convert_error)) } +#[wasm_bindgen] +pub fn open_document() -> Result<(), JsValue> { + EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentsMessage::OpenDocument).map_err(convert_error)) +} + +#[wasm_bindgen] +pub fn open_document_file(name: String, content: String) -> Result<(), JsValue> { + EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentsMessage::OpenDocumentFile(name, content)).map_err(convert_error)) +} + +#[wasm_bindgen] +pub fn save_document() -> Result<(), JsValue> { + EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::SaveDocument)).map_err(convert_error) +} + #[wasm_bindgen] pub fn close_document(document: usize) -> Result<(), JsValue> { EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentsMessage::CloseDocument(document)).map_err(convert_error)) diff --git a/frontend/wasm/src/wrappers.rs b/frontend/wasm/src/wrappers.rs index c0a6e3a1a6..2f8165898b 100644 --- a/frontend/wasm/src/wrappers.rs +++ b/frontend/wasm/src/wrappers.rs @@ -1,9 +1,25 @@ use crate::shims::Error; +use editor::consts::FILE_SAVE_SUFFIX; use editor::input::keyboard::Key; use editor::tool::{SelectAppendMode, ToolType}; use editor::Color as InnerColor; use wasm_bindgen::prelude::*; +#[wasm_bindgen] +pub fn file_save_suffix() -> String { + FILE_SAVE_SUFFIX.into() +} + +#[wasm_bindgen] +pub fn i32_max() -> i32 { + i32::MAX +} + +#[wasm_bindgen] +pub fn i32_min() -> i32 { + i32::MIN +} + #[wasm_bindgen] pub struct Color(InnerColor); diff --git a/graphene/Cargo.toml b/graphene/Cargo.toml index dc4860715e..44fb5830f4 100644 --- a/graphene/Cargo.toml +++ b/graphene/Cargo.toml @@ -15,4 +15,5 @@ kurbo = { git = "https://github.com/GraphiteEditor/kurbo.git", features = [ "serde", ] } serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } glam = { version = "0.17", features = ["serde"] } diff --git a/graphene/src/document.rs b/graphene/src/document.rs index f5004892c0..de93fc3749 100644 --- a/graphene/src/document.rs +++ b/graphene/src/document.rs @@ -4,15 +4,17 @@ use std::{ }; use glam::{DAffine2, DVec2}; +use serde::{Deserialize, Serialize}; use crate::{ layers::{self, Folder, Layer, LayerData, LayerDataType, Shape}, DocumentError, DocumentResponse, LayerId, Operation, Quad, }; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Document { pub root: Layer, + #[serde(skip)] pub hasher: DefaultHasher, } @@ -31,6 +33,10 @@ fn split_path(path: &[LayerId]) -> Result<(&[LayerId], LayerId), DocumentError> } impl Document { + pub fn with_content(serialized_content: &String) -> Result { + serde_json::from_str(serialized_content).map_err(|e| DocumentError::InvalidFile(e.to_string())) + } + /// Wrapper around render, that returns the whole document as a Response. pub fn render_root(&mut self) -> String { self.root.render(&mut vec![]); @@ -41,6 +47,12 @@ impl Document { self.hasher.finish() } + pub fn serialize_document(&self) -> String { + let val = serde_json::to_string(self); + // We fully expect the serialization to succeed + val.unwrap() + } + /// Checks whether each layer under `path` intersects with the provided `quad` and adds all intersection layers as paths to `intersections`. pub fn intersects_quad(&self, quad: Quad, path: &mut Vec, intersections: &mut Vec>) { self.layer(path).unwrap().intersects_quad(quad, path, intersections); diff --git a/graphene/src/layers/mod.rs b/graphene/src/layers/mod.rs index aeeafce4aa..a154240483 100644 --- a/graphene/src/layers/mod.rs +++ b/graphene/src/layers/mod.rs @@ -64,6 +64,10 @@ struct DAffine2Ref { pub translation: DVec2, } +fn return_true() -> bool { + true +} + #[derive(Debug, PartialEq, Deserialize, Serialize)] pub struct Layer { pub visible: bool, @@ -71,8 +75,11 @@ pub struct Layer { pub data: LayerDataType, #[serde(with = "DAffine2Ref")] pub transform: glam::DAffine2, + #[serde(skip)] pub cache: String, + #[serde(skip)] pub thumbnail_cache: String, + #[serde(skip, default = "return_true")] pub cache_dirty: bool, pub blend_mode: BlendMode, pub opacity: f64, diff --git a/graphene/src/lib.rs b/graphene/src/lib.rs index cc9e3e4c4b..b4739f634b 100644 --- a/graphene/src/lib.rs +++ b/graphene/src/lib.rs @@ -11,7 +11,7 @@ pub use response::DocumentResponse; pub type LayerId = u64; -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub enum DocumentError { LayerNotFound, InvalidPath, @@ -19,4 +19,5 @@ pub enum DocumentError { NotAFolder, NonReorderableSelection, NotAShape, + InvalidFile(String), }