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"