import { useEffect } from 'react' import { useValue } from 'signia-react' import { TLKeyboardEventInfo, TLPointerEventInfo } from '../app/types/event-types' import { preventDefault } from '../utils/dom' import { useApp } from './useApp' import { useContainer } from './useContainer' export function useDocumentEvents() { const app = useApp() const container = useContainer() const isAppFocused = useValue('isFocused', () => app.isFocused, [app]) useEffect(() => { if (!isAppFocused) return const handleKeyDown = (e: KeyboardEvent) => { if ( e.altKey && (app.isIn('zoom') || !app.root.path.value.endsWith('.idle')) && !isFocusingInput() ) { // On windows the alt key opens the menu bar. // We want to prevent that if the user is doing something else, // e.g. resizing a shape preventDefault(e) } if ((e as any).isKilled) return ;(e as any).isKilled = true switch (e.key) { case '=': { if (e.metaKey || e.ctrlKey) { preventDefault(e) return } break } case '-': { if (e.metaKey || e.ctrlKey) { preventDefault(e) return } break } case '0': { if (e.metaKey || e.ctrlKey) { preventDefault(e) return } break } case 'Tab': { if (isFocusingInput() || app.isMenuOpen) { return } break } case ',': { if (!isFocusingInput()) { preventDefault(e) if (!app.inputs.keys.has('Comma')) { const { x, y, z } = app.inputs.currentScreenPoint const { pageState: { hoveredId }, } = app app.inputs.keys.add('Comma') const info: TLPointerEventInfo = { type: 'pointer', name: 'pointer_down', point: { x, y, z }, shiftKey: e.shiftKey, altKey: e.altKey, ctrlKey: e.metaKey || e.ctrlKey, pointerId: 0, button: 0, isPen: app.isPenMode, ...(hoveredId ? { target: 'shape', shape: app.getShapeById(hoveredId)!, } : { target: 'canvas', }), } app.dispatch(info) return } } break } case 'Escape': { if (!app.inputs.keys.has('Escape')) { app.inputs.keys.add('Escape') app.cancel() // Pressing escape will focus the document.body, // which will cause the app to lose focus, which // will break additional shortcuts. We need to // refocus the container in order to keep these // shortcuts working. container.focus() } return } default: { if (isFocusingInput() || app.isMenuOpen) { return } } } const info: TLKeyboardEventInfo = { type: 'keyboard', name: app.inputs.keys.has(e.code) ? 'key_repeat' : 'key_down', key: e.key, code: e.code, shiftKey: e.shiftKey, altKey: e.altKey, ctrlKey: e.metaKey || e.ctrlKey, } app.dispatch(info) } const handleKeyUp = (e: KeyboardEvent) => { if ((e as any).isKilled) return ;(e as any).isKilled = true if (isFocusingInput() || app.isMenuOpen) { return } // Use the , key to send pointer events if (e.key === ',') { if (document.activeElement?.ELEMENT_NODE) preventDefault(e) if (app.inputs.keys.has(e.code)) { const { x, y, z } = app.inputs.currentScreenPoint const { pageState: { hoveredId }, } = app app.inputs.keys.delete(e.code) const info: TLPointerEventInfo = { type: 'pointer', name: 'pointer_up', point: { x, y, z }, shiftKey: e.shiftKey, altKey: e.altKey, ctrlKey: e.metaKey || e.ctrlKey, pointerId: 0, button: 0, isPen: app.isPenMode, ...(hoveredId ? { target: 'shape', shape: app.getShapeById(hoveredId)!, } : { target: 'canvas', }), } app.dispatch(info) return } } const info: TLKeyboardEventInfo = { type: 'keyboard', name: 'key_up', key: e.key, code: e.code, shiftKey: e.shiftKey, altKey: e.altKey, ctrlKey: e.metaKey || e.ctrlKey, } app.dispatch(info) } function handleTouchStart(e: TouchEvent) { if (container.contains(e.target as Node)) { // Center point of the touch area const touchXPosition = e.touches[0].pageX // Size of the touch area const touchXRadius = e.touches[0].radiusX || 0 // We set a threshold (10px) on both sizes of the screen, // if the touch area overlaps with the screen edges // it's likely to trigger the navigation. We prevent the // touchstart event in that case. if ( touchXPosition - touchXRadius < 10 || touchXPosition + touchXRadius > app.viewportScreenBounds.width - 10 ) { if ((e.target as HTMLElement)?.tagName === 'BUTTON') { // Force a click before bailing ;(e.target as HTMLButtonElement)?.click() } preventDefault(e) } } } // Prevent wheel events that occur inside of the container const handleWheel = (e: WheelEvent) => { if (container.contains(e.target as Node) && (e.ctrlKey || e.metaKey)) { preventDefault(e) } } function handleBlur() { app.complete() } function handleFocus() { app.updateViewportScreenBounds() } container.addEventListener('touchstart', handleTouchStart, { passive: false }) document.addEventListener('wheel', handleWheel, { passive: false }) document.addEventListener('gesturestart', preventDefault) document.addEventListener('gesturechange', preventDefault) document.addEventListener('gestureend', preventDefault) document.addEventListener('keydown', handleKeyDown) document.addEventListener('keyup', handleKeyUp) window.addEventListener('blur', handleBlur) window.addEventListener('focus', handleFocus) return () => { container.removeEventListener('touchstart', handleTouchStart) document.removeEventListener('wheel', handleWheel) document.removeEventListener('gesturestart', preventDefault) document.removeEventListener('gesturechange', preventDefault) document.removeEventListener('gestureend', preventDefault) document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keyup', handleKeyUp) window.removeEventListener('blur', handleBlur) window.removeEventListener('focus', handleFocus) } }, [app, container, isAppFocused]) } const INPUTS = ['input', 'select', 'button', 'textarea'] function isFocusingInput() { const { activeElement } = document if ( activeElement && (activeElement.getAttribute('contenteditable') || INPUTS.indexOf(activeElement.tagName.toLowerCase()) > -1) ) { return true } return false }