diff --git a/CHANGELOG.md b/CHANGELOG.md index 9296be8b..b58c7a67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [14.3.0](https://github.com/streamich/react-use/compare/v14.2.0...v14.3.0) (2020-05-16) + + +### Features + +* 🎸 add useScratch hook ([2a2a298](https://github.com/streamich/react-use/commit/2a2a298b73f7beb9a2a61c309e649be3d2527473)) + # [14.2.0](https://github.com/streamich/react-use/compare/v14.1.1...v14.2.0) (2020-04-24) diff --git a/README.md b/README.md index 3f9d1250..c31946d8 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,13 @@ - [`useNetwork`](./docs/useNetwork.md) — tracks state of user's internet connection. - [`useOrientation`](./docs/useOrientation.md) — tracks state of device's screen orientation. - [`usePageLeave`](./docs/usePageLeave.md) — triggers when mouse leaves page boundaries. + - [`useScratch`](./docs/useScratch.md) — tracks mouse click-and-scrub state. - [`useScroll`](./docs/useScroll.md) — tracks an HTML element's scroll position. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-usescroll--docs) - [`useScrolling`](./docs/useScrolling.md) — tracks whether HTML element is scrolling. - - [`useSize`](./docs/useSize.md) — tracks an HTML element's size. - [`useStartTyping`](./docs/useStartTyping.md) — detects when user starts typing. - [`useWindowScroll`](./docs/useWindowScroll.md) — tracks `Window` scroll position. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-usewindowscroll--docs) - [`useWindowSize`](./docs/useWindowSize.md) — tracks `Window` dimensions. [![][img-demo]](https://codesandbox.io/s/m7ln22668) - - [`useMeasure`](./docs/useMeasure.md) — tracks an HTML element's dimensions using the Resize Observer API.[![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-usemeasure--demo) + - [`useMeasure`](./docs/useMeasure.md) and [`useSize`](./docs/useSize.md) — tracks an HTML element's dimensions. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-usemeasure--demo) - [`createBreakpoint`](./docs/createBreakpoint.md) — tracks `innerWidth` - [`useScrollbarWidth`](./docs/useScrollbarWidth.md) — detects browser's native scrollbars width. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-usescrollbarwidth--demo)
diff --git a/docs/useScratch.md b/docs/useScratch.md new file mode 100644 index 00000000..0ba6c311 --- /dev/null +++ b/docs/useScratch.md @@ -0,0 +1,75 @@ +# `useScratch` + +React sensor hook that tracks state of mouse "scrubs" (or "scratches"). + +## Usage + +```jsx +import useScratch from 'react-use/lib/useScratch'; + +const Demo = () => { + const [ref, state] = useScratch(); + + const blockStyle: React.CSSProperties = { + position: 'relative', + width: 400, + height: 400, + border: '1px solid tomato', + }; + + const preStyle: React.CSSProperties = { + pointerEvents: 'none', + userSelect: 'none', + }; + + let { x = 0, y = 0, dx = 0, dy = 0 } = state; + if (dx < 0) [x, dx] = [x + dx, -dx]; + if (dy < 0) [y, dy] = [y + dy, -dy]; + + const rectangleStyle: React.CSSProperties = { + position: 'absolute', + left: x, + top: y, + width: dx, + height: dy, + border: '1px solid tomato', + pointerEvents: 'none', + userSelect: 'none', + }; + + return ( +
+
{JSON.stringify(state, null, 4)}
+ {state.isScratching &&
} +
+ ); +}; +``` + +## Reference + +```ts +const [ref, state] = useScratch(); +``` + +`state` is: + +```ts +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; +} +``` diff --git a/package.json b/package.json index d7683cf9..7971255e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-use", - "version": "14.2.0", + "version": "14.3.0", "description": "Collection of React Hooks", "main": "lib/index.js", "module": "esm/index.js", @@ -53,6 +53,7 @@ "fast-shallow-equal": "^1.0.0", "js-cookie": "^2.2.1", "nano-css": "^5.2.1", + "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/index.ts b/src/index.ts index 6d018164..d632f7c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,7 @@ export { default as useRaf } from './useRaf'; export { default as useRafLoop } from './useRafLoop'; export { default as useRafState } from './useRafState'; export { default as useSearchParam } from './useSearchParam'; +export { default as useScratch } from './useScratch'; export { default as useScroll } from './useScroll'; export { default as useScrolling } from './useScrolling'; export { default as useSessionStorage } from './useSessionStorage'; diff --git a/src/useScratch.ts b/src/useScratch.ts new file mode 100644 index 00000000..2f73530a --- /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 = {}): [(el: HTMLElement | null) => void, ScratchSensorState] => { + 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, onScratchStart, onScratch, onScratchEnd]); + + return [setEl, state]; +}; + +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 [ref, state] = 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/stories/useScratch.story.tsx b/stories/useScratch.story.tsx new file mode 100644 index 00000000..43e9e31b --- /dev/null +++ b/stories/useScratch.story.tsx @@ -0,0 +1,46 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { useScratch } from '../src'; +import ShowDocs from './util/ShowDocs'; + +const Demo = () => { + const [ref, state] = useScratch(); + + const blockStyle: React.CSSProperties = { + position: 'relative', + width: 400, + height: 400, + border: '1px solid tomato', + }; + + const preStyle: React.CSSProperties = { + pointerEvents: 'none', + userSelect: 'none', + }; + + let { x = 0, y = 0, dx = 0, dy = 0 } = state; + if (dx < 0) [x, dx] = [x + dx, -dx]; + if (dy < 0) [y, dy] = [y + dy, -dy]; + + const rectangleStyle: React.CSSProperties = { + position: 'absolute', + left: x, + top: y, + width: dx, + height: dy, + border: '1px solid tomato', + pointerEvents: 'none', + userSelect: 'none', + }; + + return ( +
+
{JSON.stringify(state, null, 4)}
+ {state.isScratching &&
} +
+ ); +}; + +storiesOf('Sensors/useScratch', module) + .add('Docs', () => ) + .add('Demo', () => ); diff --git a/yarn.lock b/yarn.lock index 93d9ab48..dd88d6d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13181,6 +13181,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.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"