| 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); |
| } |
|
|