Rework wasm initialization and reduce global state (#379)

* wasm: do the async initialization only once

This allows the rest of the app to access wasm synchronously.

This allows removing of a global.

* provide the wasm via vue provide/inject.

There's still code directly accessing the wasm. That will be changed later.

* MenuBarInput: use injected wasm instead of the global instance

* Let the App handle event listeners

* move stateful modules into state/

* state/fullscreen: create per instance

* App: load the initial document list on mount.
This got lost a few commits ago. Now it's back.

* state/dialog: create per instance

* util/input: remove dependency on global dialog instance

* state/documents: create per instance

* reponse-handler: move into EditorWasm

* comingSoon: move into dialog

* wasm: allow instantiating multiple editors

* input handlers: do not look at canvases outside the mounted App

* input: listen on the container instead of the window when possible

* - removed proxy from wasm-loader
- integrated with js-dispatcher
- state functions to classes
- integrated some upstream changes

* fix errors caused by merge

* Getting closer:
- added global state to track all instances
- fix fullscreen close trigger
- wasm-loader is statefull
- panic across instanes

* - fix outline while using editor
- removed circular import rule
- added editorInstance to js message constructor

* - changed input handler to a class
- still need a better way of handeling it in App.vue

* - fixed single instance of inputManager to weakmap

* - fix no-explicit-any in a few places
- removed global state from input.ts

* simplified two long lines

* removed global state

* removed $data from App

* add mut self to functions in api.rs

* Update Workspace.vue

remove outdated import

* fixed missing import

* Changes throughout code review; note this causes some bugs to be fixed in a later commit

* PR review round 1

* - fix coming soon bugs
- changed folder structure

* moved declaration to .d.ts

* - changed from classes to functions
- moved decs back to app.vue

* removed need to export js function to rust

* changed folder structure

* fixed indentation breaking multiline strings

* Fix eslint rule to whitelist @/../

* Simplify strip-indents implementation

* replace type assertions with better annotations or proper runtime checks

* Small tweaks and code rearranging improvements after second code review pass

* maybe fix mouse events

* Add back preventDefault for mouse scroll

* code review round 2

* Comment improvements

* -removed runtime checks
- fixed layers not showing

* - extened proxy to cover classes
- stopped multiple panics from logging
- Stop wasm-bindgen from mut ref counting our struct

* cleaned up messageConstructors exports

* Fix input and fullscreen regressions

Co-authored-by: Max Fisher <maxmfishernj@gmail.com>
Co-authored-by: mfish33 <32677537+mfish33@users.noreply.github.com>
Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Christian Authmann
2021-12-19 22:37:19 -08:00
committed by Keavon Chambers
co-authored by Max Fisher mfish33 Keavon Chambers
parent 3c1ed1a235
commit 0aefa1bb07
39 changed files with 1524 additions and 1412 deletions
+118
View File
@@ -0,0 +1,118 @@
import { reactive, readonly } from "vue";
import { TextButtonWidget } from "@/components/widgets/widgets";
import { EditorState } from "@/state/wasm-loader";
import { DisplayAboutGraphiteDialog } from "@/dispatcher/js-messages";
import { stripIndents } from "@/utilities/strip-indents";
export function createDialogState(editor: EditorState) {
const state = reactive({
visible: false,
icon: "",
heading: "",
details: "",
buttons: [] as TextButtonWidget[],
});
const createDialog = (icon: string, heading: string, details: string, buttons: TextButtonWidget[]) => {
state.visible = true;
state.icon = icon;
state.heading = heading;
state.details = details;
state.buttons = buttons;
};
const dismissDialog = () => {
state.visible = false;
};
const submitDialog = () => {
const firstEmphasizedButton = state.buttons.find((button) => button.props.emphasized && button.callback);
if (firstEmphasizedButton) {
// If statement satisfies TypeScript
if (firstEmphasizedButton.callback) firstEmphasizedButton.callback();
}
};
const dialogIsVisible = (): boolean => {
return state.visible;
};
const comingSoon = (issueNumber?: number) => {
const bugMessage = `— but you can help add it!\nSee issue #${issueNumber} on GitHub.`;
const details = `This feature is not implemented yet${issueNumber ? bugMessage : ""}`;
const okButton: TextButtonWidget = {
kind: "TextButton",
callback: async () => dismissDialog(),
props: { label: "OK", emphasized: true, minWidth: 96 },
};
const issueButton: TextButtonWidget = {
kind: "TextButton",
callback: async () => window.open(`https://github.com/GraphiteEditor/Graphite/issues/${issueNumber}`, "_blank"),
props: { label: `Issue #${issueNumber}`, minWidth: 96 },
};
const buttons = [okButton];
if (issueNumber) buttons.push(issueButton);
createDialog("Warning", "Coming soon", details, buttons);
};
const onAboutHandler = () => {
const date = new Date(process.env.VUE_APP_COMMIT_DATE || "");
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 timezoneName = Intl.DateTimeFormat(undefined, { timeZoneName: "long" })
.formatToParts(new Date())
.find((part) => part.type === "timeZoneName");
const timezoneNameString = timezoneName && timezoneName.value;
const hash = (process.env.VUE_APP_COMMIT_HASH || "").substring(0, 12);
const details = stripIndents`
Release Series: ${process.env.VUE_APP_RELEASE_SERIES}
Date: ${dateString} ${timeString} ${timezoneNameString}
Hash: ${hash}
Branch: ${process.env.VUE_APP_COMMIT_BRANCH}
`;
const buttons: TextButtonWidget[] = [
{
kind: "TextButton",
callback: () => window.open("https://www.graphite.design", "_blank"),
props: { label: "Website", emphasized: false, minWidth: 0 },
},
{
kind: "TextButton",
callback: () => window.open("https://github.com/GraphiteEditor/Graphite/graphs/contributors", "_blank"),
props: { label: "Credits", emphasized: false, minWidth: 0 },
},
{
kind: "TextButton",
callback: () => window.open("https://raw.githubusercontent.com/GraphiteEditor/Graphite/master/LICENSE.txt", "_blank"),
props: { label: "License", emphasized: false, minWidth: 0 },
},
{
kind: "TextButton",
callback: () => window.open("/third-party-licenses.txt", "_blank"),
props: { label: "Third-Party Licenses", emphasized: false, minWidth: 0 },
},
];
createDialog("GraphiteLogo", "Graphite", details, buttons);
};
// Run on creation
editor.dispatcher.subscribeJsMessage(DisplayAboutGraphiteDialog, () => onAboutHandler());
return {
state: readonly(state),
createDialog,
dismissDialog,
submitDialog,
dialogIsVisible,
comingSoon,
};
}
export type DialogState = ReturnType<typeof createDialogState>;
+136
View File
@@ -0,0 +1,136 @@
/* eslint-disable max-classes-per-file */
import { reactive, readonly } from "vue";
import { DialogState } from "@/state/dialog";
import { download, upload } from "@/utilities/files";
import { EditorState } from "@/state/wasm-loader";
import {
DisplayConfirmationToCloseAllDocuments,
DisplayConfirmationToCloseDocument,
ExportDocument,
OpenDocumentBrowse,
SaveDocument,
SetActiveDocument,
UpdateOpenDocumentsList,
} from "@/dispatcher/js-messages";
class DocumentSaveState {
readonly displayName: string;
constructor(readonly name: string, readonly isSaved: boolean) {
this.displayName = `${name}${isSaved ? "" : "*"}`;
}
}
export function createDocumentsState(editor: EditorState, dialogState: DialogState) {
const state = reactive({
unsaved: false,
documents: [] as DocumentSaveState[],
activeDocumentIndex: 0,
});
const selectDocument = (tabIndex: number) => {
editor.instance.select_document(tabIndex);
};
const closeDocumentWithConfirmation = (tabIndex: number) => {
// Close automatically if it's already saved, no confirmation is needed
const targetDocument = state.documents[tabIndex];
if (targetDocument.isSaved) {
editor.instance.close_document(tabIndex);
return;
}
// Switch to the document that's being prompted to close
selectDocument(tabIndex);
// Show the close confirmation prompt
dialogState.createDialog("File", "Save changes before closing?", targetDocument.displayName, [
{
kind: "TextButton",
callback: () => {
editor.instance.save_document();
dialogState.dismissDialog();
},
props: { label: "Save", emphasized: true, minWidth: 96 },
},
{
kind: "TextButton",
callback: () => {
editor.instance.close_document(tabIndex);
dialogState.dismissDialog();
},
props: { label: "Discard", minWidth: 96 },
},
{
kind: "TextButton",
callback: () => {
dialogState.dismissDialog();
},
props: { label: "Cancel", minWidth: 96 },
},
]);
};
const closeAllDocumentsWithConfirmation = () => {
dialogState.createDialog("Copy", "Close all documents?", "Unsaved work will be lost!", [
{
kind: "TextButton",
callback: () => {
editor.instance.close_all_documents();
dialogState.dismissDialog();
},
props: { label: "Discard All", minWidth: 96 },
},
{
kind: "TextButton",
callback: () => {
dialogState.dismissDialog();
},
props: { label: "Cancel", minWidth: 96 },
},
]);
};
// Set up message subscriptions on creation
editor.dispatcher.subscribeJsMessage(UpdateOpenDocumentsList, (updateOpenDocumentList) => {
state.documents = updateOpenDocumentList.open_documents.map(({ name, isSaved }) => new DocumentSaveState(name, isSaved));
});
editor.dispatcher.subscribeJsMessage(SetActiveDocument, (setActiveDocument) => {
state.activeDocumentIndex = setActiveDocument.document_index;
});
editor.dispatcher.subscribeJsMessage(DisplayConfirmationToCloseDocument, (displayConfirmationToCloseDocument) => {
closeDocumentWithConfirmation(displayConfirmationToCloseDocument.document_index);
});
editor.dispatcher.subscribeJsMessage(DisplayConfirmationToCloseAllDocuments, () => {
closeAllDocumentsWithConfirmation();
});
editor.dispatcher.subscribeJsMessage(OpenDocumentBrowse, async () => {
const extension = editor.rawWasm.file_save_suffix();
const data = await upload(extension);
editor.instance.open_document_file(data.filename, data.content);
});
editor.dispatcher.subscribeJsMessage(ExportDocument, (exportDocument) => {
download(exportDocument.name, exportDocument.document);
});
editor.dispatcher.subscribeJsMessage(SaveDocument, (saveDocument) => {
download(saveDocument.name, saveDocument.document);
});
// Get the initial documents
editor.instance.get_open_documents_list();
return {
state: readonly(state),
selectDocument,
closeDocumentWithConfirmation,
closeAllDocumentsWithConfirmation,
};
}
export type DocumentsState = ReturnType<typeof createDocumentsState>;
+47
View File
@@ -0,0 +1,47 @@
import { reactive, readonly } from "vue";
export function createFullscreenState() {
const state = reactive({
windowFullscreen: false,
keyboardLocked: false,
});
const fullscreenModeChanged = () => {
state.windowFullscreen = Boolean(document.fullscreenElement);
if (!state.windowFullscreen) state.keyboardLocked = false;
};
// Experimental Keyboard API: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/keyboard
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const keyboardLockApiSupported: Readonly<boolean> = "keyboard" in navigator && "lock" in (navigator as any).keyboard;
const enterFullscreen = async () => {
await document.documentElement.requestFullscreen();
if (keyboardLockApiSupported) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (navigator as any).keyboard.lock(["ControlLeft", "ControlRight"]);
state.keyboardLocked = true;
}
};
// eslint-disable-next-line class-methods-use-this
const exitFullscreen = async () => {
await document.exitFullscreen();
};
const toggleFullscreen = async () => {
if (state.windowFullscreen) await exitFullscreen();
else await enterFullscreen();
};
return {
state: readonly(state),
keyboardLockApiSupported,
enterFullscreen,
exitFullscreen,
toggleFullscreen,
fullscreenModeChanged,
};
}
export type FullscreenState = ReturnType<typeof createFullscreenState>;
+76
View File
@@ -0,0 +1,76 @@
/* eslint-disable func-names */
import { createJsDispatcher } from "@/dispatcher/js-dispatcher";
import { JsMessageType } from "@/dispatcher/js-messages";
export type WasmInstance = typeof import("@/../wasm/pkg");
export type RustEditorInstance = InstanceType<WasmInstance["Editor"]>;
let wasmImport: WasmInstance | null = null;
export async function initWasm() {
if (wasmImport !== null) return;
wasmImport = await import("@/../wasm/pkg").then(panicProxy);
}
// This works by proxying every function call wrapping a try-catch block to filter out redundant and confusing
// `RuntimeError: unreachable` exceptions sent to the console
function panicProxy<T extends object>(module: T): T {
const proxyHandler = {
get(target: T, propKey: string | symbol, receiver: unknown): unknown {
const targetValue = Reflect.get(target, propKey, receiver);
// Keep the original value being accessed if it isn't a function
const isFunction = typeof targetValue === "function";
if (!isFunction) return targetValue;
// Special handling to wrap the return of a constructor in the proxy
const isClass = isFunction && /^\s*class\s+/.test(targetValue.toString());
if (isClass) {
return function (...args: unknown[]) {
// eslint-disable-next-line new-cap
const result = new targetValue(...args);
return panicProxy(result);
};
}
// Replace the original function with a wrapper function that runs the original in a try-catch block
return function (...args: unknown[]) {
let result;
try {
// @ts-expect-error TypeScript does not know what `this` is, since it should be able to be anything
result = targetValue.apply(this, args);
} catch (err) {
// Suppress `unreachable` WebAssembly.RuntimeError exceptions
if (!`${err}`.startsWith("RuntimeError: unreachable")) throw err;
}
return result;
};
},
};
return new Proxy<T>(module, proxyHandler);
}
function getWasmInstance() {
if (wasmImport) return wasmImport;
throw new Error("Editor WASM backend was not initialized at application startup");
}
export function createEditorState() {
const dispatcher = createJsDispatcher();
const rawWasm = getWasmInstance();
const rustCallback = (messageType: JsMessageType, data: Record<string, unknown>) => {
dispatcher.handleJsMessage(messageType, data, rawWasm, instance);
};
const instance = new rawWasm.Editor(rustCallback);
return {
dispatcher,
rawWasm,
instance,
};
}
export type EditorState = Readonly<ReturnType<typeof createEditorState>>;