Split apart the frontend Editor type into SubscriptionsRouter and EditorHandle (#3923)

* Remove the unused Editor.raw/wasmMemory/wasmImport

* Split out Editor.subscriptions

* Replace editor.handle.* with editor.* (1 of 2)

* Replace editor.handle.* with editor.* (2 of 2)

* Replace Editor typedef with EditorHandle import

* Pluralize subscription-router and rename subscriptionsRef->subscriptionsRouter and editorRef->editorHandle

* Remove editor.ts

* Update the readme

* Fix demo art loading bug
This commit is contained in:
Keavon Chambers
2026-03-20 23:34:13 -07:00
committed by GitHub
parent 64fd12a1a0
commit ed7987c881
40 changed files with 549 additions and 584 deletions
+6 -6
View File
@@ -1,4 +1,4 @@
import type { Editor } from "@graphite/editor";
import type { EditorHandle } from "@graphite/../wasm/pkg/graphite_wasm";
import { extractPixelData } from "@graphite/utility-functions/rasterization";
import { stripIndents } from "@graphite/utility-functions/strip-indents";
@@ -83,7 +83,7 @@ export function insertAtCaret(text: string) {
element.dispatchEvent(new Event("input", { bubbles: true }));
}
export async function triggerClipboardRead(editor: Editor) {
export async function triggerClipboardRead(editor: EditorHandle) {
// 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 {
@@ -105,7 +105,7 @@ export async function triggerClipboardRead(editor: Editor) {
const blob = await item.getType("text/plain");
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") editor.handle.pasteText(reader.result);
if (typeof reader.result === "string") editor.pasteText(reader.result);
};
reader.readAsText(blob);
return true;
@@ -119,7 +119,7 @@ export async function triggerClipboardRead(editor: Editor) {
const blob = await item.getType("text/plain");
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") editor.handle.pasteSvg(undefined, reader.result);
if (typeof reader.result === "string") editor.pasteSvg(undefined, reader.result);
};
reader.readAsText(blob);
return true;
@@ -132,7 +132,7 @@ export async function triggerClipboardRead(editor: Editor) {
reader.onload = async () => {
if (reader.result instanceof ArrayBuffer) {
const imageData = await extractPixelData(new Blob([reader.result], { type: imageType }));
editor.handle.pasteImage(undefined, new Uint8Array(imageData.data), imageData.width, imageData.height);
editor.pasteImage(undefined, new Uint8Array(imageData.data), imageData.width, imageData.height);
}
};
reader.readAsArrayBuffer(blob);
@@ -170,6 +170,6 @@ export async function triggerClipboardRead(editor: Editor) {
};
const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err);
editor.handle.errorDialog("Cannot access clipboard", message);
editor.errorDialog("Cannot access clipboard", message);
}
}
+6 -6
View File
@@ -1,4 +1,4 @@
import type { Editor } from "@graphite/editor";
import type { EditorHandle } from "@graphite/../wasm/pkg/graphite_wasm";
import { extractPixelData } from "@graphite/utility-functions/rasterization";
export function downloadFileURL(filename: string, url: string) {
@@ -66,18 +66,18 @@ 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: Editor, mouse?: [number, number], insertParentId?: bigint, insertIndex?: number) {
export async function pasteFile(item: DataTransferItem, editor: EditorHandle, mouse?: [number, number], insertParentId?: bigint, insertIndex?: number) {
const file = item.getAsFile();
if (!file) return;
if (file.type.startsWith("image/svg")) {
const svg = await file.text();
editor.handle.pasteSvg(file.name, svg, mouse?.[0], mouse?.[1], insertParentId, insertIndex);
editor.pasteSvg(file.name, svg, mouse?.[0], mouse?.[1], insertParentId, insertIndex);
} else if (file.type.startsWith("image/")) {
const imageData = await extractPixelData(file);
editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, mouse?.[0], mouse?.[1], insertParentId, insertIndex);
} else if (file.name.endsWith("." + editor.handle.fileExtension())) {
editor.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, mouse?.[0], mouse?.[1], insertParentId, insertIndex);
} else if (file.name.endsWith("." + editor.fileExtension())) {
// TODO: When we eventually have sub-documents, this should be changed to import the document as a node instead of opening it in a separate tab
editor.handle.openFile(file.name, await file.bytes());
editor.openFile(file.name, await file.bytes());
}
}
+25 -25
View File
@@ -1,7 +1,7 @@
import { get } from "svelte/store";
import { isPlatformNative } from "@graphite/../wasm/pkg/graphite_wasm";
import type { Editor } from "@graphite/editor";
import type { EditorHandle } from "@graphite/../wasm/pkg/graphite_wasm";
import type { DialogStore } from "@graphite/stores/dialog";
import type { DocumentStore } from "@graphite/stores/document";
import { toggleFullscreen } from "@graphite/stores/fullscreen";
@@ -79,7 +79,7 @@ export async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent, dia
return true;
}
export async function onKeyDown(e: KeyboardEvent, editor: Editor, dialogStore: DialogStore) {
export async function onKeyDown(e: KeyboardEvent, editor: EditorHandle, 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"];
@@ -88,29 +88,29 @@ export async function onKeyDown(e: KeyboardEvent, editor: Editor, dialogStore: D
if (await shouldRedirectKeyboardEventToBackend(e, dialogStore)) {
e.preventDefault();
const modifiers = makeKeyboardModifiersBitfield(e);
editor.handle.onKeyDown(key, modifiers, e.repeat);
editor.onKeyDown(key, modifiers, e.repeat);
return;
}
if (get(dialogStore).visible && key === "Escape") {
editor.handle.onDialogDismiss();
editor.onDialogDismiss();
}
}
export async function onKeyUp(e: KeyboardEvent, editor: Editor, dialogStore: DialogStore) {
export async function onKeyUp(e: KeyboardEvent, editor: EditorHandle, dialogStore: DialogStore) {
const key = await getLocalizedScanCode(e);
if (await shouldRedirectKeyboardEventToBackend(e, dialogStore)) {
e.preventDefault();
const modifiers = makeKeyboardModifiersBitfield(e);
editor.handle.onKeyUp(key, modifiers, e.repeat);
editor.onKeyUp(key, modifiers, e.repeat);
}
}
// Pointer events
// While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend
export function onPointerMove(e: PointerEvent, editor: Editor, documentStore: DocumentStore) {
export function onPointerMove(e: PointerEvent, editor: EditorHandle, documentStore: DocumentStore) {
potentiallyRestoreCanvasFocus(e);
if (!e.buttons) viewportPointerInteractionOngoing = false;
@@ -124,11 +124,11 @@ export function onPointerMove(e: PointerEvent, editor: Editor, documentStore: Do
if (!viewportPointerInteractionOngoing && (inFloatingMenu || inGraphOverlay)) return;
const modifiers = makeKeyboardModifiersBitfield(e);
if (detectShake(e)) editor.handle.onMouseShake(e.clientX, e.clientY, e.buttons, modifiers);
editor.handle.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers);
if (detectShake(e)) editor.onMouseShake(e.clientX, e.clientY, e.buttons, modifiers);
editor.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers);
}
export function onPointerDown(e: PointerEvent, editor: Editor, dialogStore: DialogStore) {
export function onPointerDown(e: PointerEvent, editor: EditorHandle, dialogStore: DialogStore) {
potentiallyRestoreCanvasFocus(e);
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
@@ -138,7 +138,7 @@ export function onPointerDown(e: PointerEvent, editor: Editor, dialogStore: Dial
const inTextInput = e.target === textToolInteractiveInputElement;
if (get(dialogStore).visible && !inDialog) {
editor.handle.onDialogDismiss();
editor.onDialogDismiss();
e.preventDefault();
e.stopPropagation();
}
@@ -146,7 +146,7 @@ export function onPointerDown(e: PointerEvent, editor: Editor, dialogStore: Dial
if (!inTextInput && !inContextMenu) {
if (textToolInteractiveInputElement) {
const isLeftOrRightClick = e.button === BUTTON_RIGHT || e.button === BUTTON_LEFT;
editor.handle.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText), isLeftOrRightClick);
editor.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText), isLeftOrRightClick);
} else {
viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element;
}
@@ -154,11 +154,11 @@ export function onPointerDown(e: PointerEvent, editor: Editor, dialogStore: Dial
if (viewportPointerInteractionOngoing && isTargetingCanvas instanceof Element) {
const modifiers = makeKeyboardModifiersBitfield(e);
editor.handle.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers);
editor.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers);
}
}
export function onPointerUp(e: PointerEvent, editor: Editor) {
export function onPointerUp(e: PointerEvent, editor: EditorHandle) {
potentiallyRestoreCanvasFocus(e);
// Don't let the browser navigate back or forward when using the buttons on some mice
@@ -172,12 +172,12 @@ export function onPointerUp(e: PointerEvent, editor: Editor) {
if (textToolInteractiveInputElement) return;
const modifiers = makeKeyboardModifiersBitfield(e);
editor.handle.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers);
editor.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers);
}
// Mouse events
export function onPotentialDoubleClick(e: MouseEvent, editor: Editor) {
export function onPotentialDoubleClick(e: MouseEvent, editor: EditorHandle) {
if (textToolInteractiveInputElement || inPointerLock) return;
// Allow only events within the viewport or node graph boundaries
@@ -196,7 +196,7 @@ export function onPotentialDoubleClick(e: MouseEvent, editor: Editor) {
if (e.button === BUTTON_FORWARD) buttons = 16; // Forward
const modifiers = makeKeyboardModifiersBitfield(e);
editor.handle.onDoubleClick(e.clientX, e.clientY, buttons, modifiers);
editor.onDoubleClick(e.clientX, e.clientY, buttons, modifiers);
}
export function onMouseDown(e: MouseEvent) {
@@ -216,7 +216,7 @@ export function onPointerLockChange() {
// Wheel events
export function onWheelScroll(e: WheelEvent, editor: Editor) {
export function onWheelScroll(e: WheelEvent, editor: EditorHandle) {
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
@@ -235,7 +235,7 @@ export function onWheelScroll(e: WheelEvent, editor: Editor) {
if (isTargetingCanvas) {
e.preventDefault();
const modifiers = makeKeyboardModifiersBitfield(e);
editor.handle.onWheelScroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
editor.onWheelScroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
}
}
@@ -247,15 +247,15 @@ export function onModifyInputField(e: CustomEvent) {
// Window events
export async function onBeforeUnload(e: BeforeUnloadEvent, editor: Editor, portfolioStore: PortfolioStore) {
export async function onBeforeUnload(e: BeforeUnloadEvent, editor: EditorHandle, portfolioStore: PortfolioStore) {
const activeDocument = get(portfolioStore).documents[get(portfolioStore).activeDocumentIndex];
if (activeDocument && !activeDocument.details.isAutoSaved) editor.handle.triggerAutoSave(activeDocument.id);
if (activeDocument && !activeDocument.details.isAutoSaved) editor.triggerAutoSave(activeDocument.id);
// Skip the message if the editor crashed, since work is already lost
if (await editor.handle.hasCrashed()) return;
if (await editor.hasCrashed()) return;
// Skip the message during development, since it's annoying when testing
if (await editor.handle.inDevelopmentMode()) return;
if (await editor.inDevelopmentMode()) return;
const allDocumentsSaved = get(portfolioStore).documents.reduce((acc, doc) => acc && doc.details.isSaved, true);
if (!allDocumentsSaved) {
@@ -264,13 +264,13 @@ export async function onBeforeUnload(e: BeforeUnloadEvent, editor: Editor, portf
}
}
export function onPaste(e: ClipboardEvent, editor: Editor) {
export function onPaste(e: ClipboardEvent, editor: EditorHandle) {
const dataTransfer = e.clipboardData;
if (!dataTransfer || targetIsTextField(e.target || undefined)) return;
e.preventDefault();
Array.from(dataTransfer.items).forEach(async (item) => {
if (item.type === "text/plain") item.getAsString((text) => editor.handle.pasteText(text));
if (item.type === "text/plain") item.getAsString((text) => editor.pasteText(text));
await pasteFile(item, editor);
});
}
+22
View File
@@ -1,3 +1,5 @@
import type { EditorHandle } from "@graphite/../wasm/pkg/graphite_wasm";
export type RequestResult = { body: string; status: number };
// Special implementation using the legacy XMLHttpRequest API that provides callbacks to get:
@@ -31,3 +33,23 @@ export function requestWithUploadDownloadProgress(
return [promise, xhrValue];
}
// 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) {
const demoArtwork = window.location.hash.trim().match(/#demo\/(.*)/)?.[1];
if (!demoArtwork) return;
try {
const url = new URL(`/demo-artwork/${demoArtwork}.${editor.fileExtension()}`, document.location.href);
const response = await fetch(url);
if (!response.ok) throw new Error();
const filename = url.pathname.split("/").pop() || `Untitled.${editor.fileExtension()}`;
const content = await response.bytes();
editor.openFile(filename, content);
history.replaceState("", "", `${window.location.pathname}${window.location.search}`);
} catch {
// Do nothing
}
}
+15 -15
View File
@@ -1,9 +1,9 @@
import * as idb from "idb-keyval";
import { get } from "svelte/store";
import type { Editor } from "@graphite/editor";
import type { EditorHandle } from "@graphite/../wasm/pkg/graphite_wasm";
import type { PortfolioStore } from "@graphite/stores/portfolio";
import type { MessageBody } from "@graphite/subscription-router";
import type { MessageBody } from "/src/subscriptions-router";
export async function storeCurrentDocumentId(documentId: string) {
const indexedDbStorage = idb.createStore("graphite", "store");
@@ -65,7 +65,7 @@ export async function removeDocument(id: string, portfolio: PortfolioStore) {
}
}
export async function loadFirstDocument(editor: Editor) {
export async function loadFirstDocument(editor: EditorHandle) {
const indexedDbStorage = idb.createStore("graphite", "store");
const previouslySavedDocuments = await idb.get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", indexedDbStorage);
@@ -87,19 +87,19 @@ export async function loadFirstDocument(editor: Editor) {
if (currentDocumentId !== undefined && String(currentDocumentId) in previouslySavedDocuments) {
const doc = previouslySavedDocuments[String(currentDocumentId)];
editor.handle.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
editor.handle.selectDocument(currentDocumentId);
editor.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
editor.selectDocument(currentDocumentId);
} else {
const len = orderedSavedDocuments.length;
if (len > 0) {
const doc = orderedSavedDocuments[len - 1];
editor.handle.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
editor.handle.selectDocument(doc.documentId);
editor.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
editor.selectDocument(doc.documentId);
}
}
}
export async function loadRestDocuments(editor: Editor) {
export async function loadRestDocuments(editor: EditorHandle) {
const indexedDbStorage = idb.createStore("graphite", "store");
const previouslySavedDocuments = await idb.get<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", indexedDbStorage);
@@ -126,15 +126,15 @@ export async function loadRestDocuments(editor: Editor) {
for (let i = currentIndex - 1; i >= 0; i--) {
const { documentId, document, details } = orderedSavedDocuments[i];
const { name, isSaved } = details;
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, true);
editor.openAutoSavedDocument(documentId, name, isSaved, document, true);
}
for (let i = currentIndex + 1; i < orderedSavedDocuments.length; i++) {
const { documentId, document, details } = orderedSavedDocuments[i];
const { name, isSaved } = details;
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, false);
editor.openAutoSavedDocument(documentId, name, isSaved, document, false);
}
editor.handle.selectDocument(currentDocumentId);
editor.selectDocument(currentDocumentId);
}
// No valid current document: open all remaining documents and select the last one
else {
@@ -143,10 +143,10 @@ export async function loadRestDocuments(editor: Editor) {
for (let i = length - 2; i >= 0; i--) {
const { documentId, document, details } = orderedSavedDocuments[i];
const { name, isSaved } = details;
editor.handle.openAutoSavedDocument(documentId, name, isSaved, document, true);
editor.openAutoSavedDocument(documentId, name, isSaved, document, true);
}
if (length > 0) editor.handle.selectDocument(orderedSavedDocuments[length - 1].documentId);
if (length > 0) editor.selectDocument(orderedSavedDocuments[length - 1].documentId);
}
}
@@ -177,11 +177,11 @@ export async function saveEditorPreferences(preferences: unknown) {
await idb.set("preferences", preferences, indexedDbStorage);
}
export async function loadEditorPreferences(editor: Editor) {
export async function loadEditorPreferences(editor: EditorHandle) {
const indexedDbStorage = idb.createStore("graphite", "store");
const preferences = await idb.get<Record<string, unknown>>("preferences", indexedDbStorage);
editor.handle.loadPreferences(preferences ? JSON.stringify(preferences) : undefined);
editor.loadPreferences(preferences ? JSON.stringify(preferences) : undefined);
}
export async function wipeDocuments() {
+3 -3
View File
@@ -1,6 +1,6 @@
import type { Editor } from "@graphite/editor";
import type { EditorHandle } from "@graphite/../wasm/pkg/graphite_wasm";
export function setupViewportResizeObserver(editor: Editor): () => void {
export function setupViewportResizeObserver(editor: EditorHandle): () => void {
const viewports = Array.from(window.document.querySelectorAll("[data-viewport-container]"));
if (viewports.length <= 0) return () => {};
@@ -40,7 +40,7 @@ export function setupViewportResizeObserver(editor: Editor): () => void {
continue;
}
editor.handle.updateViewport(bounds.x, bounds.y, logicalWidth, logicalHeight, scale);
editor.updateViewport(bounds.x, bounds.y, logicalWidth, logicalHeight, scale);
}
});