diff --git a/editor/src/communication/dispatcher.rs b/editor/src/communication/dispatcher.rs index 242a897226..591906e09e 100644 --- a/editor/src/communication/dispatcher.rs +++ b/editor/src/communication/dispatcher.rs @@ -96,7 +96,7 @@ impl Dispatcher { #[cfg(test)] mod test { - use crate::{document::DocumentMessageHandler, message_prelude::*, misc::test_utils::EditorTestUtils, Editor}; + use crate::{communication::set_uuid_seed, document::DocumentMessageHandler, message_prelude::*, misc::test_utils::EditorTestUtils, Editor}; use graphene::{color::Color, Operation}; fn init_logger() { @@ -108,6 +108,7 @@ mod test { /// 2. A blue shape /// 3. A green ellipse fn create_editor_with_three_layers() -> Editor { + set_uuid_seed(0); let mut editor = Editor::new(); editor.select_primary_color(Color::RED); diff --git a/editor/src/communication/mod.rs b/editor/src/communication/mod.rs index 47a881ac67..aeada11990 100644 --- a/editor/src/communication/mod.rs +++ b/editor/src/communication/mod.rs @@ -9,7 +9,7 @@ use rand_chacha::{ use spin::Mutex; pub use crate::input::InputPreprocessor; -use std::collections::VecDeque; +use std::{cell::Cell, collections::VecDeque}; pub type ActionList = Vec>; @@ -29,10 +29,21 @@ where fn actions(&self) -> ActionList; } +thread_local! { + pub static UUID_SEED: Cell> = Cell::new(None); +} + +pub fn set_uuid_seed(random_seed: u64) { + UUID_SEED.with(|seed| seed.set(Some(random_seed))) +} + pub fn generate_uuid() -> u64 { let mut lock = RNG.lock(); if lock.is_none() { - *lock = Some(ChaCha20Rng::seed_from_u64(0)); + UUID_SEED.with(|seed| { + let random_seed = seed.get().expect("random seed not set before editor was initialized"); + *lock = Some(ChaCha20Rng::seed_from_u64(random_seed)); + }) } lock.as_mut().map(ChaCha20Rng::next_u64).unwrap() } diff --git a/editor/src/document/document_file.rs b/editor/src/document/document_file.rs index 3a52ab8b1a..c2b7f09b4e 100644 --- a/editor/src/document/document_file.rs +++ b/editor/src/document/document_file.rs @@ -5,6 +5,7 @@ pub use super::layer_panel::*; use super::movement_handler::{MovementMessage, MovementMessageHandler}; use super::transform_layer_handler::{TransformLayerMessage, TransformLayerMessageHandler}; +use crate::consts::DEFAULT_DOCUMENT_NAME; use crate::consts::{ASYMPTOTIC_EFFECT, FILE_EXPORT_SUFFIX, FILE_SAVE_SUFFIX, SCALE_EFFECT, SCROLLBAR_SPACING}; use crate::input::InputPreprocessor; use crate::message_prelude::*; @@ -186,6 +187,13 @@ impl DocumentMessageHandler { } } + pub fn is_unmodified_default(&self) -> bool { + self.serialize_root().len() == Self::default().serialize_root().len() + && self.document_undo_history.len() == 0 + && self.document_redo_history.len() == 0 + && self.name.starts_with(DEFAULT_DOCUMENT_NAME) + } + fn select_layer(&mut self, path: &[LayerId]) -> Option { if self.graphene_document.layer(path).ok()?.overlay { return None; @@ -413,6 +421,14 @@ impl DocumentMessageHandler { self.current_identifier() == self.saved_document_identifier } + pub fn set_save_state(&mut self, is_saved: bool) { + if is_saved { + self.saved_document_identifier = self.current_identifier(); + } else { + self.saved_document_identifier = generate_uuid(); + } + } + pub fn layer_panel_entry(&mut self, path: Vec) -> Result { let data: LayerData = *layer_data(&mut self.layer_data, &path); let layer = self.graphene_document.layer(&path)?; @@ -493,7 +509,8 @@ impl MessageHandler for DocumentMessageHand ) } SaveDocument => { - self.saved_document_identifier = self.current_identifier(); + self.set_save_state(true); + responses.push_back(DocumentsMessage::AutoSaveActiveDocument.into()); // Update the save status of the just saved document responses.push_back(DocumentsMessage::UpdateOpenDocumentsList.into()); diff --git a/editor/src/document/document_message_handler.rs b/editor/src/document/document_message_handler.rs index e3cf95c6ec..a63d577564 100644 --- a/editor/src/document/document_message_handler.rs +++ b/editor/src/document/document_message_handler.rs @@ -30,8 +30,16 @@ pub enum DocumentsMessage { RequestAboutGraphiteDialog, NewDocument, OpenDocument, + OpenDocumentFileWithId { + document: String, + document_name: String, + document_id: u64, + document_is_saved: bool, + }, OpenDocumentFile(String, String), UpdateOpenDocumentsList, + AutoSaveDocument(u64), + AutoSaveActiveDocument, NextDocument, PrevDocument, } @@ -76,11 +84,23 @@ impl DocumentsMessageHandler { name } - fn load_document(&mut self, new_document: DocumentMessageHandler, responses: &mut VecDeque) { - let new_id = generate_uuid(); - self.active_document_id = new_id; - self.document_ids.push(new_id); - self.documents.insert(new_id, new_document); + // TODO Fix how this doesn't preserve tab order upon loading new document from file>load + fn load_document(&mut self, new_document: DocumentMessageHandler, document_id: u64, replace_first_empty: bool, responses: &mut VecDeque) { + // Special case when loading a document on an empty page + if replace_first_empty && self.active_document().is_unmodified_default() { + responses.push_back(DocumentsMessage::CloseDocument(self.active_document_id).into()); + + let active_document_index = self + .document_ids + .iter() + .position(|id| self.active_document_id == *id) + .expect("Did not find matching active document id"); + self.document_ids.insert(active_document_index + 1, document_id); + } else { + self.document_ids.push(document_id); + } + + self.documents.insert(document_id, new_document); // Send the new list of document tab names let open_documents = self @@ -97,12 +117,7 @@ impl DocumentsMessageHandler { responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into()); - responses.push_back(DocumentsMessage::SelectDocument(self.active_document_id).into()); - responses.push_back(DocumentMessage::RenderDocument.into()); - responses.push_back(DocumentMessage::DocumentStructureChanged.into()); - for layer in self.active_document().layer_data.keys() { - responses.push_back(DocumentMessage::LayerChanged(layer.clone()).into()); - } + responses.push_back(DocumentsMessage::SelectDocument(document_id).into()); } // Returns an iterator over the open documents in order @@ -140,6 +155,10 @@ impl MessageHandler for DocumentsMessageHa } Document(message) => self.active_document_mut().process_action(message, ipp, responses), SelectDocument(id) => { + let active_document = self.active_document(); + if !active_document.is_saved() { + responses.push_back(DocumentsMessage::AutoSaveDocument(self.active_document_id).into()); + } self.active_document_id = id; responses.push_back(FrontendMessage::SetActiveDocument { document_id: id }.into()); responses.push_back(RenderDocument.into()); @@ -210,6 +229,7 @@ impl MessageHandler for DocumentsMessageHa // Update the list of new documents on the front end, active tab, and ensure that document renders responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into()); responses.push_back(FrontendMessage::SetActiveDocument { document_id: self.active_document_id }.into()); + responses.push_back(FrontendMessage::RemoveAutoSaveDocument { document_id: id }.into()); responses.push_back(RenderDocument.into()); responses.push_back(DocumentMessage::DocumentStructureChanged.into()); for layer in self.active_document().layer_data.keys() { @@ -219,16 +239,33 @@ impl MessageHandler for DocumentsMessageHa NewDocument => { let name = self.generate_new_document_name(); let new_document = DocumentMessageHandler::with_name(name, ipp); - self.load_document(new_document, responses); + self.load_document(new_document, generate_uuid(), false, responses); } OpenDocument => { responses.push_back(FrontendMessage::OpenDocumentBrowse.into()); } - OpenDocumentFile(name, serialized_contents) => { - let document = DocumentMessageHandler::with_name_and_content(name, serialized_contents, ipp); + OpenDocumentFile(document_name, document) => { + responses.push_back( + DocumentsMessage::OpenDocumentFileWithId { + document, + document_name, + document_id: generate_uuid(), + document_is_saved: true, + } + .into(), + ); + } + OpenDocumentFileWithId { + document_name, + document_id, + document, + document_is_saved, + } => { + let document = DocumentMessageHandler::with_name_and_content(document_name, document, ipp); match document { - Ok(document) => { - self.load_document(document, responses); + Ok(mut document) => { + document.set_save_state(document_is_saved); + self.load_document(document, document_id, true, responses); } Err(e) => responses.push_back( FrontendMessage::DisplayError { @@ -254,18 +291,33 @@ impl MessageHandler for DocumentsMessageHa .collect::>(); responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into()); } + AutoSaveDocument(id) => { + let document = self.documents.get(&id).unwrap(); + responses.push_back( + FrontendMessage::AutoSaveDocument { + document: document.graphene_document.serialize_document(), + details: FrontendDocumentDetails { + is_saved: document.is_saved(), + id, + name: document.name.clone(), + }, + } + .into(), + ) + } + AutoSaveActiveDocument => responses.push_back(DocumentsMessage::AutoSaveDocument(self.active_document_id).into()), NextDocument => { let current_index = self.document_index(self.active_document_id); let next_index = (current_index + 1) % self.document_ids.len(); let next_id = self.document_ids[next_index]; - responses.push_back(SelectDocument(next_id).into()); + responses.push_back(DocumentsMessage::SelectDocument(next_id).into()); } PrevDocument => { let len = self.document_ids.len(); let current_index = self.document_index(self.active_document_id); let prev_index = (current_index + len - 1) % len; let prev_id = self.document_ids[prev_index]; - responses.push_back(SelectDocument(prev_id).into()); + responses.push_back(DocumentsMessage::SelectDocument(prev_id).into()); } Copy => { let paths = self.active_document().selected_layers_sorted(); diff --git a/editor/src/document/movement_handler.rs b/editor/src/document/movement_handler.rs index 4dac02142b..c45ad165b8 100644 --- a/editor/src/document/movement_handler.rs +++ b/editor/src/document/movement_handler.rs @@ -6,7 +6,6 @@ use crate::{ input::{mouse::ViewportBounds, mouse::ViewportPosition, InputPreprocessor}, }; use graphene::document::Document; -use graphene::layers::style::ViewMode; use graphene::Operation as DocumentOperation; use glam::DVec2; diff --git a/editor/src/frontend/frontend_message_handler.rs b/editor/src/frontend/frontend_message_handler.rs index b9e50ce87e..b389c67433 100644 --- a/editor/src/frontend/frontend_message_handler.rs +++ b/editor/src/frontend/frontend_message_handler.rs @@ -31,6 +31,8 @@ pub enum FrontendMessage { UpdateRulers { origin: (f64, f64), spacing: f64, interval: f64 }, ExportDocument { document: String, name: String }, SaveDocument { document: String, name: String }, + AutoSaveDocument { document: String, details: FrontendDocumentDetails }, + RemoveAutoSaveDocument { document_id: u64 }, OpenDocumentBrowse, UpdateWorkingColors { primary: Color, secondary: Color }, SetCanvasZoom { new_zoom: f64 }, diff --git a/editor/src/lib.rs b/editor/src/lib.rs index ff5a8339d5..e9e3144d9c 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -3,7 +3,7 @@ extern crate graphite_proc_macros; -mod communication; +pub mod communication; #[macro_use] pub mod misc; pub mod consts; @@ -31,6 +31,8 @@ pub struct Editor { } impl Editor { + /// Construct a new editor instance. + /// Remember to provide a random seed with `editor::communication::set_uuid_seed(seed)` before any editors can be used. pub fn new() -> Self { Self { dispatcher: Dispatcher::new() } } diff --git a/editor/src/tool/tools/eyedropper.rs b/editor/src/tool/tools/eyedropper.rs index 359a8d10ec..e7b8a69e8b 100644 --- a/editor/src/tool/tools/eyedropper.rs +++ b/editor/src/tool/tools/eyedropper.rs @@ -62,8 +62,8 @@ impl Fsm for EyedropperToolFsmState { self, event: ToolMessage, document: &DocumentMessageHandler, - tool_data: &DocumentToolData, - data: &mut Self::ToolData, + _tool_data: &DocumentToolData, + _data: &mut Self::ToolData, input: &InputPreprocessor, responses: &mut VecDeque, ) -> Self { diff --git a/editor/src/tool/tools/fill.rs b/editor/src/tool/tools/fill.rs index ce0765f1a8..8a1737b87e 100644 --- a/editor/src/tool/tools/fill.rs +++ b/editor/src/tool/tools/fill.rs @@ -63,7 +63,7 @@ impl Fsm for FillToolFsmState { event: ToolMessage, document: &DocumentMessageHandler, tool_data: &DocumentToolData, - data: &mut Self::ToolData, + _data: &mut Self::ToolData, input: &InputPreprocessor, responses: &mut VecDeque, ) -> Self { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ffeafc50b3..9ee7b9df98 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -230,6 +230,7 @@ import LayoutRow from "@/components/layout/LayoutRow.vue"; import { createEditorState, EditorState } from "@/state/wasm-loader"; import { createInputManager, InputManager } from "@/lifetime/input"; import { initErrorHandling } from "@/lifetime/errors"; +import { createAutoSaveManager } from "@/lifetime/auto-save"; // Vue injects don't play well with TypeScript, and all injects will show up as `any`. As a workaround, we can define these types. declare module "@vue/runtime-core" { @@ -259,6 +260,7 @@ export default defineComponent({ const documents = createDocumentsState(editor, dialog); const fullscreen = createFullscreenState(); initErrorHandling(editor, dialog); + createAutoSaveManager(editor, documents); return { editor, diff --git a/frontend/src/dispatcher/js-messages.ts b/frontend/src/dispatcher/js-messages.ts index 8b7f75e547..2e105bb6ef 100644 --- a/frontend/src/dispatcher/js-messages.ts +++ b/frontend/src/dispatcher/js-messages.ts @@ -19,18 +19,26 @@ export class JsMessage { // for details about how to transform the JSON from wasm-bindgen into classes. // ============================================================================ -export class FrontendDocumentDetails { +// Allows the auto save system to use a string for the id rather than a BigInt. +// IndexedDb does not allow for BigInts as primary keys. TypeScript does not allow +// subclasses to change the type of class variables in subclasses. It is an abstract +// class to point out that it should not be instantiated directly. +export abstract class DocumentDetails { readonly name!: string; readonly is_saved!: boolean; - readonly id!: BigInt; + readonly id!: BigInt | string; get displayName() { return `${this.name}${this.is_saved ? "" : "*"}`; } } +export class FrontendDocumentDetails extends DocumentDetails { + readonly id!: BigInt; +} + export class UpdateOpenDocumentsList extends JsMessage { @Type(() => FrontendDocumentDetails) readonly open_documents!: FrontendDocumentDetails[]; @@ -296,6 +304,24 @@ export const LayerTypeOptions = { export type LayerType = typeof LayerTypeOptions[keyof typeof LayerTypeOptions]; +export class IndexedDbDocumentDetails extends DocumentDetails { + @Transform(({ value }: { value: BigInt }) => value.toString()) + id!: string; +} + +export class AutoSaveDocument extends JsMessage { + document!: string; + + @Type(() => IndexedDbDocumentDetails) + details!: IndexedDbDocumentDetails; +} + +export class RemoveAutoSaveDocument extends JsMessage { + // Use a string since IndexedDB can not use BigInts for keys + @Transform(({ value }: { value: BigInt }) => value.toString()) + document_id!: string; +} + // Any is used since the type of the object should be known from the rust side // eslint-disable-next-line @typescript-eslint/no-explicit-any type JSMessageFactory = (data: any, wasm: WasmInstance, instance: RustEditorInstance) => JsMessage; @@ -322,5 +348,7 @@ export const messageConstructors: Record = { DisplayConfirmationToCloseDocument, DisplayConfirmationToCloseAllDocuments, DisplayAboutGraphiteDialog, + AutoSaveDocument, + RemoveAutoSaveDocument, } as const; export type JsMessageType = keyof typeof messageConstructors; diff --git a/frontend/src/lifetime/auto-save.ts b/frontend/src/lifetime/auto-save.ts new file mode 100644 index 0000000000..0097833b6b --- /dev/null +++ b/frontend/src/lifetime/auto-save.ts @@ -0,0 +1,77 @@ +import { AutoSaveDocument, RemoveAutoSaveDocument } from "@/dispatcher/js-messages"; +import { DocumentsState } from "@/state/documents"; +import { EditorState } from "@/state/wasm-loader"; + +const GRAPHITE_INDEXED_DB_NAME = "graphite-indexed-db"; +const GRAPHITE_INDEXED_DB_VERSION = 1; +const GRAPHITE_AUTO_SAVE_STORE = "auto-save-documents"; +const GRAPHITE_AUTO_SAVE_ORDER_KEY = "auto-save-documents-order"; + +const databaseConnection: Promise = new Promise((resolve) => { + const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION); + + dbOpenRequest.onupgradeneeded = () => { + const db = dbOpenRequest.result; + if (!db.objectStoreNames.contains(GRAPHITE_AUTO_SAVE_STORE)) { + db.createObjectStore(GRAPHITE_AUTO_SAVE_STORE, { keyPath: "details.id" }); + } + }; + + dbOpenRequest.onerror = () => { + // eslint-disable-next-line no-console + console.error("Graphite IndexedDb error:", dbOpenRequest.error); + }; + + dbOpenRequest.onsuccess = () => { + resolve(dbOpenRequest.result); + }; +}); + +export function createAutoSaveManager(editor: EditorState, documents: DocumentsState) { + const openAutoSavedDocuments = async (): Promise => { + const db = await databaseConnection; + const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readonly"); + const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).getAll(); + + return new Promise((resolve) => { + request.onsuccess = () => { + const previouslySavedDocuments: AutoSaveDocument[] = request.result; + + const documentOrder: string[] = JSON.parse(window.localStorage.getItem(GRAPHITE_AUTO_SAVE_ORDER_KEY) || "[]"); + const orderedSavedDocuments = documentOrder.map((id) => previouslySavedDocuments.find((autoSave) => autoSave.details.id === id)).filter((x) => x !== undefined) as AutoSaveDocument[]; + + orderedSavedDocuments.forEach((doc: AutoSaveDocument) => { + editor.instance.open_auto_saved_document(BigInt(doc.details.id), doc.details.name, doc.details.is_saved, doc.document); + }); + resolve(undefined); + }; + }); + }; + + const storeDocumentOrder = () => { + // Make sure to store as string since JSON does not play nice with BigInt + const documentOrder = documents.state.documents.map((doc) => doc.id.toString()); + window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder)); + }; + + editor.dispatcher.subscribeJsMessage(AutoSaveDocument, async (autoSaveDocument) => { + const db = await databaseConnection; + const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite"); + transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).put(autoSaveDocument); + storeDocumentOrder(); + }); + + editor.dispatcher.subscribeJsMessage(RemoveAutoSaveDocument, async (removeAutoSaveDocument) => { + const db = await databaseConnection; + const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite"); + transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).delete(removeAutoSaveDocument.document_id); + storeDocumentOrder(); + }); + + // On creation + openAutoSavedDocuments(); + + return { + openAutoSavedDocuments, + }; +} diff --git a/frontend/src/lifetime/input.ts b/frontend/src/lifetime/input.ts index 5798ac4b7a..4beb12d22e 100644 --- a/frontend/src/lifetime/input.ts +++ b/frontend/src/lifetime/input.ts @@ -169,6 +169,9 @@ export function createInputManager(editor: EditorState, container: HTMLElement, }; const onBeforeUnload = (e: BeforeUnloadEvent) => { + const activeDocument = document.state.documents[document.state.activeDocumentIndex]; + if (!activeDocument.is_saved) editor.instance.trigger_auto_save(activeDocument.id); + // Skip the message if the editor crashed, since work is already lost if (editor.instance.has_crashed()) return; diff --git a/frontend/src/state/wasm-loader.ts b/frontend/src/state/wasm-loader.ts index d6ac0d4847..f369141b08 100644 --- a/frontend/src/state/wasm-loader.ts +++ b/frontend/src/state/wasm-loader.ts @@ -10,7 +10,12 @@ let wasmImport: WasmInstance | null = null; export async function initWasm() { if (wasmImport !== null) return; - wasmImport = await import("@/../wasm/pkg").then(panicProxy); + // Separating in two lines satisfies typescript when used below + const importedWasm = await import("@/../wasm/pkg").then(panicProxy); + wasmImport = importedWasm; + + const randomSeed = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)); + importedWasm.set_random_seed(randomSeed); } // This works by proxying every function call wrapping a try-catch block to filter out redundant and confusing diff --git a/frontend/wasm/src/api.rs b/frontend/wasm/src/api.rs index 25ba2295a5..268de6e9dd 100644 --- a/frontend/wasm/src/api.rs +++ b/frontend/wasm/src/api.rs @@ -175,11 +175,26 @@ impl JsEditorHandle { self.dispatch(message); } + pub fn open_auto_saved_document(&self, document_id: u64, document_name: String, document_is_saved: bool, document: String) { + let message = DocumentsMessage::OpenDocumentFileWithId { + document_id, + document_name, + document_is_saved, + document, + }; + self.dispatch(message); + } + pub fn save_document(&self) { let message = DocumentMessage::SaveDocument; self.dispatch(message); } + pub fn trigger_auto_save(&self, document_id: u64) { + let message = DocumentsMessage::AutoSaveDocument(document_id); + self.dispatch(message); + } + pub fn close_document(&self, document_id: u64) { let message = DocumentsMessage::CloseDocument(document_id); self.dispatch(message); @@ -509,3 +524,8 @@ pub fn i32_max() -> i32 { pub fn i32_min() -> i32 { i32::MIN } + +#[wasm_bindgen] +pub fn set_random_seed(seed: u64) { + editor::communication::set_uuid_seed(seed) +} diff --git a/graphene/src/consts.rs b/graphene/src/consts.rs index e23a178feb..a89da61cde 100644 --- a/graphene/src/consts.rs +++ b/graphene/src/consts.rs @@ -1,5 +1,8 @@ use crate::color::Color; +// Document +pub const GRAPHENE_DOCUMENT_VERSION: &'static str = "0.0.1"; + // RENDERING pub const LAYER_OUTLINE_STROKE_COLOR: Color = Color::BLACK; pub const LAYER_OUTLINE_STROKE_WIDTH: f32 = 1.; diff --git a/graphene/src/document.rs b/graphene/src/document.rs index 8e43e53da1..bc210bbe34 100644 --- a/graphene/src/document.rs +++ b/graphene/src/document.rs @@ -1,3 +1,4 @@ +use crate::consts::GRAPHENE_DOCUMENT_VERSION; use std::{ cmp::max, collections::hash_map::DefaultHasher, @@ -19,6 +20,7 @@ pub struct Document { /// This identifier is not a hash and is not guaranteed to be equal for equivalent documents. #[serde(skip)] pub state_identifier: DefaultHasher, + pub graphene_document_version: String, } impl Default for Document { @@ -26,6 +28,7 @@ impl Default for Document { Self { root: Layer::new(LayerDataType::Folder(Folder::default()), DAffine2::IDENTITY.to_cols_array()), state_identifier: DefaultHasher::new(), + graphene_document_version: GRAPHENE_DOCUMENT_VERSION.to_string(), } } }