Graphite/frontend/src/io-managers/input.ts

274 lines
11 KiB
TypeScript

import { DialogState } from "@/state-providers/dialog";
import { FullscreenState } from "@/state-providers/fullscreen";
import { PortfolioState } from "@/state-providers/portfolio";
import { makeKeyboardModifiersBitfield, textInputCleanup, getLatinKey } from "@/utility-functions/keyboard-entry";
import { Editor } from "@/wasm-communication/editor";
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
type EventListenerTarget = {
addEventListener: typeof window.addEventListener;
removeEventListener: typeof window.removeEventListener;
};
export function createInputManager(editor: Editor, container: HTMLElement, dialog: DialogState, document: PortfolioState, fullscreen: FullscreenState): () => void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: boolean | AddEventListenerOptions }[] = [
{ target: window, eventName: "resize", action: (): void => onWindowResize(container) },
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent): void => onBeforeUnload(e) },
{ target: window.document, eventName: "contextmenu", action: (e: MouseEvent): void => e.preventDefault() },
{ target: window.document, eventName: "fullscreenchange", action: (): void => fullscreen.fullscreenModeChanged() },
{ target: window, eventName: "keyup", action: (e: KeyboardEvent): void => onKeyUp(e) },
{ target: window, eventName: "keydown", action: (e: KeyboardEvent): void => onKeyDown(e) },
{ target: window, eventName: "pointermove", action: (e: PointerEvent): void => onPointerMove(e) },
{ target: window, eventName: "pointerdown", action: (e: PointerEvent): void => onPointerDown(e) },
{ target: window, eventName: "pointerup", action: (e: PointerEvent): void => onPointerUp(e) },
{ target: window, eventName: "dblclick", action: (e: PointerEvent): void => onDoubleClick(e) },
{ target: window, eventName: "mousedown", action: (e: MouseEvent): void => onMouseDown(e) },
{ target: window, eventName: "wheel", action: (e: WheelEvent): void => onMouseScroll(e), options: { passive: false } },
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent): void => onModifyInputField(e) },
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent): void => onPaste(e) },
];
let viewportPointerInteractionOngoing = false;
let textInput = undefined as undefined | HTMLDivElement;
// Keyboard events
function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): boolean {
// Don't redirect when a modal is covering the workspace
if (dialog.dialogIsVisible()) return false;
const key = getLatinKey(e);
if (!key) return false;
// Don't redirect user input from text entry into HTML elements
if (key !== "escape" && !(e.ctrlKey && key === "enter") && targetIsTextField(e.target)) {
return false;
}
// Don't redirect paste
if (key === "v" && e.ctrlKey) return false;
// Don't redirect a fullscreen request
if (key === "f11" && e.type === "keydown" && !e.repeat) {
e.preventDefault();
fullscreen.toggleFullscreen();
return false;
}
// Don't redirect a reload request
if (key === "f5") return false;
// Don't redirect debugging tools
if (key === "f12" || key === "f8") return false;
if (e.ctrlKey && e.shiftKey && key === "c") return false;
if (e.ctrlKey && e.shiftKey && key === "i") return false;
if (e.ctrlKey && e.shiftKey && key === "j") return false;
// Don't redirect tab or enter if not in canvas (to allow navigating elements)
const inCanvas = e.target instanceof Element && e.target.closest("[data-canvas]");
if (!inCanvas && (key === "tab" || key === "enter")) return false;
// Redirect to the backend
return true;
}
function onKeyDown(e: KeyboardEvent): void {
const key = getLatinKey(e);
if (!key) return;
if (shouldRedirectKeyboardEventToBackend(e)) {
e.preventDefault();
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_key_down(key, modifiers);
return;
}
if (dialog.dialogIsVisible()) {
if (key === "escape") dialog.dismissDialog();
}
}
function onKeyUp(e: KeyboardEvent): void {
const key = getLatinKey(e);
if (!key) return;
if (shouldRedirectKeyboardEventToBackend(e)) {
e.preventDefault();
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_key_up(key, modifiers);
}
}
// Pointer events
// While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend
function onPointerMove(e: PointerEvent): void {
if (!e.buttons) viewportPointerInteractionOngoing = false;
// Don't redirect pointer movement to the backend if there's no ongoing interaction and it's over a floating menu on top of the canvas
// TODO: A better approach is to pass along a boolean to the backend's input preprocessor so it can know if it's being occluded by the GUI.
// TODO: This would allow it to properly decide to act on removing hover focus from something that was hovered in the canvas before moving over the GUI.
// TODO: Further explanation: https://github.com/GraphiteEditor/Graphite/pull/623#discussion_r866436197
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
if (!viewportPointerInteractionOngoing && inFloatingMenu) return;
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_mouse_move(e.clientX, e.clientY, e.buttons, modifiers);
}
function onPointerDown(e: PointerEvent): void {
const { target } = e;
const inCanvas = target instanceof Element && target.closest("[data-canvas]");
const inDialog = target instanceof Element && target.closest("[data-dialog-modal] [data-floating-menu-content]");
const inTextInput = target === textInput;
if (dialog.dialogIsVisible() && !inDialog) {
dialog.dismissDialog();
e.preventDefault();
e.stopPropagation();
}
if (!inTextInput) {
if (textInput) editor.instance.on_change_text(textInputCleanup(textInput.innerText));
else if (inCanvas) viewportPointerInteractionOngoing = true;
}
if (viewportPointerInteractionOngoing) {
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_mouse_down(e.clientX, e.clientY, e.buttons, modifiers);
}
}
function onPointerUp(e: PointerEvent): void {
if (!e.buttons) viewportPointerInteractionOngoing = false;
if (!textInput) {
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_mouse_up(e.clientX, e.clientY, e.buttons, modifiers);
}
}
function onDoubleClick(e: PointerEvent): void {
if (!e.buttons) viewportPointerInteractionOngoing = false;
if (!textInput) {
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_double_click(e.clientX, e.clientY, e.buttons, modifiers);
}
}
// Mouse events
function onMouseDown(e: MouseEvent): void {
// Block middle mouse button auto-scroll mode (the circlar widget that appears and allows quick scrolling by moving the cursor above or below it)
// This has to be in `mousedown`, not `pointerdown`, to avoid blocking Vue's middle click detection on HTML elements
if (e.button === 1) e.preventDefault();
}
function onMouseScroll(e: WheelEvent): void {
const { target } = e;
const inCanvas = target instanceof Element && target.closest("[data-canvas]");
// Redirect vertical scroll wheel movement into a horizontal scroll on a horizontally scrollable element
// There seems to be no possible way to properly employ the browser's smooth scrolling interpolation
const horizontalScrollableElement = target instanceof Element && target.closest("[data-scrollable-x]");
if (horizontalScrollableElement && e.deltaY !== 0) {
horizontalScrollableElement.scrollTo(horizontalScrollableElement.scrollLeft + e.deltaY, 0);
return;
}
if (inCanvas) {
e.preventDefault();
const modifiers = makeKeyboardModifiersBitfield(e);
editor.instance.on_mouse_scroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
}
}
function onModifyInputField(e: CustomEvent): void {
textInput = e.detail;
}
// Window events
function onWindowResize(container: HTMLElement): void {
const viewports = Array.from(container.querySelectorAll("[data-canvas]"));
const boundsOfViewports = viewports.map((canvas) => {
const bounds = canvas.getBoundingClientRect();
return [bounds.left, bounds.top, bounds.right, bounds.bottom];
});
const flattened = boundsOfViewports.flat();
const data = Float64Array.from(flattened);
if (boundsOfViewports.length > 0) editor.instance.bounds_of_viewports(data);
}
function onBeforeUnload(e: BeforeUnloadEvent): void {
const activeDocument = document.state.documents[document.state.activeDocumentIndex];
if (!activeDocument.is_saved) editor.instance.trigger_auto_save(activeDocument.id);
// Skip the message if the editor crashed, since work is already lost
if (editor.instance.has_crashed()) return;
// Skip the message during development, since it's annoying when testing
if (process.env.NODE_ENV === "development") return;
const allDocumentsSaved = document.state.documents.reduce((acc, doc) => acc && doc.is_saved, true);
if (!allDocumentsSaved) {
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
e.preventDefault();
}
}
function onPaste(e: ClipboardEvent): void {
const dataTransfer = e.clipboardData;
if (!dataTransfer || targetIsTextField(e.target)) return;
e.preventDefault();
Array.from(dataTransfer.items).forEach((item) => {
if (item.type === "text/plain") {
item.getAsString((text) => {
if (text.startsWith("graphite/layer: ")) {
editor.instance.paste_serialized_data(text.substring(16, text.length));
}
});
}
const file = item.getAsFile();
if (file?.type.startsWith("image")) {
file.arrayBuffer().then((buffer): void => {
const u8Array = new Uint8Array(buffer);
editor.instance.paste_image(file.type, u8Array, undefined, undefined);
});
}
});
}
function targetIsTextField(target: EventTarget | null): boolean {
return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable);
}
// Event bindings
function bindListeners(): void {
// Add event bindings for the lifetime of the application
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options));
}
function unbindListeners(): void {
// Remove event bindings after the lifetime of the application (or on hot-module replacement during development)
listeners.forEach(({ target, eventName, action, options }) => target.removeEventListener(eventName, action, options));
}
// Initialization
// Bind the event listeners
bindListeners();
// Resize on creation
onWindowResize(container);
// Return the destructor
return unbindListeners;
}