Rename EditorHandle -> EditorWrapper and organize editor_api.rs (#3925)

* Rename EditorHandle -> EditorWrapper and organize editor_api.rs

* pub -> pub(crate)
This commit is contained in:
Keavon Chambers 2026-03-21 03:27:57 -07:00 committed by GitHub
parent 9bcac1af2d
commit 087b4cd71f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 352 additions and 358 deletions

View File

@ -1949,15 +1949,6 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
}
NodeGraphMessage::UpdateBoxSelection => {
if let Some((box_selection_start, _)) = self.box_selection_start {
// The mouse button was released but we missed the pointer up event
// if ((e.buttons & 1) === 0) {
// completeBoxSelection();
// boxSelection = undefined;
// } else if ((e.buttons & 2) !== 0) {
// editor.handle.selectNodes(new BigUint64Array(previousSelection));
// boxSelection = undefined;
// }
let Some(network_metadata) = network_interface.network_metadata(selection_network_path) else {
log::error!("Could not get network metadata in UpdateBoxSelection");
return;

View File

@ -5,11 +5,11 @@
import type { MessageName, SubscriptionsRouter } from "/src/subscriptions-router";
import { loadDemoArtwork } from "/src/utility-functions/network";
import { operatingSystem } from "/src/utility-functions/platform";
import init, { EditorHandle, receiveNativeMessage } from "/wasm/pkg/graphite_wasm";
import init, { EditorWrapper, receiveNativeMessage } from "/wasm/pkg/graphite_wasm";
import type { FrontendMessage } from "/wasm/pkg/graphite_wasm";
let subscriptions: SubscriptionsRouter | undefined = undefined;
let editor: EditorHandle | undefined = undefined;
let editor: EditorWrapper | undefined = undefined;
onMount(async () => {
// Initialize the Wasm module
@ -23,7 +23,7 @@
// Create the editor and subscriptions router
const randomSeed = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
subscriptions = createSubscriptionsRouter();
editor = EditorHandle.create(operatingSystem(), randomSeed, (messageType: MessageName, messageData: FrontendMessage) => {
editor = EditorWrapper.create(operatingSystem(), randomSeed, (messageType: MessageName, messageData: FrontendMessage) => {
subscriptions?.handleFrontendMessage(messageType, messageData);
});

View File

@ -8,7 +8,7 @@ Svelte components that build the Graphite editor GUI from layouts, panels, widge
TypeScript files, constructed by the editor frontend, which manage the input/output of browser APIs and link this functionality with the editor backend. These files subscribe to frontend messages to execute JS APIs, and in response to these APIs or user interactions, they may call functions in the backend (defined in `/frontend/wasm/editor_api.rs`).
Each manager module stores its dependencies (like `subscriptionsRouter` and `editorHandle`) in module-level variables and exports a `create*()` and `destroy*()` function pair. `Editor.svelte` calls each `create*()` constructor in its `onMount` and calls each `destroy*()` in its `onDestroy`. Managers replace themselves during HMR updates if they are modified live during development.
Each manager module stores its dependencies (like `subscriptionsRouter` and `editorWrapper`) in module-level variables and exports a `create*()` and `destroy*()` function pair. `Editor.svelte` calls each `create*()` constructor in its `onMount` and calls each `destroy*()` in its `onDestroy`. Managers replace themselves during HMR updates if they are modified live during development.
## Stores: `stores/`
@ -26,11 +26,11 @@ TypeScript files which define and `export` individual helper functions for use e
## Subscriptions router: `subscriptions-router.ts`
Associates messages from the backend with subscribers in the frontend, and routes messages to subscriber callbacks. This module provides a `subscribeFrontendMessage(messageType, callback)` function which JS code throughout the frontend can call to be registered as the exclusive handler for a chosen message type. The router's other function, `handleFrontendMessage(messageType, messageData)`, is called via the callback passed to `EditorHandle.create()` in `App.svelte` when the backend sends a `FrontendMessage`. When this occurs, the subscriptions router delivers the message to the subscriber by executing its registered `callback` function.
Associates messages from the backend with subscribers in the frontend, and routes messages to subscriber callbacks. This module provides a `subscribeFrontendMessage(messageType, callback)` function which JS code throughout the frontend can call to be registered as the exclusive handler for a chosen message type. The router's other function, `handleFrontendMessage(messageType, messageData)`, is called via the callback passed to `EditorWrapper.create()` in `App.svelte` when the backend sends a `FrontendMessage`. When this occurs, the subscriptions router delivers the message to the subscriber by executing its registered `callback` function.
## Svelte app entry point: `App.svelte`
The entry point for the Svelte application. Initializes the Wasm module, creates the `EditorHandle` backend instance and the subscriptions router, and renders `Editor.svelte` once both are ready. The `EditorHandle` is the wasm-bindgen interface to the Rust editor backend (defined in `/frontend/wasm/editor_api.rs`), providing access to callable backend functions. Both the editor and subscriptions router are passed as props to `Editor.svelte` and set as Svelte contexts for use throughout the component tree.
The entry point for the Svelte application. Initializes the Wasm module, creates the `EditorWrapper` backend instance and the subscriptions router, and renders `Editor.svelte` once both are ready. The `EditorWrapper` is the wasm-bindgen interface to the Rust editor backend (defined in `/frontend/wasm/editor_api.rs`), providing access to callable backend functions. Both the editor and subscriptions router are passed as props to `Editor.svelte` and set as Svelte contexts for use throughout the component tree.
## Editor base instance: `Editor.svelte`

View File

@ -16,11 +16,11 @@
import { createPortfolioStore, destroyPortfolioStore } from "/src/stores/portfolio";
import { createTooltipStore, destroyTooltipStore } from "/src/stores/tooltip";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
// Graphite Wasm editor and subscriptions router
export let subscriptions: SubscriptionsRouter;
export let editor: EditorHandle;
export let editor: EditorWrapper;
setContext("subscriptions", subscriptions);
setContext("editor", editor);

View File

@ -5,10 +5,10 @@
import ShortcutLabel from "/src/components/widgets/labels/ShortcutLabel.svelte";
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
import type { TooltipStore } from "/src/stores/tooltip";
import type { EditorHandle, LabeledShortcut } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper, LabeledShortcut } from "/wasm/pkg/graphite_wasm";
const tooltip = getContext<TooltipStore>("tooltip");
const editor = getContext<EditorHandle>("editor");
const editor = getContext<EditorWrapper>("editor");
let self: FloatingMenu | undefined;

View File

@ -17,7 +17,7 @@
import { textInputCleanup } from "/src/utility-functions/keyboard-entry";
import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization";
import { setupViewportResizeObserver } from "/src/utility-functions/viewports";
import type { Color, EditorHandle, MenuDirection, MouseCursorIcon } from "/wasm/pkg/graphite_wasm";
import type { Color, EditorWrapper, MenuDirection, MouseCursorIcon } from "/wasm/pkg/graphite_wasm";
let rulerHorizontal: RulerInput | undefined;
let rulerVertical: RulerInput | undefined;
@ -25,7 +25,7 @@
let gradientStopPicker: ColorPicker | undefined;
const subscriptions = getContext<SubscriptionsRouter>("subscriptions");
const editor = getContext<EditorHandle>("editor");
const editor = getContext<EditorWrapper>("editor");
const appWindow = getContext<AppWindowStore>("appWindow");
const document = getContext<DocumentStore>("document");

View File

@ -13,7 +13,7 @@
import { pasteFile } from "/src/utility-functions/files";
import { operatingSystem } from "/src/utility-functions/platform";
import { patchLayout } from "/src/utility-functions/widgets";
import type { EditorHandle, LayerPanelEntry, LayerStructureEntry, Layout } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper, LayerPanelEntry, LayerStructureEntry, Layout } from "/wasm/pkg/graphite_wasm";
type LayerListingInfo = {
folderIndex: number;
@ -40,7 +40,7 @@
};
const subscriptions = getContext<SubscriptionsRouter>("subscriptions");
const editor = getContext<EditorHandle>("editor");
const editor = getContext<EditorWrapper>("editor");
const nodeGraph = getContext<NodeGraphStore>("nodeGraph");
const tooltip = getContext<TooltipStore>("tooltip");

View File

@ -9,10 +9,10 @@
import { pasteFile } from "/src/utility-functions/files";
import { patchLayout } from "/src/utility-functions/widgets";
import { isPlatformNative } from "/wasm/pkg/graphite_wasm";
import type { EditorHandle, Layout } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper, Layout } from "/wasm/pkg/graphite_wasm";
const subscriptions = getContext<SubscriptionsRouter>("subscriptions");
const editor = getContext<EditorHandle>("editor");
const editor = getContext<EditorWrapper>("editor");
let welcomePanelButtonsLayout: Layout = [];

View File

@ -11,13 +11,13 @@
import type { DocumentStore } from "/src/stores/document";
import type { NodeGraphStore } from "/src/stores/node-graph";
import { closeContextMenu } from "/src/stores/node-graph";
import type { EditorHandle, FrontendGraphInput, FrontendGraphOutput, FrontendNode } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper, FrontendGraphInput, FrontendGraphOutput, FrontendNode } from "/wasm/pkg/graphite_wasm";
const GRID_COLLAPSE_SPACING = 10;
const GRID_SIZE = 24;
const FADE_TRANSITION = { duration: 200, easing: cubicInOut };
const editor = getContext<EditorHandle>("editor");
const editor = getContext<EditorWrapper>("editor");
const nodeGraph = getContext<NodeGraphStore>("nodeGraph");
const documentState = getContext<DocumentStore>("document");

View File

@ -4,7 +4,7 @@
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
import WidgetSpan from "/src/components/widgets/WidgetSpan.svelte";
import type { EditorHandle, LayoutTarget, WidgetSection as WidgetSectionData } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper, LayoutTarget, WidgetSection as WidgetSectionData } from "/wasm/pkg/graphite_wasm";
export let widgetData: WidgetSectionData;
export let layoutTarget: LayoutTarget;
@ -15,7 +15,7 @@
let expanded = true;
const editor = getContext<EditorHandle>("editor");
const editor = getContext<EditorWrapper>("editor");
</script>
<!-- TODO: Implement collapsable sections with properties system -->

View File

@ -23,7 +23,7 @@
import ShortcutLabel from "/src/components/widgets/labels/ShortcutLabel.svelte";
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
import { parseFillChoice } from "/src/utility-functions/colors";
import type { EditorHandle, LayoutTarget, Widget, WidgetInstance } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper, LayoutTarget, Widget, WidgetInstance } from "/wasm/pkg/graphite_wasm";
// Extract the discriminant key names from the Widget tagged enum union (e.g. "TextButton" | "CheckboxInput" | ...)
type WidgetKind = Widget extends infer T ? (T extends Record<infer K, unknown> ? K & string : never) : never;
@ -32,7 +32,7 @@
// A Widget tagged enum unwrapped into a correlated [kind, props] tuple
type UnwrappedWidget = { [K in WidgetKind]: [kind: K, props: WidgetProps<K>] }[WidgetKind];
const editor = getContext<EditorHandle>("editor");
const editor = getContext<EditorWrapper>("editor");
export let widgets: WidgetInstance[];
export let direction: "row" | "column";

View File

@ -5,7 +5,7 @@
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "/src/managers/input";
import { browserVersion } from "/src/utility-functions/platform";
import { evaluateMathExpression, isPlatformNative } from "/wasm/pkg/graphite_wasm";
import type { ActionShortcut, EditorHandle, NumberInputIncrementBehavior, NumberInputMode } from "/wasm/pkg/graphite_wasm";
import type { ActionShortcut, EditorWrapper, NumberInputIncrementBehavior, NumberInputMode } from "/wasm/pkg/graphite_wasm";
const BUTTONS_LEFT = 0b0000_0001;
const BUTTONS_RIGHT = 0b0000_0010;
@ -14,7 +14,7 @@
const dispatch = createEventDispatcher<{ value: number | undefined; startHistoryTransaction: undefined }>();
const editor = getContext<EditorHandle>("editor");
const editor = getContext<EditorWrapper>("editor");
// Content
/// When `value` is not provided (i.e. it's `undefined`), a dash is displayed.

View File

@ -4,9 +4,9 @@
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import { fillChoiceColor, colorToRgbaCSS } from "/src/utility-functions/colors";
import type { Color, EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { Color, EditorWrapper } from "/wasm/pkg/graphite_wasm";
const editor = getContext<EditorHandle>("editor");
const editor = getContext<EditorWrapper>("editor");
// Content
export let primary: Color;

View File

@ -9,7 +9,7 @@
import Welcome from "/src/components/panels/Welcome.svelte";
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
type PanelType = keyof typeof PANEL_COMPONENTS;
@ -23,7 +23,7 @@
const BUTTON_LEFT = 0;
const BUTTON_MIDDLE = 1;
const editor = getContext<EditorHandle>("editor");
const editor = getContext<EditorWrapper>("editor");
export let tabMinWidths = false;
export let tabCloseButtons = false;

View File

@ -9,12 +9,12 @@
import type { TooltipStore } from "/src/stores/tooltip";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { patchLayout } from "/src/utility-functions/widgets";
import type { EditorHandle, Layout } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper, Layout } from "/wasm/pkg/graphite_wasm";
import { isPlatformNative } from "/wasm/pkg/graphite_wasm";
const keyboardLockApiSupported = navigator.keyboard !== undefined && "lock" in navigator.keyboard;
const editor = getContext<EditorHandle>("editor");
const editor = getContext<EditorWrapper>("editor");
const subscriptions = getContext<SubscriptionsRouter>("subscriptions");
const appWindow = getContext<AppWindowStore>("appWindow");
const fullscreen = getContext<FullscreenStore>("fullscreen");

View File

@ -4,7 +4,7 @@
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import Panel from "/src/components/window/Panel.svelte";
import type { PortfolioStore } from "/src/stores/portfolio";
import type { EditorHandle, OpenDocument } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper, OpenDocument } from "/wasm/pkg/graphite_wasm";
const MIN_PANEL_SIZE = 100;
const PANEL_SIZES = {
@ -38,7 +38,7 @@
return { name, unsaved, tooltipLabel: name, tooltipDescription };
});
const editor = getContext<EditorHandle>("editor");
const editor = getContext<EditorWrapper>("editor");
const portfolio = getContext<PortfolioStore>("portfolio");
function resizePanel(e: PointerEvent) {

View File

@ -1,15 +1,15 @@
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { insertAtCaret, readAtCaret } from "/src/utility-functions/clipboard";
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
let editorHandle: EditorHandle | undefined = undefined;
let editorWrapper: EditorWrapper | undefined = undefined;
export function createClipboardManager(subscriptions: SubscriptionsRouter, editor: EditorHandle) {
export function createClipboardManager(subscriptions: SubscriptionsRouter, editor: EditorWrapper) {
destroyClipboardManager();
subscriptionsRouter = subscriptions;
editorHandle = editor;
editorWrapper = editor;
subscriptions.subscribeFrontendMessage("TriggerClipboardWrite", (data) => {
// If the Clipboard API is supported in the browser, copy text to the clipboard
@ -36,5 +36,5 @@ export function destroyClipboardManager() {
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
import.meta.hot?.accept((newModule) => {
if (subscriptionsRouter && editorHandle) newModule?.createClipboardManager(subscriptionsRouter, editorHandle);
if (subscriptionsRouter && editorWrapper) newModule?.createClipboardManager(subscriptionsRouter, editorWrapper);
});

View File

@ -1,19 +1,19 @@
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
type ApiResponse = { family: string; variants: string[]; files: Record<string, string> }[];
const FONT_LIST_API = "https://api.graphite.art/font-list";
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
let editorHandle: EditorHandle | undefined = undefined;
let editorWrapper: EditorWrapper | undefined = undefined;
let abortController: AbortController | undefined = undefined;
export function createFontsManager(subscriptions: SubscriptionsRouter, editor: EditorHandle) {
export function createFontsManager(subscriptions: SubscriptionsRouter, editor: EditorWrapper) {
destroyFontsManager();
subscriptionsRouter = subscriptions;
editorHandle = editor;
editorWrapper = editor;
abortController = new AbortController();
subscriptions.subscribeFrontendMessage("TriggerFontCatalogLoad", async () => {
@ -71,5 +71,5 @@ export function destroyFontsManager() {
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
import.meta.hot?.accept((newModule) => {
if (subscriptionsRouter && editorHandle) newModule?.createFontsManager(subscriptionsRouter, editorHandle);
if (subscriptionsRouter && editorWrapper) newModule?.createFontsManager(subscriptionsRouter, editorWrapper);
});

View File

@ -20,7 +20,7 @@ import {
onPaste,
onPointerLockChange,
} from "/src/utility-functions/input";
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield" | "pointerlockchange" | "pointerlockerror";
type EventListenerTarget = {
@ -33,35 +33,35 @@ export const PRESS_REPEAT_DELAY_MS = 400;
export const PRESS_REPEAT_INTERVAL_MS = 72;
export const PRESS_REPEAT_INTERVAL_RAPID_MS = 10;
const listeners: Listener[] = [
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent) => editorHandle && portfolioStore && onBeforeUnload(e, editorHandle, portfolioStore) },
{ target: window, eventName: "keyup", action: (e: KeyboardEvent) => editorHandle && dialogStore && onKeyUp(e, editorHandle, dialogStore) },
{ target: window, eventName: "keydown", action: (e: KeyboardEvent) => editorHandle && dialogStore && onKeyDown(e, editorHandle, dialogStore) },
{ target: window, eventName: "pointermove", action: (e: PointerEvent) => editorHandle && documentStore && onPointerMove(e, editorHandle, documentStore) },
{ target: window, eventName: "pointerdown", action: (e: PointerEvent) => editorHandle && dialogStore && onPointerDown(e, editorHandle, dialogStore) },
{ target: window, eventName: "pointerup", action: (e: PointerEvent) => editorHandle && onPointerUp(e, editorHandle) },
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent) => editorWrapper && portfolioStore && onBeforeUnload(e, editorWrapper, portfolioStore) },
{ target: window, eventName: "keyup", action: (e: KeyboardEvent) => editorWrapper && dialogStore && onKeyUp(e, editorWrapper, dialogStore) },
{ target: window, eventName: "keydown", action: (e: KeyboardEvent) => editorWrapper && dialogStore && onKeyDown(e, editorWrapper, dialogStore) },
{ target: window, eventName: "pointermove", action: (e: PointerEvent) => editorWrapper && documentStore && onPointerMove(e, editorWrapper, documentStore) },
{ target: window, eventName: "pointerdown", action: (e: PointerEvent) => editorWrapper && dialogStore && onPointerDown(e, editorWrapper, dialogStore) },
{ target: window, eventName: "pointerup", action: (e: PointerEvent) => editorWrapper && onPointerUp(e, editorWrapper) },
{ target: window, eventName: "mousedown", action: (e: MouseEvent) => onMouseDown(e) },
{ target: window, eventName: "mouseup", action: (e: MouseEvent) => editorHandle && onPotentialDoubleClick(e, editorHandle) },
{ target: window, eventName: "wheel", action: (e: WheelEvent) => editorHandle && onWheelScroll(e, editorHandle), options: { passive: false } },
{ target: window, eventName: "mouseup", action: (e: MouseEvent) => editorWrapper && onPotentialDoubleClick(e, editorWrapper) },
{ target: window, eventName: "wheel", action: (e: WheelEvent) => editorWrapper && onWheelScroll(e, editorWrapper), options: { passive: false } },
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent) => onModifyInputField(e) },
{ target: window, eventName: "focusout", action: () => onFocusOut() },
{ 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) => editorHandle && onPaste(e, editorHandle) },
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => editorWrapper && onPaste(e, editorWrapper) },
{ target: window.document, eventName: "pointerlockchange", action: onPointerLockChange },
{ target: window.document, eventName: "pointerlockerror", action: onPointerLockChange },
];
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
let editorHandle: EditorHandle | undefined = undefined;
let editorWrapper: EditorWrapper | undefined = undefined;
let dialogStore: DialogStore | undefined = undefined;
let portfolioStore: PortfolioStore | undefined = undefined;
let documentStore: DocumentStore | undefined = undefined;
export function createInputManager(subscriptions: SubscriptionsRouter, editor: EditorHandle, dialog: DialogStore, portfolio: PortfolioStore, doc: DocumentStore) {
export function createInputManager(subscriptions: SubscriptionsRouter, editor: EditorWrapper, dialog: DialogStore, portfolio: PortfolioStore, doc: DocumentStore) {
destroyInputManager();
subscriptionsRouter = subscriptions;
editorHandle = editor;
editorWrapper = editor;
dialogStore = dialog;
portfolioStore = portfolio;
documentStore = doc;
@ -98,6 +98,6 @@ export function destroyInputManager() {
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
import.meta.hot?.accept((newModule) => {
if (subscriptionsRouter && editorHandle && dialogStore && portfolioStore && documentStore)
newModule?.createInputManager(subscriptionsRouter, editorHandle, dialogStore, portfolioStore, documentStore);
if (subscriptionsRouter && editorWrapper && dialogStore && portfolioStore && documentStore)
newModule?.createInputManager(subscriptionsRouter, editorWrapper, dialogStore, portfolioStore, documentStore);
});

View File

@ -1,15 +1,15 @@
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { localizeTimestamp } from "/src/utility-functions/time";
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
let editorHandle: EditorHandle | undefined = undefined;
let editorWrapper: EditorWrapper | undefined = undefined;
export function createLocalizationManager(subscriptions: SubscriptionsRouter, editor: EditorHandle) {
export function createLocalizationManager(subscriptions: SubscriptionsRouter, editor: EditorWrapper) {
destroyLocalizationManager();
subscriptionsRouter = subscriptions;
editorHandle = editor;
editorWrapper = editor;
subscriptions.subscribeFrontendMessage("TriggerAboutGraphiteLocalizedCommitDate", (data) => {
const localized = localizeTimestamp(data.commitDate);
@ -26,5 +26,5 @@ export function destroyLocalizationManager() {
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
import.meta.hot?.accept((newModule) => {
if (subscriptionsRouter && editorHandle) newModule?.createLocalizationManager(subscriptionsRouter, editorHandle);
if (subscriptionsRouter && editorWrapper) newModule?.createLocalizationManager(subscriptionsRouter, editorWrapper);
});

View File

@ -1,17 +1,17 @@
import type { PortfolioStore } from "/src/stores/portfolio";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { saveEditorPreferences, loadEditorPreferences, storeDocument, removeDocument, loadFirstDocument, loadRestDocuments, saveActiveDocument } from "/src/utility-functions/persistence";
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
let editorHandle: EditorHandle | undefined = undefined;
let editorWrapper: EditorWrapper | undefined = undefined;
let portfolioStore: PortfolioStore | undefined = undefined;
export function createPersistenceManager(subscriptions: SubscriptionsRouter, editor: EditorHandle, portfolio: PortfolioStore) {
export function createPersistenceManager(subscriptions: SubscriptionsRouter, editor: EditorWrapper, portfolio: PortfolioStore) {
destroyPersistenceManager();
subscriptionsRouter = subscriptions;
editorHandle = editor;
editorWrapper = editor;
portfolioStore = portfolio;
subscriptions.subscribeFrontendMessage("TriggerSavePreferences", async (data) => {
@ -63,5 +63,5 @@ export function destroyPersistenceManager() {
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
import.meta.hot?.accept((newModule) => {
if (subscriptionsRouter && editorHandle && portfolioStore) newModule?.createPersistenceManager(subscriptionsRouter, editorHandle, portfolioStore);
if (subscriptionsRouter && editorWrapper && portfolioStore) newModule?.createPersistenceManager(subscriptionsRouter, editorWrapper, portfolioStore);
});

View File

@ -4,7 +4,7 @@ import type { Writable } from "svelte/store";
import type { IconName } from "/src/icons";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { patchLayout } from "/src/utility-functions/widgets";
import type { EditorHandle, Layout } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper, Layout } from "/wasm/pkg/graphite_wasm";
export type DialogStore = ReturnType<typeof createDialogStore>;
@ -35,7 +35,7 @@ const store: Writable<DialogStoreState> = import.meta.hot?.data?.store || writab
if (import.meta.hot) import.meta.hot.data.store = store;
const { subscribe, update } = store;
export function createDialogStore(subscriptions: SubscriptionsRouter, editor: EditorHandle) {
export function createDialogStore(subscriptions: SubscriptionsRouter, editor: EditorWrapper) {
destroyDialogStore();
subscriptionsRouter = subscriptions;

View File

@ -3,7 +3,7 @@ import type { Writable } from "svelte/store";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files";
import { rasterizeSVG } from "/src/utility-functions/rasterization";
import type { EditorHandle, OpenDocument } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper, OpenDocument } from "/wasm/pkg/graphite_wasm";
export type PortfolioStore = ReturnType<typeof createPortfolioStore>;
@ -31,7 +31,7 @@ const store: Writable<PortfolioStoreState> = import.meta.hot?.data?.store || wri
if (import.meta.hot) import.meta.hot.data.store = store;
const { subscribe, update } = store;
export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: EditorHandle) {
export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: EditorWrapper) {
destroyPortfolioStore();
subscriptionsRouter = subscriptions;

View File

@ -1,6 +1,6 @@
import { extractPixelData } from "/src/utility-functions/rasterization";
import { stripIndents } from "/src/utility-functions/strip-indents";
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
export function readAtCaret(cut: boolean): string | undefined {
const element = window.document.activeElement;
@ -83,7 +83,7 @@ export function insertAtCaret(text: string) {
element.dispatchEvent(new Event("input", { bubbles: true }));
}
export async function triggerClipboardRead(editor: EditorHandle) {
export async function triggerClipboardRead(editor: EditorWrapper) {
// 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 {

View File

@ -1,5 +1,5 @@
import { extractPixelData } from "/src/utility-functions/rasterization";
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
export function downloadFileURL(filename: string, url: string) {
const element = document.createElement("a");
@ -66,7 +66,7 @@ export async function upload(accept: string, textOrData: "text" | "data" | "both
}
export type UploadResult<T> = { filename: string; type: string; content: T };
export async function pasteFile(item: DataTransferItem, editor: EditorHandle, mouse?: [number, number], insertParentId?: bigint, insertIndex?: number) {
export async function pasteFile(item: DataTransferItem, editor: EditorWrapper, mouse?: [number, number], insertParentId?: bigint, insertIndex?: number) {
const file = item.getAsFile();
if (!file) return;

View File

@ -6,7 +6,7 @@ import type { PortfolioStore } from "/src/stores/portfolio";
import { pasteFile } from "/src/utility-functions/files";
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "/src/utility-functions/keyboard-entry";
import { operatingSystem } from "/src/utility-functions/platform";
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
import { isPlatformNative } from "/wasm/pkg/graphite_wasm";
const BUTTON_LEFT = 0;
@ -78,7 +78,7 @@ export async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent, dia
return true;
}
export async function onKeyDown(e: KeyboardEvent, editor: EditorHandle, dialogStore: DialogStore) {
export async function onKeyDown(e: KeyboardEvent, editor: EditorWrapper, dialogStore: DialogStore) {
const key = await getLocalizedScanCode(e);
const NO_KEY_REPEAT_MODIFIER_KEYS = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "MetaLeft", "MetaRight", "AltLeft", "AltRight", "AltGraph", "CapsLock", "Fn", "FnLock"];
@ -96,7 +96,7 @@ export async function onKeyDown(e: KeyboardEvent, editor: EditorHandle, dialogSt
}
}
export async function onKeyUp(e: KeyboardEvent, editor: EditorHandle, dialogStore: DialogStore) {
export async function onKeyUp(e: KeyboardEvent, editor: EditorWrapper, dialogStore: DialogStore) {
const key = await getLocalizedScanCode(e);
if (await shouldRedirectKeyboardEventToBackend(e, dialogStore)) {
@ -109,7 +109,7 @@ export async function onKeyUp(e: KeyboardEvent, editor: EditorHandle, dialogStor
// 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
export function onPointerMove(e: PointerEvent, editor: EditorHandle, documentStore: DocumentStore) {
export function onPointerMove(e: PointerEvent, editor: EditorWrapper, documentStore: DocumentStore) {
potentiallyRestoreCanvasFocus(e);
if (!e.buttons) viewportPointerInteractionOngoing = false;
@ -127,7 +127,7 @@ export function onPointerMove(e: PointerEvent, editor: EditorHandle, documentSto
editor.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers);
}
export function onPointerDown(e: PointerEvent, editor: EditorHandle, dialogStore: DialogStore) {
export function onPointerDown(e: PointerEvent, editor: EditorWrapper, dialogStore: DialogStore) {
potentiallyRestoreCanvasFocus(e);
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
@ -157,7 +157,7 @@ export function onPointerDown(e: PointerEvent, editor: EditorHandle, dialogStore
}
}
export function onPointerUp(e: PointerEvent, editor: EditorHandle) {
export function onPointerUp(e: PointerEvent, editor: EditorWrapper) {
potentiallyRestoreCanvasFocus(e);
// Don't let the browser navigate back or forward when using the buttons on some mice
@ -176,7 +176,7 @@ export function onPointerUp(e: PointerEvent, editor: EditorHandle) {
// Mouse events
export function onPotentialDoubleClick(e: MouseEvent, editor: EditorHandle) {
export function onPotentialDoubleClick(e: MouseEvent, editor: EditorWrapper) {
if (textToolInteractiveInputElement || inPointerLock) return;
// Allow only events within the viewport or node graph boundaries
@ -215,7 +215,7 @@ export function onPointerLockChange() {
// Wheel events
export function onWheelScroll(e: WheelEvent, editor: EditorHandle) {
export function onWheelScroll(e: WheelEvent, editor: EditorWrapper) {
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
@ -246,7 +246,7 @@ export function onModifyInputField(e: CustomEvent) {
// Window events
export async function onBeforeUnload(e: BeforeUnloadEvent, editor: EditorHandle, portfolioStore: PortfolioStore) {
export async function onBeforeUnload(e: BeforeUnloadEvent, editor: EditorWrapper, portfolioStore: PortfolioStore) {
const activeDocument = get(portfolioStore).documents[get(portfolioStore).activeDocumentIndex];
if (activeDocument && !activeDocument.details.isAutoSaved) editor.triggerAutoSave(activeDocument.id);
@ -263,7 +263,7 @@ export async function onBeforeUnload(e: BeforeUnloadEvent, editor: EditorHandle,
}
}
export function onPaste(e: ClipboardEvent, editor: EditorHandle) {
export function onPaste(e: ClipboardEvent, editor: EditorWrapper) {
const dataTransfer = e.clipboardData;
if (!dataTransfer || targetIsTextField(e.target || undefined)) return;
e.preventDefault();

View File

@ -1,4 +1,4 @@
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
export type RequestResult = { body: string; status: number };
@ -35,7 +35,7 @@ export function requestWithUploadDownloadProgress(
}
// If the URL hash fragment contains a demo artwork path (e.g. #demo/isometric-light), fetch and open it
export async function loadDemoArtwork(editor: EditorHandle) {
export async function loadDemoArtwork(editor: EditorWrapper) {
const demoArtwork = window.location.hash.trim().match(/#demo\/(.*)/)?.[1];
if (!demoArtwork) return;

View File

@ -2,7 +2,7 @@ import * as idb from "idb-keyval";
import { get } from "svelte/store";
import type { PortfolioStore } from "/src/stores/portfolio";
import type { MessageBody } from "/src/subscriptions-router";
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
export async function storeCurrentDocumentId(documentId: string) {
const indexedDbStorage = idb.createStore("graphite", "store");
@ -64,7 +64,7 @@ export async function removeDocument(id: string, portfolio: PortfolioStore) {
}
}
export async function loadFirstDocument(editor: EditorHandle) {
export async function loadFirstDocument(editor: EditorWrapper) {
const indexedDbStorage = idb.createStore("graphite", "store");
const previouslySavedDocuments = await idb.get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", indexedDbStorage);
@ -98,7 +98,7 @@ export async function loadFirstDocument(editor: EditorHandle) {
}
}
export async function loadRestDocuments(editor: EditorHandle) {
export async function loadRestDocuments(editor: EditorWrapper) {
const indexedDbStorage = idb.createStore("graphite", "store");
const previouslySavedDocuments = await idb.get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", indexedDbStorage);
@ -176,7 +176,7 @@ export async function saveEditorPreferences(preferences: unknown) {
await idb.set("preferences", preferences, indexedDbStorage);
}
export async function loadEditorPreferences(editor: EditorHandle) {
export async function loadEditorPreferences(editor: EditorWrapper) {
const indexedDbStorage = idb.createStore("graphite", "store");
const preferences = await idb.get<Record<string, unknown>>("preferences", indexedDbStorage);

View File

@ -1,6 +1,6 @@
import type { EditorHandle } from "/wasm/pkg/graphite_wasm";
import type { EditorWrapper } from "/wasm/pkg/graphite_wasm";
export function setupViewportResizeObserver(editor: EditorHandle): () => void {
export function setupViewportResizeObserver(editor: EditorWrapper): () => void {
const viewports = Array.from(window.document.querySelectorAll("[data-viewport-container]"));
if (viewports.length <= 0) return () => {};

View File

@ -10,8 +10,8 @@ Assorted function and struct definitions used in the Wasm wrapper.
## Native communication: `src/native_communication.rs`
Handles receiving serialized `FrontendMessage`s from the native desktop app via an `ArrayBuffer` and forwarding them to JS through the editor handle.
Handles receiving serialized `FrontendMessage`s from the native desktop app via an `ArrayBuffer` and forwarding them to JS through the editor wrapper.
## Wasm wrapper initialization: `src/lib.rs`
Entry point for the Rust codebase in the Wasm environment. Sets up panic hooks and logging, and defines thread-local storage for the editor instance, editor handle, message buffer, and panic dialog callback.
Entry point for the Rust codebase in the Wasm environment. Sets up panic hooks and logging, and defines thread-local storage for the editor instance, editor wrapper, message buffer, and panic dialog callback.

View File

@ -4,8 +4,14 @@
// It serves as a thin wrapper over the editor backend API that relies
// on the dispatcher messaging system and more complex Rust data types.
//
use crate::helpers::translate_key;
use crate::{EDITOR_HANDLE, EDITOR_HAS_CRASHED, Error, FRONTEND_READY, MESSAGE_BUFFER, PANIC_DIALOG_MESSAGE_CALLBACK};
#[cfg(not(feature = "native"))]
use crate::EDITOR;
#[cfg(not(feature = "native"))]
use crate::helpers::poll_node_graph_evaluation;
use crate::helpers::{auto_save_all_documents, calculate_hash, render_image_data_to_canvases, request_animation_frame, set_timeout, translate_key, wrapper};
use crate::{EDITOR_HAS_CRASHED, EDITOR_WRAPPER, Error, FRONTEND_READY, MESSAGE_BUFFER, PANIC_DIALOG_MESSAGE_CALLBACK};
#[cfg(not(feature = "native"))]
use editor::application::{Editor, Environment, Host, Platform};
use editor::consts::FILE_EXTENSION;
use editor::messages::clipboard::utility_types::ClipboardContentRaw;
use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys;
@ -16,85 +22,52 @@ use editor::messages::portfolio::utility_types::{FontCatalog, FontCatalogFamily}
use editor::messages::prelude::*;
use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
use graph_craft::document::NodeId;
use graphene_std::raster::Image;
use graphene_std::raster::color::Color;
use graphene_std::vector::GradientStops;
use js_sys::{Object, Reflect};
use serde::Serialize;
use serde_wasm_bindgen::{self, from_value};
use std::cell::RefCell;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, window};
#[cfg(not(feature = "native"))]
use crate::EDITOR;
#[cfg(not(feature = "native"))]
use editor::application::{Editor, Environment, Host, Platform};
static IMAGE_DATA_HASH: AtomicU64 = AtomicU64::new(0);
fn calculate_hash<T: std::hash::Hash>(t: &T) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
let mut hasher = DefaultHasher::new();
t.hash(&mut hasher);
hasher.finish()
}
/// Provides a handle to access the raw Wasm memory.
#[wasm_bindgen(js_name = wasmMemory)]
pub fn wasm_memory() -> JsValue {
wasm_bindgen::memory()
}
#[wasm_bindgen(js_name = isPlatformNative)]
pub fn is_platform_native() -> bool {
#[cfg(feature = "native")]
{
true
}
#[cfg(not(feature = "native"))]
{
false
}
}
// ============================================================================
/// This struct is, via wasm-bindgen, used by JS to interact with the editor backend. It does this by calling functions, which are `impl`ed
#[wasm_bindgen]
#[derive(Clone)]
pub struct EditorHandle {
pub struct EditorWrapper {
/// This callback is called by the editor's dispatcher when directing `FrontendMessage`s from Rust to JS
frontend_message_handler_callback: js_sys::Function,
}
// Defined separately from the `impl` block below since this `impl` block lacks the `#[wasm_bindgen]` attribute.
// Quirks in wasm-bindgen prevent functions in `#[wasm_bindgen]` `impl` blocks from being made publicly accessible from Rust.
impl EditorHandle {
impl EditorWrapper {
pub fn send_frontend_message_to_js_rust_proxy(&self, message: FrontendMessage) {
self.send_frontend_message_to_js(message);
}
fn initialize_handle(frontend_message_handler_callback: js_sys::Function) -> EditorHandle {
fn initialize_wrapper(frontend_message_handler_callback: js_sys::Function) -> EditorWrapper {
let panic_callback = frontend_message_handler_callback.clone();
let editor_handle = EditorHandle { frontend_message_handler_callback };
if EDITOR_HANDLE.with(|handle| handle.lock().ok().map(|mut guard| *guard = Some(editor_handle.clone()))).is_none() {
log::error!("Attempted to initialize the editor handle more than once");
let editor_wrapper = EditorWrapper { frontend_message_handler_callback };
if EDITOR_WRAPPER.with(|wrapper| wrapper.lock().ok().map(|mut guard| *guard = Some(editor_wrapper.clone()))).is_none() {
log::error!("Attempted to initialize the editor wrapper more than once");
}
PANIC_DIALOG_MESSAGE_CALLBACK.with_borrow_mut(|callback| *callback = Some(panic_callback));
editor_handle
editor_wrapper
}
}
#[wasm_bindgen]
impl EditorHandle {
impl EditorWrapper {
// ========================
// Editor wrapper machinery
// ========================
#[cfg(not(feature = "native"))]
pub fn create(platform: String, uuid_random_seed: u64, frontend_message_handler_callback: js_sys::Function) -> EditorHandle {
pub fn create(platform: String, uuid_random_seed: u64, frontend_message_handler_callback: js_sys::Function) -> EditorWrapper {
let editor = Editor::new(
Environment {
platform: Platform::Web,
@ -108,21 +81,20 @@ impl EditorHandle {
uuid_random_seed,
);
if EDITOR.with(|handle| handle.lock().ok().map(|mut guard| *guard = Some(editor))).is_none() {
if EDITOR.with(|wrapper| wrapper.lock().ok().map(|mut guard| *guard = Some(editor))).is_none() {
log::error!("Attempted to initialize the editor more than once");
}
Self::initialize_handle(frontend_message_handler_callback)
Self::initialize_wrapper(frontend_message_handler_callback)
}
#[cfg(feature = "native")]
pub fn create(_platform: String, _uuid_random_seed: u64, frontend_message_handler_callback: js_sys::Function) -> EditorHandle {
Self::initialize_handle(frontend_message_handler_callback)
pub fn create(_platform: String, _uuid_random_seed: u64, frontend_message_handler_callback: js_sys::Function) -> EditorWrapper {
Self::initialize_wrapper(frontend_message_handler_callback)
}
// Sends a message to the dispatcher in the Editor Backend
#[cfg(not(feature = "native"))]
fn dispatch<T: Into<Message>>(&self, message: T) {
pub(crate) fn dispatch<T: Into<Message>>(&self, message: T) {
// Process no further messages after a crash to avoid spamming the console
use crate::MESSAGE_BUFFER;
if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) {
@ -146,9 +118,8 @@ impl EditorHandle {
self.send_frontend_message_to_js(message);
}
}
#[cfg(feature = "native")]
fn dispatch<T: Into<Message>>(&self, message: T) {
pub(crate) fn dispatch<T: Into<Message>>(&self, message: T) {
let message: Message = message.into();
let Ok(serialized_message) = ron::to_string(&message) else {
log::error!("Failed to serialize message");
@ -158,7 +129,7 @@ impl EditorHandle {
}
// Sends a FrontendMessage to JavaScript
fn send_frontend_message_to_js(&self, message: FrontendMessage) {
pub(crate) fn send_frontend_message_to_js(&self, message: FrontendMessage) {
if let FrontendMessage::UpdateImageData { ref image_data } = message {
let new_hash = calculate_hash(image_data);
let prev_hash = IMAGE_DATA_HASH.load(Ordering::Relaxed);
@ -182,10 +153,9 @@ impl EditorHandle {
}
}
// ========================================================================
// Add additional JS -> Rust wrapper functions below as needed for calling
// the backend from the web frontend.
// ========================================================================
// ================================================
// Functions for calling the editor in Rust from JS
// ================================================
/// 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)]
@ -215,7 +185,7 @@ impl EditorHandle {
wasm_bindgen_futures::spawn_local(poll_node_graph_evaluation());
if !EDITOR_HAS_CRASHED.load(Ordering::SeqCst) {
handle(|handle| {
wrapper(|wrapper| {
// Process all messages that have been queued up
let mut messages = MESSAGE_BUFFER.take();
messages.push(
@ -230,7 +200,7 @@ impl EditorHandle {
// <https://github.com/GraphiteEditor/Graphite/pull/2562#discussion_r2041102786>
messages.push(BroadcastMessage::TriggerEvent(EventMessage::AnimationFrame).into());
handle.dispatch(Message::Batched { messages: messages.into() });
wrapper.dispatch(Message::Batched { messages: messages.into() });
});
}
@ -486,13 +456,6 @@ impl EditorHandle {
self.dispatch(message);
}
/// Zoom the canvas to fit all content
#[wasm_bindgen(js_name = zoomCanvasToFitAll)]
pub fn zoom_canvas_to_fit_all(&self) {
let message = DocumentMessage::ZoomCanvasToFitAll;
self.dispatch(message);
}
/// Mouse movement within the screenspace bounds of the viewport
#[wasm_bindgen(js_name = onMouseMove)]
pub fn on_mouse_move(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) {
@ -689,6 +652,7 @@ impl EditorHandle {
self.dispatch(GradientToolMessage::CloseStopColorPicker);
}
/// Toggle clipping the alpha of a layer to the alpha of the layer below it in the layer stack
#[wasm_bindgen(js_name = clipLayer)]
pub fn clip_layer(&self, id: u64) {
let id = NodeId(id);
@ -843,6 +807,7 @@ impl EditorHandle {
self.dispatch(message);
}
/// Pastes an SVG given its string representation
#[wasm_bindgen(js_name = pasteSvg)]
pub fn paste_svg(&self, name: Option<String>, svg: String, mouse_x: Option<f64>, mouse_y: Option<f64>, insert_parent_id: Option<u64>, insert_index: Option<usize>) {
let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y)));
@ -933,7 +898,21 @@ impl EditorHandle {
}
}
// ============================================================================
// ====================================================================
// Static functions callable from JavaScript without an Editor instance
// ====================================================================
#[wasm_bindgen(js_name = isPlatformNative)]
pub fn is_platform_native() -> bool {
#[cfg(feature = "native")]
{
true
}
#[cfg(not(feature = "native"))]
{
false
}
}
#[wasm_bindgen(js_name = evaluateMathExpression)]
pub fn evaluate_math_expression(expression: &str) -> Option<f64> {
@ -972,176 +951,3 @@ pub fn evaluate_gradient_at_position(t: f64, position: Vec<f64>, midpoint: Vec<f
serde_wasm_bindgen::to_value(&color).unwrap()
}
/// Helper function for calling JS's `requestAnimationFrame` with the given closure
fn request_animation_frame(f: &Closure<dyn FnMut(f64)>) {
web_sys::window()
.expect("No global `window` exists")
.request_animation_frame(f.as_ref().unchecked_ref())
.expect("Failed to call `requestAnimationFrame`");
}
/// Helper function for calling JS's `setTimeout` with the given closure and delay
fn set_timeout(f: &Closure<dyn FnMut()>, delay: Duration) {
let delay = delay.clamp(Duration::ZERO, Duration::from_millis(i32::MAX as u64)).as_millis() as i32;
web_sys::window()
.expect("No global `window` exists")
.set_timeout_with_callback_and_timeout_and_arguments_0(f.as_ref().unchecked_ref(), delay)
.expect("Failed to call `setTimeout`");
}
/// Provides access to the `Editor` by calling the given closure with it as an argument.
#[cfg(not(feature = "native"))]
fn editor<T: Default>(callback: impl FnOnce(&mut editor::application::Editor) -> T) -> T {
EDITOR.with(|editor| {
let mut guard = editor.try_lock();
let Ok(Some(editor)) = guard.as_deref_mut() else {
log::error!("Failed to borrow editor");
return T::default();
};
callback(editor)
})
}
/// Provides access to the `Editor` and its `EditorHandle` by calling the given closure with them as arguments.
#[cfg(not(feature = "native"))]
pub(crate) fn editor_and_handle(callback: impl FnOnce(&mut Editor, &mut EditorHandle)) {
handle(|editor_handle| {
editor(|editor| {
// Call the closure with the editor and its handle
callback(editor, editor_handle);
})
});
}
/// Provides access to the `EditorHandle` by calling the given closure with them as arguments.
pub(crate) fn handle(callback: impl FnOnce(&mut EditorHandle)) {
EDITOR_HANDLE.with(|editor_handle| {
let mut guard = editor_handle.try_lock();
let Ok(Some(editor_handle)) = guard.as_deref_mut() else {
log::error!("Failed to borrow editor handle");
return;
};
// Call the closure with the editor and its handle
callback(editor_handle);
});
}
#[cfg(not(feature = "native"))]
async fn poll_node_graph_evaluation() {
// Process no further messages after a crash to avoid spamming the console
if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) {
return;
}
if !editor::node_graph_executor::run_node_graph().await.0 {
return;
}
editor_and_handle(|editor, handle| {
let mut messages = VecDeque::new();
if let Err(e) = editor.poll_node_graph_evaluation(&mut messages) {
// TODO: This is a hacky way to suppress the error, but it shouldn't be generated in the first place
if e != "No active document" {
error!("Error evaluating node graph:\n{e}");
}
}
// Clear the error display if there are no more errors
if !messages.is_empty() {
crate::NODE_GRAPH_ERROR_DISPLAYED.store(false, Ordering::SeqCst);
}
// Batch responses to pool frontend updates
let batched = Message::Batched {
messages: messages.into_iter().collect(),
};
// Send each `FrontendMessage` to the JavaScript frontend
for response in editor.handle_message(batched) {
handle.send_frontend_message_to_js(response);
}
// If the editor cannot be borrowed then it has encountered a panic - we should just ignore new dispatches
});
}
fn auto_save_all_documents() {
// Process no further messages after a crash to avoid spamming the console
if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) {
return;
}
handle(|handle| {
handle.dispatch(PortfolioMessage::AutoSaveAllDocuments);
});
}
fn render_image_data_to_canvases(image_data: &[(u64, Image<Color>)]) {
let window = match window() {
Some(window) => window,
None => {
error!("Cannot render canvas: window object not found");
return;
}
};
let document = window.document().expect("window should have a document");
let window_obj = Object::from(window);
let image_canvases_key = JsValue::from_str("imageCanvases");
let canvases_obj = match Reflect::get(&window_obj, &image_canvases_key) {
Ok(obj) if !obj.is_undefined() && !obj.is_null() => obj,
_ => {
let new_obj = Object::new();
if Reflect::set(&window_obj, &image_canvases_key, &new_obj).is_err() {
error!("Failed to create and set imageCanvases object on window");
return;
}
new_obj.into()
}
};
let canvases_obj = Object::from(canvases_obj);
for (placeholder_id, image) in image_data.iter() {
let canvas_name = placeholder_id.to_string();
let js_key = JsValue::from_str(&canvas_name);
if Reflect::has(&canvases_obj, &js_key).unwrap_or(false) || image.width == 0 || image.height == 0 {
continue;
}
let canvas: HtmlCanvasElement = document
.create_element("canvas")
.expect("Failed to create canvas element")
.dyn_into::<HtmlCanvasElement>()
.expect("Failed to cast element to HtmlCanvasElement");
canvas.set_width(image.width);
canvas.set_height(image.height);
let context: CanvasRenderingContext2d = canvas
.get_context("2d")
.expect("Failed to get 2d context")
.expect("2d context was not found")
.dyn_into::<CanvasRenderingContext2d>()
.expect("Failed to cast context to CanvasRenderingContext2d");
let u8_data: Vec<u8> = image.data.iter().flat_map(|color| color.to_rgba8_srgb()).collect();
let clamped_u8_data = wasm_bindgen::Clamped(&u8_data[..]);
match ImageData::new_with_u8_clamped_array_and_sh(clamped_u8_data, image.width, image.height) {
Ok(image_data_obj) => {
if context.put_image_data(&image_data_obj, 0., 0.).is_err() {
error!("Failed to put image data on canvas for id: {placeholder_id}");
}
}
Err(e) => {
error!("Failed to create ImageData for id: {placeholder_id}: {e:?}");
}
}
let js_value = JsValue::from(canvas);
if Reflect::set(&canvases_obj, &js_key, &js_value).is_err() {
error!("Failed to set canvas '{canvas_name}' on imageCanvases object");
}
}
}

View File

@ -1,7 +1,203 @@
#[cfg(not(feature = "native"))]
use crate::EDITOR;
use crate::editor_api::EditorWrapper;
use crate::{EDITOR_HAS_CRASHED, EDITOR_WRAPPER};
#[cfg(not(feature = "native"))]
use editor::application::Editor;
use editor::messages::input_mapper::utility_types::input_keyboard::Key;
use editor::messages::prelude::*;
use graphene_std::raster::Image;
use graphene_std::raster::color::Color;
use js_sys::{Object, Reflect};
use std::sync::atomic::Ordering;
use std::time::Duration;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, window};
/// Helper function for calling JS's `requestAnimationFrame` with the given closure
pub(crate) fn request_animation_frame(f: &Closure<dyn FnMut(f64)>) {
web_sys::window()
.expect("No global `window` exists")
.request_animation_frame(f.as_ref().unchecked_ref())
.expect("Failed to call `requestAnimationFrame`");
}
/// Helper function for calling JS's `setTimeout` with the given closure and delay
pub(crate) fn set_timeout(f: &Closure<dyn FnMut()>, delay: Duration) {
let delay = delay.clamp(Duration::ZERO, Duration::from_millis(i32::MAX as u64)).as_millis() as i32;
web_sys::window()
.expect("No global `window` exists")
.set_timeout_with_callback_and_timeout_and_arguments_0(f.as_ref().unchecked_ref(), delay)
.expect("Failed to call `setTimeout`");
}
/// Provides access to the `Editor` by calling the given closure with it as an argument.
#[cfg(not(feature = "native"))]
fn editor<T: Default>(callback: impl FnOnce(&mut editor::application::Editor) -> T) -> T {
EDITOR.with(|editor| {
let mut guard = editor.try_lock();
let Ok(Some(editor)) = guard.as_deref_mut() else {
log::error!("Failed to borrow editor");
return T::default();
};
callback(editor)
})
}
/// Provides access to the `Editor` and its `EditorWrapper` by calling the given closure with them as arguments.
#[cfg(not(feature = "native"))]
pub(crate) fn editor_and_wrapper(callback: impl FnOnce(&mut Editor, &mut EditorWrapper)) {
wrapper(|wrapper| {
editor(|editor| {
// Call the closure with the editor and its wrapper
callback(editor, wrapper);
})
});
}
/// Provides access to the `EditorWrapper` by calling the given closure with them as arguments.
pub(crate) fn wrapper(callback: impl FnOnce(&mut EditorWrapper)) {
EDITOR_WRAPPER.with(|wrapper| {
let mut guard = wrapper.try_lock();
let Ok(Some(wrapper)) = guard.as_deref_mut() else {
log::error!("Failed to borrow editor wrapper");
return;
};
// Call the closure with the editor and its wrapper
callback(wrapper);
});
}
#[cfg(not(feature = "native"))]
pub(crate) async fn poll_node_graph_evaluation() {
// Process no further messages after a crash to avoid spamming the console
if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) {
return;
}
if !editor::node_graph_executor::run_node_graph().await.0 {
return;
}
editor_and_wrapper(|editor, wrapper| {
let mut messages = VecDeque::new();
if let Err(e) = editor.poll_node_graph_evaluation(&mut messages) {
// TODO: This is a hacky way to suppress the error, but it shouldn't be generated in the first place
if e != "No active document" {
error!("Error evaluating node graph:\n{e}");
}
}
// Clear the error display if there are no more errors
if !messages.is_empty() {
crate::NODE_GRAPH_ERROR_DISPLAYED.store(false, Ordering::SeqCst);
}
// Batch responses to pool frontend updates
let batched = Message::Batched {
messages: messages.into_iter().collect(),
};
// Send each `FrontendMessage` to the JavaScript frontend
for response in editor.handle_message(batched) {
wrapper.send_frontend_message_to_js(response);
}
// If the editor cannot be borrowed then it has encountered a panic - we should just ignore new dispatches
});
}
pub(crate) fn auto_save_all_documents() {
// Process no further messages after a crash to avoid spamming the console
if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) {
return;
}
wrapper(|wrapper| {
wrapper.dispatch(PortfolioMessage::AutoSaveAllDocuments);
});
}
pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image<Color>)]) {
let window = match window() {
Some(window) => window,
None => {
error!("Cannot render canvas: window object not found");
return;
}
};
let document = window.document().expect("window should have a document");
let window_obj = Object::from(window);
let image_canvases_key = JsValue::from_str("imageCanvases");
let canvases_obj = match Reflect::get(&window_obj, &image_canvases_key) {
Ok(obj) if !obj.is_undefined() && !obj.is_null() => obj,
_ => {
let new_obj = Object::new();
if Reflect::set(&window_obj, &image_canvases_key, &new_obj).is_err() {
error!("Failed to create and set imageCanvases object on window");
return;
}
new_obj.into()
}
};
let canvases_obj = Object::from(canvases_obj);
for (placeholder_id, image) in image_data.iter() {
let canvas_name = placeholder_id.to_string();
let js_key = JsValue::from_str(&canvas_name);
if Reflect::has(&canvases_obj, &js_key).unwrap_or(false) || image.width == 0 || image.height == 0 {
continue;
}
let canvas: HtmlCanvasElement = document
.create_element("canvas")
.expect("Failed to create canvas element")
.dyn_into::<HtmlCanvasElement>()
.expect("Failed to cast element to HtmlCanvasElement");
canvas.set_width(image.width);
canvas.set_height(image.height);
let context: CanvasRenderingContext2d = canvas
.get_context("2d")
.expect("Failed to get 2d context")
.expect("2d context was not found")
.dyn_into::<CanvasRenderingContext2d>()
.expect("Failed to cast context to CanvasRenderingContext2d");
let u8_data: Vec<u8> = image.data.iter().flat_map(|color| color.to_rgba8_srgb()).collect();
let clamped_u8_data = wasm_bindgen::Clamped(&u8_data[..]);
match ImageData::new_with_u8_clamped_array_and_sh(clamped_u8_data, image.width, image.height) {
Ok(image_data_obj) => {
if context.put_image_data(&image_data_obj, 0., 0.).is_err() {
error!("Failed to put image data on canvas for id: {placeholder_id}");
}
}
Err(e) => {
error!("Failed to create ImageData for id: {placeholder_id}: {e:?}");
}
}
let js_value = JsValue::from(canvas);
if Reflect::set(&canvases_obj, &js_key, &js_value).is_err() {
error!("Failed to set canvas '{canvas_name}' on imageCanvases object");
}
}
}
pub(crate) fn calculate_hash<T: std::hash::Hash>(t: &T) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
let mut hasher = DefaultHasher::new();
t.hash(&mut hasher);
hasher.finish()
}
/// Translate a keyboard key from its JS name to its Rust `Key` enum
pub fn translate_key(name: &str) -> Key {
pub(crate) fn translate_key(name: &str) -> Key {
use Key::*;
trace!("Key event received: {name}");

View File

@ -8,6 +8,7 @@ pub mod editor_api;
pub mod helpers;
pub mod native_communication;
use crate::helpers::wrapper;
use editor::messages::prelude::*;
use std::panic;
use std::sync::Mutex;
@ -24,7 +25,7 @@ thread_local! {
#[cfg(not(feature = "native"))]
pub static EDITOR: Mutex<Option<editor::application::Editor>> = const { Mutex::new(None) };
pub static MESSAGE_BUFFER: std::cell::RefCell<Vec<Message>> = const { std::cell::RefCell::new(Vec::new()) };
pub static EDITOR_HANDLE: Mutex<Option<editor_api::EditorHandle>> = const { Mutex::new(None) };
pub static EDITOR_WRAPPER: Mutex<Option<editor_api::EditorWrapper>> = const { Mutex::new(None) };
pub static PANIC_DIALOG_MESSAGE_CALLBACK: std::cell::RefCell<Option<js_sys::Function>> = const { std::cell::RefCell::new(None) };
}
@ -53,7 +54,7 @@ pub fn panic_hook(info: &panic::PanicHookInfo) {
if !NODE_GRAPH_ERROR_DISPLAYED.load(Ordering::SeqCst) {
NODE_GRAPH_ERROR_DISPLAYED.store(true, Ordering::SeqCst);
editor_api::handle(|handle| {
wrapper(|wrapper| {
let error = r#"
<rect x="50%" y="50%" width="600" height="100" transform="translate(-300 -50)" rx="4" fill="var(--color-error-red)" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="18" fill="var(--color-2-mildblack)">
@ -63,7 +64,7 @@ pub fn panic_hook(info: &panic::PanicHookInfo) {
/text>"#
// It's a mystery why the `/text>` tag above needs to be missing its `<`, but when it exists it prints the `<` character in the text. However this works with it removed.
.to_string();
handle.send_frontend_message_to_js_rust_proxy(FrontendMessage::UpdateDocumentArtwork { svg: error });
wrapper.send_frontend_message_to_js_rust_proxy(FrontendMessage::UpdateDocumentArtwork { svg: error });
});
}
@ -116,7 +117,7 @@ fn send_panic_dialog_via_callback(panic_info: String) -> Result<(), String> {
fn send_panic_dialog_deferred(panic_info: String) {
let callback = Closure::once_into_js(move || {
if send_panic_dialog_via_callback(panic_info).is_err() {
log::error!("Failed to send crash dialog after panic because the editor handle is unavailable");
log::error!("Failed to send crash dialog after panic because the editor wrapper is unavailable");
}
});

View File

@ -1,20 +1,20 @@
use crate::editor_api::EditorWrapper;
use crate::helpers::wrapper;
use editor::messages::prelude::FrontendMessage;
use js_sys::{ArrayBuffer, Uint8Array};
use wasm_bindgen::prelude::*;
use crate::editor_api::{self, EditorHandle};
#[wasm_bindgen(js_name = "receiveNativeMessage")]
pub fn receive_native_message(buffer: ArrayBuffer) {
let buffer = Uint8Array::new(buffer.as_ref()).to_vec();
match ron::from_str::<Vec<FrontendMessage>>(str::from_utf8(buffer.as_slice()).unwrap()) {
Ok(messages) => {
let callback = move |handle: &mut EditorHandle| {
let callback = move |wrapper: &mut EditorWrapper| {
for message in messages {
handle.send_frontend_message_to_js_rust_proxy(message);
wrapper.send_frontend_message_to_js_rust_proxy(message);
}
};
editor_api::handle(callback);
wrapper(callback);
}
Err(e) => log::error!("Failed to deserialize frontend messages: {e:?}"),
}