Port frontend from Vue to Svelte (WIP in separate folder, many bugs) (#964)

* Make `tauri build` just work

* Move folder: frontend/wasm -> wasm

* Create SvelteKit project with 'npx create-svelte'

* Move wasm-communication into seperate npm package

* Use wasm-pack directly and pack package.json

* Got it to work

* Add primitive build script for wasm npm module

* Fix wasm build script (python)

* Clean up glue code

* Rewrite wasm build script in node.js

* Add serde-reflection to trace types

* Add traced types

* Generate typescript (.d.ts) from Rust types

* Update .d.ts

* Finalize TS types

* Add script to update .d.ts

* Add watch command to build wasm-bindgen

* Make wasm work again

* Add sass; fix build script for windows

* Describe requirement for wasm-pack

* Add license

* Copy and reorganize vue components

* translate LayoutCol.vue

* Split app.scss into pieces

* Translate LayoutRow.svelte

* Rename scss files

* Fix compile issues on Windows

* WIP port TitleBar

* Support classes for LayoutCol/Row

* Restructure based on Vue codebase

* Port all components in window folder

* Port FloatingMenu

* Port Document panel component

* Update readme after folder move

* Update typegen: print discriminant by default

* Update typegen: Merge from branch 'tailwind' 4f14fedb

Fixes bigint & bytes

* Made Vue/webpack/eslint to accept wasm package at new location

This is quite a hack.

Those two packages are both named the same. Yes, it's an npm package inside another npm package.

- frontend/src/wasm-communication/
- frontend-svelte/glue/

'wasm/pkg/index.js' imports the correct one registered when linking.

* Port LayerTree

* Port NodeGraph

* Port Properties

* Port components in /floating-menus

* Finish porting all Vue -> Svelte components

* Change import prefix

* Revert type generation

* Revert moved wasm folder

* Revert all of @locriacyber's work on this branch

- Remove Vite and restore Webpack
- Remove SvelteKit
- Remove everything except the components I ported to Svelte
- Restore all frontend files from Vue code, now altered for Svelte

* Convert Vue's 'reactive' and 'provide' to Svelte's stores and contexts

* Fix event emitting and bi-di data flow

* Undo removal of 'update:' events

* Fix 'update:' event dispatching

* Fix usage in parent of bi-di component props

* Fix component typing, more progress towards no errors

* The page builds and opens!

* Add loading spinner and remove postcss dependency

* Make the basics of document editing work

* Fix rebase history

Co-authored-by: Locria Cyber <74560659+locriacyber@users.noreply.github.com>
This commit is contained in:
Keavon Chambers
2023-01-13 01:05:20 -08:00
co-authored by Locria Cyber
parent 2f7c45771e
commit dcc3eadf44
239 changed files with 27757 additions and 13 deletions
@@ -0,0 +1,10 @@
import { type Editor } from "@/wasm-communication/editor";
import { TriggerTextCopy } from "@/wasm-communication/messages";
export function createClipboardManager(editor: Editor): void {
// Subscribe to process backend event
editor.subscriptions.subscribeJsMessage(TriggerTextCopy, (triggerTextCopy) => {
// If the Clipboard API is supported in the browser, copy text to the clipboard
navigator.clipboard?.writeText?.(triggerTextCopy.copyText);
});
}
+26
View File
@@ -0,0 +1,26 @@
let draggingElement: HTMLElement | undefined;
export function createDragManager(): () => void {
const clearDraggingElement = (): void => {
draggingElement = undefined;
};
// Add the event listener
document.addEventListener("drop", clearDraggingElement);
// Return the destructor
return () => {
// We use setTimeout to sequence this drop after any potential users in the current call stack progression, since this will begin in an entirely new call stack later
setTimeout(() => {
document.removeEventListener("drop", clearDraggingElement);
}, 0);
};
}
export function beginDraggingElement(element: HTMLElement): void {
draggingElement = element;
}
export function currentDraggingElement(): HTMLElement | undefined {
return draggingElement;
}
@@ -0,0 +1,9 @@
import { type Editor } from "@/wasm-communication/editor";
import { TriggerVisitLink } from "@/wasm-communication/messages";
export function createHyperlinkManager(editor: Editor): void {
// Subscribe to process backend event
editor.subscriptions.subscribeJsMessage(TriggerVisitLink, async (triggerOpenLink) => {
window.open(triggerOpenLink.url, "_blank");
});
}
+365
View File
@@ -0,0 +1,365 @@
import { get } from "svelte/store";
import { type DialogState } from "@/state-providers/dialog";
import { type FullscreenState } from "@/state-providers/fullscreen";
import { type PortfolioState } from "@/state-providers/portfolio";
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "@/utility-functions/keyboard-entry";
import { platformIsMac } from "@/utility-functions/platform";
import { stripIndents } from "@/utility-functions/strip-indents";
import { type Editor } from "@/wasm-communication/editor";
import { TriggerPaste } from "@/wasm-communication/messages";
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
type EventListenerTarget = {
addEventListener: typeof window.addEventListener;
removeEventListener: typeof window.removeEventListener;
};
export function createInputManager(editor: Editor, dialog: DialogState, document: PortfolioState, fullscreen: FullscreenState): () => void {
window.document.body.focus();
let viewportPointerInteractionOngoing = false;
let textInput = undefined as undefined | HTMLDivElement;
let canvasFocused = true;
function blurApp(): void {
canvasFocused = false;
}
// Event listeners
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: boolean | AddEventListenerOptions }[] = [
{ target: window, eventName: "resize", action: (): void => onWindowResize(window.document.body) },
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent): Promise<void> => onBeforeUnload(e) },
{ target: window.document, eventName: "contextmenu", action: (e: MouseEvent): void => e.preventDefault() },
{ target: window.document, eventName: "fullscreenchange", action: (): void => fullscreen.fullscreenModeChanged() },
{ target: window, eventName: "keyup", action: (e: KeyboardEvent): Promise<void> => onKeyUp(e) },
{ target: window, eventName: "keydown", action: (e: KeyboardEvent): Promise<void> => onKeyDown(e) },
{ target: window, eventName: "pointermove", action: (e: PointerEvent): void => onPointerMove(e) },
{ target: window, eventName: "pointerdown", action: (e: PointerEvent): void => onPointerDown(e) },
{ target: window, eventName: "pointerup", action: (e: PointerEvent): void => onPointerUp(e) },
{ target: window, eventName: "dblclick", action: (e: PointerEvent): void => onDoubleClick(e) },
{ target: window, eventName: "mousedown", action: (e: MouseEvent): void => onMouseDown(e) },
{ target: window, eventName: "wheel", action: (e: WheelEvent): void => onWheelScroll(e), options: { passive: false } },
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent): void => onModifyInputField(e) },
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent): void => onPaste(e) },
{ target: window.document.body, eventName: "blur", action: (): void => blurApp() }, // TODO: Svelte: check if this works with the new target of `body`
];
// Event bindings
function bindListeners(): void {
// Add event bindings for the lifetime of the application
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options));
}
function unbindListeners(): void {
// Remove event bindings after the lifetime of the application (or on hot-module replacement during development)
listeners.forEach(({ target, eventName, action, options }) => target.removeEventListener(eventName, action, options));
}
// Keyboard events
async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): Promise<boolean> {
// Don't redirect when a modal is covering the workspace
if (get(dialog).visible) return false;
const key = await getLocalizedScanCode(e);
// TODO: Switch to a system where everything is sent to the backend, then the input preprocessor makes decisions and kicks some inputs back to the frontend
const accelKey = platformIsMac() ? e.metaKey : e.ctrlKey;
// Don't redirect user input from text entry into HTML elements
if (targetIsTextField(e.target || undefined) && key !== "Escape" && !(key === "Enter" && accelKey)) return false;
// Don't redirect paste
if (key === "KeyV" && accelKey) return false;
// Don't redirect a fullscreen request
if (key === "F11" && e.type === "keydown" && !e.repeat) {
e.preventDefault();
fullscreen.toggleFullscreen();
return false;
}
// Don't redirect a reload request
if (key === "F5") return false;
if (key === "KeyR" && accelKey) return false;
// Don't redirect debugging tools
if (["F12", "F8"].includes(key)) return false;
if (["KeyC", "KeyI", "KeyJ"].includes(key) && accelKey && e.shiftKey) return false;
// Don't redirect tab or enter if not in canvas (to allow navigating elements)
if (!canvasFocused && !targetIsTextField(e.target || undefined) && ["Tab", "Enter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key)) return false;
// Redirect to the backend
return true;
}
async function onKeyDown(e: KeyboardEvent): Promise<void> {
const key = await getLocalizedScanCode(e);
const NO_KEY_REPEAT_MODIFIER_KEYS = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "MetaLeft", "MetaRight", "AltLeft", "AltRight", "AltGraph", "CapsLock", "Fn", "FnLock"];
if (e.repeat && NO_KEY_REPEAT_MODIFIER_KEYS.includes(key)) return;
if (await shouldRedirectKeyboardEventToBackend(e)) {
e.preventDefault();
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.onKeyDown(key, modifiers);
return;
}
if (get(dialog).visible && key === "Escape") {
dialog.dismissDialog();
}
}
async function onKeyUp(e: KeyboardEvent): Promise<void> {
const key = await getLocalizedScanCode(e);
if (await shouldRedirectKeyboardEventToBackend(e)) {
e.preventDefault();
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.onKeyUp(key, modifiers);
}
}
// Pointer events
// While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend
function onPointerMove(e: PointerEvent): void {
if (!e.buttons) viewportPointerInteractionOngoing = false;
// Don't redirect pointer movement to the backend if there's no ongoing interaction and it's over a floating menu on top of the canvas
// TODO: A better approach is to pass along a boolean to the backend's input preprocessor so it can know if it's being occluded by the GUI.
// TODO: This would allow it to properly decide to act on removing hover focus from something that was hovered in the canvas before moving over the GUI.
// TODO: Further explanation: https://github.com/GraphiteEditor/Graphite/pull/623#discussion_r866436197
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
if (!viewportPointerInteractionOngoing && inFloatingMenu) return;
const { target } = e;
const newInCanvas = (target instanceof Element && target.closest("[data-canvas]")) instanceof Element && !targetIsTextField(window.document.activeElement || undefined);
if (newInCanvas && !canvasFocused) {
canvasFocused = true;
window.document.body.focus();
}
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers);
}
function onPointerDown(e: PointerEvent): void {
const { target } = e;
const isTargetingCanvas = target instanceof Element && target.closest("[data-canvas]");
const inDialog = target instanceof Element && target.closest("[data-dialog-modal] [data-floating-menu-content]");
const inTextInput = target === textInput;
if (get(dialog).visible && !inDialog) {
dialog.dismissDialog();
e.preventDefault();
e.stopPropagation();
}
if (!inTextInput) {
if (textInput) editor.instance.onChangeText(textInputCleanup(textInput.innerText));
else viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element;
}
if (viewportPointerInteractionOngoing) {
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers);
}
}
function onPointerUp(e: PointerEvent): void {
if (!e.buttons) viewportPointerInteractionOngoing = false;
if (!textInput) {
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers);
}
}
function onDoubleClick(e: PointerEvent): void {
if (!e.buttons) viewportPointerInteractionOngoing = false;
if (!textInput) {
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.onDoubleClick(e.clientX, e.clientY, e.buttons, modifiers);
}
}
// Mouse events
function onMouseDown(e: MouseEvent): void {
// Block middle mouse button auto-scroll mode (the circlar widget that appears and allows quick scrolling by moving the cursor above or below it)
// This has to be in `mousedown`, not `pointerdown`, to avoid blocking Vue's middle click detection on HTML elements
if (e.button === 1) e.preventDefault();
}
function onWheelScroll(e: WheelEvent): void {
const { target } = e;
const isTargetingCanvas = target instanceof Element && target.closest("[data-canvas]");
// Redirect vertical scroll wheel movement into a horizontal scroll on a horizontally scrollable element
// There seems to be no possible way to properly employ the browser's smooth scrolling interpolation
const horizontalScrollableElement = target instanceof Element && target.closest("[data-scrollable-x]");
if (horizontalScrollableElement && e.deltaY !== 0) {
horizontalScrollableElement.scrollTo(horizontalScrollableElement.scrollLeft + e.deltaY, 0);
return;
}
if (isTargetingCanvas) {
e.preventDefault();
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.onWheelScroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
}
}
function onModifyInputField(e: CustomEvent): void {
textInput = e.detail;
}
// Window events
function onWindowResize(container: HTMLElement): void {
const viewports = Array.from(container.querySelectorAll("[data-canvas]"));
const boundsOfViewports = viewports.map((canvas) => {
const bounds = canvas.getBoundingClientRect();
return [bounds.left, bounds.top, bounds.right, bounds.bottom];
});
const flattened = boundsOfViewports.flat();
const data = Float64Array.from(flattened);
if (boundsOfViewports.length > 0) editor.instance.boundsOfViewports(data);
}
async function onBeforeUnload(e: BeforeUnloadEvent): Promise<void> {
const activeDocument = get(document).documents[get(document).activeDocumentIndex];
if (activeDocument && !activeDocument.isAutoSaved) editor.instance.triggerAutoSave(activeDocument.id);
// Skip the message if the editor crashed, since work is already lost
if (await editor.instance.hasCrashed()) return;
// Skip the message during development, since it's annoying when testing
if (await editor.instance.inDevelopmentMode()) return;
const allDocumentsSaved = get(document).documents.reduce((acc, doc) => acc && doc.isSaved, true);
if (!allDocumentsSaved) {
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
e.preventDefault();
}
}
function onPaste(e: ClipboardEvent): void {
const dataTransfer = e.clipboardData;
if (!dataTransfer || targetIsTextField(e.target || undefined)) return;
e.preventDefault();
Array.from(dataTransfer.items).forEach((item) => {
if (item.type === "text/plain") {
item.getAsString((text) => {
if (text.startsWith("graphite/layer: ")) {
editor.instance.pasteSerializedData(text.substring(16, text.length));
} else if (text.startsWith("graphite/nodes: ")) {
editor.instance.pasteSerializedNodes(text.substring(16, text.length));
}
});
}
const file = item.getAsFile();
if (file?.type.startsWith("image")) {
file.arrayBuffer().then((buffer): void => {
const u8Array = new Uint8Array(buffer);
editor.instance.pasteImage(file.type, u8Array);
});
}
});
}
// Frontend message subscriptions
editor.subscriptions.subscribeJsMessage(TriggerPaste, async () => {
// In the try block, attempt to read from the Clipboard API, which may not have permission and may not be supported in all browsers
// In the catch block, explain to the user why the paste failed and how to fix or work around the problem
try {
// Attempt to check if the clipboard permission is denied, and throw an error if that is the case
// In Firefox, the `clipboard-read` permission isn't supported, so attempting to query it throws an error
// In Safari, the entire Permissions API isn't supported, so the query never occurs and this block is skipped without an error and we assume we might have permission
const clipboardRead = "clipboard-read" as PermissionName;
const permission = await navigator.permissions?.query({ name: clipboardRead });
if (permission?.state === "denied") throw new Error("Permission denied");
// Read the clipboard contents if the Clipboard API is available
const clipboardItems = await navigator.clipboard.read();
if (!clipboardItems) throw new Error("Clipboard API unsupported");
// Read any layer data or images from the clipboard
Array.from(clipboardItems).forEach(async (item) => {
// Read plain text and, if it is a layer, pass it to the editor
if (item.types.includes("text/plain")) {
const blob = await item.getType("text/plain");
const reader = new FileReader();
reader.onload = (): void => {
const text = reader.result as string;
if (text.startsWith("graphite/layer: ")) {
editor.instance.pasteSerializedData(text.substring(16, text.length));
}
};
reader.readAsText(blob);
}
// Read an image from the clipboard and pass it to the editor to be loaded
const imageType = item.types.find((type) => type.startsWith("image/"));
if (imageType) {
const blob = await item.getType(imageType);
const reader = new FileReader();
reader.onload = (): void => {
const u8Array = new Uint8Array(reader.result as ArrayBuffer);
editor.instance.pasteImage(imageType, u8Array);
};
reader.readAsArrayBuffer(blob);
}
});
} catch (err) {
const unsupported = stripIndents`
This browser does not support reading from the clipboard.
Use the keyboard shortcut to paste instead.
`;
const denied = stripIndents`
The browser's clipboard permission has been denied.
Open the browser's website settings (usually accessible
just left of the URL) to allow this permission.
`;
const matchMessage = {
"clipboard-read": unsupported,
"Clipboard API unsupported": unsupported,
"Permission denied": denied,
};
const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err);
editor.instance.errorDialog("Cannot access clipboard", message);
}
});
// Initialization
// Bind the event listeners
bindListeners();
// Resize on creation
onWindowResize(window.document.body);
// Return the destructor
return unbindListeners;
}
function targetIsTextField(target: EventTarget | HTMLElement | undefined): boolean {
return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable);
}
@@ -0,0 +1,25 @@
import { type Editor } from "@/wasm-communication/editor";
import { TriggerAboutGraphiteLocalizedCommitDate } from "@/wasm-communication/messages";
export function createLocalizationManager(editor: Editor): void {
function localizeTimestamp(utc: string): string {
// Timestamp
const date = new Date(utc);
if (Number.isNaN(date.getTime())) return utc;
const timezoneName = Intl.DateTimeFormat(undefined, { timeZoneName: "long" })
.formatToParts(new Date())
.find((part) => part.type === "timeZoneName");
const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const timeString = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
const timezoneNameString = timezoneName?.value;
return `${dateString} ${timeString} ${timezoneNameString}`;
}
// Subscribe to process backend event
editor.subscriptions.subscribeJsMessage(TriggerAboutGraphiteLocalizedCommitDate, (triggerAboutGraphiteLocalizedCommitDate) => {
const localized = localizeTimestamp(triggerAboutGraphiteLocalizedCommitDate.commitDate);
editor.instance.requestAboutGraphiteDialogWithLocalizedCommitDate(localized);
});
}
+104
View File
@@ -0,0 +1,104 @@
import { wipeDocuments } from "@/io-managers/persistence";
import { type DialogState } from "@/state-providers/dialog";
import { type IconName } from "@/utility-functions/icons";
import { browserVersion, operatingSystem } from "@/utility-functions/platform";
import { stripIndents } from "@/utility-functions/strip-indents";
import { type Editor } from "@/wasm-communication/editor";
import type { TextLabel } from "@/wasm-communication/messages";
import { type TextButtonWidget, type WidgetLayout, Widget, DisplayDialogPanic } from "@/wasm-communication/messages";
export function createPanicManager(editor: Editor, dialogState: DialogState): void {
// Code panic dialog and console error
editor.subscriptions.subscribeJsMessage(DisplayDialogPanic, (displayDialogPanic) => {
// `Error.stackTraceLimit` is only available in V8/Chromium
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Error as any).stackTraceLimit = Infinity;
const stackTrace = new Error().stack || "";
const panicDetails = `${displayDialogPanic.panicInfo}${stackTrace ? `\n\n${stackTrace}` : ""}`;
// eslint-disable-next-line no-console
console.error(panicDetails);
const panicDialog = preparePanicDialog(displayDialogPanic.header, displayDialogPanic.description, panicDetails);
dialogState.createPanicDialog(...panicDialog);
});
}
function preparePanicDialog(header: string, details: string, panicDetails: string): [IconName, WidgetLayout, TextButtonWidget[]] {
const headerLabel: TextLabel = { kind: "TextLabel", value: header, disabled: false, bold: true, italic: false, tableAlign: false, minWidth: 0, multiline: false, tooltip: "" };
const detailsLabel: TextLabel = { kind: "TextLabel", value: details, disabled: false, bold: false, italic: false, tableAlign: false, minWidth: 0, multiline: true, tooltip: "" };
const widgets: WidgetLayout = {
layout: [{ rowWidgets: [new Widget(headerLabel, 0n)] }, { rowWidgets: [new Widget(detailsLabel, 1n)] }],
layoutTarget: undefined,
};
const reloadButton: TextButtonWidget = {
callback: async () => window.location.reload(),
props: { kind: "TextButton", label: "Reload", emphasized: true, minWidth: 96 },
};
const copyErrorLogButton: TextButtonWidget = {
callback: async () => navigator.clipboard.writeText(panicDetails),
props: { kind: "TextButton", label: "Copy Error Log", emphasized: false, minWidth: 96 },
};
const reportOnGithubButton: TextButtonWidget = {
callback: async () => window.open(githubUrl(panicDetails), "_blank"),
props: { kind: "TextButton", label: "Report Bug", emphasized: false, minWidth: 96 },
};
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];
}
function githubUrl(panicDetails: string): string {
const url = new URL("https://github.com/GraphiteEditor/Graphite/issues/new");
let body = stripIndents`
**Describe the Crash**
Explain clearly what you were doing when the crash occurred.
**Steps To Reproduce**
Describe precisely how the crash occurred, step by step, starting with a new editor window.
1. Open the Graphite Editor at https://editor.graphite.rs
2.
3.
4.
5.
**Additional Details**
Provide any further information or context that you think would be helpful in fixing the issue. Screenshots or video can be linked or attached to this issue.
**Browser and OS**
${browserVersion()}, ${operatingSystem(true).replace("Unknown", "YOUR OPERATING SYSTEM")}
**Stack Trace**
Copied from the crash dialog in the Graphite Editor:
`;
body += "\n\n```\n";
body += panicDetails.trimEnd();
body += "\n```";
const fields = {
title: "[Crash Report] ",
body,
labels: ["Crash"].join(","),
projects: [].join(","),
milestone: "",
assignee: "",
template: "",
};
Object.entries(fields).forEach(([field, value]) => {
if (value) url.searchParams.set(field, value);
});
return url.toString();
}
@@ -0,0 +1,101 @@
import { createStore, del, get, set, update } from "idb-keyval";
import { get as getFromStore } from "svelte/store";
import { type PortfolioState } from "@/state-providers/portfolio";
import { type Editor } from "@/wasm-communication/editor";
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument, TriggerSavePreferences, TriggerLoadAutoSaveDocuments, TriggerLoadPreferences } from "@/wasm-communication/messages";
const graphiteStore = createStore("graphite", "store");
export function createPersistenceManager(editor: Editor, portfolio: PortfolioState): void {
// DOCUMENTS
async function storeDocumentOrder(): Promise<void> {
const documentOrder = getFromStore(portfolio).documents.map((doc) => String(doc.id));
await set("documents_tab_order", documentOrder, graphiteStore);
}
async function storeDocument(autoSaveDocument: TriggerIndexedDbWriteDocument): Promise<void> {
await update<Record<string, TriggerIndexedDbWriteDocument>>(
"documents",
(old) => {
const documents = old || {};
documents[autoSaveDocument.details.id] = autoSaveDocument;
return documents;
},
graphiteStore
);
await storeDocumentOrder();
}
async function removeDocument(id: string): Promise<void> {
await update<Record<string, TriggerIndexedDbWriteDocument>>(
"documents",
(old) => {
const documents = old || {};
delete documents[id];
return documents;
},
graphiteStore
);
await storeDocumentOrder();
}
async function loadDocuments(): Promise<void> {
const previouslySavedDocuments = await get<Record<string, TriggerIndexedDbWriteDocument>>("documents", graphiteStore);
const documentOrder = await get<string[]>("documents_tab_order", graphiteStore);
if (!previouslySavedDocuments || !documentOrder) return;
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
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);
});
}
// PREFERENCES
async function savePreferences(preferences: TriggerSavePreferences["preferences"]): Promise<void> {
await set("preferences", preferences, graphiteStore);
}
async function loadPreferences(): Promise<void> {
const preferences = await get<Record<string, unknown>>("preferences", graphiteStore);
if (!preferences) return;
editor.instance.loadPreferences(JSON.stringify(preferences));
}
// FRONTEND MESSAGE SUBSCRIPTIONS
// Subscribe to process backend events
editor.subscriptions.subscribeJsMessage(TriggerSavePreferences, async (preferences) => {
await savePreferences(preferences.preferences);
});
editor.subscriptions.subscribeJsMessage(TriggerLoadPreferences, async () => {
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();
});
}
export async function wipeDocuments(): Promise<void> {
await del("documents_tab_order", graphiteStore);
await del("documents", graphiteStore);
}