From 2e2c4fe180a6009144d8747161885ddcbace2d2d Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 19 Mar 2026 18:25:34 -0700 Subject: [PATCH] Restructure frontend TS files so managers/stores export destructors instead of returning them from their constructors (#3919) * Replace parameter passing with getContext and extract destroy functions to module-level exports * Resend layouts from Rust when editor is re-mounted on HMR * Code review --- editor/src/messages/layout/layout_message.rs | 1 + .../messages/layout/layout_message_handler.rs | 14 + .../layout/utility_types/layout_widget.rs | 35 +- frontend/eslint.config.js | 1 + frontend/src/components/Editor.svelte | 67 +- .../components/floating-menus/Dialog.svelte | 2 +- frontend/src/managers/clipboard.ts | 31 +- frontend/src/managers/fonts.ts | 36 +- frontend/src/managers/hyperlink.ts | 25 +- frontend/src/managers/input.ts | 998 +++++++++--------- frontend/src/managers/localization.ts | 25 +- frontend/src/managers/panic.ts | 27 +- frontend/src/managers/persistence.ts | 148 +-- frontend/src/stores/app-window.ts | 44 +- frontend/src/stores/dialog.ts | 45 +- frontend/src/stores/document.ts | 43 +- frontend/src/stores/fullscreen.ts | 33 +- frontend/src/stores/node-graph.ts | 84 +- frontend/src/stores/portfolio.ts | 62 +- frontend/src/stores/tooltip.ts | 159 ++- frontend/wasm/src/editor_api.rs | 6 + 21 files changed, 986 insertions(+), 900 deletions(-) diff --git a/editor/src/messages/layout/layout_message.rs b/editor/src/messages/layout/layout_message.rs index ff0ded38..6376f628 100644 --- a/editor/src/messages/layout/layout_message.rs +++ b/editor/src/messages/layout/layout_message.rs @@ -8,6 +8,7 @@ pub enum LayoutMessage { layout_target: LayoutTarget, widget_id: WidgetId, }, + ResendAllLayouts, SendLayout { layout: Layout, layout_target: LayoutTarget, diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index 73dfa2ae..2033a486 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -33,6 +33,20 @@ impl MessageHandler> for LayoutMessageHa // Resend that diff self.send_diff(vec![diff], layout_target, responses, action_input_mapping); } + LayoutMessage::ResendAllLayouts => { + // Collect non-empty layouts and their indices, then clear the stored copies so diffs compute as full re-sends + let layouts_to_resend: Vec<_> = self + .layouts + .iter_mut() + .enumerate() + .filter(|(_, layout)| !layout.0.is_empty()) + .map(|(i, layout)| (LayoutTarget::from(i as u8), std::mem::take(layout))) + .collect(); + + for (layout_target, layout) in layouts_to_resend { + self.diff_and_send_layout_to_frontend(layout_target, layout, responses, action_input_mapping); + } + } LayoutMessage::SendLayout { layout, layout_target } => { self.diff_and_send_layout_to_frontend(layout_target, layout, responses, action_input_mapping); } diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index ac136311..91eaaa99 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -20,10 +20,30 @@ impl core::fmt::Display for WidgetId { } } -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[derive(PartialEq, Clone, Debug, Hash, Eq, Copy, serde::Serialize, serde::Deserialize)] -#[repr(u8)] -pub enum LayoutTarget { +macro_rules! define_layout_target { + ($($(#[$attr:meta])* $variant:ident),* $(,)?) => { + #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] + #[derive(PartialEq, Clone, Debug, Hash, Eq, Copy, serde::Serialize, serde::Deserialize)] + #[repr(u8)] + pub enum LayoutTarget { + $($(#[$attr])* $variant,)* + // KEEP THIS ENUM LAST + // This is a marker that is used to define an array that is used to hold widgets + #[serde(skip)] + _LayoutTargetLength, + } + + impl From for LayoutTarget { + fn from(value: u8) -> Self { + match value { + $(x if x == Self::$variant as u8 => Self::$variant,)* + _ => panic!("Invalid LayoutTarget discriminant: {value}"), + } + } + } + }; +} +define_layout_target!( /// The spreadsheet panel allows for the visualisation of data in the graph. DataPanel, /// Contains the action buttons at the bottom of the dialog. Must be shown with the `FrontendMessage::DisplayDialog` message. @@ -58,12 +78,7 @@ pub enum LayoutTarget { WelcomeScreenButtons, /// The color swatch for the working colors and a flip and reset button found at the bottom of the tool shelf. WorkingColors, - - // KEEP THIS ENUM LAST - // This is a marker that is used to define an array that is used to hold widgets - #[serde(skip)] - _LayoutTargetLength, -} +); /// For use by structs that define a UI widget layout by implementing the layout() function belonging to this trait. /// The send_layout() function can then be called by other code which is a part of the same struct so as to send the layout to the frontend. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 1680a240..5faa1d3b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -70,6 +70,7 @@ export default defineConfig([ ignoreRestSiblings: true, }, ], + "@typescript-eslint/no-non-null-assertion": "error", "@typescript-eslint/consistent-type-imports": "error", "@typescript-eslint/consistent-type-definitions": ["error", "type"], "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }], diff --git a/frontend/src/components/Editor.svelte b/frontend/src/components/Editor.svelte index 46e93952..e914bdab 100644 --- a/frontend/src/components/Editor.svelte +++ b/frontend/src/components/Editor.svelte @@ -2,20 +2,20 @@ import { onMount, onDestroy, setContext } from "svelte"; import type { Editor } from "@graphite/editor"; - 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 { createClipboardManager, destroyClipboardManager } from "@graphite/managers/clipboard"; + import { createFontsManager, destroyFontsManager } from "@graphite/managers/fonts"; + import { createHyperlinkManager, destroyHyperlinkManager } from "@graphite/managers/hyperlink"; + import { createInputManager, destroyInputManager } from "@graphite/managers/input"; + import { createLocalizationManager, destroyLocalizationManager } from "@graphite/managers/localization"; + import { createPanicManager, destroyPanicManager } from "@graphite/managers/panic"; + import { createPersistenceManager, destroyPersistenceManager } from "@graphite/managers/persistence"; + import { createAppWindowStore, destroyAppWindowStore } from "@graphite/stores/app-window"; + import { createDialogStore, destroyDialogStore } from "@graphite/stores/dialog"; + import { createDocumentStore, destroyDocumentStore } from "@graphite/stores/document"; + import { createFullscreenStore, destroyFullscreenStore } from "@graphite/stores/fullscreen"; + import { createNodeGraphStore, destroyNodeGraphStore } from "@graphite/stores/node-graph"; + import { createPortfolioStore, destroyPortfolioStore } from "@graphite/stores/portfolio"; + import { createTooltipStore, destroyTooltipStore } from "@graphite/stores/tooltip"; import MainWindow from "@graphite/components/window/MainWindow.svelte"; @@ -34,24 +34,41 @@ }; Object.entries(stores).forEach(([key, store]) => setContext(key, store)); - const managers = { - clipboard: createClipboardManager(editor), - hyperlink: createHyperlinkManager(editor), - localization: createLocalizationManager(editor), - panic: createPanicManager(editor), - persistence: createPersistenceManager(editor, stores.portfolio), - fonts: createFontsManager(editor), - input: createInputManager(editor, stores.dialog, stores.portfolio, stores.document, stores.fullscreen), - }; - onMount(() => { + createClipboardManager(editor); + createHyperlinkManager(editor); + createLocalizationManager(editor); + createPanicManager(editor); + createPersistenceManager(editor, stores.portfolio); + createFontsManager(editor); + createInputManager(editor, stores.dialog, stores.portfolio, stores.document); + // 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(); + + // Re-send all UI layouts from Rust so the frontend has them after an HMR re-mount + editor.handle.resendAllLayouts(); }); onDestroy(() => { - [...Object.values(stores), ...Object.values(managers)].forEach(({ destroy }) => destroy()); + // Stores + destroyDialogStore(); + destroyTooltipStore(); + destroyDocumentStore(); + destroyFullscreenStore(); + destroyNodeGraphStore(); + destroyPortfolioStore(); + destroyAppWindowStore(); + + // Managers + destroyClipboardManager(); + destroyHyperlinkManager(); + destroyLocalizationManager(); + destroyPanicManager(); + destroyPersistenceManager(); + destroyFontsManager(); + destroyInputManager(); }); diff --git a/frontend/src/components/floating-menus/Dialog.svelte b/frontend/src/components/floating-menus/Dialog.svelte index 3b1b4552..63c14607 100644 --- a/frontend/src/components/floating-menus/Dialog.svelte +++ b/frontend/src/components/floating-menus/Dialog.svelte @@ -3,7 +3,7 @@ import { wipeDocuments } from "@graphite/managers/persistence"; import type { DialogStore } from "@graphite/stores/dialog"; - import { crashReportUrl } from "/src/utility-functions/crash-report"; + import { crashReportUrl } from "@graphite/utility-functions/crash-report"; import FloatingMenu from "@graphite/components/layout/FloatingMenu.svelte"; import LayoutCol from "@graphite/components/layout/LayoutCol.svelte"; diff --git a/frontend/src/managers/clipboard.ts b/frontend/src/managers/clipboard.ts index 26568526..d6088499 100644 --- a/frontend/src/managers/clipboard.ts +++ b/frontend/src/managers/clipboard.ts @@ -1,33 +1,32 @@ import type { Editor } from "@graphite/editor"; -let currentCleanup: (() => void) | undefined; -let currentArgs: [Editor] | undefined; +let editorRef: Editor | undefined = undefined; export function createClipboardManager(editor: Editor) { - currentArgs = [editor]; + editorRef = 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 navigator.clipboard?.writeText?.(data.content); }); + editor.subscriptions.subscribeFrontendMessage("TriggerSelectionRead", async (data) => { editor.handle.readSelection(readAtCaret(data.cut), data.cut); }); + 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; + +export function destroyClipboardManager() { + const editor = editorRef; + if (!editor) return; + + editor.subscriptions.unsubscribeFrontendMessage("TriggerClipboardWrite"); + editor.subscriptions.unsubscribeFrontendMessage("TriggerSelectionRead"); + editor.subscriptions.unsubscribeFrontendMessage("TriggerSelectionWrite"); +} function readAtCaret(cut: boolean): string | undefined { const element = window.document.activeElement; @@ -112,6 +111,6 @@ function insertAtCaret(text: string) { // 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); + destroyClipboardManager(); + if (editorRef) newModule?.createClipboardManager(editorRef); }); diff --git a/frontend/src/managers/fonts.ts b/frontend/src/managers/fonts.ts index a27ede12..82846957 100644 --- a/frontend/src/managers/fonts.ts +++ b/frontend/src/managers/fonts.ts @@ -4,17 +4,16 @@ type ApiResponse = { family: string; variants: string[]; files: Record void) | undefined; -let currentArgs: [Editor] | undefined; +let editorRef: Editor | undefined = undefined; +let abortController: AbortController | undefined = undefined; export function createFontsManager(editor: Editor) { - currentArgs = [editor]; - const abortController = new AbortController(); + editorRef = editor; + abortController = new AbortController(); - // Subscribe to process backend events editor.subscriptions.subscribeFrontendMessage("TriggerFontCatalogLoad", async () => { try { - const response = await fetch(FONT_LIST_API, { signal: abortController.signal }); + const response = await fetch(FONT_LIST_API, abortController ? { signal: abortController.signal } : undefined); 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; @@ -42,7 +41,7 @@ export function createFontsManager(editor: Editor) { try { if (!data.url) throw new Error("No URL provided for font data load"); - const response = await fetch(data.url, { signal: abortController.signal }); + const response = await fetch(data.url, abortController ? { signal: abortController.signal } : undefined); if (!response.ok) throw new Error(`Font data request failed with status ${response.status}`); const buffer = await response.arrayBuffer(); const bytes = new Uint8Array(buffer); @@ -54,20 +53,19 @@ export function createFontsManager(editor: Editor) { 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; + +export function destroyFontsManager() { + const editor = editorRef; + if (!editor) return; + + abortController?.abort(); + editor.subscriptions.unsubscribeFrontendMessage("TriggerFontCatalogLoad"); + editor.subscriptions.unsubscribeFrontendMessage("TriggerFontDataLoad"); +} // 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); + destroyFontsManager(); + if (editorRef) newModule?.createFontsManager(editorRef); }); diff --git a/frontend/src/managers/hyperlink.ts b/frontend/src/managers/hyperlink.ts index 414c5131..4662917a 100644 --- a/frontend/src/managers/hyperlink.ts +++ b/frontend/src/managers/hyperlink.ts @@ -1,27 +1,24 @@ import type { Editor } from "@graphite/editor"; -let currentCleanup: (() => void) | undefined; -let currentArgs: [Editor] | undefined; +let editorRef: Editor | undefined = undefined; export function createHyperlinkManager(editor: Editor) { - currentArgs = [editor]; + editorRef = 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; + +export function destroyHyperlinkManager() { + const editor = editorRef; + if (!editor) return; + + editor.subscriptions.unsubscribeFrontendMessage("TriggerVisitLink"); +} // 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); + destroyHyperlinkManager(); + if (editorRef) newModule?.createHyperlinkManager(editorRef); }); diff --git a/frontend/src/managers/input.ts b/frontend/src/managers/input.ts index da3a3d1c..a40ece31 100644 --- a/frontend/src/managers/input.ts +++ b/frontend/src/managers/input.ts @@ -5,7 +5,6 @@ import type { Editor } from "@graphite/editor"; 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"; @@ -29,507 +28,542 @@ type EventListenerTarget = { removeEventListener: typeof window.removeEventListener; }; -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(); - - let viewportPointerInteractionOngoing = false; - let textToolInteractiveInputElement: HTMLDivElement | undefined = undefined; - let canvasFocused = true; - let inPointerLock = false; - const shakeSamples: { x: number; y: number; time: number }[] = []; - let lastShakeTime = 0; - - // Event listeners - - const listeners: { target: EventListenerTarget; eventName: EventName; action(event: Event): void; options?: AddEventListenerOptions }[] = [ - { target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent) => onBeforeUnload(e) }, - { target: window, eventName: "keyup", action: (e: KeyboardEvent) => onKeyUp(e) }, - { target: window, eventName: "keydown", action: (e: KeyboardEvent) => onKeyDown(e) }, - { target: window, eventName: "pointermove", action: (e: PointerEvent) => onPointerMove(e) }, - { target: window, eventName: "pointerdown", action: (e: PointerEvent) => onPointerDown(e) }, - { target: window, eventName: "pointerup", action: (e: PointerEvent) => onPointerUp(e) }, - { target: window, eventName: "mousedown", action: (e: MouseEvent) => onMouseDown(e) }, - { target: window, eventName: "mouseup", action: (e: MouseEvent) => onPotentialDoubleClick(e) }, - { target: window, eventName: "wheel", action: (e: WheelEvent) => onWheelScroll(e), options: { passive: false } }, - { 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: () => 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 }, - ]; - - // Event bindings - - function bindListeners() { - // Add event bindings for the lifetime of the application - listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options)); - } - function unbindListeners() { - // Remove event bindings after the lifetime of the application (or on hot-module replacement during development) - listeners.forEach(({ target, eventName, action, options }) => target.removeEventListener(eventName, action, options)); - } - - // Keyboard events - - async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): Promise { - // Don't redirect when a dialog is covering the workspace - if (get(dialog).visible) return false; - - const key = await getLocalizedScanCode(e); - - // TODO: Switch to a system where everything is sent to the backend, then the input preprocessor makes decisions and kicks some inputs back to the frontend - const accelKey = operatingSystem() === "Mac" ? e.metaKey : e.ctrlKey; - - // Cut, copy, and paste is handled in the backend on desktop - if (isPlatformNative() && accelKey && ["KeyX", "KeyC", "KeyV"].includes(key)) return true; - // But on web, we want to not redirect paste - if (!isPlatformNative() && key === "KeyV" && accelKey) return false; - - // Don't redirect user input from text entry into HTML elements - if (targetIsTextField(e.target || undefined) && key !== "Escape" && !(accelKey && ["Enter", "NumpadEnter"].includes(key))) return false; - - // Don't redirect tab or enter if not in canvas (to allow navigating elements) - potentiallyRestoreCanvasFocus(e); - if ( - !canvasFocused && - !targetIsTextField(e.target || undefined) && - ["Tab", "Enter", "NumpadEnter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key) && - !(e.ctrlKey || e.metaKey || e.altKey) - ) - return false; - - // Don't redirect if a MenuList is open - if (window.document.querySelector("[data-floating-menu-content]")) return false; - - // Web-only keyboard shortcuts - if (!isPlatformNative()) { - // 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(); - toggleFullscreen(); - return false; - } - - // Don't redirect a reload request - if (key === "F5") return false; - if (key === "KeyR" && accelKey) return false; - - // Don't redirect debugging tools - if (["F12", "F8"].includes(key)) return false; - if (["KeyC", "KeyI", "KeyJ"].includes(key) && accelKey && e.shiftKey) return false; - } - - // Redirect to the backend - return true; - } - - async function onKeyDown(e: KeyboardEvent) { - const key = await getLocalizedScanCode(e); - - const NO_KEY_REPEAT_MODIFIER_KEYS = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "MetaLeft", "MetaRight", "AltLeft", "AltRight", "AltGraph", "CapsLock", "Fn", "FnLock"]; - if (e.repeat && NO_KEY_REPEAT_MODIFIER_KEYS.includes(key)) return; - - if (await shouldRedirectKeyboardEventToBackend(e)) { - e.preventDefault(); - const modifiers = makeKeyboardModifiersBitfield(e); - editor.handle.onKeyDown(key, modifiers, e.repeat); - return; - } - - if (get(dialog).visible && key === "Escape") { - editor.handle.onDialogDismiss(); - } - } - - async function onKeyUp(e: KeyboardEvent) { - const key = await getLocalizedScanCode(e); - - if (await shouldRedirectKeyboardEventToBackend(e)) { - e.preventDefault(); - const modifiers = makeKeyboardModifiersBitfield(e); - editor.handle.onKeyUp(key, modifiers, e.repeat); - } - } - - // Pointer events - - // While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend - function onPointerMove(e: PointerEvent) { - potentiallyRestoreCanvasFocus(e); - - if (!e.buttons) viewportPointerInteractionOngoing = false; - - // Don't redirect pointer movement to the backend if there's no ongoing interaction and it's over a floating menu, or the graph overlay, on top of the canvas - // TODO: A better approach is to pass along a boolean to the backend's input preprocessor so it can know if it's being occluded by the GUI. - // TODO: This would allow it to properly decide to act on removing hover focus from something that was hovered in the canvas before moving over the GUI. - // TODO: Further explanation: https://github.com/GraphiteEditor/Graphite/pull/623#discussion_r866436197 - const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]"); - const inGraphOverlay = get(document).graphViewOverlayOpen; - if (!viewportPointerInteractionOngoing && (inFloatingMenu || inGraphOverlay)) return; - - const modifiers = makeKeyboardModifiersBitfield(e); - if (detectShake(e)) editor.handle.onMouseShake(e.clientX, e.clientY, e.buttons, modifiers); - editor.handle.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers); - } - - function onPointerDown(e: PointerEvent) { - potentiallyRestoreCanvasFocus(e); - - const { target } = e; - const inFloatingMenu = target instanceof Element && target.closest("[data-floating-menu-content]"); - const isTargetingCanvas = !inFloatingMenu && target instanceof Element && target.closest("[data-viewport], [data-viewport-container], [data-node-graph]"); - const inDialog = target instanceof Element && target.closest("[data-dialog] [data-floating-menu-content]"); - const inContextMenu = target instanceof Element && target.closest("[data-context-menu]"); - const inTextInput = target === textToolInteractiveInputElement; - - if (get(dialog).visible && !inDialog) { - editor.handle.onDialogDismiss(); - e.preventDefault(); - e.stopPropagation(); - } - - if (!inTextInput && !inContextMenu) { - if (textToolInteractiveInputElement) { - const isLeftOrRightClick = e.button === BUTTON_RIGHT || e.button === BUTTON_LEFT; - editor.handle.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText), isLeftOrRightClick); - } else { - viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element; - } - } - - if (viewportPointerInteractionOngoing && isTargetingCanvas instanceof Element) { - const modifiers = makeKeyboardModifiersBitfield(e); - editor.handle.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers); - } - } - - function onPointerUp(e: PointerEvent) { - potentiallyRestoreCanvasFocus(e); - - // Don't let the browser navigate back or forward when using the buttons on some mice - // TODO: This works in Chrome but not in Firefox - // TODO: Possible workaround: use the browser's history API to block navigation: - // TODO: - if (e.button === BUTTON_BACK || e.button === BUTTON_FORWARD) e.preventDefault(); - - if (!e.buttons) viewportPointerInteractionOngoing = false; - - if (textToolInteractiveInputElement) return; - - const modifiers = makeKeyboardModifiersBitfield(e); - editor.handle.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers); - } - - // Mouse events - - function onPotentialDoubleClick(e: MouseEvent) { - if (textToolInteractiveInputElement || inPointerLock) return; - - // Allow only events within the viewport or node graph boundaries - const { target } = e; - const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-viewport-container], [data-node-graph]"); - if (!(isTargetingCanvas instanceof Element)) return; - - // Allow only repeated increments of double-clicks (not 1, 3, 5, etc.) - if (e.detail % 2 == 1) return; - - // `e.buttons` is always 0 in the `mouseup` event, so we have to convert from `e.button` instead - let buttons = 1; - if (e.button === BUTTON_LEFT) buttons = 1; // Left - if (e.button === BUTTON_RIGHT) buttons = 2; // Right - if (e.button === BUTTON_MIDDLE) buttons = 4; // Middle - if (e.button === BUTTON_BACK) buttons = 8; // Back - if (e.button === BUTTON_FORWARD) buttons = 16; // Forward - - const modifiers = makeKeyboardModifiersBitfield(e); - editor.handle.onDoubleClick(e.clientX, e.clientY, buttons, modifiers); - } - - function onMouseDown(e: MouseEvent) { - // Block middle mouse button auto-scroll mode (the circular gizmo that appears and allows quick scrolling by moving the cursor above or below it) - if (e.button === BUTTON_MIDDLE) e.preventDefault(); - } - - function onContextMenu(e: MouseEvent) { - if (!targetIsTextField(e.target || undefined) && e.target !== textToolInteractiveInputElement) { - e.preventDefault(); - } - } - - function onPointerLockChange() { - inPointerLock = Boolean(window.document.pointerLockElement); - } - - // Wheel events - - function onWheelScroll(e: WheelEvent) { - const { target } = e; - const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-viewport-container], [data-node-graph]"); - - // Prevent zooming the entire page when using Ctrl + scroll wheel outside of the viewport - if (e.ctrlKey && !isTargetingCanvas) { - e.preventDefault(); - } - - // Redirect vertical scroll wheel movement into a horizontal scroll on a horizontally scrollable element - // There seems to be no possible way to properly employ the browser's smooth scrolling interpolation - const horizontalScrollableElement = target instanceof Element && target.closest("[data-scrollable-x]"); - if (horizontalScrollableElement && e.deltaY !== 0) { - horizontalScrollableElement.scrollTo(horizontalScrollableElement.scrollLeft + e.deltaY, 0); - return; - } - - if (isTargetingCanvas) { - e.preventDefault(); - const modifiers = makeKeyboardModifiersBitfield(e); - editor.handle.onWheelScroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers); - } - } - - // Receives a custom event dispatched when the user begins interactively editing with the text tool. - // We keep a copy of the text input element to check against when it's active for text entry. - function onModifyInputField(e: CustomEvent) { - textToolInteractiveInputElement = e.detail; - } - - // Window events - - async function onBeforeUnload(e: BeforeUnloadEvent) { - const activeDocument = get(portfolio).documents[get(portfolio).activeDocumentIndex]; - if (activeDocument && !activeDocument.details.isAutoSaved) editor.handle.triggerAutoSave(activeDocument.id); - - // Skip the message if the editor crashed, since work is already lost - if (await editor.handle.hasCrashed()) return; - - // Skip the message during development, since it's annoying when testing - if (await editor.handle.inDevelopmentMode()) return; - - const allDocumentsSaved = get(portfolio).documents.reduce((acc, doc) => acc && doc.details.isSaved, true); - if (!allDocumentsSaved) { - e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?"; - e.preventDefault(); - } - } - - function onPaste(e: ClipboardEvent) { - const dataTransfer = e.clipboardData; - if (!dataTransfer || targetIsTextField(e.target || undefined)) return; - e.preventDefault(); - - Array.from(dataTransfer.items).forEach(async (item) => { - if (item.type === "text/plain") item.getAsString((text) => editor.handle.pasteText(text)); - await pasteFile(item, editor); - }); - } - - function detectShake(e: PointerEvent | MouseEvent): boolean { - const SENSITIVITY_DIRECTION_CHANGES = 3; - const SENSITIVITY_DISTANCE_TO_DISPLACEMENT_RATIO = 0.1; - const DETECTION_WINDOW_MS = 500; - const DEBOUNCE_MS = 1000; - - // Add the current mouse position and time to our list of samples - const now = Date.now(); - shakeSamples.push({ x: e.clientX, y: e.clientY, time: now }); - - // Remove samples that are older than our time window - while (shakeSamples.length > 0 && now - shakeSamples[0].time > DETECTION_WINDOW_MS) { - shakeSamples.shift(); - } - - // We can't be shaking if it's too early in terms of samples or debounce time - if (shakeSamples.length <= 3 || now - lastShakeTime <= DEBOUNCE_MS) return false; - - // Calculate the total distance traveled - let totalDistanceSquared = 0; - for (let i = 1; i < shakeSamples.length; i += 1) { - const p1 = shakeSamples[i - 1]; - const p2 = shakeSamples[i]; - totalDistanceSquared += (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; - } - - // Count the number of times the mouse changes direction significantly, and the average position of the mouse - let directionChanges = 0; - const averagePoint = { x: 0, y: 0 }; - let averagePointCount = 0; - for (let i = 0; i < shakeSamples.length - 2; i += 1) { - const p1 = shakeSamples[i]; - const p2 = shakeSamples[i + 1]; - const p3 = shakeSamples[i + 2]; - - const vector1 = { x: p2.x - p1.x, y: p2.y - p1.y }; - const vector2 = { x: p3.x - p2.x, y: p3.y - p2.y }; - - // Check if the dot product is negative, which indicates the angle between vectors is > 90 degrees - if (vector1.x * vector2.x + vector1.y * vector2.y < 0) directionChanges += 1; - - averagePoint.x += p2.x; - averagePoint.y += p2.y; - averagePointCount += 1; - } - if (averagePointCount > 0) { - averagePoint.x /= averagePointCount; - averagePoint.y /= averagePointCount; - } - - // Calculate the displacement (the distance between the first and last mouse positions) - const lastPoint = shakeSamples[shakeSamples.length - 1]; - const displacementSquared = (lastPoint.x - averagePoint.x) ** 2 + (lastPoint.y - averagePoint.y) ** 2; - - // A shake is detected if the mouse has traveled a lot but not moved far, and has changed direction enough times - if (SENSITIVITY_DISTANCE_TO_DISPLACEMENT_RATIO * totalDistanceSquared >= displacementSquared && directionChanges >= SENSITIVITY_DIRECTION_CHANGES) { - lastShakeTime = now; - shakeSamples.length = 0; - - return true; - } - - return false; - } - - // Frontend message subscriptions - - editor.subscriptions.subscribeFrontendMessage("TriggerClipboardRead", async () => { - // In the try block, attempt to read from the Clipboard API, which may not have permission and may not be supported in all browsers - // In the catch block, explain to the user why the paste failed and how to fix or work around the problem - try { - // Attempt to check if the clipboard permission is denied, and throw an error if that is the case - // In Firefox, the `clipboard-read` permission isn't supported, so attempting to query it throws an error - // In Safari, the entire Permissions API isn't supported, so the query never occurs and this block is skipped without an error and we assume we might have permission - const permission = await navigator.permissions?.query({ name: "clipboard-read" }); - if (permission?.state === "denied") throw new Error("Permission denied"); - - // Read the clipboard contents if the Clipboard API is available - const clipboardItems = await navigator.clipboard.read(); - if (!clipboardItems) throw new Error("Clipboard API unsupported"); - - // Read any layer data or images from the clipboard - const success = await Promise.any( - Array.from(clipboardItems).map(async (item) => { - // Read plain text and, if it is a layer, pass it to the editor - if (item.types.includes("text/plain")) { - const blob = await item.getType("text/plain"); - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === "string") editor.handle.pasteText(reader.result); - }; - reader.readAsText(blob); - return true; - } - - // Read an image from the clipboard and pass it to the editor to be loaded - const imageType = item.types.find((type) => type.startsWith("image/")); - - // Import the actual SVG content if it's an SVG - if (imageType?.includes("svg")) { - const blob = await item.getType("text/plain"); - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === "string") editor.handle.pasteSvg(undefined, reader.result); - }; - reader.readAsText(blob); - return true; - } - - // Import the bitmap image if it's an image - if (imageType) { - const blob = await item.getType(imageType); - const reader = new FileReader(); - reader.onload = async () => { - if (reader.result instanceof ArrayBuffer) { - const imageData = await extractPixelData(new Blob([reader.result], { type: imageType })); - editor.handle.pasteImage(undefined, new Uint8Array(imageData.data), imageData.width, imageData.height); - } - }; - reader.readAsArrayBuffer(blob); - return true; - } - - // The API limits what kinds of data we can access, so we can get copied images and our text encodings of copied nodes, but not files (like - // .graphite or even image files). However, the user can paste those with Ctrl+V, which we recommend they in the error message that's shown to them. - return false; - }), - ); - - if (!success) throw new Error("No valid clipboard data"); - } catch (err) { - const unsupported = stripIndents` - This browser does not support reading from the clipboard. - Use the standard keyboard shortcut to paste instead. - `; - const denied = stripIndents` - The browser's clipboard permission has been denied. - - Open the browser's website settings (usually accessible - just left of the URL bar) to allow this permission. - `; - const nothing = stripIndents` - No valid clipboard data was found. You may have better - success pasting with the standard keyboard shortcut instead. - `; - - const matchMessage = { - "clipboard-read": unsupported, - "Clipboard API unsupported": unsupported, - "Permission denied": denied, - "No valid clipboard data": nothing, - }; - const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err); - - editor.handle.errorDialog("Cannot access clipboard", message); - } +let editorRef: Editor | undefined = undefined; +let dialogStore: DialogStore | undefined = undefined; +let portfolioStore: PortfolioStore | undefined = undefined; +let documentStore: DocumentStore | undefined = undefined; + +let viewportPointerInteractionOngoing = false; +let textToolInteractiveInputElement: HTMLDivElement | undefined = undefined; +let canvasFocused = true; +let inPointerLock = false; +let lastShakeTime = 0; +const shakeSamples: { x: number; y: number; time: number }[] = []; + +const listeners: { target: EventListenerTarget; eventName: EventName; action(event: Event): void; options?: AddEventListenerOptions }[] = [ + { target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent) => onBeforeUnload(e) }, + { target: window, eventName: "keyup", action: (e: KeyboardEvent) => onKeyUp(e) }, + { target: window, eventName: "keydown", action: (e: KeyboardEvent) => onKeyDown(e) }, + { target: window, eventName: "pointermove", action: (e: PointerEvent) => onPointerMove(e) }, + { target: window, eventName: "pointerdown", action: (e: PointerEvent) => onPointerDown(e) }, + { target: window, eventName: "pointerup", action: (e: PointerEvent) => onPointerUp(e) }, + { target: window, eventName: "mousedown", action: (e: MouseEvent) => onMouseDown(e) }, + { target: window, eventName: "mouseup", action: (e: MouseEvent) => onPotentialDoubleClick(e) }, + { target: window, eventName: "wheel", action: (e: WheelEvent) => onWheelScroll(e), options: { passive: false } }, + { 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: () => 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 }, +]; + +export function createInputManager(editor: Editor, dialog: DialogStore, portfolio: PortfolioStore, doc: DocumentStore) { + editorRef = editor; + dialogStore = dialog; + portfolioStore = portfolio; + documentStore = doc; + + editor.subscriptions.subscribeFrontendMessage("TriggerClipboardRead", () => { + triggerClipboardRead(); }); - // Pointer lock movement events on desktop editor.subscriptions.subscribeFrontendMessage("WindowPointerLockMove", (data) => { + // Desktop app only: dispatch custom pointer lock movement events const event = new CustomEvent("pointerlockmove", { detail: { x: data.position[0], y: data.position[1] } }); window.dispatchEvent(event); }); - // Helper functions + // INITIALIZATION - function potentiallyRestoreCanvasFocus(e: Event) { - const { target } = e; - const newInCanvasArea = - (target instanceof Element && target.closest("[data-viewport], [data-viewport-container], [data-graph]")) instanceof Element && - !targetIsTextField(window.document.activeElement || undefined); - if (!canvasFocused && newInCanvasArea) { - canvasFocused = true; - app?.focus(); - } - } - - // Initialization + // Focus the app container + const app = window.document.querySelector("[data-app-container]"); + if (app instanceof HTMLElement) app.focus(); // Bind the event listeners bindListeners(); +} - // Return the destructor - function destroy() { - unbindListeners(); - editor.subscriptions.unsubscribeFrontendMessage("TriggerClipboardRead"); - editor.subscriptions.unsubscribeFrontendMessage("WindowPointerLockMove"); +// Return the destructor +export function destroyInputManager() { + const editor = editorRef; + if (!editor) return; + + unbindListeners(); + editor.subscriptions.unsubscribeFrontendMessage("TriggerClipboardRead"); + editor.subscriptions.unsubscribeFrontendMessage("WindowPointerLockMove"); +} + +async function triggerClipboardRead() { + const editor = editorRef; + if (!editor) return; + + // In the try block, attempt to read from the Clipboard API, which may not have permission and may not be supported in all browsers + // In the catch block, explain to the user why the paste failed and how to fix or work around the problem + try { + // Attempt to check if the clipboard permission is denied, and throw an error if that is the case + // In Firefox, the `clipboard-read` permission isn't supported, so attempting to query it throws an error + // In Safari, the entire Permissions API isn't supported, so the query never occurs and this block is skipped without an error and we assume we might have permission + const permission = await navigator.permissions?.query({ name: "clipboard-read" }); + if (permission?.state === "denied") throw new Error("Permission denied"); + + // Read the clipboard contents if the Clipboard API is available + const clipboardItems = await navigator.clipboard.read(); + if (!clipboardItems) throw new Error("Clipboard API unsupported"); + + // Read any layer data or images from the clipboard + const success = await Promise.any( + Array.from(clipboardItems).map(async (item) => { + // Read plain text and, if it is a layer, pass it to the editor + if (item.types.includes("text/plain")) { + const blob = await item.getType("text/plain"); + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") editor.handle.pasteText(reader.result); + }; + reader.readAsText(blob); + return true; + } + + // Read an image from the clipboard and pass it to the editor to be loaded + const imageType = item.types.find((type) => type.startsWith("image/")); + + // Import the actual SVG content if it's an SVG + if (imageType?.includes("svg")) { + const blob = await item.getType("text/plain"); + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") editor.handle.pasteSvg(undefined, reader.result); + }; + reader.readAsText(blob); + return true; + } + + // Import the bitmap image if it's an image + if (imageType) { + const blob = await item.getType(imageType); + const reader = new FileReader(); + reader.onload = async () => { + if (reader.result instanceof ArrayBuffer) { + const imageData = await extractPixelData(new Blob([reader.result], { type: imageType })); + editor.handle.pasteImage(undefined, new Uint8Array(imageData.data), imageData.width, imageData.height); + } + }; + reader.readAsArrayBuffer(blob); + return true; + } + + // The API limits what kinds of data we can access, so we can get copied images and our text encodings of copied nodes, but not files (like + // .graphite or even image files). However, the user can paste those with Ctrl+V, which we recommend they in the error message that's shown to them. + return false; + }), + ); + + if (!success) throw new Error("No valid clipboard data"); + } catch (err) { + const unsupported = stripIndents` + This browser does not support reading from the clipboard. + Use the standard keyboard shortcut to paste instead. + `; + const denied = stripIndents` + The browser's clipboard permission has been denied. + + Open the browser's website settings (usually accessible + just left of the URL bar) to allow this permission. + `; + const nothing = stripIndents` + No valid clipboard data was found. You may have better + success pasting with the standard keyboard shortcut instead. + `; + + const matchMessage = { + "clipboard-read": unsupported, + "Clipboard API unsupported": unsupported, + "Permission denied": denied, + "No valid clipboard data": nothing, + }; + const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err); + + editor.handle.errorDialog("Cannot access clipboard", message); + } +} + +// Event bindings + +function bindListeners() { + // Add event bindings for the lifetime of the application + listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options)); +} + +function unbindListeners() { + // Remove event bindings after the lifetime of the application (or on hot-module replacement during development) + listeners.forEach(({ target, eventName, action, options }) => target.removeEventListener(eventName, action, options)); +} + +// Keyboard events + +async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): Promise { + if (!dialogStore) return false; + + // Don't redirect when a dialog is covering the workspace + if (get(dialogStore).visible) return false; + + const key = await getLocalizedScanCode(e); + + // TODO: Switch to a system where everything is sent to the backend, then the input preprocessor makes decisions and kicks some inputs back to the frontend + const accelKey = operatingSystem() === "Mac" ? e.metaKey : e.ctrlKey; + + // Cut, copy, and paste is handled in the backend on desktop + if (isPlatformNative() && accelKey && ["KeyX", "KeyC", "KeyV"].includes(key)) return true; + // But on web, we want to not redirect paste + if (!isPlatformNative() && key === "KeyV" && accelKey) return false; + + // Don't redirect user input from text entry into HTML elements + if (targetIsTextField(e.target || undefined) && key !== "Escape" && !(accelKey && ["Enter", "NumpadEnter"].includes(key))) return false; + + // Don't redirect tab or enter if not in canvas (to allow navigating elements) + potentiallyRestoreCanvasFocus(e); + if ( + !canvasFocused && + !targetIsTextField(e.target || undefined) && + ["Tab", "Enter", "NumpadEnter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key) && + !(e.ctrlKey || e.metaKey || e.altKey) + ) + return false; + + // Don't redirect if a MenuList is open + if (window.document.querySelector("[data-floating-menu-content]")) return false; + + // Web-only keyboard shortcuts + if (!isPlatformNative()) { + // 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(); + toggleFullscreen(); + return false; + } + + // Don't redirect a reload request + if (key === "F5") return false; + if (key === "KeyR" && accelKey) return false; + + // Don't redirect debugging tools + if (["F12", "F8"].includes(key)) return false; + if (["KeyC", "KeyI", "KeyJ"].includes(key) && accelKey && e.shiftKey) return false; } - currentCleanup = destroy; - return { destroy }; + // Redirect to the backend + return true; +} + +async function onKeyDown(e: KeyboardEvent) { + const editor = editorRef; + if (!editor || !dialogStore) return; + + const key = await getLocalizedScanCode(e); + + const NO_KEY_REPEAT_MODIFIER_KEYS = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "MetaLeft", "MetaRight", "AltLeft", "AltRight", "AltGraph", "CapsLock", "Fn", "FnLock"]; + if (e.repeat && NO_KEY_REPEAT_MODIFIER_KEYS.includes(key)) return; + + if (await shouldRedirectKeyboardEventToBackend(e)) { + e.preventDefault(); + const modifiers = makeKeyboardModifiersBitfield(e); + editor.handle.onKeyDown(key, modifiers, e.repeat); + return; + } + + if (get(dialogStore).visible && key === "Escape") { + editor.handle.onDialogDismiss(); + } +} + +async function onKeyUp(e: KeyboardEvent) { + const editor = editorRef; + if (!editor) return; + + const key = await getLocalizedScanCode(e); + + if (await shouldRedirectKeyboardEventToBackend(e)) { + e.preventDefault(); + const modifiers = makeKeyboardModifiersBitfield(e); + editor.handle.onKeyUp(key, modifiers, e.repeat); + } +} + +// Pointer events + +// While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend +function onPointerMove(e: PointerEvent) { + const editor = editorRef; + if (!editor || !documentStore) return; + + potentiallyRestoreCanvasFocus(e); + + if (!e.buttons) viewportPointerInteractionOngoing = false; + + // Don't redirect pointer movement to the backend if there's no ongoing interaction and it's over a floating menu, or the graph overlay, on top of the canvas + // TODO: A better approach is to pass along a boolean to the backend's input preprocessor so it can know if it's being occluded by the GUI. + // TODO: This would allow it to properly decide to act on removing hover focus from something that was hovered in the canvas before moving over the GUI. + // TODO: Further explanation: https://github.com/GraphiteEditor/Graphite/pull/623#discussion_r866436197 + const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]"); + const inGraphOverlay = get(documentStore).graphViewOverlayOpen; + if (!viewportPointerInteractionOngoing && (inFloatingMenu || inGraphOverlay)) return; + + const modifiers = makeKeyboardModifiersBitfield(e); + if (detectShake(e)) editor.handle.onMouseShake(e.clientX, e.clientY, e.buttons, modifiers); + editor.handle.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers); +} + +function onPointerDown(e: PointerEvent) { + const editor = editorRef; + if (!editor || !dialogStore) return; + + potentiallyRestoreCanvasFocus(e); + + const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]"); + const isTargetingCanvas = !inFloatingMenu && e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]"); + const inDialog = e.target instanceof Element && e.target.closest("[data-dialog] [data-floating-menu-content]"); + const inContextMenu = e.target instanceof Element && e.target.closest("[data-context-menu]"); + const inTextInput = e.target === textToolInteractiveInputElement; + + if (get(dialogStore).visible && !inDialog) { + editor.handle.onDialogDismiss(); + e.preventDefault(); + e.stopPropagation(); + } + + if (!inTextInput && !inContextMenu) { + if (textToolInteractiveInputElement) { + const isLeftOrRightClick = e.button === BUTTON_RIGHT || e.button === BUTTON_LEFT; + editor.handle.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText), isLeftOrRightClick); + } else { + viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element; + } + } + + if (viewportPointerInteractionOngoing && isTargetingCanvas instanceof Element) { + const modifiers = makeKeyboardModifiersBitfield(e); + editor.handle.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers); + } +} + +function onPointerUp(e: PointerEvent) { + const editor = editorRef; + if (!editor) return; + + potentiallyRestoreCanvasFocus(e); + + // Don't let the browser navigate back or forward when using the buttons on some mice + // TODO: This works in Chrome but not in Firefox + // TODO: Possible workaround: use the browser's history API to block navigation: + // TODO: + if (e.button === BUTTON_BACK || e.button === BUTTON_FORWARD) e.preventDefault(); + + if (!e.buttons) viewportPointerInteractionOngoing = false; + + if (textToolInteractiveInputElement) return; + + const modifiers = makeKeyboardModifiersBitfield(e); + editor.handle.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers); +} + +// Mouse events + +function onPotentialDoubleClick(e: MouseEvent) { + const editor = editorRef; + if (!editor) return; + + if (textToolInteractiveInputElement || inPointerLock) return; + + // Allow only events within the viewport or node graph boundaries + const isTargetingCanvas = e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]"); + if (!(isTargetingCanvas instanceof Element)) return; + + // Allow only repeated increments of double-clicks (not 1, 3, 5, etc.) + if (e.detail % 2 == 1) return; + + // `e.buttons` is always 0 in the `mouseup` event, so we have to convert from `e.button` instead + let buttons = 1; + if (e.button === BUTTON_LEFT) buttons = 1; // Left + if (e.button === BUTTON_RIGHT) buttons = 2; // Right + if (e.button === BUTTON_MIDDLE) buttons = 4; // Middle + if (e.button === BUTTON_BACK) buttons = 8; // Back + if (e.button === BUTTON_FORWARD) buttons = 16; // Forward + + const modifiers = makeKeyboardModifiersBitfield(e); + editor.handle.onDoubleClick(e.clientX, e.clientY, buttons, modifiers); +} + +function onMouseDown(e: MouseEvent) { + // Block middle mouse button auto-scroll mode (the circular gizmo that appears and allows quick scrolling by moving the cursor above or below it) + if (e.button === BUTTON_MIDDLE) e.preventDefault(); +} + +function onContextMenu(e: MouseEvent) { + if (!targetIsTextField(e.target || undefined) && e.target !== textToolInteractiveInputElement) { + e.preventDefault(); + } +} + +function onPointerLockChange() { + inPointerLock = Boolean(window.document.pointerLockElement); +} + +// Wheel events + +function onWheelScroll(e: WheelEvent) { + const editor = editorRef; + if (!editor) return; + + const isTargetingCanvas = e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]"); + + // Prevent zooming the entire page when using Ctrl + scroll wheel outside of the viewport + if (e.ctrlKey && !isTargetingCanvas) { + e.preventDefault(); + } + + // Redirect vertical scroll wheel movement into a horizontal scroll on a horizontally scrollable element + // There seems to be no possible way to properly employ the browser's smooth scrolling interpolation + const horizontalScrollableElement = e.target instanceof Element && e.target.closest("[data-scrollable-x]"); + if (horizontalScrollableElement && e.deltaY !== 0) { + horizontalScrollableElement.scrollTo(horizontalScrollableElement.scrollLeft + e.deltaY, 0); + return; + } + + if (isTargetingCanvas) { + e.preventDefault(); + const modifiers = makeKeyboardModifiersBitfield(e); + editor.handle.onWheelScroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers); + } +} + +// Receives a custom event dispatched when the user begins interactively editing with the text tool. +// We keep a copy of the text input element to check against when it's active for text entry. +function onModifyInputField(e: CustomEvent) { + textToolInteractiveInputElement = e.detail; +} + +// Window events + +async function onBeforeUnload(e: BeforeUnloadEvent) { + const editor = editorRef; + if (!editor || !portfolioStore) return; + + const activeDocument = get(portfolioStore).documents[get(portfolioStore).activeDocumentIndex]; + if (activeDocument && !activeDocument.details.isAutoSaved) editor.handle.triggerAutoSave(activeDocument.id); + + // Skip the message if the editor crashed, since work is already lost + if (await editor.handle.hasCrashed()) return; + + // Skip the message during development, since it's annoying when testing + if (await editor.handle.inDevelopmentMode()) return; + + const allDocumentsSaved = get(portfolioStore).documents.reduce((acc, doc) => acc && doc.details.isSaved, true); + if (!allDocumentsSaved) { + e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?"; + e.preventDefault(); + } +} + +function onPaste(e: ClipboardEvent) { + const editor = editorRef; + if (!editor) return; + + const dataTransfer = e.clipboardData; + if (!dataTransfer || targetIsTextField(e.target || undefined)) return; + e.preventDefault(); + + Array.from(dataTransfer.items).forEach(async (item) => { + if (item.type === "text/plain") item.getAsString((text) => editor.handle.pasteText(text)); + await pasteFile(item, editor); + }); +} + +function detectShake(e: PointerEvent | MouseEvent): boolean { + const SENSITIVITY_DIRECTION_CHANGES = 3; + const SENSITIVITY_DISTANCE_TO_DISPLACEMENT_RATIO = 0.1; + const DETECTION_WINDOW_MS = 500; + const DEBOUNCE_MS = 1000; + + // Add the current mouse position and time to our list of samples + const now = Date.now(); + shakeSamples.push({ x: e.clientX, y: e.clientY, time: now }); + + // Remove samples that are older than our time window + while (shakeSamples.length > 0 && now - shakeSamples[0].time > DETECTION_WINDOW_MS) { + shakeSamples.shift(); + } + + // We can't be shaking if it's too early in terms of samples or debounce time + if (shakeSamples.length <= 3 || now - lastShakeTime <= DEBOUNCE_MS) return false; + + // Calculate the total distance traveled + let totalDistanceSquared = 0; + for (let i = 1; i < shakeSamples.length; i += 1) { + const p1 = shakeSamples[i - 1]; + const p2 = shakeSamples[i]; + totalDistanceSquared += (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; + } + + // Count the number of times the mouse changes direction significantly, and the average position of the mouse + let directionChanges = 0; + const averagePoint = { x: 0, y: 0 }; + let averagePointCount = 0; + for (let i = 0; i < shakeSamples.length - 2; i += 1) { + const p1 = shakeSamples[i]; + const p2 = shakeSamples[i + 1]; + const p3 = shakeSamples[i + 2]; + + const vector1 = { x: p2.x - p1.x, y: p2.y - p1.y }; + const vector2 = { x: p3.x - p2.x, y: p3.y - p2.y }; + + // Check if the dot product is negative, which indicates the angle between vectors is > 90 degrees + if (vector1.x * vector2.x + vector1.y * vector2.y < 0) directionChanges += 1; + + averagePoint.x += p2.x; + averagePoint.y += p2.y; + averagePointCount += 1; + } + if (averagePointCount > 0) { + averagePoint.x /= averagePointCount; + averagePoint.y /= averagePointCount; + } + + // Calculate the displacement (the distance between the first and last mouse positions) + const lastPoint = shakeSamples[shakeSamples.length - 1]; + const displacementSquared = (lastPoint.x - averagePoint.x) ** 2 + (lastPoint.y - averagePoint.y) ** 2; + + // A shake is detected if the mouse has traveled a lot but not moved far, and has changed direction enough times + if (SENSITIVITY_DISTANCE_TO_DISPLACEMENT_RATIO * totalDistanceSquared >= displacementSquared && directionChanges >= SENSITIVITY_DIRECTION_CHANGES) { + lastShakeTime = now; + shakeSamples.length = 0; + + return true; + } + + return false; } -export type InputManager = ReturnType; function targetIsTextField(target: EventTarget | HTMLElement | undefined): boolean { return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable); } +function potentiallyRestoreCanvasFocus(e: Event) { + const appElement = window.document.querySelector("[data-app-container]"); + const app = appElement instanceof HTMLElement ? appElement : null; + + const newInCanvasArea = + (e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-graph]")) instanceof Element && + !targetIsTextField(window.document.activeElement || undefined); + if (!canvasFocused && newInCanvasArea) { + canvasFocused = true; + app?.focus(); + } +} + // 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); + destroyInputManager(); + if (editorRef && dialogStore && portfolioStore && documentStore) newModule?.createInputManager(editorRef, dialogStore, portfolioStore, documentStore); }); diff --git a/frontend/src/managers/localization.ts b/frontend/src/managers/localization.ts index a52a0632..166b4874 100644 --- a/frontend/src/managers/localization.ts +++ b/frontend/src/managers/localization.ts @@ -1,25 +1,22 @@ import type { Editor } from "@graphite/editor"; -let currentCleanup: (() => void) | undefined; -let currentArgs: [Editor] | undefined; +let editorRef: Editor | undefined = undefined; export function createLocalizationManager(editor: Editor) { - currentArgs = [editor]; + editorRef = 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; + +export function destroyLocalizationManager() { + const editor = editorRef; + if (!editor) return; + + editor.subscriptions.unsubscribeFrontendMessage("TriggerAboutGraphiteLocalizedCommitDate"); +} function localizeTimestamp(utc: string): { timestamp: string; year: string } { // Timestamp @@ -38,6 +35,6 @@ function localizeTimestamp(utc: string): { timestamp: string; year: string } { // 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); + destroyLocalizationManager(); + if (editorRef) newModule?.createLocalizationManager(editorRef); }); diff --git a/frontend/src/managers/panic.ts b/frontend/src/managers/panic.ts index 389b6015..bb1b210b 100644 --- a/frontend/src/managers/panic.ts +++ b/frontend/src/managers/panic.ts @@ -1,18 +1,18 @@ import type { Editor } from "@graphite/editor"; import { createCrashDialog } from "@graphite/stores/dialog"; -let currentCleanup: (() => void) | undefined; -let currentArgs: [Editor] | undefined; +let editorRef: Editor | undefined = undefined; export function createPanicManager(editor: Editor) { - currentArgs = [editor]; - // Code panic dialog and console error + editorRef = editor; + 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 @@ -20,18 +20,17 @@ export function createPanicManager(editor: Editor) { createCrashDialog(panicDetails); }); - - function destroy() { - editor.subscriptions.unsubscribeFrontendMessage("DisplayDialogPanic"); - } - - currentCleanup = destroy; - return { destroy }; } -export type PanicManager = ReturnType; + +export function destroyPanicManager() { + const editor = editorRef; + if (!editor) return; + + editor.subscriptions.unsubscribeFrontendMessage("DisplayDialogPanic"); +} // 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); + destroyPanicManager(); + if (editorRef) newModule?.createPanicManager(editorRef); }); diff --git a/frontend/src/managers/persistence.ts b/frontend/src/managers/persistence.ts index 02f0f198..c3acefc9 100644 --- a/frontend/src/managers/persistence.ts +++ b/frontend/src/managers/persistence.ts @@ -1,46 +1,51 @@ -import { createStore, del, get, set, update } from "idb-keyval"; -import { get as getFromStore } from "svelte/store"; +import * as idb from "idb-keyval"; +import { get } 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; +let editorRef: Editor | undefined = undefined; +let portfolioStore: PortfolioStore | undefined = undefined; export function createPersistenceManager(editor: Editor, portfolio: PortfolioStore) { - currentArgs = [editor, portfolio]; - // DOCUMENTS + editorRef = editor; + portfolioStore = portfolio; - // 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 indexedDbStorage = idb.createStore("graphite", "store"); + + const previouslySavedDocuments = await idb.get>>("documents", indexedDbStorage); + const documentId = String(data.documentId); - const previouslySavedDocuments = await get>>("documents", graphiteStore); // TODO: Eventually remove this document upgrade code // Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed @@ -55,89 +60,86 @@ export function createPersistenceManager(editor: Editor, portfolio: PortfolioSto 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; - -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); +export function destroyPersistenceManager() { + const editor = editorRef; + if (!editor) return; + + 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"); } -async function storeCurrentDocumentId(documentId: string) { - await set("current_document_id", String(documentId), graphiteStore); +export async function storeCurrentDocumentId(documentId: string) { + const indexedDbStorage = idb.createStore("graphite", "store"); + + await idb.set("current_document_id", String(documentId), indexedDbStorage); } -async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersistenceWriteDocument">, portfolio: PortfolioStore) { - await update>>( +export async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersistenceWriteDocument">, portfolio: PortfolioStore) { + const indexedDbStorage = idb.createStore("graphite", "store"); + + await idb.update>>( "documents", (old) => { const documents = old || {}; documents[String(autoSaveDocument.documentId)] = autoSaveDocument; return documents; }, - graphiteStore, + indexedDbStorage, ); - await storeDocumentOrder(portfolio); + const documentOrder = get(portfolio).documents.map((doc) => String(doc.id)); + await idb.set("documents_tab_order", documentOrder, indexedDbStorage); await storeCurrentDocumentId(String(autoSaveDocument.documentId)); } -async function removeDocument(id: string, portfolio: PortfolioStore) { - await update>>( +export async function removeDocument(id: string, portfolio: PortfolioStore) { + const indexedDbStorage = idb.createStore("graphite", "store"); + + await idb.update>>( "documents", (old) => { const documents = old || {}; delete documents[id]; return documents; }, - graphiteStore, + indexedDbStorage, ); - await update( + await idb.update( "documents_tab_order", (old) => { const order = old || []; return order.filter((docId) => docId !== id); }, - graphiteStore, + indexedDbStorage, ); - const documentCount = getFromStore(portfolio).documents.length; + const documentCount = get(portfolio).documents.length; if (documentCount > 0) { - const documentIndex = getFromStore(portfolio).activeDocumentIndex; - const documentId = String(getFromStore(portfolio).documents[documentIndex].id); + const documentIndex = get(portfolio).activeDocumentIndex; + const documentId = String(get(portfolio).documents[documentIndex].id); - const tabOrder = (await get("documents_tab_order", graphiteStore)) || []; + const tabOrder = (await idb.get("documents_tab_order", indexedDbStorage)) || []; if (tabOrder.includes(documentId)) { await storeCurrentDocumentId(documentId); } } else { - await del("current_document_id", graphiteStore); + await idb.del("current_document_id", indexedDbStorage); } } -async function loadFirstDocument(editor: Editor) { - const previouslySavedDocuments = await get>>("documents", graphiteStore); +export async function loadFirstDocument(editor: Editor) { + const indexedDbStorage = idb.createStore("graphite", "store"); + + const previouslySavedDocuments = await idb.get>>("documents", indexedDbStorage); // TODO: Eventually remove this document upgrade code // Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if the browser is storing the old format as strings @@ -147,8 +149,8 @@ async function loadFirstDocument(editor: Editor) { }); } - const documentOrder = await get("documents_tab_order", graphiteStore); - const currentDocumentIdString = await get("current_document_id", graphiteStore); + const documentOrder = await idb.get("documents_tab_order", indexedDbStorage); + const currentDocumentIdString = await idb.get("current_document_id", indexedDbStorage); const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined; if (!previouslySavedDocuments || !documentOrder) return; @@ -168,8 +170,10 @@ async function loadFirstDocument(editor: Editor) { } } -async function loadRestDocuments(editor: Editor) { - const previouslySavedDocuments = await get>>("documents", graphiteStore); +export async function loadRestDocuments(editor: Editor) { + const indexedDbStorage = idb.createStore("graphite", "store"); + + const previouslySavedDocuments = await idb.get>>("documents", indexedDbStorage); // TODO: Eventually remove this document upgrade code // Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed @@ -179,8 +183,8 @@ async function loadRestDocuments(editor: Editor) { }); } - const documentOrder = await get("documents_tab_order", graphiteStore); - const currentDocumentIdString = await get("current_document_id", graphiteStore); + const documentOrder = await idb.get("documents_tab_order", indexedDbStorage); + const currentDocumentIdString = await idb.get("current_document_id", indexedDbStorage); const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined; if (!previouslySavedDocuments || !documentOrder) return; @@ -217,19 +221,29 @@ async function loadRestDocuments(editor: Editor) { } } -// PREFERENCES +export async function saveEditorPreferences(preferences: unknown) { + const indexedDbStorage = idb.createStore("graphite", "store"); -async function saveEditorPreferences(preferences: unknown) { - await set("preferences", preferences, graphiteStore); + await idb.set("preferences", preferences, indexedDbStorage); } -async function loadEditorPreferences(editor: Editor) { - const preferences = await get>("preferences", graphiteStore); +export async function loadEditorPreferences(editor: Editor) { + const indexedDbStorage = idb.createStore("graphite", "store"); + + const preferences = await idb.get>("preferences", indexedDbStorage); editor.handle.loadPreferences(preferences ? JSON.stringify(preferences) : undefined); } +export async function wipeDocuments() { + const indexedDbStorage = idb.createStore("graphite", "store"); + + await idb.del("documents_tab_order", indexedDbStorage); + await idb.del("current_document_id", indexedDbStorage); + await idb.del("documents", indexedDbStorage); +} + // 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); + destroyPersistenceManager(); + if (editorRef && portfolioStore) newModule?.createPersistenceManager(editorRef, portfolioStore); }); diff --git a/frontend/src/stores/app-window.ts b/frontend/src/stores/app-window.ts index 618b6064..4c2ce668 100644 --- a/frontend/src/stores/app-window.ts +++ b/frontend/src/stores/app-window.ts @@ -4,6 +4,8 @@ import type { Writable } from "svelte/store"; import type { AppWindowPlatform } from "@graphite/../wasm/pkg/graphite_wasm"; import type { Editor } from "@graphite/editor"; +export type AppWindowStore = ReturnType; + type AppWindowStoreState = { platform: AppWindowPlatform; maximized: boolean; @@ -19,37 +21,44 @@ const initialState: AppWindowStoreState = { uiScale: 1, }; +let editorRef: Editor | undefined = undefined; + // Store state persisted across HMR to maintain reactive subscriptions in the component tree const store: Writable = import.meta.hot?.data?.store || writable(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 + editorRef = editor; + 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; @@ -57,27 +66,16 @@ export function createAppWindowStore(editor: Editor) { }); }); - 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, - }; + return { subscribe }; } -export type AppWindowStore = ReturnType; -// 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); -}); +export function destroyAppWindowStore() { + const editor = editorRef; + if (!editor) return; + + editor.subscriptions.unsubscribeFrontendMessage("UpdatePlatform"); + editor.subscriptions.unsubscribeFrontendMessage("UpdateMaximized"); + editor.subscriptions.unsubscribeFrontendMessage("UpdateFullscreen"); + editor.subscriptions.unsubscribeFrontendMessage("UpdateViewportHolePunch"); + editor.subscriptions.unsubscribeFrontendMessage("UpdateUIScale"); +} diff --git a/frontend/src/stores/dialog.ts b/frontend/src/stores/dialog.ts index 3efb6b25..d34a04f2 100644 --- a/frontend/src/stores/dialog.ts +++ b/frontend/src/stores/dialog.ts @@ -7,6 +7,8 @@ import type { Editor } from "@graphite/editor"; import type { IconName } from "@graphite/icons"; import { patchLayout } from "@graphite/utility-functions/widgets"; +export type DialogStore = ReturnType; + type DialogStoreState = { visible: boolean; title: string; @@ -27,13 +29,16 @@ const initialState: DialogStoreState = { panicDetails: "", }; +let editorRef: Editor | undefined = undefined; + // Store state persisted across HMR to maintain reactive subscriptions in the component tree const store: Writable = import.meta.hot?.data?.store || writable(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 + editorRef = editor; + editor.subscriptions.subscribeFrontendMessage("DisplayDialog", (data) => { update((state) => { state.visible = true; @@ -71,6 +76,7 @@ export function createDialogStore(editor: Editor) { return state; }); }); + editor.subscriptions.subscribeFrontendMessage("DialogClose", () => { update((state) => { // Disallow dismissing the crash dialog since it should remain as the final notification @@ -94,23 +100,20 @@ export function createDialogStore(editor: Editor) { 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, - }; + return { subscribe }; +} + +export function destroyDialogStore() { + const editor = editorRef; + if (!editor) return; + + editor.subscriptions.unsubscribeFrontendMessage("DisplayDialog"); + editor.subscriptions.unsubscribeFrontendMessage("DialogClose"); + editor.subscriptions.unsubscribeFrontendMessage("TriggerDisplayThirdPartyLicensesDialog"); + editor.subscriptions.unsubscribeLayoutUpdate("DialogButtons"); + editor.subscriptions.unsubscribeLayoutUpdate("DialogColumn1"); + editor.subscriptions.unsubscribeLayoutUpdate("DialogColumn2"); } -export type DialogStore = ReturnType; // 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. @@ -129,11 +132,3 @@ export function createCrashDialog(panicDetails: string) { 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); -}); diff --git a/frontend/src/stores/document.ts b/frontend/src/stores/document.ts index 365433bc..75ea4f44 100644 --- a/frontend/src/stores/document.ts +++ b/frontend/src/stores/document.ts @@ -6,6 +6,8 @@ import type { Layout } from "@graphite/../wasm/pkg/graphite_wasm"; import type { Editor } from "@graphite/editor"; import { patchLayout } from "@graphite/utility-functions/widgets"; +export type DocumentStore = ReturnType; + type DocumentStoreState = { toolOptionsLayout: Layout; documentBarLayout: Layout; @@ -25,12 +27,16 @@ const initialState: DocumentStoreState = { fadeArtwork: 100, }; +let editorRef: Editor | undefined = undefined; + // Store state persisted across HMR to maintain reactive subscriptions in the component tree const store: Writable = import.meta.hot?.data?.store || writable(initialState); if (import.meta.hot) import.meta.hot.data.store = store; const { subscribe, update } = store; export function createDocumentStore(editor: Editor) { + editorRef = editor; + // Update layouts editor.subscriptions.subscribeFrontendMessage("UpdateGraphFadeArtwork", (data) => { update((state) => { @@ -87,29 +93,18 @@ export function createDocumentStore(editor: Editor) { }); }); - 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, - }; + return { subscribe }; } -export type DocumentStore = ReturnType; -// 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); -}); +export function destroyDocumentStore() { + const editor = editorRef; + if (!editor) return; + + 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"); +} diff --git a/frontend/src/stores/fullscreen.ts b/frontend/src/stores/fullscreen.ts index fc74bf75..d9394e57 100644 --- a/frontend/src/stores/fullscreen.ts +++ b/frontend/src/stores/fullscreen.ts @@ -3,6 +3,8 @@ import type { Writable } from "svelte/store"; import type { Editor } from "@graphite/editor"; +export type FullscreenStore = ReturnType; + type FullscreenStoreState = { windowFullscreen: boolean; keyboardLocked: boolean; @@ -12,28 +14,29 @@ const initialState: FullscreenStoreState = { keyboardLocked: false, }; +let editorRef: Editor | undefined = undefined; + // Store state persisted across HMR to maintain reactive subscriptions in the component tree const store: Writable = import.meta.hot?.data?.store || writable(initialState); if (import.meta.hot) import.meta.hot.data.store = store; const { subscribe, update } = store; export function createFullscreenStore(editor: Editor) { + editorRef = editor; + editor.subscriptions.subscribeFrontendMessage("WindowFullscreen", () => { toggleFullscreen(); }); - function destroy() { - editor.subscriptions.unsubscribeFrontendMessage("WindowFullscreen"); - } - - currentCleanup = destroy; - currentArgs = [editor]; - return { - subscribe, - destroy, - }; + return { subscribe }; +} + +export function destroyFullscreenStore() { + const editor = editorRef; + if (!editor) return; + + editor.subscriptions.unsubscribeFrontendMessage("WindowFullscreen"); } -export type FullscreenStore = ReturnType; export function fullscreenModeChanged() { update((state) => { @@ -67,11 +70,3 @@ export async function toggleFullscreen() { 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); -}); diff --git a/frontend/src/stores/node-graph.ts b/frontend/src/stores/node-graph.ts index 394c2b56..6fdf43aa 100644 --- a/frontend/src/stores/node-graph.ts +++ b/frontend/src/stores/node-graph.ts @@ -5,6 +5,8 @@ import type { NodeGraphErrorDiagnostic, BoxSelection, FrontendClickTargets, Cont import type { Editor } from "@graphite/editor"; import type { MessageBody } from "@graphite/subscription-router"; +export type NodeGraphStore = ReturnType; + type NodeGraphStoreState = { box: BoxSelection | undefined; clickTargets: FrontendClickTargets | undefined; @@ -51,12 +53,16 @@ const initialState: NodeGraphStoreState = { reorderExportIndex: undefined, }; +let editorRef: Editor | undefined = undefined; + // Store state persisted across HMR to maintain reactive subscriptions in the component tree const store: Writable = import.meta.hot?.data?.store || writable(initialState); if (import.meta.hot) import.meta.hot.data.store = store; const { subscribe, update } = store; export function createNodeGraphStore(editor: Editor) { + editorRef = editor; + // Set up message subscriptions on creation editor.subscriptions.subscribeFrontendMessage("SendUIMetadata", (data) => { update((state) => { @@ -65,48 +71,56 @@ export function createNodeGraphStore(editor: Editor) { return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateBox", (data) => { update((state) => { state.box = data.box; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateClickTargets", (data) => { update((state) => { state.clickTargets = data.clickTargets; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateContextMenuInformation", (data) => { update((state) => { state.contextMenuInformation = data.contextMenuInformation; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateImportReorderIndex", (data) => { update((state) => { state.reorderImportIndex = data.importIndex; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateExportReorderIndex", (data) => { update((state) => { state.reorderExportIndex = data.exportIndex; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateImportsExports", (data) => { update((state) => { state.updateImportsExports = data; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateInSelectedNetwork", (data) => { update((state) => { state.inSelectedNetwork = data.inSelectedNetwork; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateLayerWidths", (data) => { update((state) => { state.layerWidths = data.layerWidths; @@ -115,6 +129,7 @@ export function createNodeGraphStore(editor: Editor) { return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateNodeGraphNodes", (data) => { update((state) => { state.nodes.clear(); @@ -124,18 +139,21 @@ export function createNodeGraphStore(editor: Editor) { return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateNodeGraphErrorDiagnostic", (data) => { update((state) => { state.error = data.error; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateVisibleNodes", (data) => { update((state) => { state.visibleNodes = new Set(data.nodes); return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateNodeGraphWires", (data) => { update((state) => { data.wires.forEach((wireUpdate) => { @@ -154,30 +172,35 @@ export function createNodeGraphStore(editor: Editor) { return state; }); }); + editor.subscriptions.subscribeFrontendMessage("ClearAllNodeGraphWires", () => { update((state) => { state.wires.clear(); return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateNodeGraphSelection", (data) => { update((state) => { state.selected = data.selected; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateNodeGraphTransform", (data) => { update((state) => { state.transform = { scale: data.scale, x: data.translation[0], y: data.translation[1] }; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateNodeThumbnail", (data) => { update((state) => { state.thumbnails.set(data.id, data.value); return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateWirePathInProgress", (data) => { update((state) => { state.wirePathInProgress = data.wirePath; @@ -185,35 +208,32 @@ export function createNodeGraphStore(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, - destroy, - }; + return { subscribe }; +} + +export function destroyNodeGraphStore() { + const editor = editorRef; + if (!editor) return; + + 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"); } -export type NodeGraphStore = ReturnType; export function closeContextMenu() { update((state) => { @@ -221,11 +241,3 @@ export function closeContextMenu() { 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); -}); diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 89aadac1..7a6c5bf9 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -6,6 +6,8 @@ import type { Editor } from "@graphite/editor"; import { downloadFile, downloadFileBlob, upload } from "@graphite/utility-functions/files"; import { rasterizeSVG } from "@graphite/utility-functions/rasterization"; +export type PortfolioStore = ReturnType; + type PortfolioStoreState = { unsaved: boolean; documents: OpenDocument[]; @@ -23,19 +25,23 @@ const initialState: PortfolioStoreState = { layersPanelOpen: true, }; +let editorRef: Editor | undefined = undefined; + // Store state persisted across HMR to maintain reactive subscriptions in the component tree const store: Writable = import.meta.hot?.data?.store || writable(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 + editorRef = editor; + editor.subscriptions.subscribeFrontendMessage("UpdateOpenDocumentsList", (data) => { update((state) => { state.documents = data.openDocuments; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateActiveDocument", (data) => { update((state) => { // Assume we receive a correct document id @@ -44,6 +50,7 @@ export function createPortfolioStore(editor: Editor) { return state; }); }); + editor.subscriptions.subscribeFrontendMessage("TriggerFetchAndOpenDocument", async (data) => { try { const url = new URL(`demo-artwork/${data.filename}`, document.location.href); @@ -56,21 +63,26 @@ export function createPortfolioStore(editor: Editor) { }, 0); } }); + editor.subscriptions.subscribeFrontendMessage("TriggerOpen", async () => { const data = await upload(`image/*,.${editor.handle.fileExtension()}`, "data"); editor.handle.openFile(data.filename, data.content); }); + editor.subscriptions.subscribeFrontendMessage("TriggerImport", async () => { // TODO: Use the same `accept` string as in the `TriggerOpen` handler once importing Graphite documents as nodes is supported const data = await upload("image/*", "data"); editor.handle.importFile(data.filename, data.content); }); + editor.subscriptions.subscribeFrontendMessage("TriggerSaveDocument", (data) => { downloadFile(data.name, data.content); }); + editor.subscriptions.subscribeFrontendMessage("TriggerSaveFile", (data) => { downloadFile(data.name, data.content); }); + editor.subscriptions.subscribeFrontendMessage("TriggerExportImage", async (data) => { const { svg, name, mime, size } = data; @@ -87,18 +99,21 @@ export function createPortfolioStore(editor: Editor) { // Fail silently if there's an error rasterizing the SVG, such as a zero-sized image } }); + editor.subscriptions.subscribeFrontendMessage("UpdateDataPanelState", async (data) => { update((state) => { state.dataPanelOpen = data.open; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdatePropertiesPanelState", async (data) => { update((state) => { state.propertiesPanelOpen = data.open; return state; }); }); + editor.subscriptions.subscribeFrontendMessage("UpdateLayersPanelState", async (data) => { update((state) => { state.layersPanelOpen = data.open; @@ -106,33 +121,22 @@ export function createPortfolioStore(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, - }; + return { subscribe }; } -export type PortfolioStore = ReturnType; -// 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); -}); +export function destroyPortfolioStore() { + const editor = editorRef; + if (!editor) return; + + 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"); +} diff --git a/frontend/src/stores/tooltip.ts b/frontend/src/stores/tooltip.ts index ade98ea2..67b86dd4 100644 --- a/frontend/src/stores/tooltip.ts +++ b/frontend/src/stores/tooltip.ts @@ -5,8 +5,12 @@ import type { ActionShortcut } from "@graphite/../wasm/pkg/graphite_wasm"; import type { Editor } from "@graphite/editor"; import { operatingSystem } from "@graphite/utility-functions/platform"; +export type TooltipStore = ReturnType; + const SHOW_TOOLTIP_DELAY_MS = 500; +let tooltipTimeout: ReturnType | undefined = undefined; + type TooltipStoreState = { visible: boolean; element: Element | undefined; @@ -24,80 +28,15 @@ const initialState: TooltipStoreState = { fullscreenShortcut: undefined, }; +let editorRef: Editor | undefined = undefined; + // Store state persisted across HMR to maintain reactive subscriptions in the component tree const store: Writable = import.meta.hot?.data?.store || writable(initialState); if (import.meta.hot) import.meta.hot.data.store = store; const { subscribe, update } = store; export function createTooltipStore(editor: Editor) { - let tooltipTimeout: ReturnType | undefined = undefined; - - // Listen for mouse movements onto tooltip-bearing HTML elements to track the future target of a tooltip - 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) => { - state.visible = false; - state.element = element; - return state; - }); - }; - - // Listen for mouse movements to schedule and position the tooltip, or hide it immediately upon further movement - const onMouseMove = (e: MouseEvent) => { - // Hide the tooltip now that the cursor has moved - update((state) => { - state.visible = false; - return state; - }); - - // Before we schedule a new future tooltip appearance, we clear the existing one - if (tooltipTimeout) clearTimeout(tooltipTimeout); - - // Don't show tooltips while mouse buttons are pressed - if (e.buttons !== 0) return; - - // Schedule the tooltip to appear at this cursor position after a delay - tooltipTimeout = setTimeout(() => { - update((state) => { - if (state.element) { - state.visible = true; - state.position = { x: e.clientX, y: e.clientY }; - } - return state; - }); - }, SHOW_TOOLTIP_DELAY_MS); - }; - - // Hide tooltip and cancel any pending timeout when the mouse leaves the application window - 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"); - } + editorRef = editor; document.addEventListener("mouseover", onMouseOver); document.addEventListener("mousemove", onMouseMove); @@ -125,19 +64,75 @@ export function createTooltipStore(editor: Editor) { }); }); - currentCleanup = destroy; - currentArgs = [editor]; - return { - subscribe, - destroy, - }; + return { subscribe }; } -export type TooltipStore = ReturnType; -// 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); -}); +export function destroyTooltipStore() { + const editor = editorRef; + if (!editor) return; + + 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"); +} + +// Listen for mouse movements onto tooltip-bearing HTML elements to track the future target of a tooltip +function onMouseOver(e: MouseEvent) { + const element = (e.target instanceof Element && e.target.closest("[data-tooltip-label], [data-tooltip-description], [data-tooltip-shortcut]")) || undefined; + + update((state) => { + state.visible = false; + state.element = element; + return state; + }); +} + +// Listen for mouse movements to schedule and position the tooltip, or hide it immediately upon further movement +function onMouseMove(e: MouseEvent) { + // Hide the tooltip now that the cursor has moved + update((state) => { + state.visible = false; + return state; + }); + + // Before we schedule a new future tooltip appearance, we clear the existing one + if (tooltipTimeout) clearTimeout(tooltipTimeout); + + // Don't show tooltips while mouse buttons are pressed + if (e.buttons !== 0) return; + + // Schedule the tooltip to appear at this cursor position after a delay + tooltipTimeout = setTimeout(() => { + update((state) => { + if (state.element) { + state.visible = true; + state.position = { x: e.clientX, y: e.clientY }; + } + return state; + }); + }, SHOW_TOOLTIP_DELAY_MS); +} + +// Hide tooltip and cancel any pending timeout when the mouse leaves the application window +function 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; + }); +} diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index 5ec5ed9d..c29cfa32 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -187,6 +187,12 @@ impl EditorHandle { // the backend from the web frontend. // ======================================================================== + /// Re-sends all UI layouts to the frontend. Called during HMR re-mounts when the frontend has lost its layout state. + #[wasm_bindgen(js_name = resendAllLayouts)] + pub fn resend_all_layouts(&self) { + self.dispatch(LayoutMessage::ResendAllLayouts); + } + #[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