mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-16 23:08:05 +08:00
Implement IndexedDB document auto-save (#422)
* removed all use of document indicies * -add u64 support for wasm bridge * fixed rust formating * Cleaned up FrontendDocumentState in js-messages * Tiny tweaks from code review * - moved more of closeDocumentWithConfirmation to rust - updated serde_wasm_bindgen to add feature flag * working initial auto save impl * auto save is a lifetime file * - cargo fmt - fixc error message - move document version constant * code review round 1 * generate seed for uuid in js when wasm is initialized * Resolve PR feedback * Further address PR feedback * Fix failing test Co-authored-by: Keavon Chambers <keavon@keavon.com> Co-authored-by: otdavies <oliver@psyfer.io>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, MessageMaker> = {
|
||||
DisplayConfirmationToCloseDocument,
|
||||
DisplayConfirmationToCloseAllDocuments,
|
||||
DisplayAboutGraphiteDialog,
|
||||
AutoSaveDocument,
|
||||
RemoveAutoSaveDocument,
|
||||
} as const;
|
||||
export type JsMessageType = keyof typeof messageConstructors;
|
||||
|
||||
77
frontend/src/lifetime/auto-save.ts
Normal file
77
frontend/src/lifetime/auto-save.ts
Normal file
@@ -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<IDBDatabase> = 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<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user