364 lines
15 KiB
TypeScript
364 lines
15 KiB
TypeScript
import { type DialogState } from "@/state-providers/dialog";
|
|
import { type FullscreenState } from "@/state-providers/fullscreen";
|
|
import { type PortfolioState } from "@/state-providers/portfolio";
|
|
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "@/utility-functions/keyboard-entry";
|
|
import { platformIsMac } from "@/utility-functions/platform";
|
|
import { stripIndents } from "@/utility-functions/strip-indents";
|
|
import { type Editor } from "@/wasm-communication/editor";
|
|
import { TriggerPaste } from "@/wasm-communication/messages";
|
|
|
|
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 {
|
|
const app = window.document.querySelector("[data-app]") as HTMLElement | undefined;
|
|
app?.focus();
|
|
|
|
let viewportPointerInteractionOngoing = false;
|
|
let textInput = undefined as undefined | HTMLDivElement;
|
|
let canvasFocused = true;
|
|
|
|
function blurApp(): void {
|
|
canvasFocused = false;
|
|
}
|
|
|
|
// Event listeners
|
|
|
|
// 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): Promise<void> => onKeyUp(e) },
|
|
{ target: window, eventName: "keydown", action: (e: KeyboardEvent): Promise<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 => onWheelScroll(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) },
|
|
{
|
|
target: app as EventListenerTarget,
|
|
eventName: "blur",
|
|
action: (): void => blurApp(),
|
|
},
|
|
];
|
|
|
|
// 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));
|
|
}
|
|
|
|
// Keyboard events
|
|
|
|
async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): Promise<boolean> {
|
|
// Don't redirect when a modal is covering the workspace
|
|
if (dialog.dialogIsVisible()) return false;
|
|
|
|
const key = await getLocalizedScanCode(e);
|
|
|
|
// TODO: Switch to a system where everything is sent to the backend, then the input preprocessor makes decisions and kicks some inputs back to the frontend
|
|
const accelKey = platformIsMac() ? e.metaKey : e.ctrlKey;
|
|
|
|
// Don't redirect user input from text entry into HTML elements
|
|
if (targetIsTextField(e.target || undefined) && key !== "Escape" && !(key === "Enter" && accelKey)) return false;
|
|
|
|
// Don't redirect paste
|
|
if (key === "KeyV" && accelKey) 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;
|
|
if (key === "KeyR" && accelKey) return false;
|
|
|
|
// Don't redirect debugging tools
|
|
if (["F12", "F8"].includes(key)) return false;
|
|
if (["KeyC", "KeyI", "KeyJ"].includes(key) && accelKey && e.shiftKey) return false;
|
|
|
|
// Don't redirect tab or enter if not in canvas (to allow navigating elements)
|
|
if (!canvasFocused && !targetIsTextField(e.target || undefined) && ["Tab", "Enter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key)) return false;
|
|
|
|
// Redirect to the backend
|
|
return true;
|
|
}
|
|
|
|
async function onKeyDown(e: KeyboardEvent): Promise<void> {
|
|
const key = await getLocalizedScanCode(e);
|
|
|
|
if (await shouldRedirectKeyboardEventToBackend(e)) {
|
|
e.preventDefault();
|
|
const modifiers = makeKeyboardModifiersBitfield(e);
|
|
editor.instance.onKeyDown(key, modifiers);
|
|
return;
|
|
}
|
|
|
|
if (dialog.dialogIsVisible() && key === "Escape") {
|
|
dialog.dismissDialog();
|
|
}
|
|
}
|
|
|
|
async function onKeyUp(e: KeyboardEvent): Promise<void> {
|
|
const key = await getLocalizedScanCode(e);
|
|
|
|
if (await shouldRedirectKeyboardEventToBackend(e)) {
|
|
e.preventDefault();
|
|
const modifiers = makeKeyboardModifiersBitfield(e);
|
|
editor.instance.onKeyUp(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 { target } = e;
|
|
const newInCanvas = (target instanceof Element && target.closest("[data-canvas]")) instanceof Element && !targetIsTextField(window.document.activeElement || undefined);
|
|
if (newInCanvas && !canvasFocused) {
|
|
canvasFocused = true;
|
|
app?.focus();
|
|
}
|
|
|
|
const modifiers = makeKeyboardModifiersBitfield(e);
|
|
editor.instance.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers);
|
|
}
|
|
|
|
function onPointerDown(e: PointerEvent): void {
|
|
const { target } = e;
|
|
const isTargetingCanvas = 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.onChangeText(textInputCleanup(textInput.innerText));
|
|
else viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element;
|
|
}
|
|
|
|
if (viewportPointerInteractionOngoing) {
|
|
const modifiers = makeKeyboardModifiersBitfield(e);
|
|
editor.instance.onMouseDown(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.onMouseUp(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.onDoubleClick(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 onWheelScroll(e: WheelEvent): void {
|
|
const { target } = e;
|
|
const isTargetingCanvas = 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 (isTargetingCanvas) {
|
|
e.preventDefault();
|
|
const modifiers = makeKeyboardModifiersBitfield(e);
|
|
editor.instance.onWheelScroll(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.boundsOfViewports(data);
|
|
}
|
|
|
|
function onBeforeUnload(e: BeforeUnloadEvent): void {
|
|
const activeDocument = document.state.documents[document.state.activeDocumentIndex];
|
|
if (!activeDocument.isSaved) editor.instance.triggerAutoSave(activeDocument.id);
|
|
|
|
// Skip the message if the editor crashed, since work is already lost
|
|
if (editor.instance.hasCrashed()) return;
|
|
|
|
// Skip the message during development, since it's annoying when testing
|
|
if (editor.instance.inDevelopmentMode()) return;
|
|
|
|
const allDocumentsSaved = document.state.documents.reduce((acc, doc) => acc && doc.isSaved, true);
|
|
if (!allDocumentsSaved) {
|
|
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
function onPaste(e: ClipboardEvent): void {
|
|
const dataTransfer = e.clipboardData;
|
|
if (!dataTransfer || targetIsTextField(e.target || undefined)) return;
|
|
e.preventDefault();
|
|
|
|
Array.from(dataTransfer.items).forEach((item) => {
|
|
if (item.type === "text/plain") {
|
|
item.getAsString((text) => {
|
|
if (text.startsWith("graphite/layer: ")) {
|
|
editor.instance.pasteSerializedData(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.pasteImage(file.type, u8Array);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Frontend message subscriptions
|
|
|
|
editor.subscriptions.subscribeJsMessage(TriggerPaste, async () => {
|
|
// In the try block, attempt to read from the Clipboard API, which may not have permission and may not be supported in all browsers
|
|
// In the catch block, explain to the user why the paste failed and how to fix or work around the problem
|
|
try {
|
|
// Attempt to check if the clipboard permission is denied, and throw an error if that is the case
|
|
// In Firefox, the `clipboard-read` permission isn't supported, so attempting to query it throws an error
|
|
// In Safari, the entire Permissions API isn't supported, so the query never occurs and this block is skipped without an error and we assume we might have permission
|
|
const clipboardRead = "clipboard-read" as PermissionName;
|
|
const permission = await navigator.permissions?.query({ name: clipboardRead });
|
|
if (permission?.state === "denied") throw new Error("Permission denied");
|
|
|
|
// Read the clipboard contents if the Clipboard API is available
|
|
const clipboardItems = await navigator.clipboard.read();
|
|
if (!clipboardItems) throw new Error("Clipboard API unsupported");
|
|
|
|
// Read any layer data or images from the clipboard
|
|
Array.from(clipboardItems).forEach(async (item) => {
|
|
// Read plain text and, if it is a layer, pass it to the editor
|
|
if (item.types.includes("text/plain")) {
|
|
const blob = await item.getType("text/plain");
|
|
const reader = new FileReader();
|
|
reader.onload = (): void => {
|
|
const text = reader.result as string;
|
|
|
|
if (text.startsWith("graphite/layer: ")) {
|
|
editor.instance.pasteSerializedData(text.substring(16, text.length));
|
|
}
|
|
};
|
|
reader.readAsText(blob);
|
|
}
|
|
|
|
// Read an image from the clipboard and pass it to the editor to be loaded
|
|
const imageType = item.types.find((type) => type.startsWith("image/"));
|
|
if (imageType) {
|
|
const blob = await item.getType(imageType);
|
|
const reader = new FileReader();
|
|
reader.onload = (): void => {
|
|
const u8Array = new Uint8Array(reader.result as ArrayBuffer);
|
|
|
|
editor.instance.pasteImage(imageType, u8Array);
|
|
};
|
|
reader.readAsArrayBuffer(blob);
|
|
}
|
|
});
|
|
} catch (err) {
|
|
const unsupported = stripIndents`
|
|
This browser does not support reading from the clipboard.
|
|
Use the keyboard shortcut to paste instead.
|
|
`;
|
|
const denied = stripIndents`
|
|
The browser's clipboard permission has been denied.
|
|
|
|
Open the browser's website settings (usually accessible
|
|
just left of the URL) to allow this permission.
|
|
`;
|
|
|
|
const matchMessage = {
|
|
"clipboard-read": unsupported,
|
|
"Clipboard API unsupported": unsupported,
|
|
"Permission denied": denied,
|
|
};
|
|
const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err);
|
|
|
|
editor.instance.errorDialog("Cannot access clipboard", message);
|
|
}
|
|
});
|
|
|
|
// Initialization
|
|
|
|
// Bind the event listeners
|
|
bindListeners();
|
|
// Resize on creation
|
|
onWindowResize(container);
|
|
|
|
// Return the destructor
|
|
return unbindListeners;
|
|
}
|
|
|
|
function targetIsTextField(target: EventTarget | HTMLElement | undefined): boolean {
|
|
return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable);
|
|
}
|