mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-21 02:28:12 +08:00
Fix frontend HMR so the page doesn't break upon saving TS files and restructure frontend architecture (#3871)
* Clean up component setup/tear-down side effects * Clean up more component setup/tear-down side effects * Remove nonfunctional debouncer * Clean up even more component setup/tear-down side effects * Reuse backend state * Fix HMR for IO Managers and for State Providers * Rename IO Managers -> Managers and State Providers -> Stores * Restructure and partially flatten managers/stores * Code review fixes * Review fixes
This commit is contained in:
@@ -15,8 +15,7 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Destroy the Wasm editor handle
|
||||
editor?.handle.free();
|
||||
editor?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+10
-12
@@ -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/`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
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";
|
||||
|
||||
@@ -23,39 +23,35 @@
|
||||
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);
|
||||
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));
|
||||
|
||||
// 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 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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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 {
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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";
|
||||
@@ -11,7 +11,7 @@
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, afterUpdate, createEventDispatcher, tick } from "svelte";
|
||||
import { onMount, onDestroy, afterUpdate, createEventDispatcher, tick } from "svelte";
|
||||
|
||||
import type { MenuDirection } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import { browserVersion } from "@graphite/utility-functions/platform";
|
||||
@@ -54,9 +54,11 @@
|
||||
// tell the floating menu content to use it as a min-width so the floating menu is at least the width of the parent element's floating menu spawner.
|
||||
// This is the opposite concern of the natural width measurement system, which gets the natural width of the floating menu content in order for the
|
||||
// spawner widget to optionally set its min-size to the floating menu's natural width.
|
||||
let containerResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
const containerResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
resizeObserverCallback(entries);
|
||||
});
|
||||
|
||||
let dialogResizeObserver: ResizeObserver | undefined;
|
||||
let wasOpen = open;
|
||||
let measuringOngoing = false;
|
||||
let measuringOngoingGuard = false;
|
||||
@@ -79,16 +81,7 @@
|
||||
|
||||
// Called only when `open` is changed from outside this component
|
||||
async function watchOpenChange(isOpen: boolean) {
|
||||
// Mitigate a Safari rendering bug which clips the floating menu extending beyond a scrollable container.
|
||||
// The bug is possibly related to <https://bugs.webkit.org/show_bug.cgi?id=160953>, but in our case it happens when `overflow` of a parent is `auto` rather than `hidden`.
|
||||
if (browserVersion().toLowerCase().includes("safari")) {
|
||||
const scrollable = self?.closest("[data-scrollable-x], [data-scrollable-y]");
|
||||
if (scrollable instanceof HTMLElement) {
|
||||
// The issue exists when the container is set to `overflow: auto` but fine when `overflow: hidden`. So this workaround temporarily sets
|
||||
// the scrollable container to `overflow: hidden`, thus removing the scrollbars and ability to scroll until the floating menu is closed.
|
||||
scrollable.style.overflow = isOpen ? "hidden" : "";
|
||||
}
|
||||
}
|
||||
setSafariScrollableOverflow(isOpen);
|
||||
|
||||
// Switching from closed to open
|
||||
if (isOpen && !wasOpen) {
|
||||
@@ -129,12 +122,22 @@
|
||||
wasOpen = isOpen;
|
||||
}
|
||||
|
||||
// Mitigate a Safari rendering bug which clips the floating menu extending beyond a scrollable container. The bug is possibly related to
|
||||
// <https://bugs.webkit.org/show_bug.cgi?id=160953>, but in our case it happens when `overflow` of a parent is `auto` rather than `hidden`.
|
||||
// The issue exists when the container is set to `overflow: auto` but fine when `overflow: hidden`. So this workaround temporarily sets
|
||||
// the scrollable container to `overflow: hidden`, thus removing the scrollbars and ability to scroll until the floating menu is closed.
|
||||
function setSafariScrollableOverflow(hidden: boolean) {
|
||||
if (!browserVersion().toLowerCase().includes("safari")) return;
|
||||
const scrollable = self?.closest("[data-scrollable-x], [data-scrollable-y]");
|
||||
if (scrollable instanceof HTMLElement) scrollable.style.overflow = hidden ? "hidden" : "";
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Measure the content and round up its width and height to the nearest even integer.
|
||||
// This solves antialiasing issues when the content isn't cleanly divisible by 2 and gets translated by (-50%, -50%) causing all its content to be blurry.
|
||||
const floatingMenuContentDiv = floatingMenuContent?.div?.();
|
||||
if (type === "Dialog" && floatingMenuContentDiv) {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
dialogResizeObserver = new ResizeObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const existingWidth = Number(floatingMenuContentDiv.style.getPropertyValue("--even-integer-subpixel-expansion-x"));
|
||||
const existingHeight = Number(floatingMenuContentDiv.style.getPropertyValue("--even-integer-subpixel-expansion-y"));
|
||||
@@ -153,10 +156,23 @@
|
||||
floatingMenuContentDiv.style.setProperty("--even-integer-subpixel-expansion-y", `${targetHeight - height}`);
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(floatingMenuContentDiv);
|
||||
dialogResizeObserver.observe(floatingMenuContentDiv);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
containerResizeObserver.disconnect();
|
||||
dialogResizeObserver?.disconnect();
|
||||
window.removeEventListener("pointermove", pointerMoveHandler);
|
||||
window.removeEventListener("keydown", keyDownHandler);
|
||||
window.removeEventListener("pointerdown", pointerDownHandler);
|
||||
window.removeEventListener("pointerup", pointerUpHandler);
|
||||
window.removeEventListener("click", clickHandlerCapture, true);
|
||||
|
||||
// Revert Safari overflow workaround if the menu was open when destroyed
|
||||
if (open) setSafariScrollableOverflow(false);
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
// Gets the client bounds of the elements and apply relevant styles to them.
|
||||
// TODO: Use DOM attribute bindings more whilst not causing recursive updates. Turning measuring on and off both causes the component to change,
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
|
||||
import type { Color, MenuDirection, MouseCursorIcon } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { AppWindowState } from "@graphite/state-providers/app-window";
|
||||
import type { DocumentState } from "@graphite/state-providers/document";
|
||||
import type { AppWindowStore } from "@graphite/stores/app-window";
|
||||
import type { DocumentStore } from "@graphite/stores/document";
|
||||
import type { MessageBody } from "@graphite/subscription-router";
|
||||
import { fillChoiceColor, createColor } from "@graphite/utility-functions/colors";
|
||||
import { pasteFile } from "@graphite/utility-functions/files";
|
||||
import { textInputCleanup } from "@graphite/utility-functions/keyboard-entry";
|
||||
import { rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization";
|
||||
import { setupViewportResizeObserver, cleanupViewportResizeObserver } from "@graphite/utility-functions/viewports";
|
||||
import { setupViewportResizeObserver } from "@graphite/utility-functions/viewports";
|
||||
|
||||
import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte";
|
||||
import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@graphite/components/floating-menus/EyedropperPreview.svelte";
|
||||
@@ -27,8 +27,8 @@
|
||||
let gradientStopPicker: ColorPicker | undefined;
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
const appWindow = getContext<AppWindowState>("appWindow");
|
||||
const document = getContext<DocumentState>("document");
|
||||
const appWindow = getContext<AppWindowStore>("appWindow");
|
||||
const document = getContext<DocumentStore>("document");
|
||||
|
||||
// Interactive text editing
|
||||
let textInput: undefined | HTMLDivElement = undefined;
|
||||
@@ -74,6 +74,10 @@
|
||||
let canvasHeight: number | undefined = undefined;
|
||||
|
||||
let devicePixelRatio: number | undefined;
|
||||
let removeUpdatePixelRatio: (() => void) | undefined;
|
||||
let viewportResizeObserver: ResizeObserver | undefined;
|
||||
let cleanupViewportResizeObserver: (() => void) | undefined;
|
||||
let addedFontFaces: FontFace[] = [];
|
||||
|
||||
// Dimension is rounded up to the nearest even number because resizing is centered, and dividing an odd number by 2 for centering causes antialiasing
|
||||
$: canvasWidthRoundedToEven = canvasWidth && (canvasWidth % 2 === 1 ? canvasWidth + 1 : canvasWidth);
|
||||
@@ -375,7 +379,9 @@
|
||||
|
||||
if (data.fontData.length > 0 && data.fontData.buffer instanceof ArrayBuffer) {
|
||||
const fontView = new Uint8Array(data.fontData.buffer, data.fontData.byteOffset, data.fontData.byteLength);
|
||||
window.document.fonts.add(new FontFace("text-font", fontView));
|
||||
const face = new FontFace("text-font", fontView);
|
||||
window.document.fonts.add(face);
|
||||
addedFontFaces.push(face);
|
||||
textInput.style.fontFamily = "text-font";
|
||||
}
|
||||
|
||||
@@ -436,7 +442,6 @@
|
||||
// Not compatible with Safari:
|
||||
// <https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio#browser_compatibility>
|
||||
// <https://bugs.webkit.org/show_bug.cgi?id=124862>
|
||||
let removeUpdatePixelRatio: (() => void) | undefined = undefined;
|
||||
const updatePixelRatio = () => {
|
||||
removeUpdatePixelRatio?.();
|
||||
const mediaQueryList = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
||||
@@ -510,7 +515,9 @@
|
||||
|
||||
if (textInput && data.fontData.length > 0 && data.fontData.buffer instanceof ArrayBuffer) {
|
||||
const fontView = new Uint8Array(data.fontData.buffer, data.fontData.byteOffset, data.fontData.byteLength);
|
||||
window.document.fonts.add(new FontFace("text-font", fontView));
|
||||
const face = new FontFace("text-font", fontView);
|
||||
window.document.fonts.add(face);
|
||||
addedFontFaces.push(face);
|
||||
textInput.style.fontFamily = "text-font";
|
||||
}
|
||||
});
|
||||
@@ -525,18 +532,32 @@
|
||||
|
||||
// Setup ResizeObserver for pixel-perfect viewport tracking with physical dimensions
|
||||
// This must happen in onMount to ensure the viewport container element exists
|
||||
setupViewportResizeObserver(editor);
|
||||
cleanupViewportResizeObserver = setupViewportResizeObserver(editor);
|
||||
|
||||
// Also observe the inner viewport for canvas sizing and ruler updates
|
||||
const viewportResizeObserver = new ResizeObserver(() => {
|
||||
viewportResizeObserver = new ResizeObserver(() => {
|
||||
updateViewportInfo();
|
||||
});
|
||||
if (viewport) viewportResizeObserver.observe(viewport);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Cleanup the viewport resize observer
|
||||
cleanupViewportResizeObserver();
|
||||
cleanupViewportResizeObserver?.();
|
||||
viewportResizeObserver?.disconnect();
|
||||
removeUpdatePixelRatio?.();
|
||||
addedFontFaces.forEach((face) => window.document.fonts.delete(face));
|
||||
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateDocumentArtwork");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateEyedropperSamplingState");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateGradientStopColorPickerPosition");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateDocumentScrollbars");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateDocumentRulers");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateMouseCursor");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerTextCommit");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("DisplayEditableTextbox");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("DisplayEditableTextboxUpdateFontData");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("DisplayEditableTextboxTransform");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("DisplayRemoveEditableTextbox");
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
import type { LayerPanelEntry, LayerStructureEntry, Layout } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { NodeGraphState } from "@graphite/state-providers/node-graph";
|
||||
import type { TooltipState } from "@graphite/state-providers/tooltip";
|
||||
import type { NodeGraphStore } from "@graphite/stores/node-graph";
|
||||
import type { TooltipStore } from "@graphite/stores/tooltip";
|
||||
import { pasteFile } from "@graphite/utility-functions/files";
|
||||
import { operatingSystem } from "@graphite/utility-functions/platform";
|
||||
import { patchLayout } from "@graphite/utility-functions/widgets";
|
||||
@@ -42,8 +42,8 @@
|
||||
};
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
const nodeGraph = getContext<NodeGraphState>("nodeGraph");
|
||||
const tooltip = getContext<TooltipState>("tooltip");
|
||||
const nodeGraph = getContext<NodeGraphStore>("nodeGraph");
|
||||
const tooltip = getContext<TooltipStore>("tooltip");
|
||||
|
||||
let list: LayoutCol | undefined;
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
import type { FrontendGraphInput, FrontendGraphOutput, FrontendNode } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { DocumentState } from "@graphite/state-providers/document";
|
||||
import type { NodeGraphState } from "@graphite/state-providers/node-graph";
|
||||
import type { DocumentStore } from "@graphite/stores/document";
|
||||
import { closeContextMenu } from "@graphite/stores/node-graph";
|
||||
import type { NodeGraphStore } from "@graphite/stores/node-graph";
|
||||
|
||||
import NodeCatalog from "@graphite/components/floating-menus/NodeCatalog.svelte";
|
||||
import FloatingMenu from "@graphite/components/layout/FloatingMenu.svelte";
|
||||
@@ -20,8 +21,8 @@
|
||||
const FADE_TRANSITION = { duration: 200, easing: cubicInOut };
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
const nodeGraph = getContext<NodeGraphState>("nodeGraph");
|
||||
const documentState = getContext<DocumentState>("document");
|
||||
const nodeGraph = getContext<NodeGraphStore>("nodeGraph");
|
||||
const documentState = getContext<DocumentStore>("document");
|
||||
|
||||
let graph: HTMLDivElement | undefined;
|
||||
|
||||
@@ -29,7 +30,7 @@
|
||||
$: gridDotRadius = 1 + Math.floor($nodeGraph.transform.scale - 0.5 + 0.001) / 2;
|
||||
|
||||
// Close the context menu when the graph view overlay is closed
|
||||
$: if (!$documentState.graphViewOverlayOpen) nodeGraph.closeContextMenu();
|
||||
$: if (!$documentState.graphViewOverlayOpen) closeContextMenu();
|
||||
|
||||
let inputElement: HTMLInputElement;
|
||||
let hoveringImportIndex: number | undefined = undefined;
|
||||
@@ -220,7 +221,7 @@
|
||||
label="Merge Selected Nodes"
|
||||
action={() => {
|
||||
editor.handle.mergeSelectedNodes();
|
||||
nodeGraph.closeContextMenu();
|
||||
closeContextMenu();
|
||||
}}
|
||||
flush={true}
|
||||
/>
|
||||
@@ -231,7 +232,7 @@
|
||||
if ($nodeGraph.contextMenuInformation?.contextMenuData.type === "ModifyNode") {
|
||||
editor.handle.setToNodeOrLayer($nodeGraph.contextMenuInformation.contextMenuData.data.nodeId, currentlyIsNode);
|
||||
}
|
||||
nodeGraph.closeContextMenu();
|
||||
closeContextMenu();
|
||||
}}
|
||||
disabled={!$nodeGraph.contextMenuInformation.contextMenuData.data.canBeLayer}
|
||||
flush={true}
|
||||
@@ -247,7 +248,7 @@
|
||||
} else {
|
||||
editor.handle.toggleLayerLock(nodeId);
|
||||
}
|
||||
nodeGraph.closeContextMenu();
|
||||
closeContextMenu();
|
||||
}}
|
||||
flush={true}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import type { LayoutTarget, Widget, WidgetInstance } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import { parseFillChoice } from "@graphite/utility-functions/colors";
|
||||
import { debouncer } from "@graphite/utility-functions/debounce";
|
||||
|
||||
import NodeCatalog from "@graphite/components/floating-menus/NodeCatalog.svelte";
|
||||
import BreadcrumbTrailButtons from "@graphite/components/widgets/buttons/BreadcrumbTrailButtons.svelte";
|
||||
@@ -137,7 +136,7 @@
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: {
|
||||
value: (e: CustomEvent) => debouncer((value: unknown) => widgetValueCommitAndUpdate(index, value, false), { debounceTime: 120 }).debounceUpdateValue(e.detail),
|
||||
value: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, false),
|
||||
},
|
||||
}),
|
||||
},
|
||||
@@ -202,7 +201,7 @@
|
||||
incrementCallbackIncrease: () => widgetValueCommitAndUpdate(index, "Increment", false),
|
||||
incrementCallbackDecrease: () => widgetValueCommitAndUpdate(index, "Decrement", false),
|
||||
$$events: {
|
||||
value: (e: CustomEvent) => debouncer((value: unknown) => widgetValueUpdate(index, value, true)).debounceUpdateValue(e.detail),
|
||||
value: (e: CustomEvent) => widgetValueUpdate(index, e.detail, true),
|
||||
startHistoryTransaction: () => widgetValueCommit(index, props.value),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import type { Curve, CurveManipulatorGroup, ActionShortcut } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import { clamp } from "@graphite/utility-functions/math";
|
||||
|
||||
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
||||
|
||||
@@ -185,6 +184,10 @@
|
||||
dAttribute = recalculateSvgPath();
|
||||
updateCurve();
|
||||
}
|
||||
|
||||
function clamp(value: number, min = 0, max = 1): number {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="curve-input" {tooltipLabel} {tooltipDescription} {tooltipShortcut}>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { evaluateMathExpression, isPlatformNative } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { NumberInputMode, NumberInputIncrementBehavior, ActionShortcut } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/io-managers/input";
|
||||
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/managers/input";
|
||||
import { browserVersion } from "@graphite/utility-functions/platform";
|
||||
|
||||
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
|
||||
@@ -87,6 +87,12 @@
|
||||
let shiftKeyDown = false;
|
||||
// Track whether the Ctrl key is currently held down.
|
||||
let ctrlKeyDown = false;
|
||||
// Cleanup function for active drag interactions, called on destroy to prevent leaked listeners
|
||||
let activeDragCleanup: (() => void) | undefined;
|
||||
// Track the slider abort state for cleanup on destroy
|
||||
let sliderResetAbortHandler: (() => void) | undefined;
|
||||
let sliderAbortTimeout1: ReturnType<typeof setTimeout> | undefined;
|
||||
let sliderAbortTimeout2: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
$: watchValue(value, unit);
|
||||
$: sliderStepValue = isInteger ? (step === undefined ? 1 : step) : "any";
|
||||
@@ -107,10 +113,31 @@
|
||||
addEventListener("mousemove", trackShiftAndCtrl);
|
||||
});
|
||||
onDestroy(() => {
|
||||
clearTimeout(repeatTimeout);
|
||||
clearTimeout(sliderAbortTimeout1);
|
||||
clearTimeout(sliderAbortTimeout2);
|
||||
|
||||
activeDragCleanup?.();
|
||||
|
||||
// Exit pointer lock if active (non-Safari path)
|
||||
if (document.pointerLockElement) document.exitPointerLock();
|
||||
|
||||
// Remove Safari cursor-hidden workaround class if present
|
||||
const isSafari = browserVersion().toLowerCase().includes("safari");
|
||||
if (isSafari) document.body.classList.remove("cursor-hidden");
|
||||
|
||||
// Clean up any listeners related to tracking the Shift and Ctrl keys
|
||||
removeEventListener("keydown", trackShiftAndCtrl);
|
||||
removeEventListener("keyup", trackShiftAndCtrl);
|
||||
removeEventListener("mousemove", trackShiftAndCtrl);
|
||||
clearTimeout(repeatTimeout);
|
||||
|
||||
// Clean up any slider-related listeners that may be active
|
||||
removeEventListener("mousedown", sliderAbortFromMousedown);
|
||||
removeEventListener("keydown", sliderAbortFromMousedown);
|
||||
removeEventListener("pointermove", sliderAbortFromDragging);
|
||||
removeEventListener("keydown", sliderAbortFromDragging);
|
||||
removeEventListener("keydown", incrementPressAbort);
|
||||
if (sliderResetAbortHandler) removeEventListener("pointerup", sliderResetAbortHandler);
|
||||
});
|
||||
|
||||
// ===============================
|
||||
@@ -297,7 +324,7 @@
|
||||
pressingArrow = false;
|
||||
clearTimeout(repeatTimeout);
|
||||
updateValue(initialValueBeforeDragging);
|
||||
removeEventListener("keydown", onIncrementPointerUp);
|
||||
removeEventListener("keydown", incrementPressAbort);
|
||||
}
|
||||
|
||||
// =======================================
|
||||
@@ -333,10 +360,9 @@
|
||||
alreadyActedGuard = true;
|
||||
|
||||
isDragging = true;
|
||||
beginDrag(e);
|
||||
|
||||
removeEventListener("pointermove", onMove);
|
||||
removeEventListener("pointerup", onUp);
|
||||
activeDragCleanup?.();
|
||||
beginDrag(e);
|
||||
};
|
||||
// If it's a mouseup, we'll begin editing the text field.
|
||||
const onUp = () => {
|
||||
@@ -346,11 +372,15 @@
|
||||
isDragging = false;
|
||||
self?.focus();
|
||||
|
||||
removeEventListener("pointermove", onMove);
|
||||
removeEventListener("pointerup", onUp);
|
||||
activeDragCleanup?.();
|
||||
};
|
||||
addEventListener("pointermove", onMove);
|
||||
addEventListener("pointerup", onUp);
|
||||
activeDragCleanup = () => {
|
||||
removeEventListener("pointermove", onMove);
|
||||
removeEventListener("pointerup", onUp);
|
||||
activeDragCleanup = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
function beginDrag(e: PointerEvent) {
|
||||
@@ -449,16 +479,20 @@
|
||||
cumulativeDragDelta = 0;
|
||||
|
||||
// Clean up the event listeners.
|
||||
removeEventListener("pointerup", pointerUp);
|
||||
removeEventListener("pointermove", pointerMove);
|
||||
removeEventListener("pointerlockmove", pointerLockMove);
|
||||
if (usePointerLock) document.removeEventListener("pointerlockchange", pointerLockChange);
|
||||
activeDragCleanup?.();
|
||||
};
|
||||
|
||||
addEventListener("pointerup", pointerUp);
|
||||
addEventListener("pointermove", pointerMove);
|
||||
addEventListener("pointerlockmove", pointerLockMove);
|
||||
if (usePointerLock) document.addEventListener("pointerlockchange", pointerLockChange);
|
||||
activeDragCleanup = () => {
|
||||
removeEventListener("pointerup", pointerUp);
|
||||
removeEventListener("pointermove", pointerMove);
|
||||
removeEventListener("pointerlockmove", pointerLockMove);
|
||||
if (usePointerLock) document.removeEventListener("pointerlockchange", pointerLockChange);
|
||||
activeDragCleanup = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
function pointerLockMoveUpdate(delta: number, slow: boolean, snapping: boolean, initialValue: number) {
|
||||
@@ -657,7 +691,7 @@
|
||||
|
||||
// End the user's drag by instantaneously disabling and re-enabling the range input element
|
||||
if (inputRangeElement) inputRangeElement.disabled = true;
|
||||
setTimeout(() => {
|
||||
sliderAbortTimeout1 = setTimeout(() => {
|
||||
if (inputRangeElement) inputRangeElement.disabled = false;
|
||||
}, 0);
|
||||
|
||||
@@ -680,11 +714,13 @@
|
||||
// dragging the slider, hitting Escape, then releasing the mouse button. This results in being transferred by `onSliderInput()` to the
|
||||
// "Deciding" state when we should remain in the "Ready" state as set here. (For debugging, this can be visualized in CSS by
|
||||
// recoloring the fake slider handle, which is shown in the "Deciding" state.)
|
||||
setTimeout(() => (rangeSliderClickDragState = "Ready"), 0);
|
||||
sliderAbortTimeout2 = setTimeout(() => (rangeSliderClickDragState = "Ready"), 0);
|
||||
|
||||
// Clean up the event listener that was used to call this function.
|
||||
removeEventListener("pointerup", sliderResetAbort);
|
||||
sliderResetAbortHandler = undefined;
|
||||
};
|
||||
sliderResetAbortHandler = sliderResetAbort;
|
||||
addEventListener("pointerup", sliderResetAbort);
|
||||
|
||||
// Clean up the event listeners that were for tracking an abort while dragging the slider, now that we're no longer dragging it.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
|
||||
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS, PRESS_REPEAT_INTERVAL_RAPID_MS } from "@graphite/io-managers/input";
|
||||
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS, PRESS_REPEAT_INTERVAL_RAPID_MS } from "@graphite/managers/input";
|
||||
|
||||
const ARROW_CLICK_DISTANCE = 0.05;
|
||||
const ARROW_REPEAT_DISTANCE = 0.01;
|
||||
@@ -187,6 +187,10 @@
|
||||
if (e.key === "Escape") abortInteraction();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
removeEvents();
|
||||
});
|
||||
|
||||
function addEvents() {
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
import { createEventDispatcher, onMount, onDestroy } from "svelte";
|
||||
|
||||
import { evaluateGradientAtPosition } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Color, GradientStops } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
@@ -338,7 +338,9 @@
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", deleteStop);
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", deleteStop);
|
||||
});
|
||||
onDestroy(() => {
|
||||
removeEvents();
|
||||
document.removeEventListener("keydown", deleteStop);
|
||||
|
||||
@@ -62,7 +62,10 @@
|
||||
}
|
||||
|
||||
onMount(() => watchForCheckbox(forCheckbox));
|
||||
onDestroy(() => watchForCheckbox(undefined));
|
||||
onDestroy(() => {
|
||||
handlePointerLeave();
|
||||
watchForCheckbox(undefined);
|
||||
});
|
||||
</script>
|
||||
|
||||
<label
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { getContext } from "svelte";
|
||||
|
||||
import { isPlatformNative } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { AppWindowState } from "@graphite/state-providers/app-window";
|
||||
import type { DialogState } from "@graphite/state-providers/dialog";
|
||||
import type { TooltipState } from "@graphite/state-providers/tooltip";
|
||||
import type { AppWindowStore } from "@graphite/stores/app-window";
|
||||
import type { DialogStore } from "@graphite/stores/dialog";
|
||||
import type { TooltipStore } from "@graphite/stores/tooltip";
|
||||
|
||||
import Dialog from "@graphite/components/floating-menus/Dialog.svelte";
|
||||
import Tooltip from "@graphite/components/floating-menus/Tooltip.svelte";
|
||||
@@ -14,9 +14,9 @@
|
||||
import TitleBar from "@graphite/components/window/TitleBar.svelte";
|
||||
import Workspace from "@graphite/components/window/Workspace.svelte";
|
||||
|
||||
const dialog = getContext<DialogState>("dialog");
|
||||
const tooltip = getContext<TooltipState>("tooltip");
|
||||
const appWindow = getContext<AppWindowState>("appWindow");
|
||||
const dialog = getContext<DialogStore>("dialog");
|
||||
const tooltip = getContext<TooltipStore>("tooltip");
|
||||
const appWindow = getContext<AppWindowStore>("appWindow");
|
||||
</script>
|
||||
|
||||
<LayoutCol class="main-window" classes={{ "viewport-hole-punch": $appWindow.viewportHolePunch }}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { getContext, onMount, onDestroy } from "svelte";
|
||||
|
||||
import type { Layout } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
@@ -24,6 +24,11 @@
|
||||
statusBarInfoLayout = statusBarInfoLayout;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
editor.subscriptions.unsubscribeLayoutUpdate("StatusBarHints");
|
||||
editor.subscriptions.unsubscribeLayoutUpdate("StatusBarInfo");
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutRow class="status-bar">
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { getContext, onMount, onDestroy } from "svelte";
|
||||
|
||||
import { isPlatformNative } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Layout } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { AppWindowState } from "@graphite/state-providers/app-window";
|
||||
import type { FullscreenState } from "@graphite/state-providers/fullscreen";
|
||||
import type { TooltipState } from "@graphite/state-providers/tooltip";
|
||||
import type { AppWindowStore } from "@graphite/stores/app-window";
|
||||
import { enterFullscreen, exitFullscreen } from "@graphite/stores/fullscreen";
|
||||
import type { FullscreenStore } from "@graphite/stores/fullscreen";
|
||||
import type { TooltipStore } from "@graphite/stores/tooltip";
|
||||
import { patchLayout } from "@graphite/utility-functions/widgets";
|
||||
|
||||
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
|
||||
import WidgetLayout from "@graphite/components/widgets/WidgetLayout.svelte";
|
||||
|
||||
const appWindow = getContext<AppWindowState>("appWindow");
|
||||
const keyboardLockApiSupported = navigator.keyboard !== undefined && "lock" in navigator.keyboard;
|
||||
|
||||
const appWindow = getContext<AppWindowStore>("appWindow");
|
||||
const editor = getContext<Editor>("editor");
|
||||
const fullscreen = getContext<FullscreenState>("fullscreen");
|
||||
const tooltip = getContext<TooltipState>("tooltip");
|
||||
const fullscreen = getContext<FullscreenStore>("fullscreen");
|
||||
const tooltip = getContext<TooltipStore>("tooltip");
|
||||
|
||||
let menuBarLayout: Layout = [];
|
||||
|
||||
@@ -31,6 +34,10 @@
|
||||
menuBarLayout = menuBarLayout;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
editor.subscriptions.unsubscribeLayoutUpdate("MenuBar");
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutRow class="title-bar" styles={{ height: height + "px" }}>
|
||||
@@ -48,13 +55,13 @@
|
||||
{#if showFullscreenButton}
|
||||
<LayoutRow
|
||||
tooltipLabel={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
||||
tooltipDescription={$appWindow.platform === "Web" && $fullscreen.keyboardLockApiSupported
|
||||
tooltipDescription={$appWindow.platform === "Web" && keyboardLockApiSupported
|
||||
? "While fullscreen, keyboard shortcuts normally reserved by the browser become available."
|
||||
: undefined}
|
||||
tooltipShortcut={$tooltip.fullscreenShortcut}
|
||||
on:click={() => {
|
||||
if (isPlatformNative()) editor.handle.appWindowFullscreen();
|
||||
else ($fullscreen.windowFullscreen ? fullscreen.exitFullscreen : fullscreen.enterFullscreen)();
|
||||
else ($fullscreen.windowFullscreen ? exitFullscreen : enterFullscreen)();
|
||||
}}
|
||||
>
|
||||
<IconLabel icon={isFullscreen ? "FullscreenExit" : "FullscreenEnter"} />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { getContext, onDestroy } from "svelte";
|
||||
|
||||
import type { OpenDocument } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { PortfolioState } from "@graphite/state-providers/portfolio";
|
||||
import type { PortfolioStore } from "@graphite/stores/portfolio";
|
||||
|
||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
||||
@@ -24,6 +24,11 @@
|
||||
let documentPanel: Panel | undefined;
|
||||
let gutterResizeRestore: [number, number] | undefined = undefined;
|
||||
let pointerCaptureId: number | undefined = undefined;
|
||||
let activeResizeCleanup: (() => void) | undefined = undefined;
|
||||
|
||||
onDestroy(() => {
|
||||
activeResizeCleanup?.();
|
||||
});
|
||||
|
||||
$: documentPanel?.scrollTabIntoView($portfolio.activeDocumentIndex);
|
||||
|
||||
@@ -37,7 +42,7 @@
|
||||
});
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
const portfolio = getContext<PortfolioState>("portfolio");
|
||||
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||
|
||||
function resizePanel(e: PointerEvent) {
|
||||
const gutter = e.target;
|
||||
@@ -76,6 +81,7 @@
|
||||
const abortResize = () => {
|
||||
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
|
||||
removeListeners();
|
||||
activeResizeCleanup = undefined;
|
||||
|
||||
pointerCaptureId = e.pointerId;
|
||||
gutter.setPointerCapture(pointerCaptureId);
|
||||
@@ -104,6 +110,7 @@
|
||||
gutterResizeRestore = undefined;
|
||||
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
|
||||
removeListeners();
|
||||
activeResizeCleanup = undefined;
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
@@ -130,6 +137,7 @@
|
||||
};
|
||||
|
||||
addListeners();
|
||||
activeResizeCleanup = removeListeners;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
+13
-3
@@ -11,9 +11,10 @@ export type Editor = {
|
||||
raw: WebAssembly.Memory;
|
||||
handle: EditorHandle;
|
||||
subscriptions: SubscriptionRouter;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
// `wasmImport` starts uninitialized because its initialization needs to occur asynchronously, and thus needs to occur by manually calling and awaiting `initWasm()`
|
||||
// `wasmImport` starts uninitialized because its initialization needs to occur asynchronously, and thus needs to occur by manually calling and awaiting `initWasm()`.
|
||||
let wasmImport: WebAssembly.Memory | undefined;
|
||||
|
||||
// Should be called asynchronously before `createEditor()`.
|
||||
@@ -52,13 +53,14 @@ export function createEditor(): Editor {
|
||||
const subscriptions = createSubscriptionRouter();
|
||||
|
||||
// Check if the URL hash fragment has any demo artwork to be loaded
|
||||
const demoArtworkAbortController = new AbortController();
|
||||
(async () => {
|
||||
const demoArtwork = window.location.hash.trim().match(/#demo\/(.*)/)?.[1];
|
||||
if (!demoArtwork) return;
|
||||
|
||||
try {
|
||||
const url = new URL(`/demo-artwork/${demoArtwork}.${handle.fileExtension()}`, document.location.href);
|
||||
const data = await fetch(url);
|
||||
const data = await fetch(url, { signal: demoArtworkAbortController.signal });
|
||||
if (!data.ok) throw new Error();
|
||||
|
||||
const filename = url.pathname.split("/").pop() || "Untitled";
|
||||
@@ -72,5 +74,13 @@ export function createEditor(): Editor {
|
||||
}
|
||||
})();
|
||||
|
||||
return { raw, handle, subscriptions };
|
||||
function destroy() {
|
||||
handle.free();
|
||||
demoArtworkAbortController.abort();
|
||||
}
|
||||
|
||||
return { raw, handle, subscriptions, destroy };
|
||||
}
|
||||
|
||||
// Wasm state can't be hot-replaced, so we tell Vite to do a full page reload when this module changes
|
||||
import.meta.hot?.accept(() => location.reload());
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
type ApiResponse = { family: string; variants: string[]; files: Record<string, string> }[];
|
||||
|
||||
const FONT_LIST_API = "https://api.graphite.art/font-list";
|
||||
|
||||
export function createFontsManager(editor: Editor) {
|
||||
// Subscribe to process backend events
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerFontCatalogLoad", async () => {
|
||||
const response = await fetch(FONT_LIST_API);
|
||||
const fontListResponse: { items: ApiResponse } = await response.json();
|
||||
const fontListData = fontListResponse.items;
|
||||
|
||||
const catalog = fontListData.map((font) => {
|
||||
const styles = font.variants.map((variant) => {
|
||||
const weight = variant === "regular" || variant === "italic" ? 400 : parseInt(variant, 10);
|
||||
const italic = variant.endsWith("italic");
|
||||
const url = font.files[variant].replace("http://", "https://");
|
||||
|
||||
return { weight, italic, url };
|
||||
});
|
||||
return { name: font.family, styles };
|
||||
});
|
||||
|
||||
editor.handle.onFontCatalogLoad(catalog);
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerFontDataLoad", async (data) => {
|
||||
const { fontFamily, fontStyle } = data.font;
|
||||
|
||||
try {
|
||||
if (!data.url) throw new Error("No URL provided for font data load");
|
||||
const response = await fetch(data.url);
|
||||
const buffer = await response.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
editor.handle.onFontLoad(fontFamily, fontStyle, bytes);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load font:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
export function createHyperlinkManager(editor: Editor) {
|
||||
// Subscribe to process backend event
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerVisitLink", async (data) => {
|
||||
window.open(data.url, "_blank");
|
||||
});
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { createStore, del, get, set, update } from "idb-keyval";
|
||||
import { get as getFromStore } from "svelte/store";
|
||||
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { PortfolioState } from "@graphite/state-providers/portfolio";
|
||||
import type { MessageBody } from "@graphite/subscription-router";
|
||||
|
||||
const graphiteStore = createStore("graphite", "store");
|
||||
|
||||
export function createPersistenceManager(editor: Editor, portfolio: PortfolioState) {
|
||||
// DOCUMENTS
|
||||
|
||||
async function storeDocumentOrder() {
|
||||
const documentOrder = getFromStore(portfolio).documents.map((doc) => String(doc.id));
|
||||
await set("documents_tab_order", documentOrder, graphiteStore);
|
||||
}
|
||||
|
||||
async function storeCurrentDocumentId(documentId: string) {
|
||||
await set("current_document_id", String(documentId), graphiteStore);
|
||||
}
|
||||
|
||||
async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersistenceWriteDocument">) {
|
||||
await update<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>(
|
||||
"documents",
|
||||
(old) => {
|
||||
const documents = old || {};
|
||||
documents[String(autoSaveDocument.documentId)] = autoSaveDocument;
|
||||
return documents;
|
||||
},
|
||||
graphiteStore,
|
||||
);
|
||||
|
||||
await storeDocumentOrder();
|
||||
await storeCurrentDocumentId(String(autoSaveDocument.documentId));
|
||||
}
|
||||
|
||||
async function removeDocument(id: string) {
|
||||
await update<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>(
|
||||
"documents",
|
||||
(old) => {
|
||||
const documents = old || {};
|
||||
delete documents[id];
|
||||
return documents;
|
||||
},
|
||||
graphiteStore,
|
||||
);
|
||||
|
||||
await update<string[]>(
|
||||
"documents_tab_order",
|
||||
(old) => {
|
||||
const order = old || [];
|
||||
return order.filter((docId) => docId !== id);
|
||||
},
|
||||
graphiteStore,
|
||||
);
|
||||
|
||||
const documentCount = getFromStore(portfolio).documents.length;
|
||||
if (documentCount > 0) {
|
||||
const documentIndex = getFromStore(portfolio).activeDocumentIndex;
|
||||
const documentId = String(getFromStore(portfolio).documents[documentIndex].id);
|
||||
|
||||
const tabOrder = (await get<string[]>("documents_tab_order", graphiteStore)) || [];
|
||||
if (tabOrder.includes(documentId)) {
|
||||
await storeCurrentDocumentId(documentId);
|
||||
}
|
||||
} else {
|
||||
await del("current_document_id", graphiteStore);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFirstDocument() {
|
||||
const previouslySavedDocuments = await get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", graphiteStore);
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if the browser is storing the old format as strings
|
||||
if (previouslySavedDocuments) {
|
||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
const documentOrder = await get<string[]>("documents_tab_order", graphiteStore);
|
||||
const currentDocumentIdString = await get<string>("current_document_id", graphiteStore);
|
||||
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
|
||||
if (!previouslySavedDocuments || !documentOrder) return;
|
||||
|
||||
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
|
||||
|
||||
if (currentDocumentId !== undefined && String(currentDocumentId) in previouslySavedDocuments) {
|
||||
const doc = previouslySavedDocuments[String(currentDocumentId)];
|
||||
editor.handle.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
|
||||
editor.handle.selectDocument(currentDocumentId);
|
||||
} else {
|
||||
const len = orderedSavedDocuments.length;
|
||||
if (len > 0) {
|
||||
const doc = orderedSavedDocuments[len - 1];
|
||||
editor.handle.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
|
||||
editor.handle.selectDocument(doc.documentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRestDocuments() {
|
||||
const previouslySavedDocuments = await get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", graphiteStore);
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
|
||||
if (previouslySavedDocuments) {
|
||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
const documentOrder = await get<string[]>("documents_tab_order", graphiteStore);
|
||||
const currentDocumentIdString = await get<string>("current_document_id", graphiteStore);
|
||||
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
|
||||
if (!previouslySavedDocuments || !documentOrder) return;
|
||||
|
||||
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
|
||||
|
||||
if (currentDocumentId !== undefined) {
|
||||
const currentIndex = orderedSavedDocuments.findIndex((doc) => doc.documentId === currentDocumentId);
|
||||
const beforeCurrentIndex = currentIndex - 1;
|
||||
const afterCurrentIndex = currentIndex + 1;
|
||||
|
||||
for (let i = beforeCurrentIndex; i >= 0; i--) {
|
||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
||||
const { name, isSaved } = details;
|
||||
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, true);
|
||||
}
|
||||
for (let i = afterCurrentIndex; i < orderedSavedDocuments.length; i++) {
|
||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
||||
const { name, isSaved } = details;
|
||||
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, false);
|
||||
}
|
||||
|
||||
editor.handle.selectDocument(currentDocumentId);
|
||||
} else {
|
||||
const length = orderedSavedDocuments.length;
|
||||
|
||||
for (let i = length - 2; i >= 0; i--) {
|
||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
||||
const { name, isSaved } = details;
|
||||
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, true);
|
||||
}
|
||||
|
||||
if (length > 0) editor.handle.selectDocument(orderedSavedDocuments[length - 1].documentId);
|
||||
}
|
||||
}
|
||||
|
||||
// PREFERENCES
|
||||
|
||||
async function savePreferences(preferences: unknown) {
|
||||
await set("preferences", preferences, graphiteStore);
|
||||
}
|
||||
|
||||
async function loadPreferences() {
|
||||
const preferences = await get<Record<string, unknown>>("preferences", graphiteStore);
|
||||
editor.handle.loadPreferences(preferences ? JSON.stringify(preferences) : undefined);
|
||||
}
|
||||
|
||||
// FRONTEND MESSAGE SUBSCRIPTIONS
|
||||
|
||||
// Subscribe to process backend events
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerSavePreferences", async (data) => {
|
||||
await savePreferences(data.preferences);
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerLoadPreferences", async () => {
|
||||
await loadPreferences();
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerPersistenceWriteDocument", async (data) => {
|
||||
await storeDocument(data);
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerPersistenceRemoveDocument", async (data) => {
|
||||
await removeDocument(String(data.documentId));
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument", async () => {
|
||||
await loadFirstDocument();
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerLoadRestAutoSaveDocuments", async () => {
|
||||
await loadRestDocuments();
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerOpenLaunchDocuments", async () => {
|
||||
// TODO: Could be used to load documents from URL params or similar on launch
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerSaveActiveDocument", async (data) => {
|
||||
const documentId = String(data.documentId);
|
||||
const previouslySavedDocuments = await get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", graphiteStore);
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
|
||||
if (previouslySavedDocuments) {
|
||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
if (!previouslySavedDocuments) return;
|
||||
if (documentId in previouslySavedDocuments) {
|
||||
await storeCurrentDocumentId(documentId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function wipeDocuments() {
|
||||
await del("documents_tab_order", graphiteStore);
|
||||
await del("current_document_id", graphiteStore);
|
||||
await del("documents", graphiteStore);
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
// This file is the browser's entry point for the JS bundle
|
||||
|
||||
import { mount } from "svelte";
|
||||
import { mount, unmount } from "svelte";
|
||||
|
||||
import App from "@graphite/App.svelte";
|
||||
|
||||
document.body.setAttribute("data-app-container", "");
|
||||
|
||||
export default mount(App, { target: document.body });
|
||||
const app = mount(App, { target: document.body });
|
||||
|
||||
// Ensure the old component tree is properly torn down during HMR so all onDestroy hooks fire (which clean up IO managers, state providers, etc.)
|
||||
import.meta.hot?.dispose(() => unmount(app));
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor] | undefined;
|
||||
|
||||
export function createClipboardManager(editor: Editor) {
|
||||
currentArgs = [editor];
|
||||
|
||||
// Subscribe to process backend event
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerClipboardWrite", (data) => {
|
||||
// If the Clipboard API is supported in the browser, copy text to the clipboard
|
||||
@@ -12,7 +17,17 @@ export function createClipboardManager(editor: Editor) {
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerSelectionWrite", async (data) => {
|
||||
insertAtCaret(data.content);
|
||||
});
|
||||
|
||||
function destroy() {
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerClipboardWrite");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerSelectionRead");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerSelectionWrite");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
return { destroy };
|
||||
}
|
||||
export type ClipboardManager = ReturnType<typeof createClipboardManager>;
|
||||
|
||||
function readAtCaret(cut: boolean): string | undefined {
|
||||
const element = window.document.activeElement;
|
||||
@@ -94,3 +109,9 @@ function insertAtCaret(text: string) {
|
||||
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createClipboardManager(...currentArgs);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
type ApiResponse = { family: string; variants: string[]; files: Record<string, string> }[];
|
||||
|
||||
const FONT_LIST_API = "https://api.graphite.art/font-list";
|
||||
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor] | undefined;
|
||||
|
||||
export function createFontsManager(editor: Editor) {
|
||||
currentArgs = [editor];
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Subscribe to process backend events
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerFontCatalogLoad", async () => {
|
||||
try {
|
||||
const response = await fetch(FONT_LIST_API, { signal: abortController.signal });
|
||||
if (!response.ok) throw new Error(`Font catalog request failed with status ${response.status}`);
|
||||
const fontListResponse: { items: ApiResponse } = await response.json();
|
||||
const fontListData = fontListResponse.items;
|
||||
|
||||
const catalog = fontListData.map((font) => {
|
||||
const styles = font.variants.map((variant) => {
|
||||
const weight = variant === "regular" || variant === "italic" ? 400 : parseInt(variant, 10);
|
||||
const italic = variant.endsWith("italic");
|
||||
const url = font.files[variant].replace("http://", "https://");
|
||||
|
||||
return { weight, italic, url };
|
||||
});
|
||||
return { name: font.family, styles };
|
||||
});
|
||||
|
||||
editor.handle.onFontCatalogLoad(catalog);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerFontDataLoad", async (data) => {
|
||||
const { fontFamily, fontStyle } = data.font;
|
||||
|
||||
try {
|
||||
if (!data.url) throw new Error("No URL provided for font data load");
|
||||
const response = await fetch(data.url, { signal: abortController.signal });
|
||||
if (!response.ok) throw new Error(`Font data request failed with status ${response.status}`);
|
||||
const buffer = await response.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
editor.handle.onFontLoad(fontFamily, fontStyle, bytes);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load font:", error);
|
||||
}
|
||||
});
|
||||
|
||||
function destroy() {
|
||||
abortController.abort();
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerFontCatalogLoad");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerFontDataLoad");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
return { destroy };
|
||||
}
|
||||
export type FontsManager = ReturnType<typeof createFontsManager>;
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createFontsManager(...currentArgs);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor] | undefined;
|
||||
|
||||
export function createHyperlinkManager(editor: Editor) {
|
||||
currentArgs = [editor];
|
||||
|
||||
// Subscribe to process backend event
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerVisitLink", async (data) => {
|
||||
window.open(data.url, "_blank", "noopener");
|
||||
});
|
||||
|
||||
function destroy() {
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerVisitLink");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
return { destroy };
|
||||
}
|
||||
export type HyperlinkManager = ReturnType<typeof createHyperlinkManager>;
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createHyperlinkManager(...currentArgs);
|
||||
});
|
||||
@@ -2,10 +2,11 @@ import { get } from "svelte/store";
|
||||
|
||||
import { isPlatformNative } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { DialogState } from "@graphite/state-providers/dialog";
|
||||
import type { DocumentState } from "@graphite/state-providers/document";
|
||||
import type { FullscreenState } from "@graphite/state-providers/fullscreen";
|
||||
import type { PortfolioState } from "@graphite/state-providers/portfolio";
|
||||
import type { DialogStore } from "@graphite/stores/dialog";
|
||||
import type { DocumentStore } from "@graphite/stores/document";
|
||||
import { fullscreenModeChanged, toggleFullscreen } from "@graphite/stores/fullscreen";
|
||||
import type { FullscreenStore } from "@graphite/stores/fullscreen";
|
||||
import type { PortfolioStore } from "@graphite/stores/portfolio";
|
||||
import { pasteFile } from "@graphite/utility-functions/files";
|
||||
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "@graphite/utility-functions/keyboard-entry";
|
||||
import { operatingSystem } from "@graphite/utility-functions/platform";
|
||||
@@ -28,7 +29,11 @@ type EventListenerTarget = {
|
||||
removeEventListener: typeof window.removeEventListener;
|
||||
};
|
||||
|
||||
export function createInputManager(editor: Editor, dialog: DialogState, portfolio: PortfolioState, document: DocumentState, fullscreen: FullscreenState): () => void {
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor, DialogStore, PortfolioStore, DocumentStore, FullscreenStore] | undefined;
|
||||
|
||||
export function createInputManager(editor: Editor, dialog: DialogStore, portfolio: PortfolioStore, document: DocumentStore, fullscreen: FullscreenStore) {
|
||||
currentArgs = [editor, dialog, portfolio, document, fullscreen];
|
||||
const appElement = window.document.querySelector("[data-app-container]");
|
||||
const app = appElement instanceof HTMLElement ? appElement : null;
|
||||
app?.focus();
|
||||
@@ -55,7 +60,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
|
||||
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent) => onModifyInputField(e) },
|
||||
{ target: window, eventName: "focusout", action: () => (canvasFocused = false) },
|
||||
{ target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => onContextMenu(e) },
|
||||
{ target: window.document, eventName: "fullscreenchange", action: () => fullscreen.fullscreenModeChanged() },
|
||||
{ target: window.document, eventName: "fullscreenchange", action: () => fullscreenModeChanged() },
|
||||
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => onPaste(e) },
|
||||
{ target: window.document, eventName: "pointerlockchange", action: onPointerLockChange },
|
||||
{ target: window.document, eventName: "pointerlockerror", action: onPointerLockChange },
|
||||
@@ -109,7 +114,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
|
||||
// Don't redirect a fullscreen request, but process it immediately instead
|
||||
if (((operatingSystem() !== "Mac" && key === "F11") || (operatingSystem() === "Mac" && e.ctrlKey && e.metaKey && key === "KeyF")) && e.type === "keydown" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
fullscreen.toggleFullscreen();
|
||||
toggleFullscreen();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -508,9 +513,23 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
|
||||
bindListeners();
|
||||
|
||||
// Return the destructor
|
||||
return unbindListeners;
|
||||
function destroy() {
|
||||
unbindListeners();
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerClipboardRead");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("WindowPointerLockMove");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
return { destroy };
|
||||
}
|
||||
export type InputManager = ReturnType<typeof createInputManager>;
|
||||
|
||||
function targetIsTextField(target: EventTarget | HTMLElement | undefined): boolean {
|
||||
return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable);
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createInputManager(...currentArgs);
|
||||
});
|
||||
@@ -1,12 +1,25 @@
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor] | undefined;
|
||||
|
||||
export function createLocalizationManager(editor: Editor) {
|
||||
currentArgs = [editor];
|
||||
|
||||
// Subscribe to process backend event
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerAboutGraphiteLocalizedCommitDate", (data) => {
|
||||
const localized = localizeTimestamp(data.commitDate);
|
||||
editor.handle.requestAboutGraphiteDialogWithLocalizedCommitDate(localized.timestamp, localized.year);
|
||||
});
|
||||
|
||||
function destroy() {
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerAboutGraphiteLocalizedCommitDate");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
return { destroy };
|
||||
}
|
||||
export type LocalizationManager = ReturnType<typeof createLocalizationManager>;
|
||||
|
||||
function localizeTimestamp(utc: string): { timestamp: string; year: string } {
|
||||
// Timestamp
|
||||
@@ -22,3 +35,9 @@ function localizeTimestamp(utc: string): { timestamp: string; year: string } {
|
||||
const timezoneNameString = timezoneName?.value;
|
||||
return { timestamp: `${dateString} ${timeString} ${timezoneNameString}`, year: String(date.getFullYear()) };
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createLocalizationManager(...currentArgs);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import { createCrashDialog } from "@graphite/stores/dialog";
|
||||
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor] | undefined;
|
||||
|
||||
export function createPanicManager(editor: Editor) {
|
||||
currentArgs = [editor];
|
||||
// Code panic dialog and console error
|
||||
editor.subscriptions.subscribeFrontendMessage("DisplayDialogPanic", (data) => {
|
||||
// `Error.stackTraceLimit` is only available in V8/Chromium
|
||||
const previousStackTraceLimit = Error.stackTraceLimit;
|
||||
Error.stackTraceLimit = Infinity;
|
||||
const stackTrace = new Error().stack || "";
|
||||
Error.stackTraceLimit = previousStackTraceLimit;
|
||||
const panicDetails = `${data.panicInfo}${stackTrace ? `\n\n${stackTrace}` : ""}`;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(panicDetails);
|
||||
|
||||
createCrashDialog(panicDetails);
|
||||
});
|
||||
|
||||
function destroy() {
|
||||
editor.subscriptions.unsubscribeFrontendMessage("DisplayDialogPanic");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
return { destroy };
|
||||
}
|
||||
export type PanicManager = ReturnType<typeof createPanicManager>;
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createPanicManager(...currentArgs);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { createStore, del, get, set, update } from "idb-keyval";
|
||||
import { get as getFromStore } from "svelte/store";
|
||||
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { PortfolioStore } from "@graphite/stores/portfolio";
|
||||
import type { MessageBody } from "@graphite/subscription-router";
|
||||
|
||||
const graphiteStore = createStore("graphite", "store");
|
||||
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor, PortfolioStore] | undefined;
|
||||
|
||||
export function createPersistenceManager(editor: Editor, portfolio: PortfolioStore) {
|
||||
currentArgs = [editor, portfolio];
|
||||
// DOCUMENTS
|
||||
|
||||
// FRONTEND MESSAGE SUBSCRIPTIONS
|
||||
|
||||
// Subscribe to process backend events
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerSavePreferences", async (data) => {
|
||||
await saveEditorPreferences(data.preferences);
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerLoadPreferences", async () => {
|
||||
await loadEditorPreferences(editor);
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerPersistenceWriteDocument", async (data) => {
|
||||
await storeDocument(data, portfolio);
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerPersistenceRemoveDocument", async (data) => {
|
||||
await removeDocument(String(data.documentId), portfolio);
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument", async () => {
|
||||
await loadFirstDocument(editor);
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerLoadRestAutoSaveDocuments", async () => {
|
||||
await loadRestDocuments(editor);
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerOpenLaunchDocuments", async () => {
|
||||
// TODO: Could be used to load documents from URL params or similar on launch
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerSaveActiveDocument", async (data) => {
|
||||
const documentId = String(data.documentId);
|
||||
const previouslySavedDocuments = await get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", graphiteStore);
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
|
||||
if (previouslySavedDocuments) {
|
||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
if (!previouslySavedDocuments) return;
|
||||
if (documentId in previouslySavedDocuments) {
|
||||
await storeCurrentDocumentId(documentId);
|
||||
}
|
||||
});
|
||||
|
||||
function destroy() {
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerSavePreferences");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerLoadPreferences");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteDocument");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerPersistenceRemoveDocument");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerLoadRestAutoSaveDocuments");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerOpenLaunchDocuments");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerSaveActiveDocument");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
return { destroy };
|
||||
}
|
||||
export type PersistenceManager = ReturnType<typeof createPersistenceManager>;
|
||||
|
||||
export async function wipeDocuments() {
|
||||
await del("documents_tab_order", graphiteStore);
|
||||
await del("current_document_id", graphiteStore);
|
||||
await del("documents", graphiteStore);
|
||||
}
|
||||
|
||||
async function storeDocumentOrder(portfolio: PortfolioStore) {
|
||||
const documentOrder = getFromStore(portfolio).documents.map((doc) => String(doc.id));
|
||||
await set("documents_tab_order", documentOrder, graphiteStore);
|
||||
}
|
||||
|
||||
async function storeCurrentDocumentId(documentId: string) {
|
||||
await set("current_document_id", String(documentId), graphiteStore);
|
||||
}
|
||||
|
||||
async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersistenceWriteDocument">, portfolio: PortfolioStore) {
|
||||
await update<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>(
|
||||
"documents",
|
||||
(old) => {
|
||||
const documents = old || {};
|
||||
documents[String(autoSaveDocument.documentId)] = autoSaveDocument;
|
||||
return documents;
|
||||
},
|
||||
graphiteStore,
|
||||
);
|
||||
|
||||
await storeDocumentOrder(portfolio);
|
||||
await storeCurrentDocumentId(String(autoSaveDocument.documentId));
|
||||
}
|
||||
|
||||
async function removeDocument(id: string, portfolio: PortfolioStore) {
|
||||
await update<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>(
|
||||
"documents",
|
||||
(old) => {
|
||||
const documents = old || {};
|
||||
delete documents[id];
|
||||
return documents;
|
||||
},
|
||||
graphiteStore,
|
||||
);
|
||||
|
||||
await update<string[]>(
|
||||
"documents_tab_order",
|
||||
(old) => {
|
||||
const order = old || [];
|
||||
return order.filter((docId) => docId !== id);
|
||||
},
|
||||
graphiteStore,
|
||||
);
|
||||
|
||||
const documentCount = getFromStore(portfolio).documents.length;
|
||||
if (documentCount > 0) {
|
||||
const documentIndex = getFromStore(portfolio).activeDocumentIndex;
|
||||
const documentId = String(getFromStore(portfolio).documents[documentIndex].id);
|
||||
|
||||
const tabOrder = (await get<string[]>("documents_tab_order", graphiteStore)) || [];
|
||||
if (tabOrder.includes(documentId)) {
|
||||
await storeCurrentDocumentId(documentId);
|
||||
}
|
||||
} else {
|
||||
await del("current_document_id", graphiteStore);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFirstDocument(editor: Editor) {
|
||||
const previouslySavedDocuments = await get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", graphiteStore);
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if the browser is storing the old format as strings
|
||||
if (previouslySavedDocuments) {
|
||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
const documentOrder = await get<string[]>("documents_tab_order", graphiteStore);
|
||||
const currentDocumentIdString = await get<string>("current_document_id", graphiteStore);
|
||||
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
|
||||
if (!previouslySavedDocuments || !documentOrder) return;
|
||||
|
||||
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
|
||||
|
||||
if (currentDocumentId !== undefined && String(currentDocumentId) in previouslySavedDocuments) {
|
||||
const doc = previouslySavedDocuments[String(currentDocumentId)];
|
||||
editor.handle.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
|
||||
editor.handle.selectDocument(currentDocumentId);
|
||||
} else {
|
||||
const len = orderedSavedDocuments.length;
|
||||
if (len > 0) {
|
||||
const doc = orderedSavedDocuments[len - 1];
|
||||
editor.handle.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
|
||||
editor.handle.selectDocument(doc.documentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRestDocuments(editor: Editor) {
|
||||
const previouslySavedDocuments = await get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", graphiteStore);
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
|
||||
if (previouslySavedDocuments) {
|
||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
const documentOrder = await get<string[]>("documents_tab_order", graphiteStore);
|
||||
const currentDocumentIdString = await get<string>("current_document_id", graphiteStore);
|
||||
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
|
||||
if (!previouslySavedDocuments || !documentOrder) return;
|
||||
|
||||
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
|
||||
|
||||
const currentIndex = currentDocumentId !== undefined ? orderedSavedDocuments.findIndex((doc) => doc.documentId === currentDocumentId) : -1;
|
||||
|
||||
// Open documents in order around the current document, placing earlier ones before it and later ones after
|
||||
if (currentIndex !== -1 && currentDocumentId !== undefined) {
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
||||
const { name, isSaved } = details;
|
||||
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, true);
|
||||
}
|
||||
for (let i = currentIndex + 1; i < orderedSavedDocuments.length; i++) {
|
||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
||||
const { name, isSaved } = details;
|
||||
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, false);
|
||||
}
|
||||
|
||||
editor.handle.selectDocument(currentDocumentId);
|
||||
}
|
||||
// No valid current document: open all remaining documents and select the last one
|
||||
else {
|
||||
const length = orderedSavedDocuments.length;
|
||||
|
||||
for (let i = length - 2; i >= 0; i--) {
|
||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
||||
const { name, isSaved } = details;
|
||||
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, true);
|
||||
}
|
||||
|
||||
if (length > 0) editor.handle.selectDocument(orderedSavedDocuments[length - 1].documentId);
|
||||
}
|
||||
}
|
||||
|
||||
// PREFERENCES
|
||||
|
||||
async function saveEditorPreferences(preferences: unknown) {
|
||||
await set("preferences", preferences, graphiteStore);
|
||||
}
|
||||
|
||||
async function loadEditorPreferences(editor: Editor) {
|
||||
const preferences = await get<Record<string, unknown>>("preferences", graphiteStore);
|
||||
editor.handle.loadPreferences(preferences ? JSON.stringify(preferences) : undefined);
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createPersistenceManager(...currentArgs);
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
import type { AppWindowPlatform } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
export function createAppWindowState(editor: Editor) {
|
||||
const { subscribe, update } = writable<{
|
||||
platform: AppWindowPlatform;
|
||||
maximized: boolean;
|
||||
fullscreen: boolean;
|
||||
viewportHolePunch: boolean;
|
||||
uiScale: number;
|
||||
}>({
|
||||
platform: "Web",
|
||||
maximized: false,
|
||||
fullscreen: false,
|
||||
viewportHolePunch: false,
|
||||
uiScale: 1,
|
||||
});
|
||||
|
||||
// Set up message subscriptions on creation
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdatePlatform", (data) => {
|
||||
update((state) => {
|
||||
state.platform = data.platform;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateMaximized", (data) => {
|
||||
update((state) => {
|
||||
state.maximized = data.maximized;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateFullscreen", (data) => {
|
||||
update((state) => {
|
||||
state.fullscreen = data.fullscreen;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateViewportHolePunch", (data) => {
|
||||
update((state) => {
|
||||
state.viewportHolePunch = data.active;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateUIScale", (data) => {
|
||||
update((state) => {
|
||||
state.uiScale = data.scale;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
export type AppWindowState = ReturnType<typeof createAppWindowState>;
|
||||
@@ -1,105 +0,0 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
import type { Layout } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { IconName } from "@graphite/icons";
|
||||
import { patchLayout } from "@graphite/utility-functions/widgets";
|
||||
|
||||
export function createDialogState(editor: Editor) {
|
||||
const { subscribe, update } = writable<{
|
||||
visible: boolean;
|
||||
title: string;
|
||||
icon: IconName | undefined;
|
||||
buttons: Layout;
|
||||
column1: Layout;
|
||||
column2: Layout;
|
||||
panicDetails: string;
|
||||
}>({
|
||||
visible: false,
|
||||
title: "",
|
||||
icon: undefined,
|
||||
buttons: [],
|
||||
column1: [],
|
||||
column2: [],
|
||||
// Special case for the crash dialog because we cannot handle button widget callbacks from Rust once the editor has panicked
|
||||
panicDetails: "",
|
||||
});
|
||||
|
||||
function dismissDialog() {
|
||||
update((state) => {
|
||||
// Disallow dismissing the crash dialog since it can confuse users why the app stopped responding if they dismiss it without realizing what it means
|
||||
if (state.panicDetails === "") state.visible = false;
|
||||
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
// Creates a crash dialog from JS once the editor has panicked.
|
||||
// Normal dialogs are created in the Rust backend, but for the crash dialog, the editor has panicked so it cannot respond to widget callbacks.
|
||||
function createCrashDialog(panicDetails: string) {
|
||||
update((state) => {
|
||||
state.visible = true;
|
||||
|
||||
state.icon = "Failure";
|
||||
state.title = "Crash";
|
||||
state.panicDetails = panicDetails;
|
||||
|
||||
state.column1 = [];
|
||||
state.column2 = [];
|
||||
state.buttons = [];
|
||||
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to process backend events
|
||||
editor.subscriptions.subscribeFrontendMessage("DisplayDialog", (data) => {
|
||||
update((state) => {
|
||||
state.visible = true;
|
||||
|
||||
state.title = data.title;
|
||||
state.icon = data.icon;
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("DialogButtons", (data) => {
|
||||
update((state) => {
|
||||
patchLayout(state.buttons, data);
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("DialogColumn1", (data) => {
|
||||
update((state) => {
|
||||
patchLayout(state.column1, data);
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("DialogColumn2", (data) => {
|
||||
update((state) => {
|
||||
patchLayout(state.column2, data);
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("DialogClose", dismissDialog);
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerDisplayThirdPartyLicensesDialog", async () => {
|
||||
const BACKUP_URL = "https://editor.graphite.art/third-party-licenses.txt";
|
||||
let licenseText = `Content was not able to load. Please check your network connection and try again.\n\nOr visit ${BACKUP_URL} for the license notices.`;
|
||||
|
||||
const response = await fetch("/third-party-licenses.txt");
|
||||
if (response.ok && response.headers.get("Content-Type")?.includes("text/plain")) licenseText = await response.text();
|
||||
|
||||
editor.handle.requestLicensesThirdPartyDialogWithLicenseText(licenseText);
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
dismissDialog,
|
||||
createCrashDialog,
|
||||
};
|
||||
}
|
||||
export type DialogState = ReturnType<typeof createDialogState>;
|
||||
@@ -1,86 +0,0 @@
|
||||
import { tick } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
import type { Layout } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import { patchLayout } from "@graphite/utility-functions/widgets";
|
||||
|
||||
export function createDocumentState(editor: Editor) {
|
||||
const state = writable<{
|
||||
toolOptionsLayout: Layout;
|
||||
documentBarLayout: Layout;
|
||||
toolShelfLayout: Layout;
|
||||
workingColorsLayout: Layout;
|
||||
nodeGraphControlBarLayout: Layout;
|
||||
graphViewOverlayOpen: boolean;
|
||||
fadeArtwork: number;
|
||||
}>({
|
||||
toolOptionsLayout: [],
|
||||
documentBarLayout: [],
|
||||
toolShelfLayout: [],
|
||||
workingColorsLayout: [],
|
||||
nodeGraphControlBarLayout: [],
|
||||
graphViewOverlayOpen: false,
|
||||
fadeArtwork: 100,
|
||||
});
|
||||
const { subscribe, update } = state;
|
||||
|
||||
// Update layouts
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateGraphFadeArtwork", (data) => {
|
||||
update((state) => {
|
||||
state.fadeArtwork = data.percentage;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("ToolOptions", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.toolOptionsLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("DocumentBar", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.documentBarLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("ToolShelf", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.toolShelfLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("WorkingColors", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.workingColorsLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("NodeGraphControlBar", (data) => {
|
||||
update((state) => {
|
||||
patchLayout(state.nodeGraphControlBarLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
// Show or hide the graph view overlay
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateGraphViewOverlay", (data) => {
|
||||
update((state) => {
|
||||
state.graphViewOverlayOpen = data.open;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
export type DocumentState = ReturnType<typeof createDocumentState>;
|
||||
@@ -1,63 +0,0 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
export function createFullscreenState(editor: Editor) {
|
||||
// Experimental Keyboard API: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/keyboard
|
||||
const keyboardLockApiSupported: Readonly<boolean> = navigator.keyboard !== undefined && "lock" in navigator.keyboard;
|
||||
|
||||
const { subscribe, update } = writable({
|
||||
windowFullscreen: false,
|
||||
keyboardLocked: false,
|
||||
keyboardLockApiSupported,
|
||||
});
|
||||
|
||||
function fullscreenModeChanged() {
|
||||
update((state) => {
|
||||
state.windowFullscreen = Boolean(document.fullscreenElement);
|
||||
if (!state.windowFullscreen) state.keyboardLocked = false;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
async function enterFullscreen() {
|
||||
await document.documentElement.requestFullscreen();
|
||||
|
||||
if (keyboardLockApiSupported && navigator.keyboard) {
|
||||
await navigator.keyboard.lock(["ControlLeft", "ControlRight"]);
|
||||
|
||||
update((state) => {
|
||||
state.keyboardLocked = true;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function exitFullscreen() {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
return new Promise((resolve, reject) => {
|
||||
update((state) => {
|
||||
if (state.windowFullscreen) exitFullscreen().then(resolve).catch(reject);
|
||||
else enterFullscreen().then(resolve).catch(reject);
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("WindowFullscreen", () => {
|
||||
toggleFullscreen();
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
fullscreenModeChanged,
|
||||
enterFullscreen,
|
||||
exitFullscreen,
|
||||
toggleFullscreen,
|
||||
};
|
||||
}
|
||||
export type FullscreenState = ReturnType<typeof createFullscreenState>;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
|
||||
import type { AppWindowPlatform } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
type AppWindowStoreState = {
|
||||
platform: AppWindowPlatform;
|
||||
maximized: boolean;
|
||||
fullscreen: boolean;
|
||||
viewportHolePunch: boolean;
|
||||
uiScale: number;
|
||||
};
|
||||
const initialState: AppWindowStoreState = {
|
||||
platform: "Web",
|
||||
maximized: false,
|
||||
fullscreen: false,
|
||||
viewportHolePunch: false,
|
||||
uiScale: 1,
|
||||
};
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<AppWindowStoreState> = import.meta.hot?.data?.store || writable<AppWindowStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export function createAppWindowStore(editor: Editor) {
|
||||
// Set up message subscriptions on creation
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdatePlatform", (data) => {
|
||||
update((state) => {
|
||||
state.platform = data.platform;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateMaximized", (data) => {
|
||||
update((state) => {
|
||||
state.maximized = data.maximized;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateFullscreen", (data) => {
|
||||
update((state) => {
|
||||
state.fullscreen = data.fullscreen;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateViewportHolePunch", (data) => {
|
||||
update((state) => {
|
||||
state.viewportHolePunch = data.active;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateUIScale", (data) => {
|
||||
update((state) => {
|
||||
state.uiScale = data.scale;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
function destroy() {
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdatePlatform");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateMaximized");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateFullscreen");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateViewportHolePunch");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateUIScale");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
currentArgs = [editor];
|
||||
return {
|
||||
subscribe,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
export type AppWindowStore = ReturnType<typeof createAppWindowStore>;
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor] | undefined;
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createAppWindowStore(...currentArgs);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { tick } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
|
||||
import type { Layout } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { IconName } from "@graphite/icons";
|
||||
import { patchLayout } from "@graphite/utility-functions/widgets";
|
||||
|
||||
type DialogStoreState = {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
icon: IconName | undefined;
|
||||
buttons: Layout;
|
||||
column1: Layout;
|
||||
column2: Layout;
|
||||
panicDetails: string;
|
||||
};
|
||||
const initialState: DialogStoreState = {
|
||||
visible: false,
|
||||
title: "",
|
||||
icon: undefined,
|
||||
buttons: [],
|
||||
column1: [],
|
||||
column2: [],
|
||||
// Special case for the crash dialog because we cannot handle button widget callbacks from Rust once the editor has panicked
|
||||
panicDetails: "",
|
||||
};
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<DialogStoreState> = import.meta.hot?.data?.store || writable<DialogStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export function createDialogStore(editor: Editor) {
|
||||
// Subscribe to process backend events
|
||||
editor.subscriptions.subscribeFrontendMessage("DisplayDialog", (data) => {
|
||||
update((state) => {
|
||||
state.visible = true;
|
||||
|
||||
state.title = data.title;
|
||||
state.icon = data.icon;
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("DialogButtons", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.buttons, data);
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("DialogColumn1", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.column1, data);
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("DialogColumn2", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.column2, data);
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeFrontendMessage("DialogClose", () => {
|
||||
update((state) => {
|
||||
// Disallow dismissing the crash dialog since it should remain as the final notification
|
||||
if (state.panicDetails === "") state.visible = false;
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeFrontendMessage("TriggerDisplayThirdPartyLicensesDialog", async () => {
|
||||
const BACKUP_URL = "https://editor.graphite.art/third-party-licenses.txt";
|
||||
let licenseText = `Content was not able to load. Please check your network connection and try again.\n\nOr visit ${BACKUP_URL} for the license notices.`;
|
||||
|
||||
try {
|
||||
const response = await fetch("/third-party-licenses.txt");
|
||||
if (response.ok && response.headers.get("Content-Type")?.includes("text/plain")) licenseText = await response.text();
|
||||
} catch {
|
||||
// Do nothing on network error
|
||||
}
|
||||
|
||||
editor.handle.requestLicensesThirdPartyDialogWithLicenseText(licenseText);
|
||||
});
|
||||
|
||||
function destroy() {
|
||||
editor.subscriptions.unsubscribeFrontendMessage("DisplayDialog");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("DialogClose");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerDisplayThirdPartyLicensesDialog");
|
||||
editor.subscriptions.unsubscribeLayoutUpdate("DialogButtons");
|
||||
editor.subscriptions.unsubscribeLayoutUpdate("DialogColumn1");
|
||||
editor.subscriptions.unsubscribeLayoutUpdate("DialogColumn2");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
currentArgs = [editor];
|
||||
return {
|
||||
subscribe,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
export type DialogStore = ReturnType<typeof createDialogStore>;
|
||||
|
||||
// Creates a crash dialog from JS once the editor has panicked.
|
||||
// Normal dialogs are created in the Rust backend, but for the crash dialog, the editor has panicked so it cannot respond to widget callbacks.
|
||||
export function createCrashDialog(panicDetails: string) {
|
||||
update((state) => {
|
||||
state.visible = true;
|
||||
|
||||
state.icon = "Failure";
|
||||
state.title = "Crash";
|
||||
state.panicDetails = panicDetails;
|
||||
|
||||
state.column1 = [];
|
||||
state.column2 = [];
|
||||
state.buttons = [];
|
||||
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor] | undefined;
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createDialogStore(...currentArgs);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { tick } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
|
||||
import type { Layout } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import { patchLayout } from "@graphite/utility-functions/widgets";
|
||||
|
||||
type DocumentStoreState = {
|
||||
toolOptionsLayout: Layout;
|
||||
documentBarLayout: Layout;
|
||||
toolShelfLayout: Layout;
|
||||
workingColorsLayout: Layout;
|
||||
nodeGraphControlBarLayout: Layout;
|
||||
graphViewOverlayOpen: boolean;
|
||||
fadeArtwork: number;
|
||||
};
|
||||
const initialState: DocumentStoreState = {
|
||||
toolOptionsLayout: [],
|
||||
documentBarLayout: [],
|
||||
toolShelfLayout: [],
|
||||
workingColorsLayout: [],
|
||||
nodeGraphControlBarLayout: [],
|
||||
graphViewOverlayOpen: false,
|
||||
fadeArtwork: 100,
|
||||
};
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<DocumentStoreState> = import.meta.hot?.data?.store || writable<DocumentStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export function createDocumentStore(editor: Editor) {
|
||||
// Update layouts
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateGraphFadeArtwork", (data) => {
|
||||
update((state) => {
|
||||
state.fadeArtwork = data.percentage;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("ToolOptions", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.toolOptionsLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("DocumentBar", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.documentBarLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("ToolShelf", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.toolShelfLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("WorkingColors", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.workingColorsLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeLayoutUpdate("NodeGraphControlBar", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.nodeGraphControlBarLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
// Show or hide the graph view overlay
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateGraphViewOverlay", (data) => {
|
||||
update((state) => {
|
||||
state.graphViewOverlayOpen = data.open;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
function destroy() {
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateGraphFadeArtwork");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateGraphViewOverlay");
|
||||
editor.subscriptions.unsubscribeLayoutUpdate("ToolOptions");
|
||||
editor.subscriptions.unsubscribeLayoutUpdate("DocumentBar");
|
||||
editor.subscriptions.unsubscribeLayoutUpdate("ToolShelf");
|
||||
editor.subscriptions.unsubscribeLayoutUpdate("WorkingColors");
|
||||
editor.subscriptions.unsubscribeLayoutUpdate("NodeGraphControlBar");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
currentArgs = [editor];
|
||||
return {
|
||||
subscribe,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
export type DocumentStore = ReturnType<typeof createDocumentStore>;
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor] | undefined;
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createDocumentStore(...currentArgs);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { get, writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
type FullscreenStoreState = {
|
||||
windowFullscreen: boolean;
|
||||
keyboardLocked: boolean;
|
||||
};
|
||||
const initialState: FullscreenStoreState = {
|
||||
windowFullscreen: false,
|
||||
keyboardLocked: false,
|
||||
};
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<FullscreenStoreState> = import.meta.hot?.data?.store || writable<FullscreenStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export function createFullscreenStore(editor: Editor) {
|
||||
editor.subscriptions.subscribeFrontendMessage("WindowFullscreen", () => {
|
||||
toggleFullscreen();
|
||||
});
|
||||
|
||||
function destroy() {
|
||||
editor.subscriptions.unsubscribeFrontendMessage("WindowFullscreen");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
currentArgs = [editor];
|
||||
return {
|
||||
subscribe,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
export type FullscreenStore = ReturnType<typeof createFullscreenStore>;
|
||||
|
||||
export function fullscreenModeChanged() {
|
||||
update((state) => {
|
||||
state.windowFullscreen = Boolean(document.fullscreenElement);
|
||||
if (!state.windowFullscreen) state.keyboardLocked = false;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
export async function enterFullscreen() {
|
||||
await document.documentElement.requestFullscreen();
|
||||
|
||||
const keyboardLockApiSupported = navigator.keyboard !== undefined && "lock" in navigator.keyboard;
|
||||
|
||||
if (keyboardLockApiSupported && navigator.keyboard) {
|
||||
await navigator.keyboard.lock(["ControlLeft", "ControlRight"]);
|
||||
|
||||
update((state) => {
|
||||
state.keyboardLocked = true;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function exitFullscreen() {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
|
||||
export async function toggleFullscreen() {
|
||||
const state = get(store);
|
||||
if (state.windowFullscreen) await exitFullscreen();
|
||||
else await enterFullscreen();
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor] | undefined;
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createFullscreenStore(...currentArgs);
|
||||
});
|
||||
@@ -1,62 +1,62 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
|
||||
import type { NodeGraphErrorDiagnostic, BoxSelection, FrontendClickTargets, ContextMenuInformation, FrontendNode, FrontendNodeType, WirePath } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { MessageBody } from "@graphite/subscription-router";
|
||||
|
||||
export function createNodeGraphState(editor: Editor) {
|
||||
const { subscribe, update } = writable<{
|
||||
box: BoxSelection | undefined;
|
||||
clickTargets: FrontendClickTargets | undefined;
|
||||
contextMenuInformation: ContextMenuInformation | undefined;
|
||||
error: NodeGraphErrorDiagnostic | undefined;
|
||||
layerWidths: Map<bigint, number>;
|
||||
chainWidths: Map<bigint, number>;
|
||||
hasLeftInputWire: Map<bigint, boolean>;
|
||||
updateImportsExports: MessageBody<"UpdateImportsExports"> | undefined;
|
||||
nodes: Map<bigint, FrontendNode>;
|
||||
visibleNodes: Set<bigint>;
|
||||
/// The index is the exposed input index. The exports have a first key value of u32::MAX.
|
||||
wires: Map<bigint, Map<number, WirePath>>;
|
||||
wirePathInProgress: WirePath | undefined;
|
||||
nodeDescriptions: Map<string, string>;
|
||||
nodeTypes: FrontendNodeType[];
|
||||
thumbnails: Map<bigint, string>;
|
||||
selected: bigint[];
|
||||
transform: { scale: number; x: number; y: number };
|
||||
inSelectedNetwork: boolean;
|
||||
reorderImportIndex: number | undefined;
|
||||
reorderExportIndex: number | undefined;
|
||||
}>({
|
||||
box: undefined,
|
||||
clickTargets: undefined,
|
||||
contextMenuInformation: undefined,
|
||||
error: undefined,
|
||||
layerWidths: new Map(),
|
||||
chainWidths: new Map(),
|
||||
hasLeftInputWire: new Map(),
|
||||
updateImportsExports: undefined,
|
||||
nodes: new Map(),
|
||||
visibleNodes: new Set(),
|
||||
wires: new Map(),
|
||||
wirePathInProgress: undefined,
|
||||
nodeDescriptions: new Map(),
|
||||
nodeTypes: [],
|
||||
thumbnails: new Map(),
|
||||
selected: [],
|
||||
transform: { scale: 1, x: 0, y: 0 },
|
||||
inSelectedNetwork: true,
|
||||
reorderImportIndex: undefined,
|
||||
reorderExportIndex: undefined,
|
||||
});
|
||||
type NodeGraphStoreState = {
|
||||
box: BoxSelection | undefined;
|
||||
clickTargets: FrontendClickTargets | undefined;
|
||||
contextMenuInformation: ContextMenuInformation | undefined;
|
||||
error: NodeGraphErrorDiagnostic | undefined;
|
||||
layerWidths: Map<bigint, number>;
|
||||
chainWidths: Map<bigint, number>;
|
||||
hasLeftInputWire: Map<bigint, boolean>;
|
||||
updateImportsExports: MessageBody<"UpdateImportsExports"> | undefined;
|
||||
nodes: Map<bigint, FrontendNode>;
|
||||
visibleNodes: Set<bigint>;
|
||||
/// The index is the exposed input index. The exports have a first key value of u32::MAX.
|
||||
wires: Map<bigint, Map<number, WirePath>>;
|
||||
wirePathInProgress: WirePath | undefined;
|
||||
nodeDescriptions: Map<string, string>;
|
||||
nodeTypes: FrontendNodeType[];
|
||||
thumbnails: Map<bigint, string>;
|
||||
selected: bigint[];
|
||||
transform: { scale: number; x: number; y: number };
|
||||
inSelectedNetwork: boolean;
|
||||
reorderImportIndex: number | undefined;
|
||||
reorderExportIndex: number | undefined;
|
||||
};
|
||||
const initialState: NodeGraphStoreState = {
|
||||
box: undefined,
|
||||
clickTargets: undefined,
|
||||
contextMenuInformation: undefined,
|
||||
error: undefined,
|
||||
layerWidths: new Map(),
|
||||
chainWidths: new Map(),
|
||||
hasLeftInputWire: new Map(),
|
||||
updateImportsExports: undefined,
|
||||
nodes: new Map(),
|
||||
visibleNodes: new Set(),
|
||||
wires: new Map(),
|
||||
wirePathInProgress: undefined,
|
||||
nodeDescriptions: new Map(),
|
||||
nodeTypes: [],
|
||||
thumbnails: new Map(),
|
||||
selected: [],
|
||||
transform: { scale: 1, x: 0, y: 0 },
|
||||
inSelectedNetwork: true,
|
||||
reorderImportIndex: undefined,
|
||||
reorderExportIndex: undefined,
|
||||
};
|
||||
|
||||
function closeContextMenu() {
|
||||
update((state) => {
|
||||
state.contextMenuInformation = undefined;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<NodeGraphStoreState> = import.meta.hot?.data?.store || writable<NodeGraphStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export function createNodeGraphStore(editor: Editor) {
|
||||
// Set up message subscriptions on creation
|
||||
editor.subscriptions.subscribeFrontendMessage("SendUIMetadata", (data) => {
|
||||
update((state) => {
|
||||
@@ -185,9 +185,47 @@ export function createNodeGraphState(editor: Editor) {
|
||||
});
|
||||
});
|
||||
|
||||
function destroy() {
|
||||
editor.subscriptions.unsubscribeFrontendMessage("SendUIMetadata");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateBox");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateClickTargets");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateContextMenuInformation");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateImportReorderIndex");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateExportReorderIndex");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateImportsExports");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateInSelectedNetwork");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateLayerWidths");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateNodeGraphNodes");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateNodeGraphErrorDiagnostic");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateVisibleNodes");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateNodeGraphWires");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("ClearAllNodeGraphWires");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateNodeGraphSelection");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateNodeGraphTransform");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateNodeThumbnail");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateWirePathInProgress");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
currentArgs = [editor];
|
||||
return {
|
||||
subscribe,
|
||||
closeContextMenu,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
export type NodeGraphState = ReturnType<typeof createNodeGraphState>;
|
||||
export type NodeGraphStore = ReturnType<typeof createNodeGraphStore>;
|
||||
|
||||
export function closeContextMenu() {
|
||||
update((state) => {
|
||||
state.contextMenuInformation = undefined;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor] | undefined;
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createNodeGraphStore(...currentArgs);
|
||||
});
|
||||
@@ -1,27 +1,34 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
|
||||
import type { OpenDocument } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import { downloadFile, downloadFileBlob, upload } from "@graphite/utility-functions/files";
|
||||
import { rasterizeSVG } from "@graphite/utility-functions/rasterization";
|
||||
|
||||
export function createPortfolioState(editor: Editor) {
|
||||
const { subscribe, update } = writable<{
|
||||
unsaved: boolean;
|
||||
documents: OpenDocument[];
|
||||
activeDocumentIndex: number;
|
||||
dataPanelOpen: boolean;
|
||||
propertiesPanelOpen: boolean;
|
||||
layersPanelOpen: boolean;
|
||||
}>({
|
||||
unsaved: false,
|
||||
documents: [],
|
||||
activeDocumentIndex: 0,
|
||||
dataPanelOpen: false,
|
||||
propertiesPanelOpen: true,
|
||||
layersPanelOpen: true,
|
||||
});
|
||||
type PortfolioStoreState = {
|
||||
unsaved: boolean;
|
||||
documents: OpenDocument[];
|
||||
activeDocumentIndex: number;
|
||||
dataPanelOpen: boolean;
|
||||
propertiesPanelOpen: boolean;
|
||||
layersPanelOpen: boolean;
|
||||
};
|
||||
const initialState: PortfolioStoreState = {
|
||||
unsaved: false,
|
||||
documents: [],
|
||||
activeDocumentIndex: 0,
|
||||
dataPanelOpen: false,
|
||||
propertiesPanelOpen: true,
|
||||
layersPanelOpen: true,
|
||||
};
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<PortfolioStoreState> = import.meta.hot?.data?.store || writable<PortfolioStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export function createPortfolioStore(editor: Editor) {
|
||||
// Set up message subscriptions on creation
|
||||
editor.subscriptions.subscribeFrontendMessage("UpdateOpenDocumentsList", (data) => {
|
||||
update((state) => {
|
||||
@@ -99,8 +106,33 @@ export function createPortfolioState(editor: Editor) {
|
||||
});
|
||||
});
|
||||
|
||||
function destroy() {
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateOpenDocumentsList");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateActiveDocument");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerFetchAndOpenDocument");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerOpen");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerImport");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerSaveDocument");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerSaveFile");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("TriggerExportImage");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateDataPanelState");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdatePropertiesPanelState");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("UpdateLayersPanelState");
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
currentArgs = [editor];
|
||||
return {
|
||||
subscribe,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
export type PortfolioState = ReturnType<typeof createPortfolioState>;
|
||||
export type PortfolioStore = ReturnType<typeof createPortfolioStore>;
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor] | undefined;
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createPortfolioStore(...currentArgs);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
|
||||
import type { ActionShortcut } from "@graphite/../wasm/pkg/graphite_wasm";
|
||||
import type { Editor } from "@graphite/editor";
|
||||
@@ -6,27 +7,33 @@ import { operatingSystem } from "@graphite/utility-functions/platform";
|
||||
|
||||
const SHOW_TOOLTIP_DELAY_MS = 500;
|
||||
|
||||
export function createTooltipState(editor: Editor) {
|
||||
const { subscribe, update } = writable<{
|
||||
visible: boolean;
|
||||
element: Element | undefined;
|
||||
position: { x: number; y: number };
|
||||
shiftClickShortcut: ActionShortcut | undefined;
|
||||
altClickShortcut: ActionShortcut | undefined;
|
||||
fullscreenShortcut: ActionShortcut | undefined;
|
||||
}>({
|
||||
visible: false,
|
||||
element: undefined,
|
||||
position: { x: 0, y: 0 },
|
||||
shiftClickShortcut: undefined,
|
||||
altClickShortcut: undefined,
|
||||
fullscreenShortcut: undefined,
|
||||
});
|
||||
type TooltipStoreState = {
|
||||
visible: boolean;
|
||||
element: Element | undefined;
|
||||
position: { x: number; y: number };
|
||||
shiftClickShortcut: ActionShortcut | undefined;
|
||||
altClickShortcut: ActionShortcut | undefined;
|
||||
fullscreenShortcut: ActionShortcut | undefined;
|
||||
};
|
||||
const initialState: TooltipStoreState = {
|
||||
visible: false,
|
||||
element: undefined,
|
||||
position: { x: 0, y: 0 },
|
||||
shiftClickShortcut: undefined,
|
||||
altClickShortcut: undefined,
|
||||
fullscreenShortcut: undefined,
|
||||
};
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<TooltipStoreState> = import.meta.hot?.data?.store || writable<TooltipStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export function createTooltipStore(editor: Editor) {
|
||||
let tooltipTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
|
||||
// Listen for mouse movements onto tooltip-bearing HTML elements to track the future target of a tooltip
|
||||
document.addEventListener("mouseover", (e) => {
|
||||
const onMouseOver = (e: MouseEvent) => {
|
||||
const element = (e.target instanceof Element && e.target.closest("[data-tooltip-label], [data-tooltip-description], [data-tooltip-shortcut]")) || undefined;
|
||||
|
||||
update((state) => {
|
||||
@@ -34,10 +41,10 @@ export function createTooltipState(editor: Editor) {
|
||||
state.element = element;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Listen for mouse movements to schedule and position the tooltip, or hide it immediately upon further movement
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
// Hide the tooltip now that the cursor has moved
|
||||
update((state) => {
|
||||
state.visible = false;
|
||||
@@ -60,14 +67,41 @@ export function createTooltipState(editor: Editor) {
|
||||
return state;
|
||||
});
|
||||
}, SHOW_TOOLTIP_DELAY_MS);
|
||||
});
|
||||
};
|
||||
|
||||
// Hide tooltip and cancel any pending timeout when the mouse leaves the application window
|
||||
document.addEventListener("mouseleave", () => {
|
||||
const onMouseLeave = () => {
|
||||
if (tooltipTimeout) clearTimeout(tooltipTimeout);
|
||||
closeTooltip();
|
||||
});
|
||||
};
|
||||
|
||||
// Stop showing a tooltip if the user clicks or presses a key, and require the user to first move out of the element before it can re-appear
|
||||
function closeTooltip() {
|
||||
update((state) => {
|
||||
state.visible = false;
|
||||
state.element = undefined;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (tooltipTimeout) clearTimeout(tooltipTimeout);
|
||||
|
||||
document.removeEventListener("mouseover", onMouseOver);
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseleave", onMouseLeave);
|
||||
document.removeEventListener("mousedown", closeTooltip);
|
||||
document.removeEventListener("keydown", closeTooltip);
|
||||
document.removeEventListener("wheel", closeTooltip);
|
||||
|
||||
editor.subscriptions.unsubscribeFrontendMessage("SendShortcutShiftClick");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("SendShortcutAltClick");
|
||||
editor.subscriptions.unsubscribeFrontendMessage("SendShortcutFullscreen");
|
||||
}
|
||||
|
||||
document.addEventListener("mouseover", onMouseOver);
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseleave", onMouseLeave);
|
||||
document.addEventListener("mousedown", closeTooltip);
|
||||
document.addEventListener("keydown", closeTooltip);
|
||||
document.addEventListener("wheel", closeTooltip);
|
||||
@@ -91,17 +125,19 @@ export function createTooltipState(editor: Editor) {
|
||||
});
|
||||
});
|
||||
|
||||
// Stop showing a tooltip if the user clicks or presses a key, and require the user to first move out of the element before it can re-appear
|
||||
function closeTooltip() {
|
||||
update((state) => {
|
||||
state.visible = false;
|
||||
state.element = undefined;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
currentCleanup = destroy;
|
||||
currentArgs = [editor];
|
||||
return {
|
||||
subscribe,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
export type TooltipState = ReturnType<typeof createTooltipState>;
|
||||
export type TooltipStore = ReturnType<typeof createTooltipStore>;
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
let currentCleanup: (() => void) | undefined;
|
||||
let currentArgs: [Editor] | undefined;
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
currentCleanup?.();
|
||||
if (currentArgs) newModule?.createTooltipStore(...currentArgs);
|
||||
});
|
||||
@@ -12,23 +12,23 @@ export type MessageBody<T extends MessageName> = Extract<FrontendMessage, Record
|
||||
export function createSubscriptionRouter() {
|
||||
// Callbacks are wrapped at subscription time to capture their type-specific data extraction in a closure,
|
||||
// so the stored function has a uniform signature and the map doesn't need per-key generic value types.
|
||||
const subscriptions: Partial<Record<MessageName, (taggedMessage: MessageMap) => void>> = {};
|
||||
const layoutCallbacks: Partial<Record<LayoutTarget, (diffs: WidgetDiff[]) => void>> = {};
|
||||
const subscriptions = new Map<MessageName, (taggedMessage: MessageMap) => void>();
|
||||
const layoutCallbacks = new Map<LayoutTarget, (diffs: WidgetDiff[]) => void>();
|
||||
|
||||
const subscribeFrontendMessage = <T extends MessageName>(messageType: T, callback: (data: MessageMap[T]) => void) => {
|
||||
subscriptions[messageType] = (taggedMessage: MessageMap) => callback(taggedMessage[messageType]);
|
||||
subscriptions.set(messageType, (taggedMessage: MessageMap) => callback(taggedMessage[messageType]));
|
||||
};
|
||||
|
||||
const unsubscribeFrontendMessage = (messageType: MessageName) => {
|
||||
delete subscriptions[messageType];
|
||||
subscriptions.delete(messageType);
|
||||
};
|
||||
|
||||
const subscribeLayoutUpdate = (target: LayoutTarget, callback: (diffs: WidgetDiff[]) => void) => {
|
||||
layoutCallbacks[target] = callback;
|
||||
layoutCallbacks.set(target, callback);
|
||||
};
|
||||
|
||||
const unsubscribeLayoutUpdate = (target: LayoutTarget) => {
|
||||
delete layoutCallbacks[target];
|
||||
layoutCallbacks.delete(target);
|
||||
};
|
||||
|
||||
function normalizeMessage<T extends string | object>(message: T): ToMessageMap<T>;
|
||||
@@ -52,7 +52,7 @@ export function createSubscriptionRouter() {
|
||||
// Resolve the dispatch thunk, depending on whether this is a layout update or a regular message.
|
||||
// UpdateLayout messages are dispatched to layout-specific callbacks based on the layout target.
|
||||
// The thunk is re-evaluated on each retry because the callback may not be registered yet.
|
||||
let getHandler: () => ((taggedMessage: MessageMap) => void) | undefined = () => subscriptions[messageType];
|
||||
let getHandler: () => ((taggedMessage: MessageMap) => void) | undefined = () => subscriptions.get(messageType);
|
||||
|
||||
// Handle layout updates specially to route them to layout-specific callbacks and extract the diffs as the data to pass
|
||||
let target: LayoutTarget | undefined;
|
||||
@@ -61,7 +61,7 @@ export function createSubscriptionRouter() {
|
||||
target = layoutTarget;
|
||||
|
||||
getHandler = () => {
|
||||
const layoutCallback = layoutCallbacks[layoutTarget];
|
||||
const layoutCallback = layoutCallbacks.get(layoutTarget);
|
||||
if (!layoutCallback) return undefined;
|
||||
return () => layoutCallback(diff);
|
||||
};
|
||||
@@ -75,10 +75,14 @@ export function createSubscriptionRouter() {
|
||||
|
||||
if (handler) {
|
||||
handler(taggedMessage);
|
||||
} else if (retries <= 3) {
|
||||
}
|
||||
// Try again on the next stack frame, if the retry limit hasn't been exceeded yet
|
||||
else if (retries <= 3) {
|
||||
retries += 1;
|
||||
setTimeout(callCallback, 0);
|
||||
} else {
|
||||
}
|
||||
// Guard against this firing after a teardown during HMR, if no handlers are registered anymore
|
||||
else if (subscriptions.size + layoutCallbacks.size > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Received a frontend message of type ${messageType}${target ? ` (${target})` : ""} but no handler was registered for it from the client.`);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { DialogState } from "@graphite/state-providers/dialog";
|
||||
import { browserVersion, operatingSystem } from "@graphite/utility-functions/platform";
|
||||
import { stripIndents } from "@graphite/utility-functions/strip-indents";
|
||||
|
||||
export function createPanicManager(editor: Editor, dialogState: DialogState) {
|
||||
// Code panic dialog and console error
|
||||
editor.subscriptions.subscribeFrontendMessage("DisplayDialogPanic", (data) => {
|
||||
// `Error.stackTraceLimit` is only available in V8/Chromium
|
||||
Error.stackTraceLimit = Infinity;
|
||||
const stackTrace = new Error().stack || "";
|
||||
const panicDetails = `${data.panicInfo}${stackTrace ? `\n\n${stackTrace}` : ""}`;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(panicDetails);
|
||||
|
||||
dialogState.createCrashDialog(panicDetails);
|
||||
});
|
||||
}
|
||||
|
||||
export function githubUrl(panicDetails: string): string {
|
||||
export function crashReportUrl(panicDetails: string): string {
|
||||
const url = new URL("https://github.com/GraphiteEditor/Graphite/issues/new");
|
||||
|
||||
const buildUrl = (includeCrashReport: boolean) => {
|
||||
@@ -28,11 +11,11 @@ export function githubUrl(panicDetails: string): string {
|
||||
|
||||
**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.art
|
||||
2.
|
||||
3.
|
||||
4.
|
||||
5.
|
||||
1. Open the Graphite editor at https://dev.graphite.art — IMPORTANT! Confirm you have tested in this development version. It may have already been fixed since the last stable release.
|
||||
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.
|
||||
@@ -1,30 +0,0 @@
|
||||
export type Debouncer = ReturnType<typeof debouncer>;
|
||||
|
||||
export type DebouncerOptions = {
|
||||
debounceTime: number;
|
||||
};
|
||||
|
||||
export function debouncer<T>(callFn: (value: T) => unknown, { debounceTime = 60 }: Partial<DebouncerOptions> = {}) {
|
||||
let currentValue: T | undefined;
|
||||
let recentlyUpdated: boolean = false;
|
||||
|
||||
const debounceEmitValue = () => {
|
||||
recentlyUpdated = false;
|
||||
if (currentValue === undefined) return;
|
||||
debounceUpdateValue(currentValue);
|
||||
};
|
||||
|
||||
const debounceUpdateValue = (newValue: T) => {
|
||||
if (recentlyUpdated) {
|
||||
currentValue = newValue;
|
||||
return;
|
||||
}
|
||||
|
||||
callFn(newValue);
|
||||
recentlyUpdated = true;
|
||||
currentValue = undefined;
|
||||
setTimeout(debounceEmitValue, debounceTime);
|
||||
};
|
||||
|
||||
return { debounceUpdateValue };
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function clamp(value: number, min = 0, max = 1): number {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
@@ -26,11 +26,7 @@ export function browserVersion(): string {
|
||||
export type OperatingSystem = "Windows" | "Mac" | "Linux";
|
||||
|
||||
export function operatingSystem(): OperatingSystem {
|
||||
const osTable: Record<string, OperatingSystem> = {
|
||||
Windows: "Windows",
|
||||
Mac: "Mac",
|
||||
Linux: "Linux",
|
||||
};
|
||||
const osTable: Record<string, OperatingSystem> = { Windows: "Windows", Mac: "Mac", Linux: "Linux" };
|
||||
|
||||
const userAgentOS = Object.keys(osTable).find((key) => window.navigator.userAgent.includes(key));
|
||||
return osTable[userAgentOS || "Windows"];
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import type { Editor } from "@graphite/editor";
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
|
||||
export function setupViewportResizeObserver(editor: Editor) {
|
||||
// Clean up existing observer if any
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
export function setupViewportResizeObserver(editor: Editor): () => void {
|
||||
const viewports = Array.from(window.document.querySelectorAll("[data-viewport-container]"));
|
||||
if (viewports.length <= 0) return;
|
||||
if (viewports.length <= 0) return () => {};
|
||||
|
||||
const viewport = viewports[0];
|
||||
if (!(viewport instanceof HTMLElement)) return;
|
||||
if (!(viewport instanceof HTMLElement)) return () => {};
|
||||
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
|
||||
@@ -52,11 +45,8 @@ export function setupViewportResizeObserver(editor: Editor) {
|
||||
});
|
||||
|
||||
resizeObserver.observe(viewport);
|
||||
}
|
||||
|
||||
export function cleanupViewportResizeObserver() {
|
||||
if (resizeObserver) {
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// on the dispatcher messaging system and more complex Rust data types.
|
||||
//
|
||||
use crate::helpers::translate_key;
|
||||
use crate::{EDITOR_HANDLE, EDITOR_HAS_CRASHED, Error, MESSAGE_BUFFER, PANIC_DIALOG_MESSAGE_CALLBACK};
|
||||
use crate::{EDITOR_HANDLE, EDITOR_HAS_CRASHED, Error, FRONTEND_READY, MESSAGE_BUFFER, PANIC_DIALOG_MESSAGE_CALLBACK};
|
||||
use editor::consts::FILE_EXTENSION;
|
||||
use editor::messages::clipboard::utility_types::ClipboardContentRaw;
|
||||
use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys;
|
||||
@@ -189,6 +189,11 @@ impl EditorHandle {
|
||||
|
||||
#[wasm_bindgen(js_name = initAfterFrontendReady)]
|
||||
pub fn init_after_frontend_ready(&self) {
|
||||
// Enforce idempotency, so if this is called again during an HMR re-mount, we don't initialize the editor backend twice
|
||||
if FRONTEND_READY.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
crate::native_communication::initialize_native_communication();
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use wasm_bindgen::prelude::*;
|
||||
|
||||
// Set up the persistent editor backend state
|
||||
pub static EDITOR_HAS_CRASHED: AtomicBool = AtomicBool::new(false);
|
||||
pub static FRONTEND_READY: AtomicBool = AtomicBool::new(false);
|
||||
pub static NODE_GRAPH_ERROR_DISPLAYED: AtomicBool = AtomicBool::new(false);
|
||||
pub static LOGGER: WasmLog = WasmLog;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user