diff --git a/docs/useKey.md b/docs/useKey.md index bd4c25a8..ef626f35 100644 --- a/docs/useKey.md +++ b/docs/useKey.md @@ -23,7 +23,7 @@ const Demo = () => { Or as render-prop: ```jsx -import UseKey from 'react-use/lib/comps/UseKey'; +import UseKey from 'react-use/lib/component/UseKey'; alert('"a" key pressed!')} /> ``` diff --git a/package.json b/package.json index 038ef8c1..dba66fd8 100644 --- a/package.json +++ b/package.json @@ -47,20 +47,19 @@ }, "homepage": "https://github.com/streamich/react-use#readme", "dependencies": { - "@types/js-cookie": "2.2.6", - "@xobotyi/scrollbar-width": "1.9.5", - "copy-to-clipboard": "^3.2.0", + "@xobotyi/scrollbar-width": "^1.9.5", + "copy-to-clipboard": "^3.3.1", "fast-deep-equal": "^3.1.3", "fast-shallow-equal": "^1.0.0", "js-cookie": "^2.2.1", - "nano-css": "^5.2.1", + "nano-css": "^5.3.1", "react-universal-interface": "^0.6.2", "resize-observer-polyfill": "^1.5.1", - "screenfull": "^5.0.0", + "screenfull": "^5.1.0", "set-harmonic-interval": "^1.0.1", - "throttle-debounce": "^2.1.0", + "throttle-debounce": "^3.0.1", "ts-easing": "^0.2.0", - "tslib": "^2.0.0" + "tslib": "^2.1.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0", @@ -82,9 +81,10 @@ "@storybook/addon-options": "5.3.21", "@storybook/react": "6.1.15", "@testing-library/react": "11.2.3", - "@testing-library/react-hooks": "3.7.0", + "@testing-library/react-hooks": "5.0.3", "@types/jest": "26.0.20", - "@types/react": "16.9.11", + "@types/js-cookie": "2.2.6", + "@types/react": "17.0.0", "@typescript-eslint/eslint-plugin": "4.14.1", "@typescript-eslint/parser": "4.14.1", "babel-core": "6.26.3", @@ -92,13 +92,13 @@ "babel-loader": "8.2.2", "babel-plugin-dynamic-import-node": "2.3.3", "eslint": "7.18.0", - "eslint-config-react-app": "5.2.1", + "eslint-config-react-app": "6.0.0", "eslint-plugin-flowtype": "5.2.0", "eslint-plugin-import": "2.22.1", "eslint-plugin-jsx-a11y": "6.4.1", "eslint-plugin-react": "7.22.0", "eslint-plugin-react-hooks": "4.2.0", - "fork-ts-checker-webpack-plugin": "5.2.1", + "fork-ts-checker-webpack-plugin": "6.1.0", "gh-pages": "3.1.0", "husky": "4.3.8", "jest": "26.6.3", @@ -108,11 +108,11 @@ "markdown-loader": "6.0.0", "prettier": "2.2.1", "raf-stub": "3.0.0", - "react": "16.14.0", - "react-dom": "16.14.0", + "react": "17.0.1", + "react-dom": "17.0.1", "react-frame-component": "4.1.3", "react-spring": "8.0.27", - "react-test-renderer": "16.14.0", + "react-test-renderer": "17.0.1", "rebound": "0.1.0", "redux-logger": "3.0.6", "redux-thunk": "2.3.0", @@ -122,7 +122,7 @@ "ts-jest": "26.5.0", "ts-loader": "8.0.14", "ts-node": "9.1.1", - "typescript": "3.9.7" + "typescript": "4.1.3" }, "config": { "commitizen": { @@ -156,7 +156,7 @@ ] }, "volta": { - "node": "10.23.2", + "node": "10.23.1", "yarn": "1.22.10" }, "collective": { diff --git a/src/comps/UseKey.tsx b/src/component/UseKey.tsx similarity index 72% rename from src/comps/UseKey.tsx rename to src/component/UseKey.tsx index 3ac19e01..0cf069c4 100644 --- a/src/comps/UseKey.tsx +++ b/src/component/UseKey.tsx @@ -1,5 +1,5 @@ import useKey from '../useKey'; -import createRenderProp from '../util/createRenderProp'; +import createRenderProp from '../factory/createRenderProp'; const UseKey = createRenderProp(useKey, ({ filter, fn, deps, ...rest }) => [filter, fn, rest, deps]); diff --git a/src/createBreakpoint.ts b/src/factory/createBreakpoint.ts similarity index 79% rename from src/createBreakpoint.ts rename to src/factory/createBreakpoint.ts index eee72085..92f5c301 100644 --- a/src/createBreakpoint.ts +++ b/src/factory/createBreakpoint.ts @@ -1,4 +1,5 @@ -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { off, on } from '../misc/util'; const createBreakpoint = ( breakpoints: { [name: string]: number } = { laptopL: 1440, laptop: 1024, tablet: 768 } @@ -10,9 +11,9 @@ const createBreakpoint = ( setScreen(window.innerWidth); }; setSideScreen(); - window.addEventListener('resize', setSideScreen); + on(window, 'resize', setSideScreen); return () => { - window.removeEventListener('resize', setSideScreen); + off(window, 'resize', setSideScreen); }; }); const sortedBreakpoints = useMemo(() => Object.entries(breakpoints).sort((a, b) => (a[1] >= b[1] ? 1 : -1)), [ diff --git a/src/createGlobalState.ts b/src/factory/createGlobalState.ts similarity index 87% rename from src/createGlobalState.ts rename to src/factory/createGlobalState.ts index cb76af8a..dca16642 100644 --- a/src/createGlobalState.ts +++ b/src/factory/createGlobalState.ts @@ -1,6 +1,6 @@ import { useState } from 'react'; -import useEffectOnce from './useEffectOnce'; -import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; +import useEffectOnce from '../useEffectOnce'; +import useIsomorphicLayoutEffect from '../useIsomorphicLayoutEffect'; export function createGlobalState(initialState?: S) { const store: { state: S | undefined; setState: (state: S) => void; setters: any[] } = { diff --git a/src/factory/createHTMLMediaHook.ts b/src/factory/createHTMLMediaHook.ts new file mode 100644 index 00000000..fbea71b3 --- /dev/null +++ b/src/factory/createHTMLMediaHook.ts @@ -0,0 +1,235 @@ +import * as React from 'react'; +import { useEffect, useRef } from 'react'; +import useSetState from '../useSetState'; +import parseTimeRanges from '../misc/parseTimeRanges'; + +export interface HTMLMediaProps extends React.AudioHTMLAttributes, React.VideoHTMLAttributes { + src: string; +} + +export interface HTMLMediaState { + buffered: any[]; + duration: number; + paused: boolean; + muted: boolean; + time: number; + volume: number; +} + +export interface HTMLMediaControls { + play: () => Promise | void; + pause: () => void; + mute: () => void; + unmute: () => void; + volume: (volume: number) => void; + seek: (time: number) => void; +} + +type createHTMLMediaHookReturn = [ + React.ReactElement, + HTMLMediaState, + HTMLMediaControls, + { current: HTMLAudioElement | null } +]; + +export default function createHTMLMediaHook(tag: 'audio' | 'video') { + return (elOrProps: HTMLMediaProps | React.ReactElement): createHTMLMediaHookReturn => { + let element: React.ReactElement | undefined; + let props: HTMLMediaProps; + + if (React.isValidElement(elOrProps)) { + element = elOrProps; + props = element.props; + } else { + props = elOrProps as HTMLMediaProps; + } + + const [state, setState] = useSetState({ + buffered: [], + time: 0, + duration: 0, + paused: true, + muted: false, + volume: 1, + }); + const ref = useRef(null); + + const wrapEvent = (userEvent, proxyEvent?) => { + return (event) => { + try { + proxyEvent && proxyEvent(event); + } finally { + userEvent && userEvent(event); + } + }; + }; + + const onPlay = () => setState({ paused: false }); + const onPause = () => setState({ paused: true }); + const onVolumeChange = () => { + const el = ref.current; + if (!el) { + return; + } + setState({ + muted: el.muted, + volume: el.volume, + }); + }; + const onDurationChange = () => { + const el = ref.current; + if (!el) { + return; + } + const { duration, buffered } = el; + setState({ + duration, + buffered: parseTimeRanges(buffered), + }); + }; + const onTimeUpdate = () => { + const el = ref.current; + if (!el) { + return; + } + setState({ time: el.currentTime }); + }; + const onProgress = () => { + const el = ref.current; + if (!el) { + return; + } + setState({ buffered: parseTimeRanges(el.buffered) }); + }; + + if (element) { + element = React.cloneElement(element, { + controls: false, + ...props, + ref, + onPlay: wrapEvent(props.onPlay, onPlay), + onPause: wrapEvent(props.onPause, onPause), + onVolumeChange: wrapEvent(props.onVolumeChange, onVolumeChange), + onDurationChange: wrapEvent(props.onDurationChange, onDurationChange), + onTimeUpdate: wrapEvent(props.onTimeUpdate, onTimeUpdate), + onProgress: wrapEvent(props.onProgress, onProgress), + }); + } else { + element = React.createElement(tag, { + controls: false, + ...props, + ref, + onPlay: wrapEvent(props.onPlay, onPlay), + onPause: wrapEvent(props.onPause, onPause), + onVolumeChange: wrapEvent(props.onVolumeChange, onVolumeChange), + onDurationChange: wrapEvent(props.onDurationChange, onDurationChange), + onTimeUpdate: wrapEvent(props.onTimeUpdate, onTimeUpdate), + onProgress: wrapEvent(props.onProgress, onProgress), + } as any); // TODO: fix this typing. + } + + // Some browsers return `Promise` on `.play()` and may throw errors + // if one tries to execute another `.play()` or `.pause()` while that + // promise is resolving. So we prevent that with this lock. + // See: https://bugs.chromium.org/p/chromium/issues/detail?id=593273 + let lockPlay: boolean = false; + + const controls = { + play: () => { + const el = ref.current; + if (!el) { + return undefined; + } + + if (!lockPlay) { + const promise = el.play(); + const isPromise = typeof promise === 'object'; + + if (isPromise) { + lockPlay = true; + const resetLock = () => { + lockPlay = false; + }; + promise.then(resetLock, resetLock); + } + + return promise; + } + return undefined; + }, + pause: () => { + const el = ref.current; + if (el && !lockPlay) { + return el.pause(); + } + }, + seek: (time: number) => { + const el = ref.current; + if (!el || state.duration === undefined) { + return; + } + time = Math.min(state.duration, Math.max(0, time)); + el.currentTime = time; + }, + volume: (volume: number) => { + const el = ref.current; + if (!el) { + return; + } + volume = Math.min(1, Math.max(0, volume)); + el.volume = volume; + setState({ volume }); + }, + mute: () => { + const el = ref.current; + if (!el) { + return; + } + el.muted = true; + }, + unmute: () => { + const el = ref.current; + if (!el) { + return; + } + el.muted = false; + }, + }; + + useEffect(() => { + const el = ref.current!; + + if (!el) { + if (process.env.NODE_ENV !== 'production') { + if (tag === 'audio') { + console.error( + 'useAudio() ref to