mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-23 02:38:12 +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:
committed by
Keavon Chambers
co-authored by
Max Fisher
mfish33
Keavon Chambers
parent
6d82672a95
commit
5ec8aaa31d
@@ -1,37 +0,0 @@
|
||||
import { reactive, readonly } from "vue";
|
||||
|
||||
import { TextButtonWidget } from "@/components/widgets/widgets";
|
||||
|
||||
const state = reactive({
|
||||
visible: false,
|
||||
icon: "",
|
||||
heading: "",
|
||||
details: "",
|
||||
buttons: [] as TextButtonWidget[],
|
||||
});
|
||||
|
||||
export function createDialog(icon: string, heading: string, details: string, buttons: TextButtonWidget[]) {
|
||||
state.visible = true;
|
||||
state.icon = icon;
|
||||
state.heading = heading;
|
||||
state.details = details;
|
||||
state.buttons = buttons;
|
||||
}
|
||||
|
||||
export function dismissDialog() {
|
||||
state.visible = false;
|
||||
}
|
||||
|
||||
export function submitDialog() {
|
||||
const firstEmphasizedButton = state.buttons.find((button) => button.props.emphasized && button.callback);
|
||||
if (firstEmphasizedButton) {
|
||||
// If statement satisfies TypeScript
|
||||
if (firstEmphasizedButton.callback) firstEmphasizedButton.callback();
|
||||
}
|
||||
}
|
||||
|
||||
export function dialogIsVisible(): boolean {
|
||||
return state.visible;
|
||||
}
|
||||
|
||||
export default readonly(state);
|
||||
@@ -1,49 +0,0 @@
|
||||
import { subscribeJsMessage } from "@/utilities/js-message-dispatcher";
|
||||
import { DisplayAboutGraphiteDialog } from "@/utilities/js-messages";
|
||||
import { createDialog } from "@/utilities/dialog";
|
||||
import { TextButtonWidget } from "@/components/widgets/widgets";
|
||||
|
||||
subscribeJsMessage(DisplayAboutGraphiteDialog, () => {
|
||||
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 = `
|
||||
Release Series: ${process.env.VUE_APP_RELEASE_SERIES}
|
||||
|
||||
Date: ${dateString} ${timeString} ${timezoneNameString}
|
||||
Hash: ${hash}
|
||||
Branch: ${process.env.VUE_APP_COMMIT_BRANCH}
|
||||
`.trim();
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import { reactive, readonly } from "vue";
|
||||
|
||||
import { createDialog, dismissDialog } from "@/utilities/dialog";
|
||||
import { subscribeJsMessage } from "@/utilities/js-message-dispatcher";
|
||||
import {
|
||||
DisplayConfirmationToCloseAllDocuments,
|
||||
SetActiveDocument,
|
||||
UpdateOpenDocumentsList,
|
||||
DisplayConfirmationToCloseDocument,
|
||||
ExportDocument,
|
||||
SaveDocument,
|
||||
OpenDocumentBrowse,
|
||||
} from "@/utilities/js-messages";
|
||||
import { download, upload } from "@/utilities/files";
|
||||
import { panicProxy } from "@/utilities/panic-proxy";
|
||||
|
||||
const wasm = import("@/../wasm/pkg").then(panicProxy);
|
||||
|
||||
class DocumentState {
|
||||
readonly displayName: string;
|
||||
|
||||
constructor(readonly name: string, readonly isSaved: boolean) {
|
||||
this.displayName = `${name}${isSaved ? "" : "*"}`;
|
||||
}
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
documents: [] as DocumentState[],
|
||||
activeDocumentIndex: 0,
|
||||
});
|
||||
|
||||
export async function selectDocument(tabIndex: number) {
|
||||
(await wasm).select_document(tabIndex);
|
||||
}
|
||||
|
||||
export async function closeDocumentWithConfirmation(tabIndex: number) {
|
||||
const targetDocument = state.documents[tabIndex];
|
||||
if (targetDocument.isSaved) {
|
||||
(await wasm).close_document(tabIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the document is being prompted to close
|
||||
await selectDocument(tabIndex);
|
||||
|
||||
const tabLabel = targetDocument.displayName;
|
||||
|
||||
createDialog("File", "Save changes before closing?", tabLabel, [
|
||||
{
|
||||
kind: "TextButton",
|
||||
callback: async () => {
|
||||
(await wasm).save_document();
|
||||
dismissDialog();
|
||||
},
|
||||
props: { label: "Save", emphasized: true, minWidth: 96 },
|
||||
},
|
||||
{
|
||||
kind: "TextButton",
|
||||
callback: async () => {
|
||||
(await wasm).close_document(tabIndex);
|
||||
dismissDialog();
|
||||
},
|
||||
props: { label: "Discard", minWidth: 96 },
|
||||
},
|
||||
{
|
||||
kind: "TextButton",
|
||||
callback: async () => {
|
||||
dismissDialog();
|
||||
},
|
||||
props: { label: "Cancel", minWidth: 96 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export async function closeAllDocumentsWithConfirmation() {
|
||||
createDialog("Copy", "Close all documents?", "Unsaved work will be lost!", [
|
||||
{
|
||||
kind: "TextButton",
|
||||
callback: async () => {
|
||||
(await wasm).close_all_documents();
|
||||
dismissDialog();
|
||||
},
|
||||
props: { label: "Discard All", minWidth: 96 },
|
||||
},
|
||||
{
|
||||
kind: "TextButton",
|
||||
callback: async () => {
|
||||
dismissDialog();
|
||||
},
|
||||
props: { label: "Cancel", minWidth: 96 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export default readonly(state);
|
||||
|
||||
subscribeJsMessage(UpdateOpenDocumentsList, (updateOpenDocumentList) => {
|
||||
state.documents = updateOpenDocumentList.open_documents.map(({ name, isSaved }) => new DocumentState(name, isSaved));
|
||||
});
|
||||
|
||||
subscribeJsMessage(SetActiveDocument, (setActiveDocument) => {
|
||||
state.activeDocumentIndex = setActiveDocument.document_index;
|
||||
});
|
||||
|
||||
subscribeJsMessage(DisplayConfirmationToCloseDocument, (displayConfirmationToCloseDocument) => {
|
||||
closeDocumentWithConfirmation(displayConfirmationToCloseDocument.document_index);
|
||||
});
|
||||
|
||||
subscribeJsMessage(DisplayConfirmationToCloseAllDocuments, () => {
|
||||
closeAllDocumentsWithConfirmation();
|
||||
});
|
||||
|
||||
subscribeJsMessage(OpenDocumentBrowse, async () => {
|
||||
const extension = (await wasm).file_save_suffix();
|
||||
const data = await upload(extension);
|
||||
(await wasm).open_document_file(data.filename, data.content);
|
||||
});
|
||||
|
||||
subscribeJsMessage(ExportDocument, (exportDocument) => {
|
||||
download(exportDocument.name, exportDocument.document);
|
||||
});
|
||||
|
||||
subscribeJsMessage(SaveDocument, (saveDocument) => {
|
||||
download(saveDocument.name, saveDocument.document);
|
||||
});
|
||||
|
||||
(async () => (await wasm).get_open_documents_list())();
|
||||
@@ -1,157 +0,0 @@
|
||||
import { createDialog, dismissDialog } from "@/utilities/dialog";
|
||||
import { TextButtonWidget } from "@/components/widgets/widgets";
|
||||
import { subscribeJsMessage } from "@/utilities/js-message-dispatcher";
|
||||
import { DisplayError, DisplayPanic } from "@/utilities/js-messages";
|
||||
|
||||
// Coming soon dialog
|
||||
export function 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);
|
||||
}
|
||||
|
||||
// Graphite error dialog
|
||||
subscribeJsMessage(DisplayError, (displayError) => {
|
||||
const okButton: TextButtonWidget = {
|
||||
kind: "TextButton",
|
||||
callback: async () => dismissDialog(),
|
||||
props: { label: "OK", emphasized: true, minWidth: 96 },
|
||||
};
|
||||
const buttons = [okButton];
|
||||
|
||||
createDialog("Warning", displayError.title, displayError.description, buttons);
|
||||
});
|
||||
|
||||
// Code panic dialog and console error
|
||||
subscribeJsMessage(DisplayPanic, (displayPanic) => {
|
||||
// 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];
|
||||
|
||||
createDialog("Warning", displayPanic.title, displayPanic.description, buttons);
|
||||
});
|
||||
|
||||
function githubUrl(panicDetails: string) {
|
||||
const url = new URL("https://github.com/GraphiteEditor/Graphite/issues/new");
|
||||
|
||||
const body = `
|
||||
**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}
|
||||
\`\`\`
|
||||
`.trim();
|
||||
|
||||
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"];
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { reactive, readonly } from "vue";
|
||||
|
||||
const state = reactive({
|
||||
windowFullscreen: false,
|
||||
keyboardLocked: false,
|
||||
});
|
||||
|
||||
export function fullscreenModeChanged() {
|
||||
state.windowFullscreen = Boolean(document.fullscreenElement);
|
||||
if (!state.windowFullscreen) state.keyboardLocked = false;
|
||||
}
|
||||
|
||||
export function keyboardLockApiSupported(): boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return "keyboard" in navigator && "lock" in (navigator as any).keyboard;
|
||||
}
|
||||
|
||||
export async function enterFullscreen() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function exitFullscreen() {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
|
||||
export async function toggleFullscreen() {
|
||||
if (state.windowFullscreen) await exitFullscreen();
|
||||
else await enterFullscreen();
|
||||
}
|
||||
|
||||
export default readonly(state);
|
||||
@@ -1,145 +0,0 @@
|
||||
import { toggleFullscreen } from "@/utilities/fullscreen";
|
||||
import { dialogIsVisible, dismissDialog, submitDialog } from "@/utilities/dialog";
|
||||
import { panicProxy } from "@/utilities/panic-proxy";
|
||||
import documents from "@/utilities/documents";
|
||||
|
||||
const wasm = import("@/../wasm/pkg").then(panicProxy);
|
||||
|
||||
let viewportMouseInteractionOngoing = false;
|
||||
|
||||
// Keyboard events
|
||||
|
||||
function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): boolean {
|
||||
// Don't redirect user input from text entry into HTML elements
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable) return false;
|
||||
|
||||
// Don't redirect when a modal is covering the workspace
|
||||
if (dialogIsVisible()) return false;
|
||||
|
||||
// Don't redirect a fullscreen request
|
||||
if (e.key.toLowerCase() === "f11" && e.type === "keydown" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
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;
|
||||
}
|
||||
|
||||
export async function onKeyDown(e: KeyboardEvent) {
|
||||
if (shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
(await wasm).on_key_down(e.key, modifiers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogIsVisible()) {
|
||||
if (e.key === "Escape") dismissDialog();
|
||||
if (e.key === "Enter") {
|
||||
submitDialog();
|
||||
|
||||
// Prevent the Enter key from acting like a click on the last clicked button, which might reopen the dialog
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function onKeyUp(e: KeyboardEvent) {
|
||||
if (shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
(await wasm).on_key_up(e.key, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse events
|
||||
|
||||
export async function onMouseMove(e: MouseEvent) {
|
||||
if (!e.buttons) viewportMouseInteractionOngoing = false;
|
||||
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
(await wasm).on_mouse_move(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
|
||||
export async function onMouseDown(e: MouseEvent) {
|
||||
const target = e.target && (e.target as HTMLElement);
|
||||
const inCanvas = target && target.closest(".canvas");
|
||||
const inDialog = target && target.closest(".dialog-modal .floating-menu-content");
|
||||
|
||||
// Block middle mouse button auto-scroll mode
|
||||
if (e.button === 1) e.preventDefault();
|
||||
|
||||
if (dialogIsVisible() && !inDialog) {
|
||||
dismissDialog();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (inCanvas) viewportMouseInteractionOngoing = true;
|
||||
|
||||
if (viewportMouseInteractionOngoing) {
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
(await wasm).on_mouse_down(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
export async function onMouseUp(e: MouseEvent) {
|
||||
if (!e.buttons) viewportMouseInteractionOngoing = false;
|
||||
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
(await wasm).on_mouse_up(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
|
||||
export async function onMouseScroll(e: WheelEvent) {
|
||||
const target = e.target && (e.target as HTMLElement);
|
||||
const inCanvas = target && target.closest(".canvas");
|
||||
|
||||
const horizontalScrollableElement = e.target instanceof Element && e.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);
|
||||
(await wasm).on_mouse_scroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
export async function onWindowResize() {
|
||||
const viewports = Array.from(document.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) (await wasm).bounds_of_viewports(data);
|
||||
}
|
||||
|
||||
export function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
const allDocumentsSaved = documents.documents.reduce((acc, doc) => doc.isSaved && acc, true);
|
||||
if (!allDocumentsSaved) {
|
||||
event.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function makeModifiersBitfield(e: MouseEvent | KeyboardEvent): number {
|
||||
return Number(e.ctrlKey) | (Number(e.shiftKey) << 1) | (Number(e.altKey) << 2);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// This file is instantiated by wasm-bindgen in `/frontend/wasm/src/lib.rs` and re-exports the `handleJsMessage` function to
|
||||
// provide access to the global copy of `js-message-dispatcher.ts` with its shared state, not an isolated duplicate with empty state
|
||||
|
||||
export { handleJsMessage } from "@/utilities/js-message-dispatcher";
|
||||
@@ -1,95 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import {
|
||||
JsMessage,
|
||||
DisplayConfirmationToCloseAllDocuments,
|
||||
DisplayConfirmationToCloseDocument,
|
||||
DisplayError,
|
||||
DisplayPanic,
|
||||
ExportDocument,
|
||||
newDisplayFolderTreeStructure as DisplayFolderTreeStructure,
|
||||
OpenDocumentBrowse,
|
||||
SaveDocument,
|
||||
SetActiveDocument,
|
||||
SetActiveTool,
|
||||
SetCanvasRotation,
|
||||
SetCanvasZoom,
|
||||
UpdateCanvas,
|
||||
UpdateOpenDocumentsList,
|
||||
UpdateRulers,
|
||||
UpdateScrollbars,
|
||||
UpdateWorkingColors,
|
||||
UpdateLayer,
|
||||
DisplayAboutGraphiteDialog,
|
||||
} from "@/utilities/js-messages";
|
||||
|
||||
const messageConstructors = {
|
||||
UpdateCanvas,
|
||||
UpdateScrollbars,
|
||||
UpdateRulers,
|
||||
ExportDocument,
|
||||
SaveDocument,
|
||||
OpenDocumentBrowse,
|
||||
DisplayFolderTreeStructure,
|
||||
UpdateLayer,
|
||||
SetActiveTool,
|
||||
SetActiveDocument,
|
||||
UpdateOpenDocumentsList,
|
||||
UpdateWorkingColors,
|
||||
SetCanvasZoom,
|
||||
SetCanvasRotation,
|
||||
DisplayError,
|
||||
DisplayPanic,
|
||||
DisplayConfirmationToCloseDocument,
|
||||
DisplayConfirmationToCloseAllDocuments,
|
||||
DisplayAboutGraphiteDialog,
|
||||
} as const;
|
||||
type JsMessageType = keyof typeof messageConstructors;
|
||||
|
||||
type JsMessageCallback<T extends JsMessage> = (messageData: T) => void;
|
||||
type JsMessageCallbackMap = {
|
||||
[message: string]: JsMessageCallback<any> | undefined;
|
||||
};
|
||||
|
||||
type Constructs<T> = new (...args: any[]) => T;
|
||||
type ConstructsJsMessage = Constructs<JsMessage> & typeof JsMessage;
|
||||
|
||||
const subscriptions = {} as JsMessageCallbackMap;
|
||||
|
||||
export function subscribeJsMessage<T extends JsMessage>(messageType: Constructs<T>, callback: JsMessageCallback<T>) {
|
||||
subscriptions[messageType.name] = callback;
|
||||
}
|
||||
|
||||
export function handleJsMessage(messageType: JsMessageType, messageData: any) {
|
||||
const messageConstructor = messageConstructors[messageType];
|
||||
if (!messageConstructor) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Received a frontend message of type "${messageType}" but but was not able to parse the data.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Messages with non-empty data are provided by wasm-bindgen as an object with one key as the message name, like: { NameOfThisMessage: { ... } }
|
||||
// Messages with empty data are provided by wasm-bindgen as a string with the message name, like: "NameOfThisMessage"
|
||||
const unwrappedMessageData = messageData[messageType] || {};
|
||||
|
||||
const isJsMessageConstructor = (fn: ConstructsJsMessage | ((data: any) => JsMessage)): fn is ConstructsJsMessage => {
|
||||
return (fn as ConstructsJsMessage).jsMessageMarker !== undefined;
|
||||
};
|
||||
let message: JsMessage;
|
||||
if (isJsMessageConstructor(messageConstructor)) {
|
||||
message = plainToInstance(messageConstructor, unwrappedMessageData);
|
||||
} else {
|
||||
message = messageConstructor(unwrappedMessageData);
|
||||
}
|
||||
|
||||
// It is ok to use constructor.name even with minification since it is used consistently with registerHandler
|
||||
const callback = subscriptions[message.constructor.name];
|
||||
|
||||
if (callback && message) {
|
||||
callback(message);
|
||||
} else if (message) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Received a frontend message of type "${messageType}" but no handler was registered for it from the client.`);
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable camelcase */
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import { Transform, Type } from "class-transformer";
|
||||
|
||||
export class JsMessage {
|
||||
// The marker provides a way to check if an object is a sub-class constructor for a jsMessage.
|
||||
static readonly jsMessageMarker = true;
|
||||
}
|
||||
|
||||
export class UpdateOpenDocumentsList extends JsMessage {
|
||||
@Transform(({ value }) => value.map((tuple: [string, boolean]) => ({ name: tuple[0], isSaved: tuple[1] })))
|
||||
readonly open_documents!: { name: string; isSaved: boolean }[];
|
||||
}
|
||||
|
||||
const To255Scale = Transform(({ value }) => value * 255);
|
||||
export class Color {
|
||||
@To255Scale
|
||||
readonly red!: number;
|
||||
|
||||
@To255Scale
|
||||
readonly green!: number;
|
||||
|
||||
@To255Scale
|
||||
readonly blue!: number;
|
||||
|
||||
readonly alpha!: number;
|
||||
|
||||
toRgba() {
|
||||
return { r: this.red, g: this.green, b: this.blue, a: this.alpha };
|
||||
}
|
||||
|
||||
toRgbaCSS() {
|
||||
const { r, g, b, a } = this.toRgba();
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateWorkingColors extends JsMessage {
|
||||
@Type(() => Color)
|
||||
readonly primary!: Color;
|
||||
|
||||
@Type(() => Color)
|
||||
readonly secondary!: Color;
|
||||
}
|
||||
|
||||
export class SetActiveTool extends JsMessage {
|
||||
readonly tool_name!: string;
|
||||
|
||||
readonly tool_options!: object;
|
||||
}
|
||||
|
||||
export class SetActiveDocument extends JsMessage {
|
||||
readonly document_index!: number;
|
||||
}
|
||||
|
||||
export class DisplayError extends JsMessage {
|
||||
readonly title!: string;
|
||||
|
||||
readonly description!: string;
|
||||
}
|
||||
|
||||
export class DisplayPanic extends JsMessage {
|
||||
readonly panic_info!: string;
|
||||
|
||||
readonly title!: string;
|
||||
|
||||
readonly description!: string;
|
||||
}
|
||||
|
||||
export class DisplayConfirmationToCloseDocument extends JsMessage {
|
||||
readonly document_index!: number;
|
||||
}
|
||||
|
||||
export class DisplayConfirmationToCloseAllDocuments extends JsMessage {}
|
||||
|
||||
export class DisplayAboutGraphiteDialog extends JsMessage {}
|
||||
|
||||
export class UpdateCanvas extends JsMessage {
|
||||
readonly document!: string;
|
||||
}
|
||||
|
||||
const TupleToVec2 = Transform(({ value }) => ({ x: value[0], y: value[1] }));
|
||||
|
||||
export class UpdateScrollbars extends JsMessage {
|
||||
@TupleToVec2
|
||||
readonly position!: { x: number; y: number };
|
||||
|
||||
@TupleToVec2
|
||||
readonly size!: { x: number; y: number };
|
||||
|
||||
@TupleToVec2
|
||||
readonly multiplier!: { x: number; y: number };
|
||||
}
|
||||
|
||||
export class UpdateRulers extends JsMessage {
|
||||
@TupleToVec2
|
||||
readonly origin!: { x: number; y: number };
|
||||
|
||||
readonly spacing!: number;
|
||||
|
||||
readonly interval!: number;
|
||||
}
|
||||
|
||||
export class ExportDocument extends JsMessage {
|
||||
readonly document!: string;
|
||||
|
||||
readonly name!: string;
|
||||
}
|
||||
|
||||
export class SaveDocument extends JsMessage {
|
||||
readonly document!: string;
|
||||
|
||||
readonly name!: string;
|
||||
}
|
||||
|
||||
export class OpenDocumentBrowse extends JsMessage {}
|
||||
|
||||
export class DocumentChanged extends JsMessage {}
|
||||
|
||||
export class DisplayFolderTreeStructure extends JsMessage {
|
||||
constructor(readonly layerId: BigInt, readonly children: DisplayFolderTreeStructure[]) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
export function newDisplayFolderTreeStructure(input: any): DisplayFolderTreeStructure {
|
||||
const { ptr, len } = input.data_buffer;
|
||||
const wasmMemoryBuffer = (window as any).wasmMemory().buffer;
|
||||
|
||||
// Decode the folder structure encoding
|
||||
const encoding = new DataView(wasmMemoryBuffer, ptr, len);
|
||||
|
||||
// The structure section indicates how to read through the upcoming layer list and assign depths to each layer
|
||||
const structureSectionLength = Number(encoding.getBigUint64(0, true));
|
||||
const structureSectionMsbSigned = new DataView(wasmMemoryBuffer, ptr + 8, structureSectionLength * 8);
|
||||
|
||||
// The layer IDs section lists each layer ID sequentially in the tree, as it will show up in the panel
|
||||
const layerIdsSection = new DataView(wasmMemoryBuffer, ptr + 8 + structureSectionLength * 8);
|
||||
|
||||
let layersEncountered = 0;
|
||||
let currentFolder = new DisplayFolderTreeStructure(BigInt(-1), []);
|
||||
const currentFolderStack = [currentFolder];
|
||||
|
||||
for (let i = 0; i < structureSectionLength; i += 1) {
|
||||
const msbSigned = structureSectionMsbSigned.getBigUint64(i * 8, true);
|
||||
const msbMask = BigInt(1) << BigInt(63);
|
||||
|
||||
// Set the MSB to 0 to clear the sign and then read the number as usual
|
||||
const numberOfLayersAtThisDepth = msbSigned & ~msbMask;
|
||||
|
||||
// Store child folders in the current folder (until we are interrupted by an indent)
|
||||
for (let j = 0; j < numberOfLayersAtThisDepth; j += 1) {
|
||||
const layerId = layerIdsSection.getBigUint64(layersEncountered * 8, true);
|
||||
layersEncountered += 1;
|
||||
|
||||
const childLayer = new DisplayFolderTreeStructure(layerId, []);
|
||||
currentFolder.children.push(childLayer);
|
||||
}
|
||||
|
||||
// Check the sign of the MSB, where a 1 is a negative (outward) indent
|
||||
const subsequentDirectionOfDepthChange = (msbSigned & msbMask) === BigInt(0);
|
||||
// Inward
|
||||
if (subsequentDirectionOfDepthChange) {
|
||||
currentFolderStack.push(currentFolder);
|
||||
currentFolder = currentFolder.children[currentFolder.children.length - 1];
|
||||
}
|
||||
// Outward
|
||||
else {
|
||||
const popped = currentFolderStack.pop();
|
||||
if (!popped) throw Error("Too many negative indents in the folder structure");
|
||||
if (popped) currentFolder = popped;
|
||||
}
|
||||
}
|
||||
|
||||
return currentFolder;
|
||||
}
|
||||
|
||||
export class UpdateLayer extends JsMessage {
|
||||
@Type(() => LayerPanelEntry)
|
||||
readonly data!: LayerPanelEntry;
|
||||
}
|
||||
|
||||
export class SetCanvasZoom extends JsMessage {
|
||||
readonly new_zoom!: number;
|
||||
}
|
||||
|
||||
export class SetCanvasRotation extends JsMessage {
|
||||
readonly new_radians!: number;
|
||||
}
|
||||
|
||||
function newPath(input: any): BigUint64Array {
|
||||
// eslint-disable-next-line
|
||||
const u32CombinedPairs = input.map((n: number[]) => BigInt((BigInt(n[0]) << BigInt(32)) | BigInt(n[1])));
|
||||
return new BigUint64Array(u32CombinedPairs);
|
||||
}
|
||||
|
||||
export type BlendMode =
|
||||
| "Normal"
|
||||
| "Multiply"
|
||||
| "Darken"
|
||||
| "ColorBurn"
|
||||
| "Screen"
|
||||
| "Lighten"
|
||||
| "ColorDodge"
|
||||
| "Overlay"
|
||||
| "SoftLight"
|
||||
| "HardLight"
|
||||
| "Difference"
|
||||
| "Exclusion"
|
||||
| "Hue"
|
||||
| "Saturation"
|
||||
| "Color"
|
||||
| "Luminosity";
|
||||
|
||||
export class LayerPanelEntry {
|
||||
name!: string;
|
||||
|
||||
visible!: boolean;
|
||||
|
||||
blend_mode!: BlendMode;
|
||||
|
||||
// On the rust side opacity is out of 1 rather than 100
|
||||
@Transform(({ value }) => value * 100)
|
||||
opacity!: number;
|
||||
|
||||
layer_type!: LayerType;
|
||||
|
||||
@Transform(({ value }) => newPath(value))
|
||||
path!: BigUint64Array;
|
||||
|
||||
@Type(() => LayerData)
|
||||
layer_data!: LayerData;
|
||||
|
||||
thumbnail!: string;
|
||||
}
|
||||
|
||||
export class LayerData {
|
||||
expanded!: boolean;
|
||||
|
||||
selected!: boolean;
|
||||
}
|
||||
|
||||
export const LayerTypeOptions = {
|
||||
Folder: "Folder",
|
||||
Shape: "Shape",
|
||||
Circle: "Circle",
|
||||
Rect: "Rect",
|
||||
Line: "Line",
|
||||
PolyLine: "PolyLine",
|
||||
Ellipse: "Ellipse",
|
||||
} as const;
|
||||
|
||||
export type LayerType = typeof LayerTypeOptions[keyof typeof LayerTypeOptions];
|
||||
@@ -1,32 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, func-names */
|
||||
|
||||
// Import this function and chain it on all `wasm` imports like: const wasm = 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
|
||||
export function panicProxy<T extends object>(module: T): T {
|
||||
const proxyHandler = {
|
||||
get(target: T, propKey: string | symbol, receiver: any): any {
|
||||
const targetValue = Reflect.get(target, propKey, receiver);
|
||||
|
||||
// Keep the original value being accessed if it isn't a function or it is a class
|
||||
// TODO: Figure out how to also wrap class constructor functions instead of skipping them for now
|
||||
const isFunction = typeof targetValue === "function";
|
||||
const isClass = isFunction && /^\s*class\s+/.test(targetValue.toString());
|
||||
if (!isFunction || isClass) return targetValue;
|
||||
|
||||
// Replace the original function with a wrapper function that runs the original in a try-catch block
|
||||
return function (...args: any) {
|
||||
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: any) {
|
||||
// Suppress `unreachable` WebAssembly.RuntimeError exceptions
|
||||
if (!`${err}`.startsWith("RuntimeError: unreachable")) throw err;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return new Proxy<T>(module, proxyHandler);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function stripIndents(stringPieces: TemplateStringsArray, ...substitutions: unknown[]) {
|
||||
const interleavedSubstitutions = stringPieces.flatMap((stringPiece, index) => [stringPiece, substitutions[index] !== undefined ? substitutions[index] : ""]);
|
||||
const stringLines = interleavedSubstitutions.join("").split("\n");
|
||||
|
||||
const visibleLineTabPrefixLengths = stringLines.map((line) => (line.match(/\S/) ? (line.match(/^(\t*)/) || [])[1].length : Infinity));
|
||||
const commonTabPrefixLength = Math.min(...visibleLineTabPrefixLengths);
|
||||
|
||||
const linesWithoutCommonTabPrefix = stringLines.map((line) => line.substring(commonTabPrefixLength));
|
||||
const multiLineString = linesWithoutCommonTabPrefix.join("\n");
|
||||
|
||||
return multiLineString.trim();
|
||||
}
|
||||
Reference in New Issue
Block a user