mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-24 01:58:12 +08:00
Vue initialization and FloatingMenu codebase refactoring and cleanup (#649)
* Clean up Vue initialization-related code * Rename folder: dispatcher -> interop * Rename folder: state -> providers * Comments and clarification * Rename JS dispatcher to subscription router * Assorted cleanup and renaming * Rename: js-messages.ts -> messages.ts * Comments * Remove unused Vue component injects * Clean up coming soon and add warning about freezing the app * Further cleanup * Dangerous changes * Simplify App.vue code * Move more disparate init code from components into managers * Rename folder: providers -> state-providers * Other * Move Document panel options bar separator to backend * Add destructors to managers to fix HMR * Comments and code style * Rename variable: font -> font_file_url * Fix async font loading; refactor janky floating menu openness and min-width measurement; fix Vetur errors * Fix misaligned canvas in viewport until panning on page (re)load * Add Vue bidirectional props documentation * More folder renaming for better terminology; add some documentation
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { Editor } from "@/wasm-communication/editor";
|
||||
|
||||
// Gets metadata populated in the `process.env` namespace by code in `frontend/vue.config.js`.
|
||||
// TODO: Move that functionality to a build.rs file so our web build system is more lightweight.
|
||||
export function createBuildMetadataManager(editor: Editor): void {
|
||||
// Release
|
||||
const release = process.env.VUE_APP_RELEASE_SERIES;
|
||||
|
||||
// Timestamp
|
||||
const date = new Date(process.env.VUE_APP_COMMIT_DATE || "");
|
||||
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;
|
||||
const timestamp = `${dateString} ${timeString} ${timezoneNameString}`;
|
||||
|
||||
// Hash
|
||||
const hash = (process.env.VUE_APP_COMMIT_HASH || "").substring(0, 8);
|
||||
|
||||
// Branch
|
||||
const branch = process.env.VUE_APP_COMMIT_BRANCH;
|
||||
|
||||
editor.instance.populate_build_metadata(release || "", timestamp, hash, branch || "");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { 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.copy_text);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { 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");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { DialogState } from "@/state-providers/dialog";
|
||||
import { FullscreenState } from "@/state-providers/fullscreen";
|
||||
import { PortfolioState } from "@/state-providers/portfolio";
|
||||
import { makeKeyboardModifiersBitfield, textInputCleanup, getLatinKey } from "@/utility-functions/keyboard-entry";
|
||||
import { Editor } from "@/wasm-communication/editor";
|
||||
|
||||
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
|
||||
type EventListenerTarget = {
|
||||
addEventListener: typeof window.addEventListener;
|
||||
removeEventListener: typeof window.removeEventListener;
|
||||
};
|
||||
|
||||
export function createInputManager(editor: Editor, container: HTMLElement, dialog: DialogState, document: PortfolioState, fullscreen: FullscreenState): () => void {
|
||||
// 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(container) },
|
||||
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent): 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): void => onKeyUp(e) },
|
||||
{ target: window, eventName: "keydown", action: (e: KeyboardEvent): 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 => onMouseScroll(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) },
|
||||
];
|
||||
|
||||
let viewportPointerInteractionOngoing = false;
|
||||
let textInput = undefined as undefined | HTMLDivElement;
|
||||
|
||||
// Keyboard events
|
||||
|
||||
function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): boolean {
|
||||
// Don't redirect when a modal is covering the workspace
|
||||
if (dialog.dialogIsVisible()) return false;
|
||||
|
||||
const key = getLatinKey(e);
|
||||
if (!key) return false;
|
||||
|
||||
// Don't redirect user input from text entry into HTML elements
|
||||
if (
|
||||
key !== "escape" &&
|
||||
!(key === "enter" && e.ctrlKey) &&
|
||||
e.target instanceof HTMLElement &&
|
||||
(e.target.nodeName === "INPUT" || e.target.nodeName === "TEXTAREA" || e.target.isContentEditable)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't redirect paste
|
||||
if (key === "v" && e.ctrlKey) 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;
|
||||
|
||||
// Don't redirect debugging tools
|
||||
if (key === "f12" || key === "f8") return false;
|
||||
if (e.ctrlKey && e.shiftKey && key === "c") return false;
|
||||
if (e.ctrlKey && e.shiftKey && key === "i") return false;
|
||||
if (e.ctrlKey && e.shiftKey && key === "j") return false;
|
||||
|
||||
// Don't redirect tab or enter if not in canvas (to allow navigating elements)
|
||||
const inCanvas = e.target instanceof Element && e.target.closest("[data-canvas]");
|
||||
if (!inCanvas && (key === "tab" || key === "enter")) return false;
|
||||
|
||||
// Redirect to the backend
|
||||
return true;
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent): void {
|
||||
const key = getLatinKey(e);
|
||||
if (!key) return;
|
||||
|
||||
if (shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.on_key_down(key, modifiers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialog.dialogIsVisible()) {
|
||||
if (key === "escape") dialog.dismissDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyUp(e: KeyboardEvent): void {
|
||||
const key = getLatinKey(e);
|
||||
if (!key) return;
|
||||
|
||||
if (shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.on_key_up(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 modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.on_mouse_move(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent): void {
|
||||
const { target } = e;
|
||||
const inCanvas = 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 (dialog.dialogIsVisible() && !inDialog) {
|
||||
dialog.dismissDialog();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (!inTextInput) {
|
||||
if (textInput) editor.instance.on_change_text(textInputCleanup(textInput.innerText));
|
||||
else if (inCanvas) viewportPointerInteractionOngoing = true;
|
||||
}
|
||||
|
||||
if (viewportPointerInteractionOngoing) {
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.on_mouse_down(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.on_mouse_up(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.on_double_click(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 onMouseScroll(e: WheelEvent): void {
|
||||
const { target } = e;
|
||||
const inCanvas = 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 (inCanvas) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.on_mouse_scroll(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.bounds_of_viewports(data);
|
||||
}
|
||||
|
||||
function onBeforeUnload(e: BeforeUnloadEvent): void {
|
||||
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;
|
||||
|
||||
// Skip the message during development, since it's annoying when testing
|
||||
if (process.env.NODE_ENV === "development") return;
|
||||
|
||||
const allDocumentsSaved = document.state.documents.reduce((acc, doc) => acc && doc.is_saved, 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) return;
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(dataTransfer.items).forEach((item) => {
|
||||
if (item.type === "text/plain") {
|
||||
item.getAsString((text) => {
|
||||
if (text.startsWith("graphite/layer: ")) {
|
||||
editor.instance.paste_serialized_data(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.paste_image(file.type, u8Array, undefined, undefined);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Initialization
|
||||
|
||||
// Bind the event listeners
|
||||
bindListeners();
|
||||
// Resize on creation
|
||||
onWindowResize(container);
|
||||
|
||||
// Return the destructor
|
||||
return unbindListeners;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { TextButtonWidget } from "@/components/widgets/buttons/TextButton";
|
||||
import { DialogState } from "@/state-providers/dialog";
|
||||
import { IconName } from "@/utility-functions/icons";
|
||||
import { stripIndents } from "@/utility-functions/strip-indents";
|
||||
import { Editor } from "@/wasm-communication/editor";
|
||||
import { DisplayDialogPanic, WidgetLayout } 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.panic_info}${stackTrace ? `\n\n${stackTrace}` : ""}`;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(panicDetails);
|
||||
|
||||
const panicDialog = preparePanicDialog(displayDialogPanic.title, displayDialogPanic.description, panicDetails);
|
||||
dialogState.createPanicDialog(...panicDialog);
|
||||
});
|
||||
}
|
||||
|
||||
function preparePanicDialog(title: string, details: string, panicDetails: string): [IconName, WidgetLayout, TextButtonWidget[]] {
|
||||
const widgets: WidgetLayout = {
|
||||
layout: [
|
||||
{
|
||||
rowWidgets: [
|
||||
{
|
||||
kind: "TextLabel",
|
||||
props: { value: title, bold: true },
|
||||
// eslint-disable-next-line camelcase
|
||||
widget_id: 0n,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
rowWidgets: [
|
||||
{
|
||||
kind: "TextLabel",
|
||||
props: { value: details, multiline: true },
|
||||
// eslint-disable-next-line camelcase
|
||||
widget_id: 0n,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line camelcase
|
||||
layout_target: null,
|
||||
};
|
||||
|
||||
const reloadButton: TextButtonWidget = {
|
||||
kind: "TextButton",
|
||||
callback: async () => window.location.reload(),
|
||||
props: { label: "Reload", emphasized: true, minWidth: 96 },
|
||||
};
|
||||
const copyErrorLogButton: TextButtonWidget = {
|
||||
kind: "TextButton",
|
||||
callback: async () => navigator.clipboard.writeText(panicDetails),
|
||||
props: { label: "Copy Error Log", emphasized: false, minWidth: 96 },
|
||||
};
|
||||
const reportOnGithubButton: TextButtonWidget = {
|
||||
kind: "TextButton",
|
||||
callback: async () => window.open(githubUrl(panicDetails), "_blank"),
|
||||
props: { label: "Report Bug", emphasized: false, minWidth: 96 },
|
||||
};
|
||||
const jsCallbackBasedButtons = [reloadButton, copyErrorLogButton, reportOnGithubButton];
|
||||
|
||||
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()}
|
||||
|
||||
**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();
|
||||
}
|
||||
|
||||
function browserVersion(): string {
|
||||
const agent = window.navigator.userAgent;
|
||||
let match = agent.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
|
||||
|
||||
if (/trident/i.test(match[1])) {
|
||||
const browser = /\brv[ :]+(\d+)/g.exec(agent) || [];
|
||||
return `IE ${browser[1] || ""}`.trim();
|
||||
}
|
||||
|
||||
if (match[1] === "Chrome") {
|
||||
let browser = agent.match(/\bEdg\/(\d+)/);
|
||||
if (browser !== null) return `Edge (Chromium) ${browser[1]}`;
|
||||
|
||||
browser = agent.match(/\bOPR\/(\d+)/);
|
||||
if (browser !== null) return `Opera ${browser[1]}`;
|
||||
}
|
||||
|
||||
match = match[2] ? [match[1], match[2]] : [navigator.appName, navigator.appVersion, "-?"];
|
||||
|
||||
const browser = agent.match(/version\/(\d+)/i);
|
||||
if (browser !== null) match.splice(1, 1, browser[1]);
|
||||
|
||||
return `${match[0]} ${match[1]}`;
|
||||
}
|
||||
|
||||
function operatingSystem(): string {
|
||||
const osTable: Record<string, string> = {
|
||||
"Windows NT 10": "Windows 10 or 11",
|
||||
"Windows NT 6.3": "Windows 8.1",
|
||||
"Windows NT 6.2": "Windows 8",
|
||||
"Windows NT 6.1": "Windows 7",
|
||||
"Windows NT 6.0": "Windows Vista",
|
||||
"Windows NT 5.1": "Windows XP",
|
||||
"Windows NT 5.0": "Windows 2000",
|
||||
Mac: "Mac",
|
||||
X11: "Unix",
|
||||
Linux: "Linux",
|
||||
Unknown: "YOUR OPERATING SYSTEM",
|
||||
};
|
||||
|
||||
const userAgentOS = Object.keys(osTable).find((key) => window.navigator.userAgent.includes(key));
|
||||
return osTable[userAgentOS || "Unknown"];
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { PortfolioState } from "@/state-providers/portfolio";
|
||||
import { Editor, getWasmInstance } from "@/wasm-communication/editor";
|
||||
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument } from "@/wasm-communication/messages";
|
||||
|
||||
const GRAPHITE_INDEXED_DB_VERSION = 2;
|
||||
const GRAPHITE_INDEXED_DB_NAME = "graphite-indexed-db";
|
||||
const GRAPHITE_AUTO_SAVE_STORE = "auto-save-documents";
|
||||
const GRAPHITE_AUTO_SAVE_ORDER_KEY = "auto-save-documents-order";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export async function createPersistenceManager(editor: Editor, portfolio: PortfolioState): Promise<() => void> {
|
||||
function storeDocumentOrder(): void {
|
||||
// Make sure to store as string since JSON does not play nice with BigInt
|
||||
const documentOrder = portfolio.state.documents.map((doc) => doc.id.toString());
|
||||
window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder));
|
||||
}
|
||||
|
||||
async function removeDocument(id: string): Promise<void> {
|
||||
const db = await databaseConnection;
|
||||
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
|
||||
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).delete(id);
|
||||
storeDocumentOrder();
|
||||
}
|
||||
|
||||
async function closeDatabaseConnection(): Promise<void> {
|
||||
const db = await databaseConnection;
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Subscribe to process backend events
|
||||
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbWriteDocument, 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.subscriptions.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => {
|
||||
removeDocument(removeAutoSaveDocument.document_id);
|
||||
});
|
||||
|
||||
// Open the IndexedDB database connection and save it to this variable, which is a promise that resolves once the connection is open
|
||||
const databaseConnection: Promise<IDBDatabase> = new Promise((resolve) => {
|
||||
const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION);
|
||||
|
||||
dbOpenRequest.onupgradeneeded = (): void => {
|
||||
const db = dbOpenRequest.result;
|
||||
// Wipes out all auto-save data on upgrade
|
||||
if (db.objectStoreNames.contains(GRAPHITE_AUTO_SAVE_STORE)) {
|
||||
db.deleteObjectStore(GRAPHITE_AUTO_SAVE_STORE);
|
||||
}
|
||||
|
||||
db.createObjectStore(GRAPHITE_AUTO_SAVE_STORE, { keyPath: "details.id" });
|
||||
};
|
||||
|
||||
dbOpenRequest.onerror = (): void => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Graphite IndexedDb error:", dbOpenRequest.error);
|
||||
};
|
||||
|
||||
dbOpenRequest.onsuccess = (): void => {
|
||||
resolve(dbOpenRequest.result);
|
||||
};
|
||||
});
|
||||
|
||||
// Open auto-save documents
|
||||
const db = await databaseConnection;
|
||||
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readonly");
|
||||
const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).getAll();
|
||||
await new Promise((resolve): void => {
|
||||
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 = getWasmInstance().graphite_version();
|
||||
orderedSavedDocuments.forEach((doc: TriggerIndexedDbWriteDocument) => {
|
||||
if (doc.version === currentDocumentVersion) {
|
||||
editor.instance.open_auto_saved_document(BigInt(doc.details.id), doc.details.name, doc.details.is_saved, doc.document);
|
||||
} else {
|
||||
removeDocument(doc.details.id);
|
||||
}
|
||||
});
|
||||
resolve(undefined);
|
||||
};
|
||||
});
|
||||
|
||||
return closeDatabaseConnection;
|
||||
}
|
||||
Reference in New Issue
Block a user