diff --git a/package.json b/package.json index b64c6a8c..ae9bb65c 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "fast-shallow-equal": "^0.1.1", "nano-css": "^5.2.1", "react-fast-compare": "^2.0.4", + "react-universal-interface": "^0.6.0", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "set-harmonic-interval": "^1.0.1", diff --git a/src/useScratch.ts b/src/useScratch.ts new file mode 100644 index 00000000..7c9dd32a --- /dev/null +++ b/src/useScratch.ts @@ -0,0 +1,180 @@ +import { useState, useEffect, useRef, FC, cloneElement } from 'react'; +import { render } from 'react-universal-interface'; + +const noop = () => {}; + +export interface ScratchSensorParams { + disabled?: boolean; + onScratch?: (state: ScratchSensorState) => void; + onScratchStart?: (state: ScratchSensorState) => void; + onScratchEnd?: (state: ScratchSensorState) => void; +} + +export interface ScratchSensorState { + isScratching: boolean; + start?: number; + end?: number; + x?: number; + y?: number; + dx?: number; + dy?: number; + docX?: number; + docY?: number; + posX?: number; + posY?: number; + elH?: number; + elW?: number; + elX?: number; + elY?: number; +} + +const useScratch = ({ + disabled, + onScratch = noop, + onScratchStart = noop, + onScratchEnd = noop, +}: ScratchSensorParams = {}): [ScratchSensorState, (el: HTMLElement | null) => void] => { + const [state, setState] = useState({ isScratching: false }); + const refState = useRef(state); + const refScratching = useRef(false); + const refAnimationFrame = useRef(null); + const [el, setEl] = useState(null); + useEffect(() => { + if (disabled) return; + if (!el) return; + + const onMoveEvent = (docX, docY) => { + cancelAnimationFrame(refAnimationFrame.current); + refAnimationFrame.current = requestAnimationFrame(() => { + const { left, top } = el.getBoundingClientRect(); + const elX = left + window.scrollX; + const elY = top + window.scrollY; + const x = docX - elX; + const y = docY - elY; + setState(oldState => { + const newState = { + ...oldState, + dx: x - (oldState.x || 0), + dy: y - (oldState.y || 0), + end: Date.now(), + isScratching: true, + }; + refState.current = newState; + onScratch(newState); + return newState; + }); + }); + }; + + const onMouseMove = event => { + onMoveEvent(event.pageX, event.pageY); + }; + + const onTouchMove = event => { + onMoveEvent(event.changedTouches[0].pageX, event.changedTouches[0].pageY); + }; + + let onMouseUp; + let onTouchEnd; + + const stopScratching = () => { + if (!refScratching.current) return; + refScratching.current = false; + refState.current = { ...refState.current, isScratching: false }; + onScratchEnd(refState.current); + setState({ isScratching: false }); + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('touchmove', onTouchMove); + window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('touchend', onTouchEnd); + }; + + onMouseUp = stopScratching; + onTouchEnd = stopScratching; + + const startScratching = (docX, docY) => { + if (!refScratching.current) return; + const { left, top } = el.getBoundingClientRect(); + const elX = left + window.scrollX; + const elY = top + window.scrollY; + const x = docX - elX; + const y = docY - elY; + const time = Date.now(); + const newState = { + isScratching: true, + start: time, + end: time, + docX, + docY, + x, + y, + dx: 0, + dy: 0, + elH: el.offsetHeight, + elW: el.offsetWidth, + elX, + elY, + }; + refState.current = newState; + onScratchStart(newState); + setState(newState); + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('touchmove', onTouchMove); + window.addEventListener('mouseup', onMouseUp); + window.addEventListener('touchend', onTouchEnd); + }; + + const onMouseDown = event => { + refScratching.current = true; + startScratching(event.pageX, event.pageY); + }; + + const onTouchStart = event => { + refScratching.current = true; + startScratching(event.changedTouches[0].pageX, event.changedTouches[0].pageY); + }; + + el.addEventListener('mousedown', onMouseDown); + el.addEventListener('touchstart', onTouchStart); + + return () => { + el.removeEventListener('mousedown', onMouseDown); + el.removeEventListener('touchstart', onTouchStart); + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('touchmove', onTouchMove); + window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('touchend', onTouchEnd); + + if (refAnimationFrame.current) cancelAnimationFrame(refAnimationFrame.current); + refAnimationFrame.current = null; + + refScratching.current = false; + refState.current = { isScratching: false }; + setState(refState.current); + }; + }, [el, disabled]); + + return [state, setEl]; +}; + +export interface ScratchSensorProps extends ScratchSensorParams { + children: (state: ScratchSensorState, ref: (el: HTMLElement | null) => void) => React.ReactElement; +} + +export const ScratchSensor: FC = props => { + const { children, ...params } = props; + const [state, ref] = useScratch(params); + const element = render(props, state); + return cloneElement(element, { + ...element.props, + ref: el => { + if (element.props.ref) { + if (typeof element.props.ref === 'object') element.props.ref.current = el; + if (typeof element.props.ref === 'function') element.props.ref(el); + } + ref(el); + }, + }); +}; + +export default useScratch; diff --git a/yarn.lock b/yarn.lock index 72017e6c..81ea8a6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11554,6 +11554,13 @@ react-transition-group@^2.2.1: prop-types "^15.6.2" react-lifecycles-compat "^3.0.4" +react-universal-interface@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/react-universal-interface/-/react-universal-interface-0.6.0.tgz#b65cbf7d71a2f3f7dd9705d8e4f06748539bd465" + integrity sha512-PzApKKWfd7gvDi1sU/D07jUqnLvFxYqvJi+GEtLvBO5tXJjKr2Sa8ETVHkMA7Jcvdwt7ttbPq7Sed1JpFdNqBQ== + dependencies: + tslib "^1.9.3" + react@16.12.0: version "16.12.0" resolved "https://registry.yarnpkg.com/react/-/react-16.12.0.tgz#0c0a9c6a142429e3614834d5a778e18aa78a0b83"