I have a slider component, which should stop moving after mouse is up. I have went through the forum and my code is very similar to the one here
const Slider = ({ mainColour }) => {
const [cursorPos, setCursorPos] = React.useState(0);
const [isSliding, setSliding] = React.useState(false);
const ref = React.useRef();
const drag = e => {
console.log("dragging");
setCursorPos(e.pageY);
};
const startDrag = e => {
setSliding(true);
window.addEventListener("mousemove", drag);
window.addEventListener("mouseup", function() {
ref.current.onmousedown = null;
ref.current.onmouseup = null;
ref.current.onmousemove = null;
setSliding(false);
window.onmouseup = null;
});
};
return (
<div
className={`cursor ${isSliding ? "active" : ""}`}
ref={ref}
style={{
top: `${cursorPos}px`,
backgroundColor: `${mainColour}`
}}
onMouseDown={event => startDrag(event)}
></div>
);
};
export default Slider;
However, when startDrag triggers, the window.onmouseup listener doesn't seem to be working and does not stop the slider. Will be appreciated for any insights why it doesn't work.
https://codesandbox.io/s/lucid-sunset-8e78r
React can trigger mouseup, you just need to use window.removeEventListener for drag() when you mouseup. That's why you see dragging in the console after mouseup, you just forgot to unsubscribe from the event :)
window.onmouseup = null; is not the same as window.removeEventListener("mousemove").
const Slider = ({ mainColour }) => {
const [cursorPos, setCursorPos] = React.useState(0);
const [isSliding, setSliding] = React.useState(false);
const ref = React.useRef();
const drag = e => {
console.log("dragging");
setCursorPos(e.pageY);
};
useEffect(() => {
if (isSliding) {
window.addEventListener("mousemove", drag);
}
}, [isSliding]);
useEffect(() => {
window.addEventListener("mouseup", function() {
window.removeEventListener("mousemove", drag);
setSliding(false);
});
});
const startDrag = () => setSliding(true);
return (
<div
className={`cursor ${isSliding ? "active" : ""}`}
ref={ref}
style={{
top: `${cursorPos}px`,
backgroundColor: `${mainColour}`
}}
onMouseDown={event => startDrag(event)}
/>
);
};
I agree with the comment from artanik, but with a very slight change. Instead of using the useEffect without any dependencies, and constantly adding and removing event listeners from the window object, I would rather only set and unset it when the isSliding changes value. Also it seems that the ref is not used anywhere, so I presume instead of using the window object you could set it only for the element in the ref.
The purpose of triggering the useEffect with an empty array once is to not run it every render. Imagine a component that would have a lot of state changing and data going through it, adding and removing a bunch of event listeners in one go in every render is not needed.
const Slider = ({ mainColour }) => {
const [cursorPos, setCursorPos] = React.useState(0);
const [isSliding, setSliding] = React.useState(false);
///only do this once, when the component mounts
useEffect(() => {
window.addEventListener("mousedown", startDrag);
window.addEventListener("mouseup", endDrag);
},[]);
//and setting and unseting the event as needed
useEffect(() => {
if (isSliding) {
window.onmousemove = handleDrag;
}
else{
window.onmousemove = null;
}
}, [isSliding]);
const startDrag = () => setSliding(true);
const endDrag = () => setSliding(false);
const handleDrag = (e) =>{
console.log("dragging");
setCursorPos(e.pageY);
}
return (
<div
className={`cursor ${isSliding ? "active" : ""}`}
style={{
top: `${cursorPos}px`,
backgroundColor: `${mainColour}`
}}
/>
);
};
Related
I want to create a generic react hook that will add a scroll event to the element and return a boolean indicating that the user has scrolled to the top of the element.
Now, the problem is this element might not be visible right away. Hence I'm not able to use useEffect. As I understand in that situation it is advised to use useCallback
So I did, and it works:
function useHasScrolled() {
const [hasScrolled, setHasScrolled] = useState(false);
const ref = useRef(null);
const setRef = useCallback((element) => {
const handleScroll = (e) => {
setHasScrolled(e.target.scrollTop !== 0);
};
if (element) {
element.addEventListener("scroll", handleScroll);
}
ref.current = element;
}, []);
return {
hasScrolled,
scrollingElementRef: setRef
};
}
I can use my hook like this:
const { hasScrolled, scrollingElementRef } = useHasScrolled();
....
return <div ref={scrollingElementRef}>....
However, the problem is, I don't know how to remove the event listener. With the useEffect hook, it's pretty straightforward - you just return the cleanup function.
Here's the codesandbox, if you want to check the implementation: https://codesandbox.io/s/pedantic-dhawan-83fdw3
Expected behavior - when node is removed from DOM - event listeners will be also removed and collected by GC.
But
Codesandbox example is a bit tricky, React treats
<div>Loading...</div>
and
<div className="scrollingDiv" ref={scrollingElementRef}>
<h1>Hello, I've finally loaded!</h1>
<Lorem />
</div>
as a same div, same object, just with different props (className and children), so when div.scrollingDiv is replaced by conditional rendering to div(loading) - event listeners are still there and accumulating.
This behavior can be fixed as is by using keys.
{loading ? (
<div key="div1">Loading...</div>
) : (
<div key="div2" className="scrollingDiv" ref={scrollingElementRef}>
<h1>Hello, I've finally loaded!</h1>
<Lorem />
</div>
)}
In that way event listeners will be removed as expected.
Another solution is to add 1 more useRef and useEffect to the custom hook to store and execute actual unsubscribe function:
function useHasScrolled() {
const [hasScrolled, setHasScrolled] = useState(false);
const ref = useRef(null);
const unsubscribeRef = useRef(null);
const setRef = useCallback((element) => {
const eventName = "scroll";
const handleScroll = (e) => {
setHasScrolled(e.target.scrollTop !== 0);
};
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
if (element) {
element.addEventListener(eventName, handleScroll);
unsubscribeRef.current = () => {
console.log("removeEventListener called on: ", element);
element.removeEventListener(eventName, handleScroll);
};
ref.current = element;
} else {
unsubscribeRef.current = null;
ref.current = null;
}
}, []);
useEffect(() => {
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
};
}, []);
return {
hasScrolled,
scrollingElementRef: setRef
};
}
That code will work without adding key.
Utility code for Chrome dev console to count scroll listeners:
Array.from(document.querySelectorAll('*'))
.reduce(function(pre, dom){
var clks = getEventListeners(dom).scroll;
pre += clks ? clks.length || 0 : 0;
return pre
}, 0)
Updated codesandbox: https://codesandbox.io/s/angry-einstein-6fb1u4?file=/src/App.js
In React I'm trying to get mouse X position after click, if I click in middle of page and move it right it must start counting from 0 not from actual e.clientX would count
here is What I do but seems like doesn't work
import React, {useState, useEffect} from 'react';
const App = () => {
const [startPosition, setStartPosition] = useState(0)
const mouseMove = (e) => {
console.log(e.clientX - startPosition)
}
const mouseClick = (e) => {
setStartPosition(e.clientX)
}
useEffect(() => {
window.addEventListener("mousedown", mouseClick)
window.addEventListener("mousemove", mouseMove)
return () => {
window.removeEventListener("mousedown", mouseClick)
window.removeEventListener("mousemove", mouseMove)
}
},[])
return (
<div className='App' />
);
}
export default App
this code still prints e.clientX not actual cordinates from clicked
If you want to get position when click, you need using onClick Event Listeners.
Example:
const App = () => {
const [startPosition, setStartPosition] = useState(0);
const mouseClick = (e) => {
setStartPosition(e.clientX);
console.log(e.clientX);
};
return <div className="App" onClick={mouseClick} />;
};
Maybe you need add height style for .App like: .App: { height: 100vh }
in useEffect while using window events [] as second argument isn't necessary
useEffect(() => {
window.addEventListener("mousedown", mouseClick)
window.addEventListener("mousemove", mouseMove)
return () => {
window.removeEventListener("mousedown", mouseClick)
window.removeEventListener("mousemove", mouseMove)
}
})
after doing this it works now
I need a way to distinguish manual scroll from programmatically called el.scrollIntoView()
I tried to google it and they have some suggestion like to use wheel event and similar, but that is not solving the problem as it is not covering every scroll type ( for example it is not including manual scroll when you drag and drop scrollBar)
Here is the code for better context:
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const autoScrollDelay: any = useRef(null);
useAutoScroll(autoScrollEnabled);
const pauseAutoScroll = () => {
if (autoScrollEnabled) {
setAutoScrollEnabled(false);
}
clearTimeout(autoScrollDelay.current);
autoScrollDelay.current = setTimeout(() => {
setAutoScrollEnabled(true);
}, 3000);
};
const onScroll = useCallback(
event => {
if (isPlaying) {
pauseAutoScroll();
}
},
[isPlaying, autoScrollEnabled],
);
useEffect(() => {
const list = document.querySelector(
"[data-testid='#panel-layout/content']",
);
list?.addEventListener('scroll', onScroll);
return () => {
list?.removeEventListener('scroll', onScroll);
clearTimeout(autoScrollDelay?.current);
};
}, [onScroll]);
Autoscroll hook
export const useAutoScroll = (enabled = true) => {
const currentTime = useSelector(state => state.timeline.currentTime);
const isPlaying = useSelector(state => state.timeline.isPlaying);
const shouldAutoScroll = currentTime && enabled && isPlaying;
useEffect(() => {
if (shouldAutoScroll) {
const currElUuid = calculateUuid();
const el = document.querySelector(`[data-uuid="${currElUuid}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
}
}, [currentTime, enabled, isPlaying, subs, lastHighlightedUuid]);
};
And then in custom hook I am calling
el.scrollIntoView({ behavior: 'smooth', block: 'end' });
, which triggers the same scroll listener as the manual one, and all the time calls my pauseAutoScroll() method which I need to prevent from being called by scrollIntoView.
Would really appreciate your help 🙌
I am trying to implement a onWheel triggered Nav for a carousel. The button nav works, while the onWheel triggers, but is somehow not accessing the initialized state of the context provider. Any insight would be appreciated. thank you.
context provider:
import CarouselContext from "../context/_carousel"
const CarouselProvider = ({ children }) => {
const [isInitialized, setIsInitialized] = useState(false)
const [carouselLength, setCarouselLength] = useState(0)
const [carouselPosition, setCarouselPosition] = useState(0)
const initializeCarousel = length => {
setCarouselLength(length)
setIsInitialized(true)
console.log(`carouselLength ${carouselLength}`)
}
const nextSlide = () => {
if (carouselPosition === carouselLength) {
setCarouselPosition(0)
} else {
setCarouselPosition(carouselPosition + 1)
}
console.log(`carouselPosition ${carouselPosition}`)
}
const previousSlide = () => {
if (carouselPosition === 0) {
setCarouselPosition(carouselLength)
} else {
setCarouselPosition(carouselPosition - 1)
}
console.log(`carouselPosition ${carouselPosition}`)
}
const state = { carouselLength, carouselPosition, isInitialized }
const methods = { initializeCarousel, nextSlide, previousSlide }
return (
<CarouselContext.Provider value={[state, methods]}>
{children}
</CarouselContext.Provider>
)
}
export default CarouselProvider
carousel structure:
return (
<Page className="works">
<CarouselProvider>
<ScrollNav>
<PreviousWorkButton />
<Carousel>
{works.map((work, index) => (
<CarouselItem key={index}>
<Work project={work} />
</CarouselItem>
))}
</Carousel>
<NextWorkButton />
</ScrollNav>
</CarouselProvider>
</Page>
)
scroll Nav (which is consoling the events are triggered, but not showing the current position of the carousel or length)
const ScrollNav = ({ children }) => {
const [, { nextSlide, previousSlide }] = useContext(CarouselContext)
const delayedScroll = useCallback(
debounce(e => changeNav(e), 500, { leading: true, trailing: false }),
[]
)
const changeNav = direction => {
if (direction === 1) {
nextSlide()
}
if (direction === -1) {
previousSlide()
}
}
const onWheel = e => {
delayedScroll(e.deltaY)
}
return <div onWheel={onWheel}>{children}</div>
}
onclick button that triggers the same event with carousel position and length persisting
const NextWorkButton = () => {
const [, { nextSlide }] = useContext(CarouselContext)
const clicked = () => {
nextSlide()
}
return (
<div className="next-work-button">
<button onClick={clicked}>
<DownArrowSvg />
</button>
</div>
)
}
edited to add console.logs in the provider as is in my local copy
console logs on click event:
carouselPosition 1
carouselLength 5
console log on wheel event (the length does not print):
carouselPosition 0
Thanks to kumarmo2 I solved this by removing the debounce and calling the event directly. I made a very hacky debounce specific to the wheel event with a timer.
my solution:
const ScrollNav = ({ children }) => {
const [, { nextSlide, previousSlide }] = useContext(CarouselContext)
const [debounce, setDebounce] = useState(false)
const timer = () => {
setTimeout(() => {
setDebounce(false)
}, 1000)
}
const changeNav = e => {
let direction = e.deltaY
if (debounce) {
return
} else if (direction >= 1) {
setDebounce(true)
timer()
nextSlide()
return
} else if (direction <= -1) {
setDebounce(true)
timer()
previousSlide()
return
}
}
return <div onWheel={changeNav}>{children}</div>
}
I think what is happening here is that, your delayedScroll is not getting updated because of useCallback. It "captures" the changeNav which in turn captures the nextSlide.
So nextSlide will be called, but since its references inside delayedScroll is not updated because of useCallback, you are facing the issue.
Can you try removing the useCallback and debounce once for delayedScroll ? and if it works, will introduce the debounce logic in the correct way.
I'm working on this Tooltip where if you mouse over it it'll show a tooltip:
But if you tap on it (with your finger) it'll show full screen (for mobile support):
Code looks like this:
export default function Tooltip({ message, children }: Props) {
const [showSmallTip, setShowSmallTip] = useState(false);
const [showBigTap, setShowBigTip] = useState(false);
const ref = useRef(null);
const pos = useBoundingBox(ref);
const handleMouseEnter = useCallback(() => {
setShowSmallTip(true);
}, [setShowSmallTip]);
const handleMouseLeave = useCallback(() => {
setShowSmallTip(false);
}, [setShowSmallTip]);
const handleTap = useCallback(() => {
console.log("TAP!")
setShowBigTip(true);
setShowSmallTip(false);
}, [setShowBigTip]);
const closeFullscreen = useCallback((ev:MouseEvent<HTMLElement>) => {
console.log('CLOSE!!!')
ev.stopPropagation();
setShowBigTip(false);
setShowSmallTip(false);
}, [setShowBigTip]);
const onTap = useTap(handleTap);
return <>
<Wrapper ref={ref} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...onTap}>
{children}
</Wrapper>
{showSmallTip ? <End container={SCROLL_ROOT}><StyledTooltip style={{ top: pos.bottom, left: (pos.left + pos.right) / 2 }}>{message}</StyledTooltip></End> : null}
{showBigTap ? <End><FullscreenTip onClick={closeFullscreen}><FullscreenText>{message}</FullscreenText></FullscreenTip></End>:null}
</>
}
Where useTap is:
export default function useTap<T = Element>(callback: VoidCallback, options?: Options): TouchEvents<T> {
const data = useRef<TouchData>(Object.create(null));
return useMemo<TouchEvents<T>>(() => {
const opt = { ...DEFAULT_OPTIONS, ...options } as Required<Options>;
return {
onTouchStart(ev) {
data.current = {
time: ev.timeStamp,
x: ev.changedTouches[0].screenX,
y: ev.changedTouches[0].screenY,
}
},
onTouchEnd(ev) {
const mx = ev.changedTouches[0].screenX - data.current.x;
const my = ev.changedTouches[0].screenY - data.current.y;
const moved = Math.sqrt(mx**2 + my**2);
const elapsed = ev.timeStamp - data.current.time;
if (moved < opt.moveThreshold && elapsed < opt.pressDelay) {
// setTimeout(() => {
callback();
// }, 0);
}
}
}
}, [callback, options])
}
The issue I'm having is when you tap on the icon it opens and closes the fullscreen tooltip immediately. i.e., it prints
TAP!
CLOSE!!!
with one single tap.
Now I know touchend fires before click, but what I can't figure out is why that would even matter?? If you look at my placement of the {...onTap} and onClick={closeFullscreen} handlers, they're siblings. The events shouldn't bubble that way (neither in the native DOM nor React's VDOM), and I certainly didn't click on <FullscreenTip> so how on earth is closeFullscreen firing?
<End> is a portal.
DEMO
Try to call ev.preventDefault() on onTouchEnd.
According to spec the touchend event is "cancelable" which means that you can use .preventDefault() to prevent mouse events.
If the preventDefault method is called on this event, it should prevent any default actions caused by any touch events associated with the same active touch point, including mouse events or scrolling.