From 19eb6ce0ab10065ec6acd6e49edd2f072729fc77 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 29 Apr 2024 03:31:39 -0700 Subject: [PATCH] Remove editor instances concept and clean up JS interop code --- editor/src/application.rs | 2 +- editor/src/dispatcher.rs | 2 +- frontend/src/App.svelte | 8 +- frontend/src/components/Editor.svelte | 8 +- .../floating-menus/ColorPicker.svelte | 2 +- .../src/components/panels/Document.svelte | 20 +- frontend/src/components/panels/Layers.svelte | 14 +- frontend/src/components/views/Graph.svelte | 38 +-- .../src/components/widgets/WidgetSpan.svelte | 6 +- .../widgets/inputs/WorkingColorsInput.svelte | 4 +- .../window/title-bar/TitleBar.svelte | 2 +- .../components/window/workspace/Panel.svelte | 6 +- .../window/workspace/Workspace.svelte | 6 +- frontend/src/io-managers/input.ts | 40 +-- frontend/src/io-managers/localization.ts | 2 +- frontend/src/io-managers/persistence.ts | 4 +- frontend/src/state-providers/dialog.ts | 4 +- frontend/src/state-providers/fonts.ts | 4 +- frontend/src/state-providers/portfolio.ts | 12 +- frontend/src/wasm-communication/editor.ts | 65 ++--- frontend/src/wasm-communication/messages.ts | 4 +- .../wasm-communication/subscription-router.ts | 6 +- frontend/wasm/src/editor_api.rs | 263 +++++++----------- frontend/wasm/src/lib.rs | 19 +- website/content/features.md | 40 +-- 25 files changed, 256 insertions(+), 325 deletions(-) diff --git a/editor/src/application.rs b/editor/src/application.rs index 9ea4169b..7628f77d 100644 --- a/editor/src/application.rs +++ b/editor/src/application.rs @@ -9,7 +9,7 @@ pub struct Editor { } impl Editor { - /// Construct a new editor instance. + /// Construct the editor. /// Remember to provide a random seed with `editor::set_uuid_seed(seed)` before any editors can be used. pub fn new() -> Self { Self { dispatcher: Dispatcher::new() } diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 2018d626..5ab3f80b 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -273,7 +273,7 @@ mod test { let _ = env_logger::builder().is_test(true).try_init(); } - /// Create an editor instance with three layers + /// Create an editor with three layers /// 1. A red rectangle /// 2. A blue shape /// 3. A green ellipse diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 87899324..fd51a513 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,11 +1,11 @@ diff --git a/frontend/src/components/Editor.svelte b/frontend/src/components/Editor.svelte index c78a7a90..a560b5e9 100644 --- a/frontend/src/components/Editor.svelte +++ b/frontend/src/components/Editor.svelte @@ -15,12 +15,12 @@ import { createNodeGraphState } from "@graphite/state-providers/node-graph"; import { createPortfolioState } from "@graphite/state-providers/portfolio"; import { operatingSystem } from "@graphite/utility-functions/platform"; - import type { createEditor } from "@graphite/wasm-communication/editor"; + import { type Editor } from "@graphite/wasm-communication/editor"; import MainWindow from "@graphite/components/window/MainWindow.svelte"; - // Graphite WASM editor instance - export let editor: ReturnType; + // Graphite WASM editor + export let editor: Editor; setContext("editor", editor); // State provider systems @@ -48,7 +48,7 @@ onMount(() => { // Initialize certain setup tasks required by the editor backend to be ready for the user now that the frontend is ready - editor.instance.initAfterFrontendReady(operatingSystem()); + editor.handle.initAfterFrontendReady(operatingSystem()); }); onDestroy(() => { diff --git a/frontend/src/components/floating-menus/ColorPicker.svelte b/frontend/src/components/floating-menus/ColorPicker.svelte index 47004973..917b8f4b 100644 --- a/frontend/src/components/floating-menus/ColorPicker.svelte +++ b/frontend/src/components/floating-menus/ColorPicker.svelte @@ -252,7 +252,7 @@ // TODO: Replace this temporary solution that only works in Chromium-based browsers with the custom color sampler used by the Eyedropper tool // eslint-disable-next-line @typescript-eslint/no-explicit-any if (!(window as any).EyeDropper) { - editor.instance.eyedropperSampleForColorPicker(); + editor.handle.eyedropperSampleForColorPicker(); return; } diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index b3fab5bc..798b8c48 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -127,14 +127,14 @@ const file = item.getAsFile(); if (file?.type.includes("svg")) { const svgData = await file.text(); - editor.instance.pasteSvg(svgData, e.clientX, e.clientY); + editor.handle.pasteSvg(svgData, e.clientX, e.clientY); return; } if (file?.type.startsWith("image")) { const imageData = await extractPixelData(file); - editor.instance.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height, e.clientX, e.clientY); + editor.handle.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height, e.clientX, e.clientY); } }); } @@ -142,23 +142,23 @@ function panCanvasX(newValue: number) { const delta = newValue - scrollbarPos.x; scrollbarPos.x = newValue; - editor.instance.panCanvas(-delta * scrollbarMultiplier.x, 0); + editor.handle.panCanvas(-delta * scrollbarMultiplier.x, 0); } function panCanvasY(newValue: number) { const delta = newValue - scrollbarPos.y; scrollbarPos.y = newValue; - editor.instance.panCanvas(0, -delta * scrollbarMultiplier.y); + editor.handle.panCanvas(0, -delta * scrollbarMultiplier.y); } function pageX(delta: number) { const move = delta < 0 ? 1 : -1; - editor.instance.panCanvasByFraction(move, 0); + editor.handle.panCanvasByFraction(move, 0); } function pageY(delta: number) { const move = delta < 0 ? 1 : -1; - editor.instance.panCanvasByFraction(0, move); + editor.handle.panCanvasByFraction(0, move); } function canvasPointerDown(e: PointerEvent) { @@ -290,7 +290,7 @@ export function triggerTextCommit() { if (!textInput) return; const textCleaned = textInputCleanup(textInput.innerText); - editor.instance.onChangeText(textCleaned); + editor.handle.onChangeText(textCleaned); } export async function displayEditableTextbox(displayEditableTextbox: DisplayEditableTextbox) { @@ -314,7 +314,7 @@ textInput.oninput = () => { if (!textInput) return; - editor.instance.updateBounds(textInputCleanup(textInput.innerText)); + editor.handle.updateBounds(textInputCleanup(textInput.innerText)); }; textInputMatrix = displayEditableTextbox.transform; const newFont = new FontFace("text-font", `url(${displayEditableTextbox.url})`); @@ -371,8 +371,8 @@ const rgb = await updateEyedropperSamplingState(mousePosition, primaryColor, secondaryColor); if (setColorChoice && rgb) { - if (setColorChoice === "Primary") editor.instance.updatePrimaryColor(...rgb, 1); - if (setColorChoice === "Secondary") editor.instance.updateSecondaryColor(...rgb, 1); + if (setColorChoice === "Primary") editor.handle.updatePrimaryColor(...rgb, 1); + if (setColorChoice === "Secondary") editor.handle.updateSecondaryColor(...rgb, 1); } }); diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index 90f3d7a6..ff3828c3 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -130,15 +130,15 @@ } function toggleLayerVisibility(id: bigint) { - editor.instance.toggleLayerVisibility(id); + editor.handle.toggleLayerVisibility(id); } function toggleLayerLock(id: bigint) { - editor.instance.toggleLayerLock(id); + editor.handle.toggleLayerLock(id); } function handleExpandArrowClick(id: bigint) { - editor.instance.toggleLayerExpansion(id); + editor.handle.toggleLayerExpansion(id); } async function onEditLayerName(listing: LayerListingInfo) { @@ -164,7 +164,7 @@ layers = layers; const name = (e.target instanceof HTMLInputElement && e.target.value) || ""; - editor.instance.setLayerName(listing.entry.id, name); + editor.handle.setLayerName(listing.entry.id, name); listing.entry.name = name; } @@ -196,11 +196,11 @@ // Don't select while we are entering text to rename the layer if (listing.editingName) return; - editor.instance.selectLayer(listing.entry.id, accel, shift); + editor.handle.selectLayer(listing.entry.id, accel, shift); } async function deselectAllLayers() { - editor.instance.deselectAllLayers(); + editor.handle.deselectAllLayers(); } function isNestingLayer(layerClassification: LayerClassification) { @@ -322,7 +322,7 @@ const { select, insertParentId, insertIndex } = draggingData; select?.(); - editor.instance.moveLayerInTree(insertParentId, insertIndex); + editor.handle.moveLayerInTree(insertParentId, insertIndex); } draggingData = undefined; fakeHighlight = undefined; diff --git a/frontend/src/components/views/Graph.svelte b/frontend/src/components/views/Graph.svelte index dabd4779..55ad9d8d 100644 --- a/frontend/src/components/views/Graph.svelte +++ b/frontend/src/components/views/Graph.svelte @@ -363,7 +363,7 @@ // Alt-click sets the clicked node as previewed if (lmb && e.altKey && nodeId !== undefined) { - editor.instance.togglePreview(nodeId); + editor.handle.togglePreview(nodeId); } // Clicked on a port dot @@ -440,7 +440,7 @@ } // Update the selection in the backend if it was modified - if (modifiedSelected) editor.instance.selectNodes(new BigUint64Array(updatedSelected)); + if (modifiedSelected) editor.handle.selectNodes(new BigUint64Array(updatedSelected)); return; } @@ -449,7 +449,7 @@ if (lmb) { previousSelection = $nodeGraph.selected; // Clear current selection - if (!e.shiftKey) editor.instance.selectNodes(new BigUint64Array(0)); + if (!e.shiftKey) editor.handle.selectNodes(new BigUint64Array(0)); const graphBounds = graph?.getBoundingClientRect(); boxSelection = { startX: e.x - (graphBounds?.x || 0), startY: e.y - (graphBounds?.y || 0), endX: e.x - (graphBounds?.x || 0), endY: e.y - (graphBounds?.y || 0) }; @@ -466,7 +466,7 @@ // const nodeId = node?.getAttribute("data-node") || undefined; // if (nodeId !== undefined) { // const id = BigInt(nodeId); - // editor.instance.enterNestedNetwork(id); + // editor.handle.enterNestedNetwork(id); // } } @@ -510,7 +510,7 @@ completeBoxSelection(); boxSelection = undefined; } else if ((e.buttons & 2) !== 0) { - editor.instance.selectNodes(new BigUint64Array(previousSelection)); + editor.handle.selectNodes(new BigUint64Array(previousSelection)); boxSelection = undefined; } else { const graphBounds = graph?.getBoundingClientRect(); @@ -534,7 +534,7 @@ } function completeBoxSelection() { - editor.instance.selectNodes(new BigUint64Array($nodeGraph.selected.concat($nodeGraph.nodes.filter((_, nodeIndex) => intersetNodeAABB(boxSelection, nodeIndex)).map((node) => node.id)))); + editor.handle.selectNodes(new BigUint64Array($nodeGraph.selected.concat($nodeGraph.nodes.filter((_, nodeIndex) => intersetNodeAABB(boxSelection, nodeIndex)).map((node) => node.id)))); } function showSelected(selected: bigint[], boxSelect: Box | undefined, node: bigint, nodeIndex: number): boolean { @@ -542,7 +542,7 @@ } function toggleLayerVisibility(id: bigint) { - editor.instance.toggleLayerVisibility(id); + editor.handle.toggleLayerVisibility(id); } function connectorToNodeIndex(svg: SVGSVGElement): { nodeId: bigint; index: number } | undefined { @@ -589,7 +589,7 @@ const selectedNodeBounds = selectedNode.getBoundingClientRect(); const containerBoundsBounds = theNodesContainer.getBoundingClientRect(); - return editor.instance.rectangleIntersects( + return editor.handle.rectangleIntersects( new Float64Array(wireCurveLocations.map((loc) => loc.x)), new Float64Array(wireCurveLocations.map((loc) => loc.y)), selectedNodeBounds.top - containerBoundsBounds.y, @@ -603,9 +603,9 @@ if (link) { const isLayer = $nodeGraph.nodes.find((n) => n.id === selectedNodeId)?.isLayer; - editor.instance.connectNodesByLink(link.linkStart, 0, selectedNodeId, isLayer ? 1 : 0); - editor.instance.connectNodesByLink(selectedNodeId, 0, link.linkEnd, Number(link.linkEndInputIndex)); - if (!isLayer) editor.instance.shiftNode(selectedNodeId); + editor.handle.connectNodesByLink(link.linkStart, 0, selectedNodeId, isLayer ? 1 : 0); + editor.handle.connectNodesByLink(selectedNodeId, 0, link.linkEnd, Number(link.linkEndInputIndex)); + if (!isLayer) editor.handle.shiftNode(selectedNodeId); } } @@ -614,7 +614,7 @@ const initialDisconnecting = disconnecting; if (disconnecting) { - editor.instance.disconnectNodes(BigInt(disconnecting.nodeId), disconnecting.inputIndex); + editor.handle.disconnectNodes(BigInt(disconnecting.nodeId), disconnecting.inputIndex); } disconnecting = undefined; @@ -625,7 +625,7 @@ if (from !== undefined && to !== undefined) { const { nodeId: outputConnectedNodeID, index: outputNodeConnectionIndex } = from; const { nodeId: inputConnectedNodeID, index: inputNodeConnectionIndex } = to; - editor.instance.connectNodesByLink(outputConnectedNodeID, outputNodeConnectionIndex, inputConnectedNodeID, inputNodeConnectionIndex); + editor.handle.connectNodesByLink(outputConnectedNodeID, outputNodeConnectionIndex, inputConnectedNodeID, inputNodeConnectionIndex); } } else if (linkInProgressFromConnector && !initialDisconnecting) { // If the add node menu is already open, we don't want to open it again @@ -645,11 +645,11 @@ } else if (draggingNodes) { if (draggingNodes.startX === e.x && draggingNodes.startY === e.y) { if (selectIfNotDragged !== undefined && ($nodeGraph.selected.length !== 1 || $nodeGraph.selected[0] !== selectIfNotDragged)) { - editor.instance.selectNodes(new BigUint64Array([selectIfNotDragged])); + editor.handle.selectNodes(new BigUint64Array([selectIfNotDragged])); } } - if ($nodeGraph.selected.length > 0 && (draggingNodes.roundX !== 0 || draggingNodes.roundY !== 0)) editor.instance.moveSelectedNodes(draggingNodes.roundX, draggingNodes.roundY); + if ($nodeGraph.selected.length > 0 && (draggingNodes.roundX !== 0 || draggingNodes.roundY !== 0)) editor.handle.moveSelectedNodes(draggingNodes.roundX, draggingNodes.roundY); checkInsertBetween(); @@ -670,7 +670,7 @@ const inputNodeConnectionIndex = 0; const x = Math.round(nodeListLocation.x / GRID_SIZE); const y = Math.round(nodeListLocation.y / GRID_SIZE) - 1; - const inputConnectedNodeID = editor.instance.createNode(nodeType, x, y); + const inputConnectedNodeID = editor.handle.createNode(nodeType, x, y); nodeListLocation = undefined; if (!linkInProgressFromConnector) return; @@ -678,7 +678,7 @@ if (from !== undefined) { const { nodeId: outputConnectedNodeID, index: outputNodeConnectionIndex } = from; - editor.instance.connectNodesByLink(outputConnectedNodeID, outputNodeConnectionIndex, inputConnectedNodeID, inputNodeConnectionIndex); + editor.handle.connectNodesByLink(outputConnectedNodeID, outputNodeConnectionIndex, inputConnectedNodeID, inputNodeConnectionIndex); } linkInProgressFromConnector = undefined; @@ -882,7 +882,7 @@
- + {node.alias || "Layer"}
@@ -929,7 +929,7 @@
- {node.alias || node.name} + {node.alias || node.name}
{#if exposedInputsOutputs.length > 0} diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index f1034c7a..1ca07939 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -58,15 +58,15 @@ } function widgetValueCommit(index: number, value: unknown) { - editor.instance.widgetValueCommit(layoutTarget, widgets[index].widgetId, value); + editor.handle.widgetValueCommit(layoutTarget, widgets[index].widgetId, value); } function widgetValueUpdate(index: number, value: unknown) { - editor.instance.widgetValueUpdate(layoutTarget, widgets[index].widgetId, value); + editor.handle.widgetValueUpdate(layoutTarget, widgets[index].widgetId, value); } function widgetValueCommitAndUpdate(index: number, value: unknown) { - editor.instance.widgetValueCommitAndUpdate(layoutTarget, widgets[index].widgetId, value); + editor.handle.widgetValueCommitAndUpdate(layoutTarget, widgets[index].widgetId, value); } // TODO: This seems to work, but verify the correctness and terseness of this, it's adapted from https://stackoverflow.com/a/67434028/775283 diff --git a/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte b/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte index b2add999..8ca3c938 100644 --- a/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte +++ b/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte @@ -27,11 +27,11 @@ } function primaryColorChanged(color: Color) { - editor.instance.updatePrimaryColor(color.red, color.green, color.blue, color.alpha); + editor.handle.updatePrimaryColor(color.red, color.green, color.blue, color.alpha); } function secondaryColorChanged(color: Color) { - editor.instance.updateSecondaryColor(color.red, color.green, color.blue, color.alpha); + editor.handle.updateSecondaryColor(color.red, color.green, color.blue, color.alpha); } diff --git a/frontend/src/components/window/title-bar/TitleBar.svelte b/frontend/src/components/window/title-bar/TitleBar.svelte index 23a3592d..54fad18f 100644 --- a/frontend/src/components/window/title-bar/TitleBar.svelte +++ b/frontend/src/components/window/title-bar/TitleBar.svelte @@ -54,7 +54,7 @@ ...entry, // Shared names with fields that need to be converted from the type used in `MenuBarEntry` to that of `MenuListEntry` - action: () => editor.instance.widgetValueCommitAndUpdate(updateMenuBarLayout.layoutTarget, entry.action.widgetId, undefined), + action: () => editor.handle.widgetValueCommitAndUpdate(updateMenuBarLayout.layoutTarget, entry.action.widgetId, undefined), children: entry.children ? entry.children.map((entries) => entries.map((entry) => menuBarEntryToMenuListEntry(entry))) : undefined, // New fields in `MenuListEntry` diff --git a/frontend/src/components/window/workspace/Panel.svelte b/frontend/src/components/window/workspace/Panel.svelte index f35e8044..9672da09 100644 --- a/frontend/src/components/window/workspace/Panel.svelte +++ b/frontend/src/components/window/workspace/Panel.svelte @@ -119,7 +119,7 @@
- editor.instance.newDocumentDialog()} /> + editor.handle.newDocumentDialog()} /> @@ -127,7 +127,7 @@
- editor.instance.openDocument()} /> + editor.handle.openDocument()} /> @@ -135,7 +135,7 @@
- editor.instance.demoArtworkDialog()} /> + editor.handle.demoArtworkDialog()} />
diff --git a/frontend/src/components/window/workspace/Workspace.svelte b/frontend/src/components/window/workspace/Workspace.svelte index 5c42dcb8..d66fe735 100644 --- a/frontend/src/components/window/workspace/Workspace.svelte +++ b/frontend/src/components/window/workspace/Workspace.svelte @@ -30,7 +30,7 @@ $: documentTabLabels = $portfolio.documents.map((doc: FrontendDocumentDetails) => { const name = doc.displayName; - if (!editor.instance.inDevelopmentMode()) return { name }; + if (!editor.handle.inDevelopmentMode()) return { name }; const tooltip = `Document ID: ${doc.id}`; return { name, tooltip }; @@ -105,8 +105,8 @@ tabCloseButtons={true} tabMinWidths={true} tabLabels={documentTabLabels} - clickAction={(tabIndex) => editor.instance.selectDocument($portfolio.documents[tabIndex].id)} - closeAction={(tabIndex) => editor.instance.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)} + clickAction={(tabIndex) => editor.handle.selectDocument($portfolio.documents[tabIndex].id)} + closeAction={(tabIndex) => editor.handle.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)} tabActiveIndex={$portfolio.activeDocumentIndex} bind:this={documentPanel} /> diff --git a/frontend/src/io-managers/input.ts b/frontend/src/io-managers/input.ts index 8ea0d752..5e8f3c85 100644 --- a/frontend/src/io-managers/input.ts +++ b/frontend/src/io-managers/input.ts @@ -108,7 +108,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli if (await shouldRedirectKeyboardEventToBackend(e)) { e.preventDefault(); const modifiers = makeKeyboardModifiersBitfield(e); - editor.instance.onKeyDown(key, modifiers, e.repeat); + editor.handle.onKeyDown(key, modifiers, e.repeat); return; } @@ -123,7 +123,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli if (await shouldRedirectKeyboardEventToBackend(e)) { e.preventDefault(); const modifiers = makeKeyboardModifiersBitfield(e); - editor.instance.onKeyUp(key, modifiers, e.repeat); + editor.handle.onKeyUp(key, modifiers, e.repeat); } } @@ -149,7 +149,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli } const modifiers = makeKeyboardModifiersBitfield(e); - editor.instance.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers); + editor.handle.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers); } function onMouseDown(e: MouseEvent) { @@ -170,13 +170,13 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli } if (!inTextInput) { - if (textToolInteractiveInputElement) editor.instance.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText)); + if (textToolInteractiveInputElement) editor.handle.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText)); else viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element; } if (viewportPointerInteractionOngoing) { const modifiers = makeKeyboardModifiersBitfield(e); - editor.instance.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers); + editor.handle.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers); } } @@ -186,7 +186,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli if (textToolInteractiveInputElement) return; const modifiers = makeKeyboardModifiersBitfield(e); - editor.instance.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers); + editor.handle.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers); } function onPotentialDoubleClick(e: MouseEvent) { @@ -202,7 +202,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli if (e.button === 2) buttons = 2; // RMB const modifiers = makeKeyboardModifiersBitfield(e); - editor.instance.onDoubleClick(e.clientX, e.clientY, buttons, modifiers); + editor.handle.onDoubleClick(e.clientX, e.clientY, buttons, modifiers); } // Mouse events @@ -222,7 +222,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli if (isTargetingCanvas) { e.preventDefault(); const modifiers = makeKeyboardModifiersBitfield(e); - editor.instance.onWheelScroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers); + editor.handle.onWheelScroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers); } } @@ -250,18 +250,18 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli const flattened = boundsOfViewports.flat(); const data = Float64Array.from(flattened); - if (boundsOfViewports.length > 0) editor.instance.boundsOfViewports(data); + if (boundsOfViewports.length > 0) editor.handle.boundsOfViewports(data); } async function onBeforeUnload(e: BeforeUnloadEvent) { const activeDocument = get(portfolio).documents[get(portfolio).activeDocumentIndex]; - if (activeDocument && !activeDocument.isAutoSaved) editor.instance.triggerAutoSave(activeDocument.id); + if (activeDocument && !activeDocument.isAutoSaved) editor.handle.triggerAutoSave(activeDocument.id); // Skip the message if the editor crashed, since work is already lost - if (await editor.instance.hasCrashed()) return; + if (await editor.handle.hasCrashed()) return; // Skip the message during development, since it's annoying when testing - if (await editor.instance.inDevelopmentMode()) return; + if (await editor.handle.inDevelopmentMode()) return; const allDocumentsSaved = get(portfolio).documents.reduce((acc, doc) => acc && doc.isSaved, true); if (!allDocumentsSaved) { @@ -279,9 +279,9 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli if (item.type === "text/plain") { item.getAsString((text) => { if (text.startsWith("graphite/layer: ")) { - editor.instance.pasteSerializedData(text.substring(16, text.length)); + editor.handle.pasteSerializedData(text.substring(16, text.length)); } else if (text.startsWith("graphite/nodes: ")) { - editor.instance.pasteSerializedNodes(text.substring(16, text.length)); + editor.handle.pasteSerializedNodes(text.substring(16, text.length)); } }); } @@ -290,14 +290,14 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli if (file?.type === "svg") { const text = await file.text(); - editor.instance.pasteSvg(text); + editor.handle.pasteSvg(text); return; } if (file?.type.startsWith("image")) { const imageData = await extractPixelData(file); - editor.instance.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height); + editor.handle.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height); } }); } @@ -329,7 +329,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli const text = reader.result as string; if (text.startsWith("graphite/layer: ")) { - editor.instance.pasteSerializedData(text.substring(16, text.length)); + editor.handle.pasteSerializedData(text.substring(16, text.length)); } }; reader.readAsText(blob); @@ -343,7 +343,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli const reader = new FileReader(); reader.onload = () => { const text = reader.result as string; - editor.instance.pasteSvg(text); + editor.handle.pasteSvg(text); }; reader.readAsText(blob); @@ -356,7 +356,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli reader.onload = async () => { if (reader.result instanceof ArrayBuffer) { const imageData = await extractPixelData(new Blob([reader.result], { type: imageType })); - editor.instance.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height); + editor.handle.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height); } }; reader.readAsArrayBuffer(blob); @@ -381,7 +381,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli }; const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err); - editor.instance.errorDialog("Cannot access clipboard", message); + editor.handle.errorDialog("Cannot access clipboard", message); } }); diff --git a/frontend/src/io-managers/localization.ts b/frontend/src/io-managers/localization.ts index fb995520..fb841c2a 100644 --- a/frontend/src/io-managers/localization.ts +++ b/frontend/src/io-managers/localization.ts @@ -20,6 +20,6 @@ export function createLocalizationManager(editor: Editor) { // Subscribe to process backend event editor.subscriptions.subscribeJsMessage(TriggerAboutGraphiteLocalizedCommitDate, (triggerAboutGraphiteLocalizedCommitDate) => { const localized = localizeTimestamp(triggerAboutGraphiteLocalizedCommitDate.commitDate); - editor.instance.requestAboutGraphiteDialogWithLocalizedCommitDate(localized.timestamp, localized.year); + editor.handle.requestAboutGraphiteDialogWithLocalizedCommitDate(localized.timestamp, localized.year); }); } diff --git a/frontend/src/io-managers/persistence.ts b/frontend/src/io-managers/persistence.ts index 2dff25e5..812176e1 100644 --- a/frontend/src/io-managers/persistence.ts +++ b/frontend/src/io-managers/persistence.ts @@ -52,7 +52,7 @@ export function createPersistenceManager(editor: Editor, portfolio: PortfolioSta const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : [])); orderedSavedDocuments?.forEach(async (doc: TriggerIndexedDbWriteDocument) => { - editor.instance.openAutoSavedDocument(BigInt(doc.details.id), doc.details.name, doc.details.isSaved, doc.document); + editor.handle.openAutoSavedDocument(BigInt(doc.details.id), doc.details.name, doc.details.isSaved, doc.document); }); } @@ -66,7 +66,7 @@ export function createPersistenceManager(editor: Editor, portfolio: PortfolioSta const preferences = await get>("preferences", graphiteStore); if (!preferences) return; - editor.instance.loadPreferences(JSON.stringify(preferences)); + editor.handle.loadPreferences(JSON.stringify(preferences)); } // FRONTEND MESSAGE SUBSCRIPTIONS diff --git a/frontend/src/state-providers/dialog.ts b/frontend/src/state-providers/dialog.ts index 1b65f69c..7c224046 100644 --- a/frontend/src/state-providers/dialog.ts +++ b/frontend/src/state-providers/dialog.ts @@ -13,7 +13,7 @@ export function createDialogState(editor: Editor) { buttons: defaultWidgetLayout(), column1: defaultWidgetLayout(), column2: defaultWidgetLayout(), - // Special case for the crash dialog because we cannot handle button widget callbacks from Rust once the editor instance has panicked + // Special case for the crash dialog because we cannot handle button widget callbacks from Rust once the editor has panicked panicDetails: "", }); @@ -27,7 +27,7 @@ export function createDialogState(editor: Editor) { } // 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 instance has panicked so it cannot respond to widget callbacks. + // Normal dialogs are created in the Rust backend, but for the crash dialog, the editor has panicked so it cannot respond to widget callbacks. function createCrashDialog(panicDetails: string) { update((state) => { state.visible = true; diff --git a/frontend/src/state-providers/fonts.ts b/frontend/src/state-providers/fonts.ts index 44200633..d2a0f9c8 100644 --- a/frontend/src/state-providers/fonts.ts +++ b/frontend/src/state-providers/fonts.ts @@ -52,9 +52,9 @@ export function createFontsState(editor: Editor) { const url = await getFontFileUrl(triggerFontLoad.font.fontFamily, triggerFontLoad.font.fontStyle); if (url) { const response = await (await fetch(url)).arrayBuffer(); - editor.instance.onFontLoad(triggerFontLoad.font.fontFamily, triggerFontLoad.font.fontStyle, url, new Uint8Array(response), triggerFontLoad.isDefault); + editor.handle.onFontLoad(triggerFontLoad.font.fontFamily, triggerFontLoad.font.fontStyle, url, new Uint8Array(response), triggerFontLoad.isDefault); } else { - editor.instance.errorDialog("Failed to load font", `The font ${triggerFontLoad.font.fontFamily} with style ${triggerFontLoad.font.fontStyle} does not exist`); + editor.handle.errorDialog("Failed to load font", `The font ${triggerFontLoad.font.fontFamily} with style ${triggerFontLoad.font.fontStyle} does not exist`); } }); diff --git a/frontend/src/state-providers/portfolio.ts b/frontend/src/state-providers/portfolio.ts index 81b3abb8..7ecef2a4 100644 --- a/frontend/src/state-providers/portfolio.ts +++ b/frontend/src/state-providers/portfolio.ts @@ -50,31 +50,31 @@ export function createPortfolioState(editor: Editor) { const data = await fetch(url); const content = await data.text(); - editor.instance.openDocumentFile(name, content); + editor.handle.openDocumentFile(name, content); } catch { // Needs to be delayed until the end of the current call stack so the existing demo artwork dialog can be closed first, otherwise this dialog won't show setTimeout(() => { - editor.instance.errorDialog("Failed to open document", "The file could not be reached over the internet. You may be offline, or it may be missing."); + editor.handle.errorDialog("Failed to open document", "The file could not be reached over the internet. You may be offline, or it may be missing."); }, 0); } }); editor.subscriptions.subscribeJsMessage(TriggerOpenDocument, async () => { - const extension = editor.instance.fileSaveSuffix(); + const extension = editor.handle.fileSaveSuffix(); const data = await upload(extension, "text"); - editor.instance.openDocumentFile(data.filename, data.content); + editor.handle.openDocumentFile(data.filename, data.content); }); editor.subscriptions.subscribeJsMessage(TriggerImport, async () => { const data = await upload("image/*", "data"); if (data.type.includes("svg")) { const svg = new TextDecoder().decode(data.content); - editor.instance.pasteSvg(svg); + editor.handle.pasteSvg(svg); return; } const imageData = await extractPixelData(new Blob([data.content], { type: data.type })); - editor.instance.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height); + editor.handle.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height); }); editor.subscriptions.subscribeJsMessage(TriggerDownloadTextFile, (triggerFileDownload) => { downloadFileText(triggerFileDownload.name, triggerFileDownload.document); diff --git a/frontend/src/wasm-communication/editor.ts b/frontend/src/wasm-communication/editor.ts index 75cabfe6..5bdb6ea9 100644 --- a/frontend/src/wasm-communication/editor.ts +++ b/frontend/src/wasm-communication/editor.ts @@ -1,30 +1,18 @@ // import { panicProxy } from "@graphite/utility-functions/panic-proxy"; import { type JsMessageType } from "@graphite/wasm-communication/messages"; import { createSubscriptionRouter, type SubscriptionRouter } from "@graphite/wasm-communication/subscription-router"; -import init, { setRandomSeed, wasmMemory, JsEditorHandle } from "@graphite-frontend/wasm/pkg/graphite_wasm.js"; +import init, { setRandomSeed, wasmMemory, EditorHandle } from "@graphite-frontend/wasm/pkg/graphite_wasm.js"; -export type WasmRawInstance = WebAssembly.Memory; -export type WasmEditorInstance = JsEditorHandle; -export type Editor = Readonly>; +export type Editor = { + raw: WebAssembly.Memory; + handle: EditorHandle; + subscriptions: SubscriptionRouter; +}; // `wasmImport` starts uninitialized because its initialization needs to occur asynchronously, and thus needs to occur by manually calling and awaiting `initWasm()` let wasmImport: WebAssembly.Memory | undefined; -const tauri = "__TAURI_METADATA__" in window && import("@tauri-apps/api"); -export async function dispatchTauri(message: unknown) { - if (!tauri) return; - - try { - const response = await (await tauri).invoke("handle_message", { message }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).editorInstance?.tauriResponse(response); - } catch { - // eslint-disable-next-line no-console - console.error("Failed to dispatch Tauri message"); - } -} - -// Should be called asynchronously before `createEditor()` +// Should be called asynchronously before `createEditor()`. export async function initWasm() { // Skip if the WASM module is already initialized if (wasmImport !== undefined) return; @@ -40,27 +28,26 @@ export async function initWasm() { const randomSeedFloat = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); const randomSeed = BigInt(randomSeedFloat); setRandomSeed(randomSeed); - if (!tauri) return; - await (await tauri).invoke("set_random_seed", { seed: randomSeedFloat }); } -// Should be called after running `initWasm()` and its promise resolving -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function createEditor() { - // Raw: Object containing several callable functions from `editor_api.rs` defined directly on the WASM module, not the editor instance (generated by wasm-bindgen) +// Should be called after running `initWasm()` and its promise resolving. +export function createEditor(): Editor { + // Raw: object containing several callable functions from `editor_api.rs` defined directly on the WASM module, not the `EditorHandle` struct (generated by wasm-bindgen) if (!wasmImport) throw new Error("Editor WASM backend was not initialized at application startup"); - const raw: WasmRawInstance = wasmImport; + const raw: WebAssembly.Memory = wasmImport; - // Instance: Object containing many functions from `editor_api.rs` that are part of the editor instance (generated by wasm-bindgen) - const instance: WasmEditorInstance = new JsEditorHandle((messageType: JsMessageType, messageData: Record) => { - // This callback is called by WASM when a FrontendMessage is received from the WASM wrapper editor instance - // We pass along the first two arguments then add our own `raw` and `instance` context for the last two arguments - subscriptions.handleJsMessage(messageType, messageData, raw, instance); + // Handle: object containing many functions from `editor_api.rs` that are part of the `EditorHandle` struct (generated by wasm-bindgen) + const handle: EditorHandle = new EditorHandle((messageType: JsMessageType, messageData: Record) => { + // This callback is called by WASM when a FrontendMessage is received from the WASM wrapper `EditorHandle` + // We pass along the first two arguments then add our own `raw` and `handle` context for the last two arguments + subscriptions.handleJsMessage(messageType, messageData, raw, handle); }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).editorInstance = instance; - // Subscriptions: Allows subscribing to messages in JS that are sent from the WASM backend + // TODO: Remove? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).editorHandle = handle; + + // Subscriptions: allows subscribing to messages in JS that are sent from the WASM backend const subscriptions: SubscriptionRouter = createSubscriptionRouter(); // Check if the URL hash fragment has any demo artwork to be loaded @@ -75,7 +62,7 @@ export function createEditor() { const filename = url.pathname.split("/").pop() || "Untitled"; const content = await data.text(); - instance.openDocumentFile(filename, content); + handle.openDocumentFile(filename, content); // Remove the hash fragment from the URL history.replaceState("", "", `${window.location.pathname}${window.location.search}`); @@ -84,14 +71,10 @@ export function createEditor() { } })(); - return { - raw, - instance, - subscriptions, - }; + return { raw, handle, subscriptions }; } export function injectImaginatePollServerStatus() { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).editorInstance?.injectImaginatePollServerStatus(); + (window as any).editorHandle?.injectImaginatePollServerStatus(); } diff --git a/frontend/src/wasm-communication/messages.ts b/frontend/src/wasm-communication/messages.ts index 5f3ecd01..910d1f15 100644 --- a/frontend/src/wasm-communication/messages.ts +++ b/frontend/src/wasm-communication/messages.ts @@ -4,7 +4,7 @@ import { Transform, Type, plainToClass } from "class-transformer"; import { type PopoverButtonStyle, type IconName, type IconSize } from "@graphite/utility-functions/icons"; -import { type WasmEditorInstance, type WasmRawInstance } from "@graphite/wasm-communication/editor"; +import { type EditorHandle } from "@graphite-frontend/wasm/pkg/graphite_wasm.js"; export class JsMessage { // The marker provides a way to check if an object is a sub-class constructor for a jsMessage. @@ -1275,7 +1275,7 @@ function createMenuLayoutRecursive(children: any[][]): MenuBarEntry[][] { // `any` is used since the type of the object should be known from the Rust side // eslint-disable-next-line @typescript-eslint/no-explicit-any -type JSMessageFactory = (data: any, wasm: WasmRawInstance, instance: WasmEditorInstance) => JsMessage; +type JSMessageFactory = (data: any, wasm: WebAssembly.Memory, handle: EditorHandle) => JsMessage; type MessageMaker = typeof JsMessage | JSMessageFactory; export const messageMakers: Record = { diff --git a/frontend/src/wasm-communication/subscription-router.ts b/frontend/src/wasm-communication/subscription-router.ts index f95f22d8..3807bfcb 100644 --- a/frontend/src/wasm-communication/subscription-router.ts +++ b/frontend/src/wasm-communication/subscription-router.ts @@ -1,7 +1,7 @@ import { plainToInstance } from "class-transformer"; -import { type WasmEditorInstance, type WasmRawInstance } from "@graphite/wasm-communication/editor"; import { type JsMessageType, messageMakers, type JsMessage } from "@graphite/wasm-communication/messages"; +import { type EditorHandle } from "@graphite-frontend/wasm/pkg/graphite_wasm.js"; type JsMessageCallback = (messageData: T) => void; // Don't know a better way of typing this since it can be any subclass of JsMessage @@ -17,7 +17,7 @@ export function createSubscriptionRouter() { subscriptions[messageType.name] = callback; }; - const handleJsMessage = (messageType: JsMessageType, messageData: Record, wasm: WasmRawInstance, instance: WasmEditorInstance) => { + const handleJsMessage = (messageType: JsMessageType, messageData: Record, wasm: WebAssembly.Memory, handle: EditorHandle) => { // Find the message maker for the message type, which can either be a JS class constructor or a function that returns an instance of the JS class const messageMaker = messageMakers[messageType]; if (!messageMaker) { @@ -42,7 +42,7 @@ export function createSubscriptionRouter() { // If the `messageMaker` is a `JsMessage` class then we use the class-transformer library's `plainToInstance` function in order to convert the JSON data into the destination class. // If it is not a `JsMessage` then it should be a custom function that creates a JsMessage from a JSON, so we call the function itself with the raw JSON as an argument. // The resulting `message` is an instance of a class that extends `JsMessage`. - const message = messageIsClass ? plainToInstance(messageMaker, unwrappedMessageData) : messageMaker(unwrappedMessageData, wasm, instance); + const message = messageIsClass ? plainToInstance(messageMaker, unwrappedMessageData) : messageMaker(unwrappedMessageData, wasm, handle); // If we have constructed a valid message, then we try and execute the callback that the frontend has associated with this message. // The frontend should always have a callback for all messages, but due to message ordering, we might have to delay a few stack frames until we do. diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index 6ec9d978..a31e4a51 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -5,7 +5,7 @@ // on the dispatcher messaging system and more complex Rust data types. // use crate::helpers::translate_key; -use crate::{Error, EDITOR_HAS_CRASHED, EDITOR_INSTANCES, JS_EDITOR_HANDLES}; +use crate::{Error, EDITOR, EDITOR_HANDLE, EDITOR_HAS_CRASHED}; use editor::application::generate_uuid; use editor::application::Editor; @@ -47,108 +47,36 @@ pub fn wasm_memory() -> JsValue { wasm_bindgen::memory() } -/// Helper function for calling JS's `requestAnimationFrame` with the given closure -fn request_animation_frame(f: &Closure) { - 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, 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`"); -} - // ============================================================================ -/// To avoid wasm-bindgen from checking mutable reference issues using WasmRefCell we must make all methods take a non-mutable reference to self. -/// Not doing this creates an issue when Rust calls into JS which calls back to Rust in the same call stack. +/// 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 JsEditorHandle { - editor_id: u64, +pub struct EditorHandle { + /// This callback is called by the editor's dispatcher when directing FrontendMessages from Rust to JS frontend_message_handler_callback: js_sys::Function, } -/// Provides access to the `Editor` instance and its `JsEditorHandle` by calling the given closure with them as arguments. -fn call_closure_with_editor_and_handle(mut f: impl FnMut(&mut Editor, &mut JsEditorHandle)) { - EDITOR_INSTANCES.with(|instances| { - JS_EDITOR_HANDLES.with(|handles| { - instances - .try_borrow_mut() - .map(|mut editors| { - for (id, editor) in editors.iter_mut() { - let Ok(mut handles) = handles.try_borrow_mut() else { - log::error!("Failed to borrow editor handles"); - continue; - }; - let Some(js_editor) = handles.get_mut(id) else { - log::error!("Editor ID ({id}) has no corresponding JsEditorHandle ID"); - continue; - }; - - // Call the closure with the editor and its handle - f(editor, js_editor) - } - }) - .unwrap_or_else(|_| log::error!("Failed to borrow editor instances")); - }) - }); -} - -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; +// 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 { + pub fn send_frontend_message_to_js_rust_proxy(&self, message: FrontendMessage) { + self.send_frontend_message_to_js(message); } - - editor::node_graph_executor::run_node_graph().await; - - call_closure_with_editor_and_handle(|editor, handle| { - let mut messages = VecDeque::new(); - editor.poll_node_graph_evaluation(&mut messages); - - // Send each `FrontendMessage` to the JavaScript frontend - for response in messages.into_iter().flat_map(|message| editor.handle_message(message)) { - 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; - } - - call_closure_with_editor_and_handle(|editor, handle| { - for message in editor.handle_message(PortfolioMessage::AutoSaveAllDocuments) { - handle.send_frontend_message_to_js(message); - } - }); -} - -// ============================================================================ - #[wasm_bindgen] -impl JsEditorHandle { +impl EditorHandle { #[wasm_bindgen(constructor)] pub fn new(frontend_message_handler_callback: js_sys::Function) -> Self { - let editor_id = generate_uuid(); let editor = Editor::new(); - let editor_handle = JsEditorHandle { - editor_id, - frontend_message_handler_callback, - }; - EDITOR_INSTANCES.with(|instances| instances.borrow_mut().insert(editor_id, editor)); - JS_EDITOR_HANDLES.with(|instances| instances.borrow_mut().insert(editor_id, editor_handle.clone())); + let editor_handle = EditorHandle { frontend_message_handler_callback }; + if EDITOR.with(|editor_cell| editor_cell.set(RefCell::new(editor))).is_err() { + log::error!("Attempted to initialize the editor more than once"); + } + if EDITOR_HANDLE.with(|handle_cell| handle_cell.set(RefCell::new(editor_handle.clone()))).is_err() { + log::error!("Attempted to initialize the editor handle more than once"); + } editor_handle } @@ -159,36 +87,16 @@ impl JsEditorHandle { return; } - #[cfg(feature = "tauri")] - { - let message: Message = message.into(); - let message = ron::to_string(&message).unwrap(); + // Get the editor, dispatch the message, and store the `FrontendMessage` queue response + editor(|editor| { + // Get the editor, then dispatch the message to the backend, and return its response `FrontendMessage` queue + let frontend_messages = editor.handle_message(message.into()); - dispatchTauri(message); - } - #[cfg(not(feature = "tauri"))] - { - // Get the editor instances, dispatch the message, and store the `FrontendMessage` queue response - let frontend_messages = EDITOR_INSTANCES.with(|instances| { - // Mutably borrow the editors, and if successful, we can access them in the closure - instances.try_borrow_mut().map(|mut editors| { - // Get the editor instance for this editor ID, then dispatch the message to the backend, and return its response `FrontendMessage` queue - editors - .get_mut(&self.editor_id) - .expect("EDITOR_INSTANCES does not contain the current editor_id") - .handle_message(message.into()) - }) - }); - - // Process any `FrontendMessage` responses resulting from the backend processing the dispatched message - if let Ok(frontend_messages) = frontend_messages { - // Send each `FrontendMessage` to the JavaScript frontend - for message in frontend_messages.into_iter() { - self.send_frontend_message_to_js(message); - } + // Send each `FrontendMessage` to the JavaScript frontend + for message in frontend_messages.into_iter() { + self.send_frontend_message_to_js(message); } - } - // If the editor cannot be borrowed then it has encountered a panic - we should just ignore new dispatches + }); } // Sends a FrontendMessage to JavaScript @@ -238,7 +146,7 @@ impl JsEditorHandle { *g.borrow_mut() = Some(Closure::new(move |timestamp| { wasm_bindgen_futures::spawn_local(poll_node_graph_evaluation()); - call_closure_with_editor_and_handle(|editor, handle| { + editor_and_handle(|editor, handle| { let micros: f64 = timestamp * 1000.; let timestamp = Duration::from_micros(micros.round() as u64); @@ -778,51 +686,13 @@ impl JsEditorHandle { self.dispatch(message); } - /// Returns the string representation of the nodes contents - #[wasm_bindgen(js_name = introspectNode)] - pub fn introspect_node(&self, node_path: Vec) -> JsValue { - let node_path = node_path.into_iter().map(NodeId).collect::>(); - let frontend_messages = EDITOR_INSTANCES.with(|instances| { - // Mutably borrow the editors, and if successful, we can access them in the closure - instances.try_borrow_mut().map(|mut editors| { - // Get the editor instance for this editor ID, then dispatch the message to the backend, and return its response `FrontendMessage` queue - let image = editors - .get_mut(&self.editor_id) - .expect("EDITOR_INSTANCES does not contain the current editor_id") - .dispatcher - .message_handlers - .portfolio_message_handler - .introspect_node(&node_path); - let image = image?; - let image = image.downcast_ref::>()?; - let serializer = serde_wasm_bindgen::Serializer::new().serialize_large_number_types_as_bigints(true); - let message_data = image.serialize(&serializer).expect("Failed to serialize FrontendMessage"); - Some(message_data) - }) - }); - frontend_messages.unwrap().unwrap_or_default() - } - #[wasm_bindgen(js_name = injectImaginatePollServerStatus)] pub fn inject_imaginate_poll_server_status(&self) { self.dispatch(PortfolioMessage::ImaginatePollServerStatus); } } -// Needed to make JsEditorHandle functions pub to Rust. -// The reason is not fully clear but it has to do with the #[wasm_bindgen] procedural macro. -impl JsEditorHandle { - pub fn send_frontend_message_to_js_rust_proxy(&self, message: FrontendMessage) { - self.send_frontend_message_to_js(message); - } -} - -impl Drop for JsEditorHandle { - fn drop(&mut self) { - // Consider removing after https://github.com/rustwasm/wasm-bindgen/pull/2984 is merged and released - EDITOR_INSTANCES.with(|instances| instances.borrow_mut().remove(&self.editor_id)); - } -} +// ============================================================================ #[wasm_bindgen(js_name = evaluateMathExpression)] pub fn evaluate_math_expression(expression: &str) -> Option { @@ -894,6 +764,85 @@ pub fn implicit_multiplication_preprocess(expression: &str) -> String { output_string.replace("logtwo(", "log2(").replace('π', "pi").replace('τ', "tau") } +/// Helper function for calling JS's `requestAnimationFrame` with the given closure +fn request_animation_frame(f: &Closure) { + 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, 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. +fn editor(callback: impl FnOnce(&mut editor::application::Editor) -> T) -> T { + EDITOR.with(|editor| { + let Some(Ok(mut editor)) = editor.get().map(RefCell::try_borrow_mut) else { + // TODO: Investigate if this should just panic instead, and if not doing so right now may be the cause of silent crashes that don't inform the user that the app has panicked + log::error!("Failed to borrow the editor"); + return T::default(); + }; + + callback(&mut *editor) + }) +} + +/// Provides access to the `Editor` and its `EditorHandle` by calling the given closure with them as arguments. +fn editor_and_handle(mut callback: impl FnMut(&mut Editor, &mut EditorHandle)) { + editor(|editor| { + EDITOR_HANDLE.with(|editor_handle| { + let Some(Ok(mut handle)) = editor_handle.get().map(RefCell::try_borrow_mut) else { + log::error!("Failed to borrow editor handle"); + return; + }; + + // Call the closure with the editor and its handle + callback(editor, &mut handle); + }) + }); +} + +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; + } + + editor::node_graph_executor::run_node_graph().await; + + editor_and_handle(|editor, handle| { + let mut messages = VecDeque::new(); + editor.poll_node_graph_evaluation(&mut messages); + + // Send each `FrontendMessage` to the JavaScript frontend + for response in messages.into_iter().flat_map(|message| editor.handle_message(message)) { + 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; + } + + editor_and_handle(|editor, handle| { + for message in editor.handle_message(PortfolioMessage::AutoSaveAllDocuments) { + handle.send_frontend_message_to_js(message); + } + }); +} + #[test] fn implicit_multiplication_preprocess_tests() { assert_eq!(implicit_multiplication_preprocess("2pi"), "2*pi"); diff --git a/frontend/wasm/src/lib.rs b/frontend/wasm/src/lib.rs index b98e043f..1ed76729 100644 --- a/frontend/wasm/src/lib.rs +++ b/frontend/wasm/src/lib.rs @@ -9,8 +9,7 @@ pub mod helpers; use editor::messages::prelude::*; -use std::cell::RefCell; -use std::collections::HashMap; +use std::cell::{OnceCell, RefCell}; use std::panic; use std::sync::atomic::{AtomicBool, Ordering}; use wasm_bindgen::prelude::*; @@ -19,9 +18,8 @@ use wasm_bindgen::prelude::*; pub static EDITOR_HAS_CRASHED: AtomicBool = AtomicBool::new(false); pub static LOGGER: WasmLog = WasmLog; thread_local! { - // TODO: Remove the concept of multiple editor instances to simplify all of this - pub static EDITOR_INSTANCES: RefCell> = RefCell::new(HashMap::new()); - pub static JS_EDITOR_HANDLES: RefCell> = RefCell::new(HashMap::new()); + pub static EDITOR: OnceCell> = OnceCell::new(); + pub static EDITOR_HANDLE: OnceCell> = OnceCell::new(); } /// Initialize the backend @@ -41,11 +39,12 @@ pub fn panic_hook(info: &panic::PanicInfo) { error!("{info}"); - JS_EDITOR_HANDLES.with(|instances| { - instances - .borrow_mut() - .values_mut() - .for_each(|instance| instance.send_frontend_message_to_js_rust_proxy(FrontendMessage::DisplayDialogPanic { panic_info: info.to_string() })) + EDITOR_HANDLE.with(|editor_handle| { + editor_handle.get().map(|handle| { + handle + .borrow_mut() + .send_frontend_message_to_js_rust_proxy(FrontendMessage::DisplayDialogPanic { panic_info: info.to_string() }) + }) }); } diff --git a/website/content/features.md b/website/content/features.md index 750d814a..693ece1d 100644 --- a/website/content/features.md +++ b/website/content/features.md @@ -126,17 +126,17 @@ Always on the bleeding edge and built to last— Graphite is written on a robust Imaginate (Stable Diffusion node/tool)
- - Graph data attribute spreadsheet + + Native desktop app (with Tauri) +
+
+ + Custom subgraph nodes
Boolean operations for shapes
-
- - Native desktop app (with Tauri) -
WebGPU accelerated rendering @@ -145,10 +145,6 @@ Always on the bleeding edge and built to last— Graphite is written on a robust Adaptive resolution raster rendering
-
- - Timeline with animation channels -
Interactive graph auto-layout @@ -170,16 +166,20 @@ Always on the bleeding edge and built to last— Graphite is written on a robust Fully-supported brush tool
- - Select mode (marquee masking) + + Timeline with animation channels +
+
+ + Graph data attribute spreadsheet
Local file browser for saving/loading
- - Custom subgraph nodes + + Local fonts access
@@ -190,8 +190,8 @@ Always on the bleeding edge and built to last— Graphite is written on a robust

— Alpha 4 —

- - Local fonts access + + Select mode (marquee masking)
@@ -229,10 +229,6 @@ Always on the bleeding edge and built to last— Graphite is written on a robust Code editor for custom nodes
-
- - Portable, embeddable render engine -

— Beta —

@@ -273,6 +269,10 @@ Always on the bleeding edge and built to last— Graphite is written on a robust Node manager and marketplace
+
+ + Portable, embeddable render engine +
Predictive graph rendering/caching