Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
});

onDestroy(() => {
// Destroy the Wasm editor handle
editor?.handle.free();
editor?.destroy();
});
</script>

Expand Down
22 changes: 10 additions & 12 deletions frontend/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,21 @@

Svelte components that build the Graphite editor GUI. These each contain a TypeScript section, a Svelte-templated HTML template section, and an SCSS stylesheet section. The aim is to avoid implementing much editor business logic here, just enough to make things interactive and communicate to the backend where the real business logic should occur.

## I/O managers: `io-managers/`
## Managers: `managers/`

TypeScript files which manage the input/output of browser APIs and link this functionality with the editor backend. These files subscribe to backend events to execute JS APIs, and in response to these APIs or user interactions, they may call functions into the backend (defined in `/frontend/wasm/editor_api.rs`).
TypeScript files which manage the input/output of browser APIs and link this functionality with the editor backend. These files subscribe to backend messages to execute JS APIs, and in response to these APIs or user interactions, they may call functions into the backend (defined in `/frontend/wasm/editor_api.rs`).

Each I/O manager is a self-contained module where one instance is created in `Editor.svelte` when it's mounted to the DOM at app startup.
Each manager module exports a factory function (e.g. `createClipboardManager(editor)`) that sets up message subscriptions and returns a `{ destroy }` object. In `Editor.svelte`, each manager is created at startup and its `destroy()` method is called on unmount to clean up subscriptions and side-effects (e.g. event listeners). Managers use self-accepting HMR to tear down and re-create with updated code during development.

During development when HMR (hot-module replacement) occurs, these are also unmounted to clean up after themselves, so they can be mounted again with the updated code. Therefore, any side-effects that these managers cause (e.g. adding event listeners to the page) need a destructor function that cleans them up. The destructor function, when applicable, is returned by the module and automatically called in `Editor.svelte` on unmount.
## Stores: `stores/`

## State providers: `state-providers/`
TypeScript files which provide reactive state to Svelte components. Each module persists a Svelte writable store at module level (surviving HMR via `import.meta.hot.data`) and exports a factory function (e.g. `createDialogStore(editor)`) that sets up backend message subscriptions and returns an object containing the store's `subscribe` method, any action methods for components to call, and a `destroy` method.

TypeScript files which provide reactive state and importable functions to Svelte components. Each module defines a Svelte writable store `const { subscribe, update } = writable({ .. });` and exports the `subscribe` method from the module in the returned object. Other functions may also be defined in the module and exported after `subscribe`, which provide a way for Svelte components to call functions to manipulate the state.
In `Editor.svelte`, each store is created and passed to Svelte's `setContext()`. Components access stores via `getContext<DialogStore>("dialog")` and use the `subscribe` method for reactive state and action methods (like `createCrashDialog()`) to trigger state changes.

In `Editor.svelte`, an instance of each of these are given to Svelte's `setContext()` function. This allows any component to access the state provider instance using `const exampleStateProvider = getContext<ExampleStateProvider>("exampleStateProvider");`.
## *Managers vs. stores*

## *I/O managers vs. state providers*

*Some state providers, similarly to I/O managers, may subscribe to backend events, call functions from `editor_api.rs` into the backend, and interact with browser APIs and user input. The difference is that state providers are meant to be made available to components via `getContext()` to use them for reactive state, while I/O managers are meant to be self-contained systems that operate for the lifetime of the application and aren't touched by Svelte components.*
*Both managers and stores subscribe to backend messages and may interact with browser APIs. The difference is that stores expose reactive state to components via `setContext()`/`getContext()`, while managers are self-contained systems that operate for the lifetime of the application and aren't accessed by Svelte components.*

## Utility functions: `utility-functions/`

Expand All @@ -30,7 +28,7 @@ TypeScript files which define and `export` individual helper functions for use e

Instantiates the Wasm and editor backend instances. The function `initWasm()` asynchronously constructs and initializes an instance of the Wasm bindings JS module provided by wasm-bindgen/wasm-pack. The function `createEditor()` constructs an instance of the editor backend. In theory there could be multiple editor instances sharing the same Wasm module instance. The function returns an object where `raw` is the Wasm memory, `handle` provides access to callable backend functions, and `subscriptions` is the subscription router (described below).

`initWasm()` occurs in `main.ts` right before the Svelte application is mounted, then `createEditor()` is run in `Editor.svelte` during the Svelte app's creation. Similarly to the state providers described above, the editor is given via `setContext()` so other components can get it via `getContext` and call functions on `editor.handle` or `editor.subscriptions`.
`initWasm()` occurs in `main.ts` right before the Svelte application is mounted, then `createEditor()` is run in `Editor.svelte` during the Svelte app's creation. Similarly to the stores described above, the editor is given via `setContext()` so other components can get it via `getContext` and call functions on `editor.handle` or `editor.subscriptions`.

## Subscription router: `subscription-router.ts`

Expand All @@ -42,7 +40,7 @@ The entry point for the Svelte application.

## Editor base instance: `Editor.svelte`

This is where we define global CSS style rules, create/destroy the editor instance, construct/destruct the I/O managers, and construct and `setContext()` the state providers.
This is where we define global CSS style rules, construct all stores and managers with the editor instance, set store contexts for component access, and clean up all `destroy()` methods on unmount.

## Global type augmentations: `global.d.ts`

Expand Down
78 changes: 37 additions & 41 deletions frontend/src/components/Editor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,56 @@
import { onMount, onDestroy, setContext } from "svelte";

import type { Editor } from "@graphite/editor";
import { createClipboardManager } from "@graphite/io-managers/clipboard";
import { createHyperlinkManager } from "@graphite/io-managers/hyperlink";
import { createInputManager } from "@graphite/io-managers/input";
import { createLocalizationManager } from "@graphite/io-managers/localization";
import { createPanicManager } from "@graphite/io-managers/panic";
import { createPersistenceManager } from "@graphite/io-managers/persistence";
import { createAppWindowState } from "@graphite/state-providers/app-window";
import { createDialogState } from "@graphite/state-providers/dialog";
import { createDocumentState } from "@graphite/state-providers/document";
import { createFontsManager } from "/src/io-managers/fonts";
import { createFullscreenState } from "@graphite/state-providers/fullscreen";
import { createNodeGraphState } from "@graphite/state-providers/node-graph";
import { createPortfolioState } from "@graphite/state-providers/portfolio";
import { createTooltipState } from "@graphite/state-providers/tooltip";
import { createClipboardManager } from "@graphite/managers/clipboard";
import { createFontsManager } from "@graphite/managers/fonts";
import { createHyperlinkManager } from "@graphite/managers/hyperlink";
import { createInputManager } from "@graphite/managers/input";
import { createLocalizationManager } from "@graphite/managers/localization";
import { createPanicManager } from "@graphite/managers/panic";
import { createPersistenceManager } from "@graphite/managers/persistence";
import { createAppWindowStore } from "@graphite/stores/app-window";
import { createDialogStore } from "@graphite/stores/dialog";
import { createDocumentStore } from "@graphite/stores/document";
import { createFullscreenStore } from "@graphite/stores/fullscreen";
import { createNodeGraphStore } from "@graphite/stores/node-graph";
import { createPortfolioStore } from "@graphite/stores/portfolio";
import { createTooltipStore } from "@graphite/stores/tooltip";

import MainWindow from "@graphite/components/window/MainWindow.svelte";

// Graphite Wasm editor
export let editor: Editor;
setContext("editor", editor);

// State provider systems
let dialog = createDialogState(editor);
setContext("dialog", dialog);
let tooltip = createTooltipState(editor);
setContext("tooltip", tooltip);
let document = createDocumentState(editor);
setContext("document", document);
let fullscreen = createFullscreenState(editor);
setContext("fullscreen", fullscreen);
let nodeGraph = createNodeGraphState(editor);
setContext("nodeGraph", nodeGraph);
let portfolio = createPortfolioState(editor);
setContext("portfolio", portfolio);
let appWindow = createAppWindowState(editor);
setContext("appWindow", appWindow);

// Initialize managers, which are isolated systems that subscribe to backend messages to link them to browser API functionality (like JS events, IndexedDB, etc.)
createClipboardManager(editor);
createHyperlinkManager(editor);
createLocalizationManager(editor);
createPanicManager(editor, dialog);
createPersistenceManager(editor, portfolio);
createFontsManager(editor);
let inputManagerDestructor = createInputManager(editor, dialog, portfolio, document, fullscreen);
const stores = {
dialog: createDialogStore(editor),
tooltip: createTooltipStore(editor),
document: createDocumentStore(editor),
fullscreen: createFullscreenStore(editor),
nodeGraph: createNodeGraphStore(editor),
portfolio: createPortfolioStore(editor),
appWindow: createAppWindowStore(editor),
};
Object.entries(stores).forEach(([key, store]) => setContext(key, store));

const managers = {
clipboard: createClipboardManager(editor),
hyperlink: createHyperlinkManager(editor),
localization: createLocalizationManager(editor),
panic: createPanicManager(editor),
persistence: createPersistenceManager(editor, stores.portfolio),
fonts: createFontsManager(editor),
input: createInputManager(editor, stores.dialog, stores.portfolio, stores.document, stores.fullscreen),
};

onMount(() => {
// Initialize certain setup tasks required by the editor backend to be ready for the user now that the frontend is ready
// Initialize certain setup tasks required by the editor backend to be ready for the user now that the frontend is ready.
// The backend handles idempotency, so this is safe to call again during HMR re-mounts.
editor.handle.initAfterFrontendReady();
});

onDestroy(() => {
// Call the destructor for each manager
inputManagerDestructor();
[...Object.values(stores), ...Object.values(managers)].forEach(({ destroy }) => destroy());
});
</script>

Expand Down
9 changes: 6 additions & 3 deletions frontend/src/components/floating-menus/ColorPicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { isPlatformNative } from "@graphite/../wasm/pkg/graphite_wasm";
import type { FillChoice, MenuDirection, Color } from "@graphite/../wasm/pkg/graphite_wasm";
import type { TooltipState } from "@graphite/state-providers/tooltip";
import type { TooltipStore } from "@graphite/stores/tooltip";
import {
contrastingOutlineFactor,
fillChoiceColor,
Expand All @@ -22,7 +22,6 @@
gradientFirstColor,
} from "@graphite/utility-functions/colors";
import type { HSV, RGB } from "@graphite/utility-functions/colors";
import { clamp } from "@graphite/utility-functions/math";

import FloatingMenu, { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
Expand Down Expand Up @@ -57,7 +56,7 @@
];

const dispatch = createEventDispatcher<{ colorOrGradient: FillChoice; startHistoryTransaction: undefined; commitHistoryTransaction: undefined }>();
const tooltip = getContext<TooltipState>("tooltip");
const tooltip = getContext<TooltipStore>("tooltip");

export let colorOrGradient: FillChoice;
export let allowNone = false;
Expand Down Expand Up @@ -438,6 +437,10 @@
setOldHSVA(hsv.h, hsv.s, hsv.v, color.alpha, false);
}

function clamp(value: number, min = 0, max = 1): number {
return Math.max(min, Math.min(value, max));
}

export function div(): HTMLDivElement | undefined {
return self?.div();
}
Expand Down
11 changes: 5 additions & 6 deletions frontend/src/components/floating-menus/Dialog.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
<script lang="ts">
import { getContext, onMount } from "svelte";

import { githubUrl } from "@graphite/io-managers/panic";
import { wipeDocuments } from "@graphite/io-managers/persistence";

import type { DialogState } from "@graphite/state-providers/dialog";
import { wipeDocuments } from "@graphite/managers/persistence";
import type { DialogStore } from "@graphite/stores/dialog";
import { crashReportUrl } from "/src/utility-functions/crash-report";

import FloatingMenu from "@graphite/components/layout/FloatingMenu.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
Expand All @@ -14,7 +13,7 @@
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
import WidgetLayout from "@graphite/components/widgets/WidgetLayout.svelte";

const dialog = getContext<DialogState>("dialog");
const dialog = getContext<DialogStore>("dialog");

let self: FloatingMenu | undefined;

Expand Down Expand Up @@ -43,7 +42,7 @@
<div class="widget-layout details">
<div class="widget-span row"><TextLabel bold={true}>The editor crashed — sorry about that</TextLabel></div>
<div class="widget-span row"><TextLabel>Please report this by filing an issue on GitHub:</TextLabel></div>
<div class="widget-span row"><TextButton label="Report Bug" icon="Warning" flush={true} action={() => window.open(githubUrl($dialog.panicDetails), "_blank")} /></div>
<div class="widget-span row"><TextButton label="Report Bug" icon="Warning" flush={true} action={() => window.open(crashReportUrl($dialog.panicDetails), "_blank")} /></div>
<div class="widget-span row"><TextLabel multiline={true}>Reload the editor to continue. If this occurs<br />immediately on repeated reloads, clear storage:</TextLabel></div>
<div class="widget-span row">
<TextButton
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/components/floating-menus/EyedropperPreview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@

import FloatingMenu from "@graphite/components/layout/FloatingMenu.svelte";

const temporaryCanvas = document.createElement("canvas");
temporaryCanvas.width = ZOOM_WINDOW_DIMENSIONS;
temporaryCanvas.height = ZOOM_WINDOW_DIMENSIONS;

let temporaryCanvas: HTMLCanvasElement | undefined;
let zoomPreviewCanvas: HTMLCanvasElement | undefined;

export let imageData: ImageData | undefined = undefined;
Expand All @@ -31,6 +28,11 @@
if (!zoomPreviewCanvas) return;
const context = zoomPreviewCanvas.getContext("2d");

if (!temporaryCanvas) {
temporaryCanvas = document.createElement("canvas");
temporaryCanvas.width = ZOOM_WINDOW_DIMENSIONS;
temporaryCanvas.height = ZOOM_WINDOW_DIMENSIONS;
}
const temporaryContext = temporaryCanvas.getContext("2d");

if (!imageData || !context || !temporaryContext) return;
Expand Down
23 changes: 17 additions & 6 deletions frontend/src/components/floating-menus/MenuList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
let reactiveEntries = entries;
let highlighted: MenuListEntry | undefined = activeEntry;
let virtualScrollingEntriesStart = 0;
let keydownListenerAdded = false;
let destroyed = false;

// `watchOpen` is called only when `open` is changed from outside this component
$: watchOpen(open);
Expand All @@ -67,11 +69,15 @@
// TODO: The current approach is hacky and blocks the allowances for shortcuts like the key to open the browser's dev tools.
onMount(async () => {
await tick();
if (open && !inNestedMenuList()) addEventListener("keydown", keydown);
if (!destroyed && open && !inNestedMenuList() && !keydownListenerAdded) {
addEventListener("keydown", keydown);
keydownListenerAdded = true;
}
});
onDestroy(async () => {
await tick();
if (!inNestedMenuList()) removeEventListener("keydown", keydown);
onDestroy(() => {
removeEventListener("keydown", keydown);
// Set the destroyed status in the closure kept by the awaited `tick()` in `onMount` in case that delayed run occurs after the component is destroyed
destroyed = true;
});

function inNestedMenuList(): boolean {
Expand Down Expand Up @@ -129,8 +135,13 @@
}

function watchOpen(open: boolean) {
if (open && !inNestedMenuList()) addEventListener("keydown", keydown);
else if (!inNestedMenuList()) removeEventListener("keydown", keydown);
if (open && !inNestedMenuList() && !keydownListenerAdded) {
addEventListener("keydown", keydown);
keydownListenerAdded = true;
} else if (!open && !inNestedMenuList() && keydownListenerAdded) {
removeEventListener("keydown", keydown);
keydownListenerAdded = false;
}

highlighted = activeEntry;
dispatch("open", open);
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/floating-menus/NodeCatalog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
import { SvelteMap } from "svelte/reactivity";

import type { FrontendNodeType } from "@graphite/../wasm/pkg/graphite_wasm";
import type { NodeGraphState } from "@graphite/state-providers/node-graph";
import type { NodeGraphStore } from "@graphite/stores/node-graph";

import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import TextButton from "@graphite/components/widgets/buttons/TextButton.svelte";
import TextInput from "@graphite/components/widgets/inputs/TextInput.svelte";
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";

const dispatch = createEventDispatcher<{ selectNodeType: string }>();
const nodeGraph = getContext<NodeGraphState>("nodeGraph");
const nodeGraph = getContext<NodeGraphStore>("nodeGraph");

// Content
export let disabled = false;
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/floating-menus/Tooltip.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@

import type { LabeledShortcut } from "@graphite/../wasm/pkg/graphite_wasm";
import type { Editor } from "@graphite/editor";
import type { TooltipState } from "@graphite/state-providers/tooltip";
import type { TooltipStore } from "@graphite/stores/tooltip";

import FloatingMenu from "@graphite/components/layout/FloatingMenu.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
import ShortcutLabel from "@graphite/components/widgets/labels/ShortcutLabel.svelte";
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";

const tooltip = getContext<TooltipState>("tooltip");
const tooltip = getContext<TooltipStore>("tooltip");
const editor = getContext<Editor>("editor");

let self: FloatingMenu | undefined;
Expand Down
Loading