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:
Keavon Chambers
2026-03-19 13:38:41 -07:00
committed by GitHub
parent eb562146ce
commit 124b17f609
54 changed files with 1385 additions and 931 deletions
+36 -40
View File
@@ -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,
+33 -12
View File
@@ -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 -4
View File
@@ -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;
+9 -8
View File
@@ -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">
+16 -9
View File
@@ -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>