|
import { get } from "svelte/store"; |
|
|
|
import { type Editor } from "@graphite/editor"; |
|
import { TriggerPaste } from "@graphite/messages"; |
|
import { type DialogState } from "@graphite/state-providers/dialog"; |
|
import { type DocumentState } from "@graphite/state-providers/document"; |
|
import { type FullscreenState } from "@graphite/state-providers/fullscreen"; |
|
import { type PortfolioState } from "@graphite/state-providers/portfolio"; |
|
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "@graphite/utility-functions/keyboard-entry"; |
|
import { platformIsMac } from "@graphite/utility-functions/platform"; |
|
import { extractPixelData } from "@graphite/utility-functions/rasterization"; |
|
import { stripIndents } from "@graphite/utility-functions/strip-indents"; |
|
import { updateBoundsOfViewports } from "@graphite/utility-functions/viewports"; |
|
|
|
const BUTTON_LEFT = 0; |
|
const BUTTON_MIDDLE = 1; |
|
const BUTTON_RIGHT = 2; |
|
const BUTTON_BACK = 3; |
|
const BUTTON_FORWARD = 4; |
|
|
|
export const PRESS_REPEAT_DELAY_MS = 400; |
|
export const PRESS_REPEAT_INTERVAL_MS = 72; |
|
export const PRESS_REPEAT_INTERVAL_RAPID_MS = 10; |
|
|
|
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield" | "pointerlockchange" | "pointerlockerror"; |
|
type EventListenerTarget = { |
|
addEventListener: typeof window.addEventListener; |
|
removeEventListener: typeof window.removeEventListener; |
|
}; |
|
|
|
export function createInputManager(editor: Editor, dialog: DialogState, portfolio: PortfolioState, document: DocumentState, fullscreen: FullscreenState): () => void { |
|
const app = window.document.querySelector("[data-app-container]") as HTMLElement | undefined; |
|
app?.focus(); |
|
|
|
let viewportPointerInteractionOngoing = false; |
|
let textToolInteractiveInputElement = undefined as undefined | HTMLDivElement; |
|
let canvasFocused = true; |
|
let inPointerLock = false; |
|
|
|
|
|
|
|
|
|
const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: AddEventListenerOptions }[] = [ |
|
{ target: window, eventName: "resize", action: () => updateBoundsOfViewports(editor, window.document.body) }, |
|
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent) => onBeforeUnload(e) }, |
|
{ target: window, eventName: "keyup", action: (e: KeyboardEvent) => onKeyUp(e) }, |
|
{ target: window, eventName: "keydown", action: (e: KeyboardEvent) => onKeyDown(e) }, |
|
{ target: window, eventName: "pointermove", action: (e: PointerEvent) => onPointerMove(e) }, |
|
{ target: window, eventName: "pointerdown", action: (e: PointerEvent) => onPointerDown(e) }, |
|
{ target: window, eventName: "pointerup", action: (e: PointerEvent) => onPointerUp(e) }, |
|
{ target: window, eventName: "mousedown", action: (e: MouseEvent) => onMouseDown(e) }, |
|
{ target: window, eventName: "mouseup", action: (e: MouseEvent) => onPotentialDoubleClick(e) }, |
|
{ target: window, eventName: "wheel", action: (e: WheelEvent) => onWheelScroll(e), options: { passive: false } }, |
|
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent) => onModifyInputField(e) }, |
|
{ target: window, eventName: "focusout", action: () => (canvasFocused = false) }, |
|
{ target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => onContextMenu(e) }, |
|
{ target: window.document, eventName: "fullscreenchange", action: () => fullscreen.fullscreenModeChanged() }, |
|
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => onPaste(e) }, |
|
{ target: window.document, eventName: "pointerlockchange", action: onPointerLockChange }, |
|
{ target: window.document, eventName: "pointerlockerror", action: onPointerLockChange }, |
|
]; |
|
|
|
|
|
|
|
function bindListeners() { |
|
|
|
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options)); |
|
} |
|
function unbindListeners() { |
|
|
|
listeners.forEach(({ target, eventName, action, options }) => target.removeEventListener(eventName, action, options)); |
|
} |
|
|
|
|
|
|
|
async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): Promise<boolean> { |
|
|
|
if (get(dialog).visible) return false; |
|
|
|
const key = await getLocalizedScanCode(e); |
|
|
|
|
|
const accelKey = platformIsMac() ? e.metaKey : e.ctrlKey; |
|
|
|
|
|
if (targetIsTextField(e.target || undefined) && key !== "Escape" && !(accelKey && ["Enter", "NumpadEnter"].includes(key))) return false; |
|
|
|
|
|
if (key === "KeyV" && accelKey) return false; |
|
|
|
|
|
if (key === "F11" && e.type === "keydown" && !e.repeat) { |
|
e.preventDefault(); |
|
fullscreen.toggleFullscreen(); |
|
return false; |
|
} |
|
|
|
|
|
if (key === "F5") return false; |
|
if (key === "KeyR" && accelKey) return false; |
|
|
|
|
|
if (["F12", "F8"].includes(key)) return false; |
|
if (["KeyC", "KeyI", "KeyJ"].includes(key) && accelKey && e.shiftKey) return false; |
|
|
|
|
|
potentiallyRestoreCanvasFocus(e); |
|
if (!canvasFocused && !targetIsTextField(e.target || undefined) && ["Tab", "Enter", "NumpadEnter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key)) return false; |
|
|
|
|
|
if (window.document.querySelector("[data-floating-menu-content]")) return false; |
|
|
|
|
|
return true; |
|
} |
|
|
|
async function onKeyDown(e: KeyboardEvent) { |
|
const key = await getLocalizedScanCode(e); |
|
|
|
const NO_KEY_REPEAT_MODIFIER_KEYS = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "MetaLeft", "MetaRight", "AltLeft", "AltRight", "AltGraph", "CapsLock", "Fn", "FnLock"]; |
|
if (e.repeat && NO_KEY_REPEAT_MODIFIER_KEYS.includes(key)) return; |
|
|
|
if (await shouldRedirectKeyboardEventToBackend(e)) { |
|
e.preventDefault(); |
|
const modifiers = makeKeyboardModifiersBitfield(e); |
|
editor.handle.onKeyDown(key, modifiers, e.repeat); |
|
return; |
|
} |
|
|
|
if (get(dialog).visible && key === "Escape") { |
|
dialog.dismissDialog(); |
|
} |
|
} |
|
|
|
async function onKeyUp(e: KeyboardEvent) { |
|
const key = await getLocalizedScanCode(e); |
|
|
|
if (await shouldRedirectKeyboardEventToBackend(e)) { |
|
e.preventDefault(); |
|
const modifiers = makeKeyboardModifiersBitfield(e); |
|
editor.handle.onKeyUp(key, modifiers, e.repeat); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
function onPointerMove(e: PointerEvent) { |
|
potentiallyRestoreCanvasFocus(e); |
|
|
|
if (!e.buttons) viewportPointerInteractionOngoing = false; |
|
|
|
|
|
|
|
|
|
|
|
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]"); |
|
const inGraphOverlay = get(document).graphViewOverlayOpen; |
|
if (!viewportPointerInteractionOngoing && (inFloatingMenu || inGraphOverlay)) return; |
|
|
|
const modifiers = makeKeyboardModifiersBitfield(e); |
|
editor.handle.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers); |
|
} |
|
|
|
function onPointerDown(e: PointerEvent) { |
|
potentiallyRestoreCanvasFocus(e); |
|
|
|
const { target } = e; |
|
const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-node-graph]"); |
|
const inDialog = target instanceof Element && target.closest("[data-dialog] [data-floating-menu-content]"); |
|
const inContextMenu = target instanceof Element && target.closest("[data-context-menu]"); |
|
const inTextInput = target === textToolInteractiveInputElement; |
|
|
|
if (get(dialog).visible && !inDialog) { |
|
dialog.dismissDialog(); |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
} |
|
|
|
if (!inTextInput && !inContextMenu) { |
|
if (textToolInteractiveInputElement) { |
|
const isLeftOrRightClick = e.button === BUTTON_RIGHT || e.button === BUTTON_LEFT; |
|
editor.handle.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText), isLeftOrRightClick); |
|
} else { |
|
viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element; |
|
} |
|
} |
|
|
|
if (viewportPointerInteractionOngoing && isTargetingCanvas instanceof Element) { |
|
const modifiers = makeKeyboardModifiersBitfield(e); |
|
editor.handle.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers); |
|
} |
|
} |
|
|
|
function onPointerUp(e: PointerEvent) { |
|
potentiallyRestoreCanvasFocus(e); |
|
|
|
|
|
|
|
|
|
|
|
if (e.button === BUTTON_BACK || e.button === BUTTON_FORWARD) e.preventDefault(); |
|
|
|
if (!e.buttons) viewportPointerInteractionOngoing = false; |
|
|
|
if (textToolInteractiveInputElement) return; |
|
|
|
const modifiers = makeKeyboardModifiersBitfield(e); |
|
editor.handle.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers); |
|
} |
|
|
|
|
|
|
|
function onPotentialDoubleClick(e: MouseEvent) { |
|
if (textToolInteractiveInputElement || inPointerLock) return; |
|
|
|
|
|
const { target } = e; |
|
const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-node-graph]"); |
|
if (!(isTargetingCanvas instanceof Element)) return; |
|
|
|
|
|
if (e.detail % 2 == 1) return; |
|
|
|
|
|
let buttons = 1; |
|
if (e.button === BUTTON_LEFT) buttons = 1; |
|
if (e.button === BUTTON_RIGHT) buttons = 2; |
|
if (e.button === BUTTON_MIDDLE) buttons = 4; |
|
if (e.button === BUTTON_BACK) buttons = 8; |
|
if (e.button === BUTTON_FORWARD) buttons = 16; |
|
|
|
const modifiers = makeKeyboardModifiersBitfield(e); |
|
editor.handle.onDoubleClick(e.clientX, e.clientY, buttons, modifiers); |
|
} |
|
|
|
function onMouseDown(e: MouseEvent) { |
|
|
|
if (e.button === BUTTON_MIDDLE) e.preventDefault(); |
|
} |
|
|
|
function onContextMenu(e: MouseEvent) { |
|
if (!targetIsTextField(e.target || undefined) && e.target !== textToolInteractiveInputElement) { |
|
e.preventDefault(); |
|
} |
|
} |
|
|
|
function onPointerLockChange() { |
|
inPointerLock = Boolean(window.document.pointerLockElement); |
|
} |
|
|
|
|
|
|
|
function onWheelScroll(e: WheelEvent) { |
|
const { target } = e; |
|
const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-node-graph]"); |
|
|
|
|
|
|
|
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.handle.onWheelScroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers); |
|
} |
|
} |
|
|
|
|
|
|
|
function onModifyInputField(e: CustomEvent) { |
|
textToolInteractiveInputElement = e.detail; |
|
} |
|
|
|
|
|
|
|
async function onBeforeUnload(e: BeforeUnloadEvent) { |
|
const activeDocument = get(portfolio).documents[get(portfolio).activeDocumentIndex]; |
|
if (activeDocument && !activeDocument.isAutoSaved) editor.handle.triggerAutoSave(activeDocument.id); |
|
|
|
|
|
if (await editor.handle.hasCrashed()) return; |
|
|
|
|
|
if (await editor.handle.inDevelopmentMode()) return; |
|
|
|
const allDocumentsSaved = get(portfolio).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) { |
|
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) => { |
|
if (text.startsWith("graphite/layer: ")) { |
|
editor.handle.pasteSerializedData(text.substring(16, text.length)); |
|
} else if (text.startsWith("graphite/nodes: ")) { |
|
editor.handle.pasteSerializedNodes(text.substring(16, text.length)); |
|
} |
|
}); |
|
} |
|
|
|
const file = item.getAsFile(); |
|
if (!file) return; |
|
|
|
if (file.type.includes("svg")) { |
|
const text = await file.text(); |
|
editor.handle.pasteSvg(file.name, text); |
|
return; |
|
} |
|
|
|
if (file.type.startsWith("image")) { |
|
const imageData = await extractPixelData(file); |
|
editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height); |
|
} |
|
|
|
if (file.name.endsWith(".graphite")) { |
|
editor.handle.openDocumentFile(file.name, await file.text()); |
|
} |
|
}); |
|
} |
|
|
|
|
|
|
|
editor.subscriptions.subscribeJsMessage(TriggerPaste, async () => { |
|
|
|
|
|
try { |
|
|
|
|
|
|
|
const clipboardRead = "clipboard-read" as PermissionName; |
|
const permission = await navigator.permissions?.query({ name: clipboardRead }); |
|
if (permission?.state === "denied") throw new Error("Permission denied"); |
|
|
|
|
|
const clipboardItems = await navigator.clipboard.read(); |
|
if (!clipboardItems) throw new Error("Clipboard API unsupported"); |
|
|
|
|
|
const success = await Promise.any( |
|
Array.from(clipboardItems).map(async (item) => { |
|
|
|
if (item.types.includes("text/plain")) { |
|
const blob = await item.getType("text/plain"); |
|
const reader = new FileReader(); |
|
reader.onload = () => { |
|
const text = reader.result as string; |
|
|
|
if (text.startsWith("graphite/layer: ")) { |
|
editor.handle.pasteSerializedData(text.substring(16, text.length)); |
|
} |
|
}; |
|
reader.readAsText(blob); |
|
return true; |
|
} |
|
|
|
|
|
const imageType = item.types.find((type) => type.startsWith("image/")); |
|
|
|
|
|
if (imageType?.includes("svg")) { |
|
const blob = await item.getType("text/plain"); |
|
const reader = new FileReader(); |
|
reader.onload = () => { |
|
const text = reader.result as string; |
|
editor.handle.pasteSvg(undefined, text); |
|
}; |
|
reader.readAsText(blob); |
|
return true; |
|
} |
|
|
|
|
|
if (imageType) { |
|
const blob = await item.getType(imageType); |
|
const reader = new FileReader(); |
|
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); |
|
} |
|
}; |
|
reader.readAsArrayBuffer(blob); |
|
return true; |
|
} |
|
|
|
|
|
|
|
return false; |
|
}), |
|
); |
|
|
|
if (!success) throw new Error("No valid clipboard data"); |
|
} catch (err) { |
|
const unsupported = stripIndents` |
|
This browser does not support reading from the clipboard. |
|
Use the standard 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 nothing = stripIndents` |
|
No valid clipboard data was found. You may have better |
|
luck pasting with the standard keyboard shortcut instead. |
|
`; |
|
|
|
const matchMessage = { |
|
"clipboard-read": unsupported, |
|
"Clipboard API unsupported": unsupported, |
|
"Permission denied": denied, |
|
"No valid clipboard data": nothing, |
|
}; |
|
const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err); |
|
|
|
editor.handle.errorDialog("Cannot access clipboard", message); |
|
} |
|
}); |
|
|
|
|
|
|
|
function potentiallyRestoreCanvasFocus(e: Event) { |
|
const { target } = e; |
|
const newInCanvasArea = (target instanceof Element && target.closest("[data-viewport], [data-graph]")) instanceof Element && !targetIsTextField(window.document.activeElement || undefined); |
|
if (!canvasFocused && newInCanvasArea) { |
|
canvasFocused = true; |
|
app?.focus(); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
bindListeners(); |
|
|
|
updateBoundsOfViewports(editor, window.document.body); |
|
|
|
|
|
return unbindListeners; |
|
} |
|
|
|
function targetIsTextField(target: EventTarget | HTMLElement | undefined): boolean { |
|
return target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable); |
|
} |
|
|