Is there a way to add long press event in react-web application?
I have list of addresses. On long press on any address, I want to fire event to delete that address followed by a confirm box.
I've created a codesandbox with a hook to handle long press and click. Basically, on mouse down, touch start events, a timer is created with setTimeout. When the provided time elapses, it triggers long press.
On mouse up, mouse leave, touchend, etc, the timer is cleared.
useLongPress.js
import { useCallback, useRef, useState } from "react";
const useLongPress = (
onLongPress,
onClick,
{ shouldPreventDefault = true, delay = 300 } = {}
) => {
const [longPressTriggered, setLongPressTriggered] = useState(false);
const timeout = useRef();
const target = useRef();
const start = useCallback(
event => {
if (shouldPreventDefault && event.target) {
event.target.addEventListener("touchend", preventDefault, {
passive: false
});
target.current = event.target;
}
timeout.current = setTimeout(() => {
onLongPress(event);
setLongPressTriggered(true);
}, delay);
},
[onLongPress, delay, shouldPreventDefault]
);
const clear = useCallback(
(event, shouldTriggerClick = true) => {
timeout.current && clearTimeout(timeout.current);
shouldTriggerClick && !longPressTriggered && onClick();
setLongPressTriggered(false);
if (shouldPreventDefault && target.current) {
target.current.removeEventListener("touchend", preventDefault);
}
},
[shouldPreventDefault, onClick, longPressTriggered]
);
return {
onMouseDown: e => start(e),
onTouchStart: e => start(e),
onMouseUp: e => clear(e),
onMouseLeave: e => clear(e, false),
onTouchEnd: e => clear(e)
};
};
const isTouchEvent = event => {
return "touches" in event;
};
const preventDefault = event => {
if (!isTouchEvent(event)) return;
if (event.touches.length < 2 && event.preventDefault) {
event.preventDefault();
}
};
export default useLongPress;
To use the hook,
App.js
import useLongPress from "./useLongPress";
export default function App() {
const onLongPress = () => {
console.log('longpress is triggered');
};
const onClick = () => {
console.log('click is triggered')
}
const defaultOptions = {
shouldPreventDefault: true,
delay: 500,
};
const longPressEvent = useLongPress(onLongPress, onClick, defaultOptions);
return (
<div className="App">
<button {...longPressEvent}>use Loooong Press</button>
</div>
);
}
Older answer for class components:
You can use MouseDown, MouseUp, TouchStart, TouchEnd events to control timers that can act as a long press event. Check out the code below
class App extends Component {
constructor() {
super()
this.handleButtonPress = this.handleButtonPress.bind(this)
this.handleButtonRelease = this.handleButtonRelease.bind(this)
}
handleButtonPress () {
this.buttonPressTimer = setTimeout(() => alert('long press activated'), 1500);
}
handleButtonRelease () {
clearTimeout(this.buttonPressTimer);
}
render() {
return (
<div
onTouchStart={this.handleButtonPress}
onTouchEnd={this.handleButtonRelease}
onMouseDown={this.handleButtonPress}
onMouseUp={this.handleButtonRelease}
onMouseLeave={this.handleButtonRelease}>
Button
</div>
);
}
}
With hooks in react 16.8 you could rewrite class with functions and hooks.
import { useState, useEffect } from 'react';
export default function useLongPress(callback = () => {}, ms = 300) {
const [startLongPress, setStartLongPress] = useState(false);
useEffect(() => {
let timerId;
if (startLongPress) {
timerId = setTimeout(callback, ms);
} else {
clearTimeout(timerId);
}
return () => {
clearTimeout(timerId);
};
}, [callback, ms, startLongPress]);
return {
onMouseDown: () => setStartLongPress(true),
onMouseUp: () => setStartLongPress(false),
onMouseLeave: () => setStartLongPress(false),
onTouchStart: () => setStartLongPress(true),
onTouchEnd: () => setStartLongPress(false),
};
}
import useLongPress from './useLongPress';
function MyComponent (props) {
const backspaceLongPress = useLongPress(props.longPressBackspaceCallback, 500);
return (
<Page>
<Button {...backspaceLongPress}>
Click me
</Button>
</Page>
);
};
Nice hook! But I would like make a small improvement. Using useCallback to wrap event handlers. This ensures these will not changed on every render.
import { useState, useEffect, useCallback } from 'react';
export default function useLongPress(callback = () => {}, ms = 300) {
const [startLongPress, setStartLongPress] = useState(false);
useEffect(() => {
let timerId;
if (startLongPress) {
timerId = setTimeout(callback, ms);
} else {
clearTimeout(timerId);
}
return () => {
clearTimeout(timerId);
};
}, [callback, ms, startLongPress]);
const start = useCallback(() => {
setStartLongPress(true);
}, []);
const stop = useCallback(() => {
setStartLongPress(false);
}, []);
return {
onMouseDown: start,
onMouseUp: stop,
onMouseLeave: stop,
onTouchStart: start,
onTouchEnd: stop,
};
}
Based on #Sublime me comment above about avoiding multiple re-renders, my version doesn't use anything that triggers renders:
export function useLongPress({
onClick = () => {},
onLongPress = () => {},
ms = 300,
} = {}) {
const timerRef = useRef(false);
const eventRef = useRef({});
const callback = useCallback(() => {
onLongPress(eventRef.current);
eventRef.current = {};
timerRef.current = false;
}, [onLongPress]);
const start = useCallback(
(ev) => {
ev.persist();
eventRef.current = ev;
timerRef.current = setTimeout(callback, ms);
},
[callback, ms]
);
const stop = useCallback(
(ev) => {
ev.persist();
eventRef.current = ev;
if (timerRef.current) {
clearTimeout(timerRef.current);
onClick(eventRef.current);
timerRef.current = false;
eventRef.current = {};
}
},
[onClick]
);
return useMemo(
() => ({
onMouseDown: start,
onMouseUp: stop,
onMouseLeave: stop,
onTouchStart: start,
onTouchEnd: stop,
}),
[start, stop]
);
}
It also provides both onLongPress and onClick and passes on the event object received.
Usage is mostly as described earlier, except arguments are now passed in an object, all are optional:
const longPressProps = useLongPress({
onClick: (ev) => console.log('on click', ev.button, ev.shiftKey),
onLongPress: (ev) => console.log('on long press', ev.button, ev.shiftKey),
});
// and later:
return (<button {...longPressProps}>click me</button>);
Here is a Typescript version of the most popular answer, in case it is useful to anybody:
(it also fixes a problem with accessing event properties within the delegated event on the timeOut by using e.persist() and cloning the event)
useLongPress.ts
import { useCallback, useRef, useState } from "react";
function preventDefault(e: Event) {
if ( !isTouchEvent(e) ) return;
if (e.touches.length < 2 && e.preventDefault) {
e.preventDefault();
}
};
export function isTouchEvent(e: Event): e is TouchEvent {
return e && "touches" in e;
};
interface PressHandlers<T> {
onLongPress: (e: React.MouseEvent<T> | React.TouchEvent<T>) => void,
onClick?: (e: React.MouseEvent<T> | React.TouchEvent<T>) => void,
}
interface Options {
delay?: number,
shouldPreventDefault?: boolean
}
export default function useLongPress<T>(
{ onLongPress, onClick }: PressHandlers<T>,
{ delay = 300, shouldPreventDefault = true }
: Options
= {}
) {
const [longPressTriggered, setLongPressTriggered] = useState(false);
const timeout = useRef<NodeJS.Timeout>();
const target = useRef<EventTarget>();
const start = useCallback(
(e: React.MouseEvent<T> | React.TouchEvent<T>) => {
e.persist();
const clonedEvent = {...e};
if (shouldPreventDefault && e.target) {
e.target.addEventListener(
"touchend",
preventDefault,
{ passive: false }
);
target.current = e.target;
}
timeout.current = setTimeout(() => {
onLongPress(clonedEvent);
setLongPressTriggered(true);
}, delay);
},
[onLongPress, delay, shouldPreventDefault]
);
const clear = useCallback((
e: React.MouseEvent<T> | React.TouchEvent<T>,
shouldTriggerClick = true
) => {
timeout.current && clearTimeout(timeout.current);
shouldTriggerClick && !longPressTriggered && onClick?.(e);
setLongPressTriggered(false);
if (shouldPreventDefault && target.current) {
target.current.removeEventListener("touchend", preventDefault);
}
},
[shouldPreventDefault, onClick, longPressTriggered]
);
return {
onMouseDown: (e: React.MouseEvent<T>) => start(e),
onTouchStart: (e: React.TouchEvent<T>) => start(e),
onMouseUp: (e: React.MouseEvent<T>) => clear(e),
onMouseLeave: (e: React.MouseEvent<T>) => clear(e, false),
onTouchEnd: (e: React.TouchEvent<T>) => clear(e)
};
};
Generic hook that avoids re-renders
This is something I'm using in production, inspired by the original answers. If there's a bug below, well I guess I have a bug in production! 🤷♂️
Usage
I wanted to keep the hook a bit more concise and allow composability if the implementation calls for it (e.g.: adding fast input vs slow input, rather than a single callback).
const [onStart, onEnd] = useLongPress(() => alert('Old School Alert'), 1000);
return (
<button
type="button"
onTouchStart={onStart}
onTouchEnd={onEnd}
>
Hold Me (Touch Only)
</button>
)
Implementation
It's a simpler implementation than it seems. Just a lot more lines of comments.
I added a bunch of comments so if you do copy/paste this into your codebase, your colleagues can understand it better during PR.
import {useCallback, useRef} from 'react';
export default function useLongPress(
// callback that is invoked at the specified duration or `onEndLongPress`
callback : () => any,
// long press duration in milliseconds
ms = 300
) {
// used to persist the timer state
// non zero values means the value has never been fired before
const timerRef = useRef<number>(0);
// clear timed callback
const endTimer = () => {
clearTimeout(timerRef.current || 0);
timerRef.current = 0;
};
// init timer
const onStartLongPress = useCallback((e) => {
// stop any previously set timers
endTimer();
// set new timeout
timerRef.current = window.setTimeout(() => {
callback();
endTimer();
}, ms);
}, [callback, ms]);
// determine to end timer early and invoke the callback or do nothing
const onEndLongPress = useCallback(() => {
// run the callback fn the timer hasn't gone off yet (non zero)
if (timerRef.current) {
endTimer();
callback();
}
}, [callback]);
return [onStartLongPress, onEndLongPress, endTimer];
}
Example
Using 500ms setting in the example. The spontaneous circle in the GIF shows when I'm pressing down.
Here's a component that provides onClick and onHold events - adapt as needed...
CodeSandbox: https://codesandbox.io/s/hold-press-event-r8q9w
Usage:
import React from 'react'
import Holdable from './holdable'
function App() {
function onClick(evt) {
alert('click ' + evt.currentTarget.id)
}
function onHold(evt) {
alert('hold ' + evt.currentTarget.id)
}
const ids = 'Label1,Label2,Label3'.split(',')
return (
<div className="App">
{ids.map(id => (
<Holdable
onClick={onClick}
onHold={onHold}
id={id}
key={id}
>
{id}
</Holdable>
))}
</div>
)
}
holdable.jsx:
import React from 'react'
const holdTime = 500 // ms
const holdDistance = 3**2 // pixels squared
export default function Holdable({id, onClick, onHold, children}) {
const [timer, setTimer] = React.useState(null)
const [pos, setPos] = React.useState([0,0])
function onPointerDown(evt) {
setPos([evt.clientX, evt.clientY]) // save position for later
const event = { ...evt } // convert synthetic event to real object
const timeoutId = window.setTimeout(timesup.bind(null, event), holdTime)
setTimer(timeoutId)
}
function onPointerUp(evt) {
if (timer) {
window.clearTimeout(timer)
setTimer(null)
onClick(evt)
}
}
function onPointerMove(evt) {
// cancel hold operation if moved too much
if (timer) {
const d = (evt.clientX - pos[0])**2 + (evt.clientY - pos[1])**2
if (d > holdDistance) {
setTimer(null)
window.clearTimeout(timer)
}
}
}
function timesup(evt) {
setTimer(null)
onHold(evt)
}
return (
<div
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerMove={onPointerMove}
id={id}
>
{children}
</div>
)
}
Note: this doesn't work with Safari yet - pointer events are coming in v13 though - https://caniuse.com/#feat=pointer
This is the simplest and best solution I could made on my own.
This way you don't need to pass the click event
Click event still working
The hook returns a function instead of the events itselves , then you can use it within a loop or conditionally and pass different callbacks to each element.
useLongPress.js
export default function useLongPress() {
return function (callback) {
let timeout;
let preventClick = false;
function start() {
timeout = setTimeout(() => {
preventClick = true;
callback();
}, 300);
}
function clear() {
timeout && clearTimeout(timeout);
preventClick = false;
}
function clickCaptureHandler(e) {
if (preventClick) {
e.stopPropagation();
preventClick = false;
}
}
return {
onMouseDown: start,
onTouchStart: start,
onMouseUp: clear,
onMouseLeave: clear,
onTouchMove: clear,
onTouchEnd: clear,
onClickCapture: clickCaptureHandler
};
}
}
Usage:
import useLongPress from './useLongPress';
export default function MyComponent(){
const onLongPress = useLongPress();
const buttons = ['button one', 'button two', 'button three'];
return (
buttons.map(text =>
<button
onClick={() => console.log('click still working')}
{...onLongPress(() => console.log('long press worked for ' + text))}
>
{text}
</button>
)
)
}
Brian's solution allows you to pass params to the children which I think is not doable with the Hook. Still, if I may suggest a bit cleaner solution for most common case where you want to add onHold behavior to a single component and you also want to be able to change the onHold timeout.
Material-UI example with Chip component:
'use strict';
const {
Chip
} = MaterialUI
function ChipHoldable({
onClick = () => {},
onHold = () => {},
hold = 500,
...props
}) {
const [timer, setTimer] = React.useState(null);
function onPointerDown(evt) {
const event = { ...evt
}; // convert synthetic event to real object
const timeoutId = window.setTimeout(timesup.bind(null, event), hold);
setTimer(timeoutId);
}
function onPointerUp(evt) {
if (timer) {
window.clearTimeout(timer);
setTimer(null);
onClick(evt);
}
}
const onContextMenu = e => e.preventDefault();
const preventDefault = e => e.preventDefault(); // so that ripple effect would be triggered
function timesup(evt) {
setTimer(null);
onHold(evt);
}
return React.createElement(Chip, {
onPointerUp,
onPointerDown,
onContextMenu,
onClick: preventDefault,
...props
});
}
const App = () => <div> {[1,2,3,4].map(i => < ChipHoldable style={{margin:"10px"}}label = {`chip${i}`}
onClick = {
() => console.log(`chip ${i} clicked`)
}
onHold = {
() => console.log(`chip ${i} long pressed`)
}
/>)}
</div>
ReactDOM.render( <App/>, document.querySelector('#root'));
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react#16/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom#16/umd/react-dom.production.min.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<script src="https://unpkg.com/#material-ui/core#latest/umd/material-ui.development.js"></script>
</body>
</html>
An adaptation of David's solution: a React hook for when you want to repeatedly fire the event. It uses setInterval instead.
export function useHoldPress(callback = () => {}, ms = 300) {
const [startHoldPress, setStartHoldPress] = useState(false);
useEffect(() => {
let timerId;
if (startHoldPress) {
timerId = setInterval(callback, ms);
} else {
clearTimeout(timerId);
}
return () => {
clearTimeout(timerId);
};
}, [startHoldPress]);
return {
onMouseDown: () => setStartHoldPress(true),
onMouseUp: () => setStartHoldPress(false),
onMouseLeave: () => setStartHoldPress(false),
onTouchStart: () => setStartHoldPress(true),
onTouchEnd: () => setStartHoldPress(false)
};
}
Ionic React LongPress Example
I use it with Ionic React, it works well.
import React, {useState} from 'react';
import { Route, Redirect } from 'react-router';
interface MainTabsProps { }
const MainTabs: React.FC<MainTabsProps> = () => {
// timeout id
var initial: any;
// setstate
const [start, setStart] = useState(false);
const handleButtonPress = () => {
initial = setTimeout(() => {
setStart(true); // start long button
console.log('long press button');
}, 1500);
}
const handleButtonRelease = () => {
setStart(false); // stop long press
clearTimeout(initial); // clear timeout
if(start===false) { // is click
console.log('click button');
}
}
return (
<IonPage>
<IonHeader>
<IonTitle>Ionic React LongPress</IonTitle>
</IonHeader>
<IonContent className="ion-padding">
<IonButton expand="block"
onMouseDown={handleButtonPress}
onMouseUp={handleButtonRelease} >LongPress</IonButton>
</IonContent>
</IonPage>
);
};
export default MainTabs;
Just wanted to point out that hooks aren't a great solution here since you can't use them in a call back.
for example, if you wanted to add long press to a number of elements:
items.map(item => <button {...useLongPress(() => handle(item))}>{item}</button>)
gets you:
... React Hooks must be called in a React function component or a
custom React Hook function
you could however use vanilla JS:
export default function longPressEvents(callback, ms = 500) {
let timeout = null
const start = () => timeout = setTimeout(callback, ms)
const stop = () => timeout && window.clearTimeout(timeout)
return callback ? {
onTouchStart: start,
onTouchMove: stop,
onTouchEnd: stop,
} : {}
}
then:
items.map(item => <button { ...longPressEvents(() => handle(item)) }>{item}</button>)
demo: https://codesandbox.io/s/long-press-hook-like-oru24?file=/src/App.js
just be aware that longPressEvents will run every render. Probably not a big deal, but something to keep in mind.
Type Script example make common long Press event
import { useCallback, useRef, useState } from "react";
interface Props {
onLongPress: (e: any) => void;
onClick: (e: any) => void;
obj: { shouldPreventDefault: boolean, delay: number }
}
const useLongPress = (props: Props) => {
const [longPressTriggered, setLongPressTriggered] = useState(false);
const timeout: any = useRef();
const target: any = useRef();
const start = useCallback(
event => {
if (props.obj.shouldPreventDefault && event.target) {
event.target.addEventListener("touchend", preventDefault, {
passive: false
});
target.current = event.target;
}
timeout.current = setTimeout(() => {
props.onLongPress(event);
setLongPressTriggered(true);
}, props.obj.delay);
},
[props]
);
const clear = useCallback(
(event, shouldTriggerClick = true) => {
timeout.current && clearTimeout(timeout.current);
shouldTriggerClick && !longPressTriggered && props.onClick(event);
setLongPressTriggered(false);
if (props.obj.shouldPreventDefault && target.current) {
target.current.removeEventListener("touchend", preventDefault);
}
},
[longPressTriggered, props]
);
return {
onMouseDown: (e: any) => start(e),
onTouchStart: (e: any) => start(e),
onMouseUp: (e: any) => clear(e),
onMouseLeave: (e: any) => clear(e, false),
onTouchEnd: (e: any) => clear(e)
};
};
const isTouchEvent = (event: any) => {
return "touches" in event;
};
const preventDefault = (event: any) => {
if (!isTouchEvent(event)) return;
if (event.touches.length < 2 && event.preventDefault) {
event.preventDefault();
}
};
export default useLongPress;
Use of that common function
import useLongPress from "shared/components/longpress";
const onLongPress = () => {
console.log('longpress is triggered');
// setlongPressCount(longPressCount + 1)
};
const onClick = () => {
console.log('click is triggered')
// setClickCount(clickCount + 1)
}
const defaultOptions = {
shouldPreventDefault: true,
delay: 500,
};
<div {...longPressEvent}></div>
After much deliberation, looking at other answers, and adding newer features, I think I now have a solid, if not the best, React long press implementation yet. Here are the highlights:
Only one fn needs to be passed that will be used for both onClick and onLongPress, though they can still be individually defined
Stores the fn in a ref so you can do state updates without having to worry about the fn going stale and not getting the latest react state
Allows for a static or dynamic delay so the longPress fn can start to execute faster or slower depending on how long the button has been held
Written in typescript
// useInterval.ts
import React from "react";
export default function useInterval(callback: any, delay: number | null) {
const savedCallback = React.useRef<any>();
React.useEffect(() => {
savedCallback.current = callback;
});
React.useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
// useLongPress.ts
import React from "react";
import useInterval from "./use-interval";
type Fn<T> = (
e: React.MouseEvent<T, MouseEvent>,
pressedTimeElapsedInMs: number
) => void;
type Opts<T extends HTMLElement> = {
shouldPreventDefault?: boolean;
delay?: number | ((pressedTimeElapsedInMs: number) => number);
onClick?: boolean | Fn<T>;
};
/**
* useLongPress hook that handles onClick and longPress events.
* if you dont pass an onClick fn, the longPress fn will be for onClick.
* the delay can be a number or a function that recieves how long the button has been pressed.
* This value can be used to calculate a dynamic value.
* The onClick and longPress fns will receive the click or touch event as the first parameter,
* and how long the button has been pressed as the second parameter.
* #param onLongPress
* #param opts
* #returns
*/
export default function useLongPress<T extends HTMLElement>(
onLongPress: Fn<T>,
opts: Opts<T> = {}
) {
const {
// default onClick to onLongPress if no onClick fn is provided
onClick = onLongPress,
shouldPreventDefault = true,
delay: initialDelay = 300,
} = opts;
// hold duration in ms
const [holdDuration, setHoldDuration] = React.useState(0);
const [longPressTriggered, setLongPressTriggered] = React.useState(false);
const [delay, setDelay] = React.useState(0);
const target = React.useRef<EventTarget | null>(null);
// store the click or touch event globally so the fn function can pass it to longPress
const evt = React.useRef<any | null>(null);
// store the latest onLongPress and onClick fns here to prevent them being stale when used
const longPressRef = React.useRef<Fn<T>>();
const clickRef = React.useRef<Fn<T>>();
// update the onClick and onLongPress fns everytime they change
React.useEffect(() => {
longPressRef.current = onLongPress;
// if false is passed as onClick option, use onLongPress fn in its place
clickRef.current = typeof onClick === "boolean" ? onLongPress : onClick;
}, [onClick, onLongPress]);
// this fn will be called onClick and in on interval when the btn is being held down
const fn = React.useCallback(() => {
// call the passed in onLongPress fn, giving it the click
// event and the length of time the btn is being held
longPressRef.current?.(evt.current, holdDuration);
// get the latest delay duration by passing the current
// hold duration if it was a fn, or just use the number
const updatedDelay =
typeof initialDelay === "function"
? initialDelay(holdDuration)
: initialDelay;
// update the delay if its dynamic
setDelay(updatedDelay);
// update how long the btn has been pressed
setHoldDuration(holdDuration + updatedDelay);
setLongPressTriggered(true);
}, [initialDelay, holdDuration]);
// start calling the fn function on an interval as the button is being held
useInterval(fn, longPressTriggered ? delay : null);
// this fn is called onMouseDown and onTouchStart
const start = React.useCallback(
(event: React.MouseEvent<T, MouseEvent> | React.TouchEvent<T>) => {
if (shouldPreventDefault && event.target) {
event.target.addEventListener("touchend", preventDefault, {
passive: false,
});
target.current = event.target;
}
// globally store the click event
evt.current = event;
// call the fn function once, which handles the onClick
fn();
},
[shouldPreventDefault, fn]
);
// this fn is called onMouseUp and onTouchEnd
const clear = React.useCallback(
(
event: React.MouseEvent<T, MouseEvent> | React.TouchEvent<T>,
shouldTriggerClick = true
) => {
// reset how long the btn has been held down
setHoldDuration(0);
if (shouldTriggerClick && !longPressTriggered) {
clickRef.current?.(
event as React.MouseEvent<T, MouseEvent>,
holdDuration
);
}
// stop the interval
setLongPressTriggered(false);
// clear the globally stored click event
evt.current = null;
if (shouldPreventDefault && target.current) {
target.current.removeEventListener("touchend", preventDefault);
}
},
[clickRef, longPressTriggered, shouldPreventDefault, holdDuration]
);
return {
onMouseDown: (e: React.MouseEvent<T, MouseEvent>) => start(e),
onMouseUp: (e: React.MouseEvent<T, MouseEvent>) => clear(e),
onMouseLeave: (e: React.MouseEvent<T, MouseEvent>) => clear(e, false),
onTouchStart: (e: React.TouchEvent<T>) => start(e),
onTouchEnd: (e: React.TouchEvent<T>) => clear(e),
};
}
const assertTouchEvt = (event: Event | TouchEvent): event is TouchEvent => {
return "touches" in event;
};
const preventDefault = (event: Event | TouchEvent) => {
if (!assertTouchEvt(event)) return;
if (event.touches.length < 2 && event.preventDefault) {
event.preventDefault();
}
};
Then the hook can be used in the following ways:
state update with default options
export default App() {
const [count, setCount] = React.useState(0)
const useIncrement = useLongPress((e, holdDurationInMs) => {
setCount(count + 1)
})
}
state update with a static delay and where the amount increases based on how many milliseconds the button has been held down
export default App() {
const [count, setCount] = React.useState(0)
const useIncrement = useLongPress((e, holdDurationInMs) => {
if (holdDurationInMs < 1000) setCount(count + (e.metaKey || e.shiftKey ? 5 : 1))
else if (holdDurationInMs < 3000) setCount(count + 5)
else setCount(count + 100)
}, {
delay: 300
})
}
state update with a dynamic delay that executes the function faster as the button is held down longer
export default App() {
const [count, setCount] = React.useState(0)
const useIncrement = useLongPress((e, holdDurationInMs) => {
setCount(count + 1)
}, {
delay: (holdDurationInMs) => {
if (holdDurationInMs < 1000) return 550;
else if (holdDurationInMs < 3000) return 450;
else if (holdDurationInMs < 8000) return 250;
else return 110;
},
})
}
Thanks, #sudo bangbang for this great custom hook.
I had some problems, though:
When I was scrolling through a table with a mobile device (touch input), this hook accidentally triggered a click during the scrolling. Of course, this is not want we want.
Another problem was if I was scrolling very slowly, the hook accidentally triggered the long press.
I managed to circumvent this behavior with subtle changes:
// Set 'shouldPreventDefault' to false to listen also to 'onMouseUp',
// would be canceled otherwise if 'shouldPreventDefault' would have been 'true'
const defaultOptions = { shouldPreventDefault: false, delay: 500 };
return {
onMouseDown: (e) => start(e),
onTouchStart: (e) => start(e),
onMouseUp: (e) => clear(e),
onMouseLeave: (e) => clear(e, false),
onTouchEnd: (e) => clear(e, false), // Do not trigger click here
onTouchMove: (e) => clear(e, false), // Do not trigger click here
};
Here is my implementation with the modifications
import { useCallback, useRef, useState } from "react";
// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// Custom hook to handle a long press event (e.g. on mobile for secondary action)
// https://stackoverflow.com/a/48057286/7220665
// Usage:
// const onLongPress = () => {console.info('long press is triggered')};
// const onClick = () => {console.info('click is triggered')};
// const defaultOptions = { shouldPreventDefault: false, delay: 500 };
// const longPressEvent = useLongPress(onLongPress, onClick, defaultOptions);
// return <button {...longPressEvent}>do long Press</button>
//
// If we are scrolling with the finger 'onTouchStart' and 'onTouchEnd' is triggered
// if we are clicking with the finger additionally to 'onTouchStart' and 'onTouchEnd' ->
// 'onMouseDown' 'onMouseUp' is triggered as well
// We do not want a click event if the user is just scrolling (e.g. in a list or table)
// That means 'onTouchEnd' should not trigger a click
// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// Hook
const useLongPress = (onLongPress, onClick, { shouldPreventDefault = true, delay = 300 } = {}) => {
// console.info("useLongPress");
const [longPressTriggered, setLongPressTriggered] = useState(false);
const timeout = useRef();
const target = useRef();
//
// Start the long press if 'onMouseDown' or 'onTouchStart'
const start = useCallback(
(event) => {
console.info("useLongPress start");
// Create listener
if (shouldPreventDefault && event.target) {
event.target.addEventListener("touchend", preventDefault, { passive: false });
target.current = event.target;
}
// A long press event has been triggered
timeout.current = setTimeout(() => {
onLongPress(event);
setLongPressTriggered(true);
}, delay);
},
[onLongPress, delay, shouldPreventDefault]
);
//
// Clear the long press if 'onMouseUp', 'onMouseLeave' or 'onTouchEnd'
const clear = useCallback(
(event, shouldTriggerClick = true) => {
console.info("useLongPress clear event:", event);
timeout.current && clearTimeout(timeout.current);
shouldTriggerClick && !longPressTriggered && onClick(event);
setLongPressTriggered(false);
// Create listener
if (shouldPreventDefault && target.current) {
target.current.removeEventListener("touchend", preventDefault);
}
},
[shouldPreventDefault, onClick, longPressTriggered]
);
//
//
return {
onMouseDown: (e) => start(e),
onTouchStart: (e) => start(e),
onMouseUp: (e) => clear(e),
onMouseLeave: (e) => clear(e, false),
onTouchEnd: (e) => clear(e, false), // Do not trigger click here
onTouchMove: (e) => clear(e, false), // Do not trigger click here
};
};
//
// Check if it is a touch event - called by 'preventDefault'
const isTouchEvent = (event) => {
console.info("useLongPress isTouchEvent");
return "touches" in event;
};
//
//
const preventDefault = (event) => {
console.info("useLongPress preventDefault");
if (!isTouchEvent(event)) return;
if (event.touches.length < 2 && event.preventDefault) {
if (event.cancelable) event.preventDefault();
}
};
export default useLongPress;
Now a click is NOT triggered onTouchUp (which will be called if we are scrolling through a list or table) but onMouseUp, which will be triggered additionally to onTouchUp if we are scrolling (although we are not really using a mouse)
Related
Requirement
I have a requirement to get the editor state in JSON format as well as the text content of the editor. In addition, I want to receive these values in the debounced way.
I wanted to get these values (as debounced) because I wanted to send them to my server.
Dependencies
"react": "^18.2.0",
"lexical": "^0.3.8",
"#lexical/react": "^0.3.8",
You don't need to touch any of Lexical's internals for this; a custom hook that reads and "stashes" the editor state into a ref and sets up a debounced callback (via use-debounce here, but you can use whatever implementation you like) is enough.
getEditorState is in charge of converting the editor state into whichever format you want to send over the wire. It's always called within editorState.read().
function useDebouncedLexicalOnChange<T>(
getEditorState: (editorState: EditorState) => T,
callback: (value: T) => void,
delay: number
) {
const lastPayloadRef = React.useRef<T | null>(null);
const callbackRef = React.useRef<(arg: T) => void | null>(callback);
React.useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const callCallbackWithLastPayload = React.useCallback(() => {
if (lastPayloadRef.current) {
callbackRef.current?.(lastPayloadRef.current);
}
}, []);
const call = useDebouncedCallback(callCallbackWithLastPayload, delay);
const onChange = React.useCallback(
(editorState) => {
editorState.read(() => {
lastPayloadRef.current = getEditorState(editorState);
call();
});
},
[call, getEditorState]
);
return onChange;
}
// ...
const getEditorState = (editorState: EditorState) => ({
text: $getRoot().getTextContent(false),
stateJson: JSON.stringify(editorState)
});
function App() {
const debouncedOnChange = React.useCallback((value) => {
console.log(new Date(), value);
// TODO: send to server
}, []);
const onChange = useDebouncedLexicalOnChange(
getEditorState,
debouncedOnChange,
1000
);
// ...
<OnChangePlugin onChange={onChange} />
}
Code
File: onChangeDebouce.tsx
import {$getRoot} from "lexical";
import { useLexicalComposerContext } from "#lexical/react/LexicalComposerContext";
import React from "react";
const CAN_USE_DOM = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined';
const useLayoutEffectImpl = CAN_USE_DOM ? React.useLayoutEffect : React.useEffect;
var useLayoutEffect = useLayoutEffectImpl;
type onChangeFunction = (editorStateJson: string, editorText: string) => void;
export const OnChangeDebounce: React.FC<{
ignoreInitialChange?: boolean;
ignoreSelectionChange?: boolean;
onChange: onChangeFunction;
wait?: number
}> = ({ ignoreInitialChange= true, ignoreSelectionChange = false, onChange, wait= 167 }) => {
const [editor] = useLexicalComposerContext();
let timerId: NodeJS.Timeout | null = null;
useLayoutEffect(() => {
return editor.registerUpdateListener(({
editorState,
dirtyElements,
dirtyLeaves,
prevEditorState
}) => {
if (ignoreSelectionChange && dirtyElements.size === 0 && dirtyLeaves.size === 0) {
return;
}
if (ignoreInitialChange && prevEditorState.isEmpty()) {
return;
}
if(timerId === null) {
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
})
}, wait);
} else {
clearTimeout(timerId);
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
});
}, wait);
}
});
}, [editor, ignoreInitialChange, ignoreSelectionChange, onChange]);
return null;
}
This is the code for the plugin and it is inspired (or copied) from OnChangePlugin of lexical
Since, lexical is in early development the implementation of OnChangePlugin might change. And in fact, there is one more parameter added as of version 0.3.8. You can check the latest code at github.
The only thing I have added is calling onChange function in timer logic.
ie.
if(timerId === null) {
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
})
}, wait);
} else {
clearTimeout(timerId);
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
});
}, wait);
}
If you are new to lexical, then you have to use declare this plugin as a child of lexical composer, something like this.
File: RichEditor.tsx
<LexicalComposer initialConfig={getRichTextConfig(namespace)}>
<div className="editor-shell lg:m-2" ref={scrollRef}>
<div className="editor-container">
{/* your other plugins */}
<RichTextPlugin
contentEditable={<ContentEditable
className={"ContentEditable__root"} />
}
placeholder={<Placeholder text={placeHolderText} />}
/>
<OnChangeDebounce onChange={onChange} />
</div>
</div>
</LexicalComposer>
In this code, as you can see I have passed the onChange function as a prop and you can also pass wait in milliseconds like this.
<OnChangeDebounce onChange={onChange} wait={1000}/>
Now the last bit is the implementation of onChange function, which is pretty straightforward
const onChange = (editorStateJson:string, editorText:string) => {
console.log("editorStateJson:", editorStateJson);
console.log("editorText:", editorText);
// send data to a server or to your data store (eg. redux)
};
Finally
Thanks to Meta and the lexical team for open sourcing this library. And lastly, the code I have provided works for me, I am no expert, feel free to comment to suggest an improvement.
Function for click outside detection -
const useClickOutside = <T extends HTMLElement>(initialIsVisible: boolean) => {
const [isVisibleState, setIsVisibleState] = useState<boolean>(initialIsVisible);
const ref = useRef<T>(null);
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setIsVisibleState(() => false);
}
};
const toggleVisibility = () => setIsVisibleState((prev) => !prev);
useEffect(() => {
document.addEventListener('click', handleClickOutside, true);
return () => {
document.removeEventListener('click', handleClickOutside, true);
};
}, []);
return { ref, isVisible: isVisibleState, toggleVisibility };
};
Usage -
const {
ref,
isVisible,
toggleVisibility,
} = useClickOutside<HTMLDivElement>(false);
I have a click event for the tooltip opening, looks like this -
Opening is working, here is the code -
<ThreeDots onClick={props.toggleVisibility} />
After firing the click event -
When clicking outside, the tooltip is closed.
The problem is when I click on the <ThreeDots onClick={props.toggleVisibility} /> again -
It does not close the tooltip. I checked the isVisibleState and it's set to false.
I am trying to create a simple "wrapper" to make HTML elements "draggable".
The problem I am having is that the "useState" variable draggable is always false within the event handler mouseMove. The event handlers mouseDown and mouseUp seem to work fine, except the fact that dragging is not properly set to true.
What am I doing wrong? Every suggestion or hint is highly appreciated!!!
PS: console.log('Dragging is set to true') and console.log('Dragging is set to false') are working fine! As far as understand it, all events "fire" properly. It's just that dragging is always false within mouseMove.
import React, { useEffect, useState, useRef } from 'react';
interface Coordinate {
x: number;
y: number;
}
const SimpleDraggableWrapper = (props: any) => {
const [offset, setOffset] = useState<Coordinate | null>(null);
const [dragging, setDragging] = useState<boolean>(false);
const element = useRef<HTMLDivElement>(null);
const mouseDown = (event: React.MouseEvent) => {
if (element.current) {
setOffset({
x: event.clientX - element.current.offsetLeft,
y: event.clientY - element.current.offsetTop,
});
setDragging(true);
console.log('Dragging set to true');
}
};
const mouseUp = (event: MouseEvent) => {
setDragging(false);
console.log('Dragging set to false');
};
const mouseMove = (event: MouseEvent) => {
console.log(dragging);
if (dragging && element.current && offset) {
element.current.style.left = `${event.clientX - offset.x}px`;
element.current.style.top = `${event.clientY - offset.y}px`;
}
};
useEffect(() => {
document.addEventListener('mouseup', mouseUp);
document.addEventListener('mousemove', mouseMove);
return () => {
document.removeEventListener('mouseup', mouseUp);
document.removeEventListener('mousemove', mouseMove);
};
}, []);
return (
<div ref={element} onMouseDown={mouseDown}>
{React.cloneElement(props.children)}
</div>
);
};
export default SimpleDraggableWrapper;
This is a common problem related to stale closures.
To know more check: https://dmitripavlutin.com/react-hooks-stale-closures/.
You can resolve this issue in two different ways:
Add dragging in the useEffect dependency array
useEffect(() => {
document.addEventListener('mouseup', mouseUp);
return () => {
document.removeEventListener('mouseup', mouseUp);
};
}, []);
useEffect(() => {
document.addEventListener('mousemove', mouseMove);
return () => {
document.removeEventListener('mousemove', mouseMove);
};
}, [dragging]);
Wrap mouseMove in a useCallback hook with dragging in its dependency array and then add mouseMouve function in the useEffect dependency array
const mouseMove = useCallback((event: MouseEvent) => {
console.log(dragging);
if (dragging && element.current && offset) {
element.current.style.left = `${event.clientX - offset.x}px`;
element.current.style.top = `${event.clientY - offset.y}px`;
}
}, [dragging]);
useEffect(() => {
document.addEventListener('mouseup', mouseUp);
return () => {
document.removeEventListener('mouseup', mouseUp);
};
}, []);
useEffect(() => {
document.addEventListener('mousemove', mouseMove);
return () => {
document.removeEventListener('mousemove', mouseMove);
};
}, [mouseMove]);
Can someone please tell me what's wrong with this and why the state of the 'video variable' remains false? So, even after the h2 element has rendered and is visible (i.e. the state of the video variable has been updated to true), when I click and call the hideVideo function, the video state remains false? Many thanks.
export default function App() {
const [message, showMessage] = useState(false);
const [video, setVideo] = useState(false);
let modalTimeout, videoTimeout;
useEffect(() => {
window.addEventListener("click", hideVideo);
setupTimeouts();
return () => {
clearTimeout(modalTimeout);
clearTimeout(videoTimeout);
};
}, []);
const setupTimeouts = () => {
modalTimeout = setTimeout(() => {
showMessage(true);
videoTimeout = setTimeout(() => {
showMessage(false);
setVideo(true);
}, 4000);
}, 2000);
};
const hideVideo = () => {
console.log(video);
showMessage(false);
if (video === true) {
setVideo(false);
}
};
return (
<div className="App">
{message && <h1>Message</h1>}
{video && <h2>Video</h2>}
</div>
);
}
When you call useEffect the window listener attach the default video value that is false to the function hideVideo() so it will be always false, I created a button to show you that the video state value does change. check the last test function
export default function App() {
const [message, showMessage] = useState(false);
const [video, setVideo] = useState(false);
let modalTimeout, videoTimeout;
useEffect(() => {
window.addEventListener("click", hideVideo);
setupTimeouts();
return () => {
clearTimeout(modalTimeout);
clearTimeout(videoTimeout);
};
}, []);
const setupTimeouts = () => {
modalTimeout = setTimeout(() => {
showMessage(true);
videoTimeout = setTimeout(() => {
showMessage(false);
setVideo(true);
}, 4000);
}, 2000);
};
const hideVideo = () => {
console.log(video);
showMessage(false);
if (video) {
setVideo(false);
}
};
const test = (event) => {
event.stopPropagation();
console.log(video)
}
return (
<>
{message && <h1>Message</h1>}
{video && <h2>Video</h2>}
<button onClick={test}>test</button>
</>
);
}
When I click, I set the saveMouseDown state to 1, when I release I set it to 0.
When I click and move the mouse I log out mouseDown and it's 0 even when my mouse is down? Yet on the screen it shows 1
import React, { useEffect, useRef, useState } from 'react';
const Home: React.FC = () => {
const [mouseDown, saveMouseDown] = useState(0);
const [canvasWidth, saveCanvasWidth] = useState(window.innerWidth);
const [canvasHeight, saveCanvasHeight] = useState(window.innerHeight);
const canvasRef = useRef<HTMLCanvasElement>(null);
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D | null;
const addEventListeners = () => {
canvas.addEventListener('mousedown', (e) => { toggleMouseDown(); }, true);
canvas.addEventListener('mouseup', (e) => { toggleMouseUp(); }, true);
};
const toggleMouseDown = () => saveMouseDown(1);
const toggleMouseUp = () => saveMouseDown(0);
const printMouse = () => console.log(mouseDown);
// ^------ Why does this print the number 1 and the 2x 0 and then 1... and not just 1?
const removeEventListeners = () => {
canvas.removeEventListener('mousedown', toggleMouseDown);
canvas.removeEventListener('mouseup', toggleMouseUp);
};
useEffect(() => {
if (canvasRef.current) {
canvas = canvasRef.current;
ctx = canvas.getContext('2d');
addEventListeners();
}
return () => removeEventListeners();
}, []);
useEffect(() => {
if (canvasRef.current) {
canvas = canvasRef.current;
canvas.addEventListener('mousemove', (e) => { printMouse(); }, true );
}
return () => canvas.removeEventListener('mousemove', printMouse);
}, [mouseDown, printMouse]);
return (
<React.Fragment>
<p>Mouse Down: {mouseDown}</p>
{/* ^------ When this does print 1? */}
<canvas
id='canvas'
ref={canvasRef}
width={canvasWidth}
height={canvasHeight}
/>
</React.Fragment>
);
};
export { Home };
You only add the move listener once when the component mounted, thus enclosing the initial mouseDown value.
Try using a second useEffect hook to specifically set/update the onMouseMove event listener when the mouseDown state changes. The remove eventListener needs to specify the same callback.
useEffect(() => {
if (canvasRef.current) {
canvas = canvasRef.current;
canvas.addEventListener('mousemove', printMouse, true );
}
return () => canvas.removeEventListener('mousemove', printMouse);;
}, [mouseDown, printMouse]);
It may be simpler to attach the event listeners directly on the canvas element, then you don't need to worry about working with enclosed stale state as much with the effect hooks.
<canvas
onMouseDown={() => setMouseDown(1)}
onMouseUp={() => setMouseDown(0)}
onMouseMove={printMouse}
width={canvasWidth}
height={canvasHeight}
/>