tldraw/packages/editor/src/lib/hooks/useDocumentEvents.ts
Steve Ruiz 57bb341593
ShapeUtil refactor, Editor cleanup (#1611)
This PR improves the ergonomics of `ShapeUtil` classes.

### Cached methods

First, I've remove the cached methods (such as `bounds`) from the
`ShapeUtil` class and lifted this to the `Editor` class.

Previously, calling `ShapeUtil.getBounds` would return the un-cached
bounds of a shape, while calling `ShapeUtil.bounds` would return the
cached bounds of a shape. We also had `Editor.getBounds`, which would
call `ShapeUtil.bounds`. It was confusing. The cached methods like
`outline` were also marked with "please don't override", which suggested
the architecture was just wrong.

The only weirdness from this is that utils sometimes reach out to the
editor for cached versions of data rather than calling their own cached
methods. It's still an easier story to tell than what we had before.

### More defaults

We now have three and only three `abstract` methods for a `ShapeUtil`:
- `getDefaultProps` (renamed from `defaultProps`)
- `getBounds`,
- `component`
-  `indicator`

Previously, we also had `getCenter` as an abstract method, though this
was usually just the middle of the bounds anyway.

### Editing bounds

This PR removes the concept of editingBounds. The viewport will no
longer animate to editing shapes.

### Active area manager

This PR also removes the active area manager, which was not being used
in the way we expected it to be.

### Dpr manager

This PR removes the dpr manager and uses a hook instead to update it
from React. This is one less runtime browser dependency in the app, one
less thing to document.

### Moving things around

This PR also continues to try to organize related methods and properties
in the editor.

### Change Type

- [x] `major` — Breaking change

### Release Notes

- [editor] renames `defaultProps` to `getDefaultProps`
- [editor] removes `outline`, `outlineSegments`, `handles`, `bounds`
- [editor] renames `renderBackground` to `backgroundComponent`
2023-06-19 14:01:18 +00:00

285 lines
7.1 KiB
TypeScript

import { useEffect } from 'react'
import { useValue } from 'signia-react'
import { TLKeyboardEventInfo, TLPointerEventInfo } from '../editor/types/event-types'
import { preventDefault } from '../utils/dom'
import { useContainer } from './useContainer'
import { useEditor } from './useEditor'
export function useDocumentEvents() {
const editor = useEditor()
const container = useContainer()
const isAppFocused = useValue('isFocused', () => editor.isFocused, [editor])
useEffect(() => {
if (typeof matchMedia !== undefined) return
function updateDevicePixelRatio() {
editor.setDevicePixelRatio(window.devicePixelRatio)
}
const MM = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
MM.addEventListener('change', updateDevicePixelRatio)
return () => {
MM.removeEventListener('change', updateDevicePixelRatio)
}
}, [editor])
useEffect(() => {
if (!isAppFocused) return
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.altKey &&
(editor.isIn('zoom') || !editor.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() || editor.isMenuOpen) {
return
}
break
}
case ',': {
if (!isFocusingInput()) {
preventDefault(e)
if (!editor.inputs.keys.has('Comma')) {
const { x, y, z } = editor.inputs.currentScreenPoint
const {
pageState: { hoveredId },
} = editor
editor.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: editor.isPenMode,
...(hoveredId
? {
target: 'shape',
shape: editor.getShapeById(hoveredId)!,
}
: {
target: 'canvas',
}),
}
editor.dispatch(info)
return
}
}
break
}
case 'Escape': {
if (!editor.inputs.keys.has('Escape')) {
editor.inputs.keys.add('Escape')
editor.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() || editor.isMenuOpen) {
return
}
}
}
const info: TLKeyboardEventInfo = {
type: 'keyboard',
name: editor.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,
}
editor.dispatch(info)
}
const handleKeyUp = (e: KeyboardEvent) => {
if ((e as any).isKilled) return
;(e as any).isKilled = true
if (isFocusingInput() || editor.isMenuOpen) {
return
}
// Use the , key to send pointer events
if (e.key === ',') {
if (document.activeElement?.ELEMENT_NODE) preventDefault(e)
if (editor.inputs.keys.has(e.code)) {
const { x, y, z } = editor.inputs.currentScreenPoint
const {
pageState: { hoveredId },
} = editor
editor.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: editor.isPenMode,
...(hoveredId
? {
target: 'shape',
shape: editor.getShapeById(hoveredId)!,
}
: {
target: 'canvas',
}),
}
editor.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,
}
editor.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 > editor.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() {
editor.complete()
}
function handleFocus() {
editor.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)
}
}, [editor, 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
}