Refactor persistent data storage code and add button to wipe data on crash (#827)
* Organize persistence.ts * Switch to simpler promise handling * Switch document list storage from localStorage to IndexedDB * Track document auto-save status to avoid re-auto-saving unnecessarily * Add button to clear storage on crash * Bump document version and test file * Switch to IDB-Keyval instead of raw IDB transactions
This commit is contained in:
parent
73233169b2
commit
9d56e86203
File diff suppressed because one or more lines are too long
|
|
@ -74,7 +74,7 @@ pub const DEFAULT_FONT_FAMILY: &str = "Merriweather";
|
||||||
pub const DEFAULT_FONT_STYLE: &str = "Normal (400)";
|
pub const DEFAULT_FONT_STYLE: &str = "Normal (400)";
|
||||||
|
|
||||||
// Document
|
// Document
|
||||||
pub const GRAPHITE_DOCUMENT_VERSION: &str = "0.0.14"; // Remember to save a simple document and replace the test file `graphite-test-document.graphite`
|
pub const GRAPHITE_DOCUMENT_VERSION: &str = "0.0.15"; // Remember to save a simple document and replace the test file `graphite-test-document.graphite`
|
||||||
pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document";
|
pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document";
|
||||||
pub const FILE_SAVE_SUFFIX: &str = ".graphite";
|
pub const FILE_SAVE_SUFFIX: &str = ".graphite";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct FrontendDocumentDetails {
|
pub struct FrontendDocumentDetails {
|
||||||
|
#[serde(rename = "isAutoSaved")]
|
||||||
|
pub is_auto_saved: bool,
|
||||||
#[serde(rename = "isSaved")]
|
#[serde(rename = "isSaved")]
|
||||||
pub is_saved: bool,
|
pub is_saved: bool,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ use serde::{Deserialize, Serialize};
|
||||||
pub struct DocumentMessageHandler {
|
pub struct DocumentMessageHandler {
|
||||||
pub graphene_document: GrapheneDocument,
|
pub graphene_document: GrapheneDocument,
|
||||||
pub saved_document_identifier: u64,
|
pub saved_document_identifier: u64,
|
||||||
|
pub auto_saved_document_identifier: u64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
|
|
||||||
|
|
@ -68,6 +69,7 @@ impl Default for DocumentMessageHandler {
|
||||||
Self {
|
Self {
|
||||||
graphene_document: GrapheneDocument::default(),
|
graphene_document: GrapheneDocument::default(),
|
||||||
saved_document_identifier: 0,
|
saved_document_identifier: 0,
|
||||||
|
auto_saved_document_identifier: 0,
|
||||||
name: String::from("Untitled Document"),
|
name: String::from("Untitled Document"),
|
||||||
version: GRAPHITE_DOCUMENT_VERSION.to_string(),
|
version: GRAPHITE_DOCUMENT_VERSION.to_string(),
|
||||||
|
|
||||||
|
|
@ -1354,10 +1356,22 @@ impl DocumentMessageHandler {
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_auto_saved(&self) -> bool {
|
||||||
|
self.current_identifier() == self.auto_saved_document_identifier
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_saved(&self) -> bool {
|
pub fn is_saved(&self) -> bool {
|
||||||
self.current_identifier() == self.saved_document_identifier
|
self.current_identifier() == self.saved_document_identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_auto_save_state(&mut self, is_saved: bool) {
|
||||||
|
if is_saved {
|
||||||
|
self.auto_saved_document_identifier = self.current_identifier();
|
||||||
|
} else {
|
||||||
|
self.auto_saved_document_identifier = generate_uuid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_save_state(&mut self, is_saved: bool) {
|
pub fn set_save_state(&mut self, is_saved: bool) {
|
||||||
if is_saved {
|
if is_saved {
|
||||||
self.saved_document_identifier = self.current_identifier();
|
self.saved_document_identifier = self.current_identifier();
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ pub enum PortfolioMessage {
|
||||||
OpenDocumentFileWithId {
|
OpenDocumentFileWithId {
|
||||||
document_id: u64,
|
document_id: u64,
|
||||||
document_name: String,
|
document_name: String,
|
||||||
|
document_is_auto_saved: bool,
|
||||||
document_is_saved: bool,
|
document_is_saved: bool,
|
||||||
document_serialized_content: String,
|
document_serialized_content: String,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ impl MessageHandler<PortfolioMessage, (&InputPreprocessorMessageHandler, &Prefer
|
||||||
}
|
}
|
||||||
PortfolioMessage::AutoSaveActiveDocument => {
|
PortfolioMessage::AutoSaveActiveDocument => {
|
||||||
if let Some(document_id) = self.active_document_id {
|
if let Some(document_id) = self.active_document_id {
|
||||||
|
if let Some(document) = self.active_document_mut() {
|
||||||
|
document.set_auto_save_state(true);
|
||||||
|
}
|
||||||
responses.push_back(PortfolioMessage::AutoSaveDocument { document_id }.into());
|
responses.push_back(PortfolioMessage::AutoSaveDocument { document_id }.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +61,7 @@ impl MessageHandler<PortfolioMessage, (&InputPreprocessorMessageHandler, &Prefer
|
||||||
FrontendMessage::TriggerIndexedDbWriteDocument {
|
FrontendMessage::TriggerIndexedDbWriteDocument {
|
||||||
document: document.serialize_document(),
|
document: document.serialize_document(),
|
||||||
details: FrontendDocumentDetails {
|
details: FrontendDocumentDetails {
|
||||||
|
is_auto_saved: document.is_auto_saved(),
|
||||||
is_saved: document.is_saved(),
|
is_saved: document.is_saved(),
|
||||||
id: document_id,
|
id: document_id,
|
||||||
name: document.name.clone(),
|
name: document.name.clone(),
|
||||||
|
|
@ -93,24 +97,15 @@ impl MessageHandler<PortfolioMessage, (&InputPreprocessorMessageHandler, &Prefer
|
||||||
|
|
||||||
if self.document_ids.is_empty() {
|
if self.document_ids.is_empty() {
|
||||||
self.active_document_id = None;
|
self.active_document_id = None;
|
||||||
} else if Some(document_id) == self.active_document_id {
|
} else if self.active_document_id.is_some() {
|
||||||
if document_index == self.document_ids.len() {
|
let document_id = if document_index == self.document_ids.len() {
|
||||||
// If we closed the last document take the one previous (same as last)
|
// If we closed the last document take the one previous (same as last)
|
||||||
responses.push_back(
|
*self.document_ids.last().unwrap()
|
||||||
PortfolioMessage::SelectDocument {
|
|
||||||
document_id: *self.document_ids.last().unwrap(),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Move to the next tab
|
// Move to the next tab
|
||||||
responses.push_back(
|
self.document_ids[document_index]
|
||||||
PortfolioMessage::SelectDocument {
|
};
|
||||||
document_id: self.document_ids[document_index],
|
responses.push_back(PortfolioMessage::SelectDocument { document_id }.into());
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the new list of document tab names
|
// Send the new list of document tab names
|
||||||
|
|
@ -282,6 +277,7 @@ impl MessageHandler<PortfolioMessage, (&InputPreprocessorMessageHandler, &Prefer
|
||||||
PortfolioMessage::OpenDocumentFileWithId {
|
PortfolioMessage::OpenDocumentFileWithId {
|
||||||
document_id: generate_uuid(),
|
document_id: generate_uuid(),
|
||||||
document_name,
|
document_name,
|
||||||
|
document_is_auto_saved: false,
|
||||||
document_is_saved: true,
|
document_is_saved: true,
|
||||||
document_serialized_content,
|
document_serialized_content,
|
||||||
}
|
}
|
||||||
|
|
@ -291,12 +287,14 @@ impl MessageHandler<PortfolioMessage, (&InputPreprocessorMessageHandler, &Prefer
|
||||||
PortfolioMessage::OpenDocumentFileWithId {
|
PortfolioMessage::OpenDocumentFileWithId {
|
||||||
document_id,
|
document_id,
|
||||||
document_name,
|
document_name,
|
||||||
|
document_is_auto_saved,
|
||||||
document_is_saved,
|
document_is_saved,
|
||||||
document_serialized_content,
|
document_serialized_content,
|
||||||
} => {
|
} => {
|
||||||
let document = DocumentMessageHandler::with_name_and_content(document_name, document_serialized_content);
|
let document = DocumentMessageHandler::with_name_and_content(document_name, document_serialized_content);
|
||||||
match document {
|
match document {
|
||||||
Ok(mut document) => {
|
Ok(mut document) => {
|
||||||
|
document.set_auto_save_state(document_is_auto_saved);
|
||||||
document.set_save_state(document_is_saved);
|
document.set_save_state(document_is_saved);
|
||||||
self.load_document(document, document_id, responses);
|
self.load_document(document, document_id, responses);
|
||||||
}
|
}
|
||||||
|
|
@ -417,10 +415,10 @@ impl MessageHandler<PortfolioMessage, (&InputPreprocessorMessageHandler, &Prefer
|
||||||
}
|
}
|
||||||
PortfolioMessage::SelectDocument { document_id } => {
|
PortfolioMessage::SelectDocument { document_id } => {
|
||||||
if let Some(document) = self.active_document() {
|
if let Some(document) = self.active_document() {
|
||||||
if !document.is_saved() {
|
if !document.is_auto_saved() {
|
||||||
// Safe to unwrap since we know that there is an active document
|
|
||||||
responses.push_back(
|
responses.push_back(
|
||||||
PortfolioMessage::AutoSaveDocument {
|
PortfolioMessage::AutoSaveDocument {
|
||||||
|
// Safe to unwrap since we know that there is an active document
|
||||||
document_id: self.active_document_id.unwrap(),
|
document_id: self.active_document_id.unwrap(),
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
|
|
@ -472,10 +470,11 @@ impl MessageHandler<PortfolioMessage, (&InputPreprocessorMessageHandler, &Prefer
|
||||||
.document_ids
|
.document_ids
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|id| {
|
.filter_map(|id| {
|
||||||
self.documents.get(id).map(|doc| FrontendDocumentDetails {
|
self.documents.get(id).map(|document| FrontendDocumentDetails {
|
||||||
is_saved: doc.is_saved(),
|
is_auto_saved: document.is_auto_saved(),
|
||||||
|
is_saved: document.is_saved(),
|
||||||
id: *id,
|
id: *id,
|
||||||
name: doc.name.clone(),
|
name: document.name.clone(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-transformer": "^0.5.0",
|
"class-transformer": "^0.5.0",
|
||||||
|
"idb-keyval": "^6.2.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"vue": "^3.2.26"
|
"vue": "^3.2.26"
|
||||||
},
|
},
|
||||||
|
|
@ -5632,6 +5633,14 @@
|
||||||
"postcss": "^8.1.0"
|
"postcss": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/idb-keyval": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==",
|
||||||
|
"dependencies": {
|
||||||
|
"safari-14-idb-fix": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
|
@ -8823,6 +8832,11 @@
|
||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safari-14-idb-fix": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog=="
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
|
@ -15431,6 +15445,14 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
|
"idb-keyval": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==",
|
||||||
|
"requires": {
|
||||||
|
"safari-14-idb-fix": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"ieee754": {
|
"ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
|
@ -17733,6 +17755,11 @@
|
||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"safari-14-idb-fix": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog=="
|
||||||
|
},
|
||||||
"safe-buffer": {
|
"safe-buffer": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"homepage": "https://graphite.rs",
|
"homepage": "https://graphite.rs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-transformer": "^0.5.0",
|
"class-transformer": "^0.5.0",
|
||||||
|
"idb-keyval": "^6.2.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"vue": "^3.2.26"
|
"vue": "^3.2.26"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,7 @@ export default defineComponent({
|
||||||
createInputManager: createInputManager(this.editor, this.$el.parentElement, this.dialog, this.portfolio, this.fullscreen),
|
createInputManager: createInputManager(this.editor, this.$el.parentElement, this.dialog, this.portfolio, this.fullscreen),
|
||||||
createLocalizationManager: createLocalizationManager(this.editor),
|
createLocalizationManager: createLocalizationManager(this.editor),
|
||||||
createPanicManager: createPanicManager(this.editor, this.dialog),
|
createPanicManager: createPanicManager(this.editor, this.dialog),
|
||||||
createPersistenceManager: await createPersistenceManager(this.editor, this.portfolio),
|
createPersistenceManager: createPersistenceManager(this.editor, this.portfolio),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize certain setup tasks required by the editor backend to be ready for the user now that the frontend is ready
|
// Initialize certain setup tasks required by the editor backend to be ready for the user now that the frontend is ready
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
|
||||||
|
|
||||||
function onBeforeUnload(e: BeforeUnloadEvent): void {
|
function onBeforeUnload(e: BeforeUnloadEvent): void {
|
||||||
const activeDocument = document.state.documents[document.state.activeDocumentIndex];
|
const activeDocument = document.state.documents[document.state.activeDocumentIndex];
|
||||||
if (!activeDocument.isSaved) editor.instance.triggerAutoSave(activeDocument.id);
|
if (activeDocument && !activeDocument.isAutoSaved) editor.instance.triggerAutoSave(activeDocument.id);
|
||||||
|
|
||||||
// Skip the message if the editor crashed, since work is already lost
|
// Skip the message if the editor crashed, since work is already lost
|
||||||
if (editor.instance.hasCrashed()) return;
|
if (editor.instance.hasCrashed()) return;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { wipeDocuments } from "@/io-managers/persistence";
|
||||||
import { type DialogState } from "@/state-providers/dialog";
|
import { type DialogState } from "@/state-providers/dialog";
|
||||||
import { type IconName } from "@/utility-functions/icons";
|
import { type IconName } from "@/utility-functions/icons";
|
||||||
import { browserVersion, operatingSystem } from "@/utility-functions/platform";
|
import { browserVersion, operatingSystem } from "@/utility-functions/platform";
|
||||||
|
|
@ -43,7 +44,14 @@ function preparePanicDialog(header: string, details: string, panicDetails: strin
|
||||||
callback: async () => window.open(githubUrl(panicDetails), "_blank"),
|
callback: async () => window.open(githubUrl(panicDetails), "_blank"),
|
||||||
props: { kind: "TextButton", label: "Report Bug", emphasized: false, minWidth: 96 },
|
props: { kind: "TextButton", label: "Report Bug", emphasized: false, minWidth: 96 },
|
||||||
};
|
};
|
||||||
const jsCallbackBasedButtons = [reloadButton, copyErrorLogButton, reportOnGithubButton];
|
const clearPersistedDataButton: TextButtonWidget = {
|
||||||
|
callback: async () => {
|
||||||
|
await wipeDocuments();
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
props: { kind: "TextButton", label: "Clear Saved Data", emphasized: false, minWidth: 96 },
|
||||||
|
};
|
||||||
|
const jsCallbackBasedButtons = [reloadButton, copyErrorLogButton, reportOnGithubButton, clearPersistedDataButton];
|
||||||
|
|
||||||
return ["Warning", widgets, jsCallbackBasedButtons];
|
return ["Warning", widgets, jsCallbackBasedButtons];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,155 +1,100 @@
|
||||||
|
import { createStore, del, get, set, update } from "idb-keyval";
|
||||||
|
|
||||||
import { type PortfolioState } from "@/state-providers/portfolio";
|
import { type PortfolioState } from "@/state-providers/portfolio";
|
||||||
import { stripIndents } from "@/utility-functions/strip-indents";
|
|
||||||
import { type Editor } from "@/wasm-communication/editor";
|
import { type Editor } from "@/wasm-communication/editor";
|
||||||
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument, TriggerSavePreferences, TriggerLoadAutoSaveDocuments, TriggerLoadPreferences } from "@/wasm-communication/messages";
|
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument, TriggerSavePreferences, TriggerLoadAutoSaveDocuments, TriggerLoadPreferences } from "@/wasm-communication/messages";
|
||||||
|
|
||||||
const GRAPHITE_INDEXED_DB_VERSION = 2;
|
const graphiteStore = createStore("graphite", "store");
|
||||||
const GRAPHITE_INDEXED_DB_NAME = "graphite-indexed-db";
|
|
||||||
|
|
||||||
const GRAPHITE_AUTO_SAVE_STORE = { name: "auto-save-documents", keyPath: "details.id" };
|
export function createPersistenceManager(editor: Editor, portfolio: PortfolioState): void {
|
||||||
const GRAPHITE_EDITOR_PREFERENCES_STORE = { name: "editor-preferences", keyPath: "key" };
|
// DOCUMENTS
|
||||||
|
|
||||||
const GRAPHITE_INDEXEDDB_STORES = [GRAPHITE_AUTO_SAVE_STORE, GRAPHITE_EDITOR_PREFERENCES_STORE];
|
async function storeDocumentOrder(): Promise<void> {
|
||||||
|
const documentOrder = portfolio.state.documents.map((doc) => String(doc.id));
|
||||||
|
|
||||||
const GRAPHITE_AUTO_SAVE_ORDER_KEY = "auto-save-documents-order";
|
await set("documents_tab_order", documentOrder, graphiteStore);
|
||||||
|
}
|
||||||
|
|
||||||
export function createPersistenceManager(editor: Editor, portfolio: PortfolioState): () => void {
|
async function storeDocument(autoSaveDocument: TriggerIndexedDbWriteDocument): Promise<void> {
|
||||||
async function initialize(): Promise<IDBDatabase> {
|
await update<Record<string, TriggerIndexedDbWriteDocument>>(
|
||||||
// Open the IndexedDB database connection and save it to this variable, which is a promise that resolves once the connection is open
|
"documents",
|
||||||
return new Promise<IDBDatabase>((resolve) => {
|
(old) => {
|
||||||
const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION);
|
const documents = old || {};
|
||||||
|
documents[autoSaveDocument.details.id] = autoSaveDocument;
|
||||||
|
return documents;
|
||||||
|
},
|
||||||
|
graphiteStore
|
||||||
|
);
|
||||||
|
|
||||||
// Handle a version mismatch if `GRAPHITE_INDEXED_DB_VERSION` is now higher than what was saved in the database
|
await storeDocumentOrder();
|
||||||
dbOpenRequest.onupgradeneeded = (): void => {
|
}
|
||||||
const db = dbOpenRequest.result;
|
|
||||||
|
|
||||||
// Wipe out all stores when a request is made to upgrade the database version to a newer one
|
async function removeDocument(id: string): Promise<void> {
|
||||||
GRAPHITE_INDEXEDDB_STORES.forEach((store) => {
|
await update<Record<string, TriggerIndexedDbWriteDocument>>(
|
||||||
if (db.objectStoreNames.contains(store.name)) db.deleteObjectStore(store.name);
|
"documents",
|
||||||
|
(old) => {
|
||||||
|
const documents = old || {};
|
||||||
|
delete documents[id];
|
||||||
|
return documents;
|
||||||
|
},
|
||||||
|
graphiteStore
|
||||||
|
);
|
||||||
|
|
||||||
db.createObjectStore(store.name, { keyPath: store.keyPath });
|
await storeDocumentOrder();
|
||||||
});
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Handle some other error by presenting it to the user
|
async function loadDocuments(): Promise<void> {
|
||||||
dbOpenRequest.onerror = (): void => {
|
const previouslySavedDocuments = await get<Record<string, TriggerIndexedDbWriteDocument>>("documents", graphiteStore);
|
||||||
const errorText = stripIndents`
|
const documentOrder = await get<string[]>("documents_tab_order", graphiteStore);
|
||||||
Documents won't be saved across reloads and later visits.
|
if (!previouslySavedDocuments || !documentOrder) return;
|
||||||
This may be caused by Firefox's private browsing mode.
|
|
||||||
|
|
||||||
Error on opening IndexDB:
|
|
||||||
${dbOpenRequest.error}
|
|
||||||
`;
|
|
||||||
editor.instance.errorDialog("Document auto-save doesn't work in this browser", errorText);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resolve the promise on a successful opening of the database connection
|
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
|
||||||
dbOpenRequest.onsuccess = (): void => {
|
|
||||||
resolve(dbOpenRequest.result);
|
const currentDocumentVersion = editor.instance.graphiteDocumentVersion();
|
||||||
};
|
orderedSavedDocuments?.forEach(async (doc: TriggerIndexedDbWriteDocument) => {
|
||||||
|
if (doc.version !== currentDocumentVersion) {
|
||||||
|
await removeDocument(doc.details.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.instance.openAutoSavedDocument(BigInt(doc.details.id), doc.details.name, doc.details.isSaved, doc.document);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function storeDocumentOrder(): void {
|
// PREFERENCES
|
||||||
// Make sure to store as string since JSON does not play nice with BigInt
|
|
||||||
const documentOrder = portfolio.state.documents.map((doc) => doc.id.toString());
|
async function savePreferences(preferences: TriggerSavePreferences["preferences"]): Promise<void> {
|
||||||
window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder));
|
await set("preferences", preferences, graphiteStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeDocument(id: string, db: IDBDatabase): Promise<void> {
|
async function loadPreferences(): Promise<void> {
|
||||||
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE.name, "readwrite");
|
const preferences = await get<Record<string, unknown>>("preferences", graphiteStore);
|
||||||
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE.name).delete(id);
|
if (!preferences) return;
|
||||||
storeDocumentOrder();
|
|
||||||
|
editor.instance.loadPreferences(JSON.stringify(preferences));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAutoSaveDocuments(db: IDBDatabase): Promise<void> {
|
// FRONTEND MESSAGE SUBSCRIPTIONS
|
||||||
let promiseResolve: (value: void | PromiseLike<void>) => void;
|
|
||||||
const promise = new Promise<void>((resolve): void => {
|
|
||||||
promiseResolve = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open auto-save documents
|
|
||||||
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE.name, "readonly");
|
|
||||||
const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE.name).getAll();
|
|
||||||
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
const previouslySavedDocuments: TriggerIndexedDbWriteDocument[] = 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 TriggerIndexedDbWriteDocument[];
|
|
||||||
|
|
||||||
const currentDocumentVersion = editor.instance.graphiteDocumentVersion();
|
|
||||||
orderedSavedDocuments.forEach(async (doc: TriggerIndexedDbWriteDocument) => {
|
|
||||||
if (doc.version === currentDocumentVersion) {
|
|
||||||
editor.instance.openAutoSavedDocument(BigInt(doc.details.id), doc.details.name, doc.details.isSaved, doc.document);
|
|
||||||
} else {
|
|
||||||
await removeDocument(doc.details.id, db);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
promiseResolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
await promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPreferences(db: IDBDatabase): Promise<void> {
|
|
||||||
let promiseResolve: (value: void | PromiseLike<void>) => void;
|
|
||||||
const promise = new Promise<void>((resolve): void => {
|
|
||||||
promiseResolve = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open auto-save documents
|
|
||||||
const transaction = db.transaction(GRAPHITE_EDITOR_PREFERENCES_STORE.name, "readonly");
|
|
||||||
const request = transaction.objectStore(GRAPHITE_EDITOR_PREFERENCES_STORE.name).getAll();
|
|
||||||
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
const preferenceEntries: { key: string; value: unknown }[] = request.result;
|
|
||||||
|
|
||||||
const preferences: Record<string, unknown> = {};
|
|
||||||
preferenceEntries.forEach(({ key, value }) => {
|
|
||||||
preferences[key] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.instance.loadPreferences(JSON.stringify(preferences));
|
|
||||||
|
|
||||||
promiseResolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
await promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to process backend events
|
// Subscribe to process backend events
|
||||||
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => {
|
|
||||||
const transaction = (await databaseConnection).transaction(GRAPHITE_AUTO_SAVE_STORE.name, "readwrite");
|
|
||||||
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE.name).put(autoSaveDocument);
|
|
||||||
|
|
||||||
storeDocumentOrder();
|
|
||||||
});
|
|
||||||
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => {
|
|
||||||
await removeDocument(removeAutoSaveDocument.documentId, await databaseConnection);
|
|
||||||
});
|
|
||||||
editor.subscriptions.subscribeJsMessage(TriggerLoadAutoSaveDocuments, async () => {
|
|
||||||
await loadAutoSaveDocuments(await databaseConnection);
|
|
||||||
});
|
|
||||||
editor.subscriptions.subscribeJsMessage(TriggerSavePreferences, async (preferences) => {
|
editor.subscriptions.subscribeJsMessage(TriggerSavePreferences, async (preferences) => {
|
||||||
Object.entries(preferences.preferences).forEach(async ([key, value]) => {
|
await savePreferences(preferences.preferences);
|
||||||
const storedObject = { key, value };
|
|
||||||
|
|
||||||
const transaction = (await databaseConnection).transaction(GRAPHITE_EDITOR_PREFERENCES_STORE.name, "readwrite");
|
|
||||||
transaction.objectStore(GRAPHITE_EDITOR_PREFERENCES_STORE.name).put(storedObject);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(TriggerLoadPreferences, async () => {
|
editor.subscriptions.subscribeJsMessage(TriggerLoadPreferences, async () => {
|
||||||
await loadPreferences(await databaseConnection);
|
await loadPreferences();
|
||||||
|
});
|
||||||
|
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => {
|
||||||
|
await storeDocument(autoSaveDocument);
|
||||||
|
});
|
||||||
|
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => {
|
||||||
|
await removeDocument(removeAutoSaveDocument.documentId);
|
||||||
|
});
|
||||||
|
editor.subscriptions.subscribeJsMessage(TriggerLoadAutoSaveDocuments, async () => {
|
||||||
|
await loadDocuments();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
const databaseConnection = initialize();
|
|
||||||
|
export async function wipeDocuments(): Promise<void> {
|
||||||
// Destructor
|
await del("documents_tab_order", graphiteStore);
|
||||||
return () => {
|
await del("documents", graphiteStore);
|
||||||
databaseConnection.then((connection) => connection.close());
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,7 @@ import { replaceBlobURLsWithBase64 } from "@/utility-functions/files";
|
||||||
|
|
||||||
// Rasterize the string of an SVG document at a given width and height and turn it into the blob data of an image file matching the given MIME type
|
// Rasterize the string of an SVG document at a given width and height and turn it into the blob data of an image file matching the given MIME type
|
||||||
export async function rasterizeSVGCanvas(svg: string, width: number, height: number, backgroundColor?: string): Promise<HTMLCanvasElement> {
|
export async function rasterizeSVGCanvas(svg: string, width: number, height: number, backgroundColor?: string): Promise<HTMLCanvasElement> {
|
||||||
let promiseResolve: (value: HTMLCanvasElement | PromiseLike<HTMLCanvasElement>) => void | undefined;
|
// A canvas to render our SVG to in order to get a raster image
|
||||||
const promise = new Promise<HTMLCanvasElement>((resolve) => {
|
|
||||||
promiseResolve = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
// A canvas to render our svg to in order to get a raster image
|
|
||||||
// https://stackoverflow.com/questions/3975499/convert-svg-to-image-jpeg-png-etc-in-the-browser
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
|
|
@ -25,38 +19,35 @@ export async function rasterizeSVGCanvas(svg: string, width: number, height: num
|
||||||
const svgWithBase64Images = await replaceBlobURLsWithBase64(svg);
|
const svgWithBase64Images = await replaceBlobURLsWithBase64(svg);
|
||||||
|
|
||||||
// Create a blob URL for our SVG
|
// Create a blob URL for our SVG
|
||||||
const image = new Image();
|
|
||||||
const svgBlob = new Blob([svgWithBase64Images], { type: "image/svg+xml;charset=utf-8" });
|
const svgBlob = new Blob([svgWithBase64Images], { type: "image/svg+xml;charset=utf-8" });
|
||||||
const url = URL.createObjectURL(svgBlob);
|
const url = URL.createObjectURL(svgBlob);
|
||||||
image.onload = (): void => {
|
|
||||||
// Draw our SVG to the canvas
|
|
||||||
context?.drawImage(image, 0, 0, width, height);
|
|
||||||
|
|
||||||
// Clean up the SVG blob URL (once the URL is revoked, the SVG blob data itself is garbage collected after `svgBlob` goes out of scope)
|
const image = new Image();
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
promiseResolve(canvas);
|
|
||||||
};
|
|
||||||
image.src = url;
|
image.src = url;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
image.onload = (): void => resolve();
|
||||||
|
});
|
||||||
|
|
||||||
return promise;
|
// Draw our SVG to the canvas
|
||||||
|
context?.drawImage(image, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Clean up the SVG blob URL (once the URL is revoked, the SVG blob data itself is garbage collected after `svgBlob` goes out of scope)
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise<Blob> {
|
export async function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise<Blob> {
|
||||||
let promiseResolve: (value: Blob | PromiseLike<Blob>) => void | undefined;
|
const canvas = await rasterizeSVGCanvas(svg, width, height, backgroundColor);
|
||||||
let promiseReject: () => void | undefined;
|
|
||||||
const promise = new Promise<Blob>((resolve, reject) => {
|
|
||||||
promiseResolve = resolve;
|
|
||||||
promiseReject = reject;
|
|
||||||
});
|
|
||||||
|
|
||||||
rasterizeSVGCanvas(svg, width, height, backgroundColor).then((canvas) => {
|
// Convert the canvas to an image of the correct MIME type
|
||||||
// Convert the canvas to an image of the correct MIME type
|
const blob = await new Promise<Blob | undefined>((resolve) => {
|
||||||
canvas.toBlob((blob) => {
|
canvas.toBlob((blob) => {
|
||||||
if (blob !== null) promiseResolve(blob);
|
resolve(blob || undefined);
|
||||||
else promiseReject();
|
|
||||||
}, mime);
|
}, mime);
|
||||||
});
|
});
|
||||||
|
|
||||||
return promise;
|
if (!blob) throw new Error("Converting canvas to blob data failed in rasterizeSVG()");
|
||||||
|
|
||||||
|
return blob;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ export class UpdateOpenDocumentsList extends JsMessage {
|
||||||
export abstract class DocumentDetails {
|
export abstract class DocumentDetails {
|
||||||
readonly name!: string;
|
readonly name!: string;
|
||||||
|
|
||||||
|
readonly isAutoSaved!: boolean;
|
||||||
|
|
||||||
readonly isSaved!: boolean;
|
readonly isSaved!: boolean;
|
||||||
|
|
||||||
readonly id!: bigint | string;
|
readonly id!: bigint | string;
|
||||||
|
|
@ -50,6 +52,11 @@ export class FrontendDocumentDetails extends DocumentDetails {
|
||||||
readonly id!: bigint;
|
readonly id!: bigint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class IndexedDbDocumentDetails extends DocumentDetails {
|
||||||
|
@Transform(({ value }: { value: bigint }) => value.toString())
|
||||||
|
id!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class TriggerIndexedDbWriteDocument extends JsMessage {
|
export class TriggerIndexedDbWriteDocument extends JsMessage {
|
||||||
document!: string;
|
document!: string;
|
||||||
|
|
||||||
|
|
@ -59,11 +66,6 @@ export class TriggerIndexedDbWriteDocument extends JsMessage {
|
||||||
version!: string;
|
version!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IndexedDbDocumentDetails extends DocumentDetails {
|
|
||||||
@Transform(({ value }: { value: bigint }) => value.toString())
|
|
||||||
id!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TriggerIndexedDbRemoveDocument extends JsMessage {
|
export class TriggerIndexedDbRemoveDocument extends JsMessage {
|
||||||
// Use a string since IndexedDB can not use BigInts for keys
|
// Use a string since IndexedDB can not use BigInts for keys
|
||||||
@Transform(({ value }: { value: bigint }) => value.toString())
|
@Transform(({ value }: { value: bigint }) => value.toString())
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,7 @@ impl JsEditorHandle {
|
||||||
let message = PortfolioMessage::OpenDocumentFileWithId {
|
let message = PortfolioMessage::OpenDocumentFileWithId {
|
||||||
document_id,
|
document_id,
|
||||||
document_name,
|
document_name,
|
||||||
|
document_is_auto_saved: true,
|
||||||
document_is_saved,
|
document_is_saved,
|
||||||
document_serialized_content,
|
document_serialized_content,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@ use wasm_bindgen::prelude::*;
|
||||||
/// When a panic occurs, notify the user and log the error to the JS console before the backend dies
|
/// When a panic occurs, notify the user and log the error to the JS console before the backend dies
|
||||||
pub fn panic_hook(info: &panic::PanicInfo) {
|
pub fn panic_hook(info: &panic::PanicInfo) {
|
||||||
let header = "The editor crashed — sorry about that";
|
let header = "The editor crashed — sorry about that";
|
||||||
let description = "An internal error occurred. Reload the editor to continue. Please report this by filing an issue on GitHub.";
|
let description = "
|
||||||
|
An internal error occurred. Please report this by filing an issue on GitHub.\n\
|
||||||
|
\n\
|
||||||
|
Reload the editor to continue. If this happens immediately on repeated reloads, clear saved data.
|
||||||
|
"
|
||||||
|
.trim();
|
||||||
|
|
||||||
error!("{}", info);
|
error!("{}", info);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue