mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-21 03:28:11 +08:00
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:
co-authored by
Max Fisher
mfish33
Keavon Chambers
parent
37be856884
commit
448e72fe02
@@ -0,0 +1,140 @@
|
||||
import { DialogState } from "@/state/dialog";
|
||||
import { TextButtonWidget } from "@/components/widgets/widgets";
|
||||
import { DisplayError, DisplayPanic } from "@/dispatcher/js-messages";
|
||||
import { EditorState } from "@/state/wasm-loader";
|
||||
import { stripIndents } from "@/utilities/strip-indents";
|
||||
|
||||
export function initErrorHandling(editor: EditorState, dialogState: DialogState) {
|
||||
// Graphite error dialog
|
||||
editor.dispatcher.subscribeJsMessage(DisplayError, (displayError) => {
|
||||
const okButton: TextButtonWidget = {
|
||||
kind: "TextButton",
|
||||
callback: async () => dialogState.dismissDialog(),
|
||||
props: { label: "OK", emphasized: true, minWidth: 96 },
|
||||
};
|
||||
const buttons = [okButton];
|
||||
|
||||
dialogState.createDialog("Warning", displayError.title, displayError.description, buttons);
|
||||
});
|
||||
|
||||
// Code panic dialog and console error
|
||||
editor.dispatcher.subscribeJsMessage(DisplayPanic, (displayPanic) => {
|
||||
// `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 = `${displayPanic.panic_info}\n\n${stackTrace}`;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(panicDetails);
|
||||
|
||||
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 buttons = [reloadButton, copyErrorLogButton, reportOnGithubButton];
|
||||
|
||||
dialogState.createDialog("Warning", displayPanic.title, displayPanic.description, buttons);
|
||||
});
|
||||
}
|
||||
|
||||
function githubUrl(panicDetails: string) {
|
||||
const url = new URL("https://github.com/GraphiteEditor/Graphite/issues/new");
|
||||
|
||||
const 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.design
|
||||
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:
|
||||
|
||||
\`\`\`
|
||||
${panicDetails}
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
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,178 @@
|
||||
import { DialogState } from "@/state/dialog";
|
||||
import { FullscreenState } from "@/state/fullscreen";
|
||||
import { DocumentsState } from "@/state/documents";
|
||||
import { EditorState } from "@/state/wasm-loader";
|
||||
|
||||
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap;
|
||||
interface EventListenerTarget {
|
||||
addEventListener: typeof window.addEventListener;
|
||||
removeEventListener: typeof window.removeEventListener;
|
||||
}
|
||||
|
||||
export function createInputManager(editor: EditorState, container: HTMLElement, dialog: DialogState, document: DocumentsState, fullscreen: FullscreenState) {
|
||||
// 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: () => onWindowResize(container) },
|
||||
{ target: window, eventName: "beforeunload", action: (e) => onBeforeUnload(e) },
|
||||
{ target: window.document, eventName: "contextmenu", action: (e) => e.preventDefault() },
|
||||
{ target: window.document, eventName: "fullscreenchange", action: () => fullscreen.fullscreenModeChanged() },
|
||||
{ target: window, eventName: "keyup", action: (e) => onKeyUp(e) },
|
||||
{ target: window, eventName: "keydown", action: (e) => onKeyDown(e) },
|
||||
{ target: window, eventName: "mousemove", action: (e) => onMouseMove(e) },
|
||||
{ target: window, eventName: "mousedown", action: (e) => onMouseDown(e) },
|
||||
{ target: window, eventName: "mouseup", action: (e) => onMouseUp(e) },
|
||||
{ target: window, eventName: "wheel", action: (e) => onMouseScroll(e), options: { passive: false } },
|
||||
];
|
||||
|
||||
let viewportMouseInteractionOngoing = false;
|
||||
|
||||
const shouldRedirectKeyboardEventToBackend = (e: KeyboardEvent): boolean => {
|
||||
// Don't redirect user input from text entry into HTML elements
|
||||
const { target } = e;
|
||||
if (target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable)) return false;
|
||||
|
||||
// Don't redirect when a modal is covering the workspace
|
||||
if (dialog.dialogIsVisible()) return false;
|
||||
|
||||
// Don't redirect a fullscreen request
|
||||
if (e.key.toLowerCase() === "f11" && e.type === "keydown" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
fullscreen.toggleFullscreen();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't redirect a reload request
|
||||
if (e.key.toLowerCase() === "f5") return false;
|
||||
|
||||
// Don't redirect debugging tools
|
||||
if (e.key.toLowerCase() === "f12") return false;
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "c") return false;
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "i") return false;
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "j") return false;
|
||||
|
||||
// Redirect to the backend
|
||||
return true;
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
editor.instance.on_key_down(e.key, modifiers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialog.dialogIsVisible()) {
|
||||
if (e.key === "Escape") dialog.dismissDialog();
|
||||
if (e.key === "Enter") {
|
||||
dialog.submitDialog();
|
||||
|
||||
// Prevent the Enter key from acting like a click on the last clicked button, which might reopen the dialog
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
editor.instance.on_key_up(e.key, modifiers);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!e.buttons) viewportMouseInteractionOngoing = false;
|
||||
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
editor.instance.on_mouse_move(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const { target } = e;
|
||||
const inCanvas = target instanceof Element && target.closest(".canvas");
|
||||
const inDialog = target instanceof Element && target.closest(".dialog-modal .floating-menu-content");
|
||||
|
||||
// Block middle mouse button auto-scroll mode
|
||||
if (e.button === 1) e.preventDefault();
|
||||
|
||||
if (dialog.dialogIsVisible() && !inDialog) {
|
||||
dialog.dismissDialog();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (inCanvas) viewportMouseInteractionOngoing = true;
|
||||
|
||||
if (viewportMouseInteractionOngoing) {
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
editor.instance.on_mouse_down(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (e: MouseEvent) => {
|
||||
if (!e.buttons) viewportMouseInteractionOngoing = false;
|
||||
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
editor.instance.on_mouse_up(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
};
|
||||
|
||||
const onMouseScroll = (e: WheelEvent) => {
|
||||
const { target } = e;
|
||||
const inCanvas = target instanceof Element && target.closest(".canvas");
|
||||
|
||||
const horizontalScrollableElement = target instanceof Element && target.closest(".scrollable-x");
|
||||
if (horizontalScrollableElement && e.deltaY !== 0) {
|
||||
horizontalScrollableElement.scrollTo(horizontalScrollableElement.scrollLeft + e.deltaY, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (inCanvas) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
editor.instance.on_mouse_scroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
|
||||
}
|
||||
};
|
||||
|
||||
const onWindowResize = (container: HTMLElement) => {
|
||||
const viewports = Array.from(container.querySelectorAll(".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);
|
||||
};
|
||||
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
const allDocumentsSaved = document.state.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();
|
||||
}
|
||||
};
|
||||
|
||||
const addListeners = () => {
|
||||
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options));
|
||||
};
|
||||
|
||||
const removeListeners = () => {
|
||||
listeners.forEach(({ target, eventName, action }) => target.removeEventListener(eventName, action));
|
||||
};
|
||||
|
||||
// Run on creation
|
||||
addListeners();
|
||||
onWindowResize(container);
|
||||
|
||||
return {
|
||||
removeListeners,
|
||||
};
|
||||
}
|
||||
export type InputManager = ReturnType<typeof createInputManager>;
|
||||
|
||||
export function makeModifiersBitfield(e: MouseEvent | KeyboardEvent): number {
|
||||
return Number(e.ctrlKey) | (Number(e.shiftKey) << 1) | (Number(e.altKey) << 2);
|
||||
}
|
||||
Reference in New Issue
Block a user