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 🙌
Related
I have a component with another component inside it that I only want to show if the user is still hovering after a short delay.
So I wrote a couple handlers for onMouseEnter and onMouseExit to set a variable to show that its hovered. Then, after sleeping, if it's still hovered I want to set a second variable to show the element.
However, hovered is showing as false no matter what. Why?
const [hovered, setHovered] = useState(false);
const [show, setShow] = useState(false);
console.log('hovered', hovered); // This shows the state correctly
const handleEnter = () => {
setHovered(true);
sleep(2000).then(() => {
console.log('checking', hovered); // This always shows false
if (hovered) {
setShow(true);
}
});
}
const handleExit = () => {
setHovered(false);
setShow(false);
}
Edit
Solution:
Replace sleep with a wrapped callback from use-debounce to prevent it from firing multiple times, and have the delay work still.
const ref = useRef(false);
const [hovered, setHovered] = useState(false);
const [show, setShow] = useState(false);
const handleHover = useDebouncedCallback(() => {
if (ref.current) setShow(true);
else setShow(false);
}, 1000);
useEffect(() => {
ref.current = hovered;
}, [hovered]);
useEffect(() => {
handleHover()
}, [hovered]);
i would recommend you to use useRef hook.
const hoveredRef = useRef(false);
const [hovered, setHovered] = useState(false);
const [show, setShow] = useState(false);
useEffect(() => {
hoveredRef.current = hovered;
}, [hovered])
useEffect(() => {
if (!hovered) return;
sleep(2000).then(() => {
console.log('checking', hoveredRef.current);
if (hoveredRef.current) {
setShow(true);
}
}, [hovered])
const handleEnter = () => {
setHovered(true);
}
const handleExit = () => {
setHovered(false);
setShow(false);
}
I didnt check it but should be ok, i dont have the rest of your code, sorry
Regarding your question Why:
const handleEnter = () => {
setHovered(true);
sleep(2000).then(() => {
console.log('checking', hovered); // This always shows false
if (hovered) {
setShow(true);
}
});
}
SetHover that you are calling here does not change the variable immediately, for all the code after this line in function it will still be false. And in the sleep.then function scope it still uses a closure-captured old "false" value.
And a little warning here, code above is not ideal, if you hover in-out 5 times within your 2 second delay - code inside sleep.then will fire 5 times successfully. But it is harmless in your case, in terms of behavior.
I'm using matchMedia in React to collapse my SideBar when the page is resizing. But the problem is if I refresh the page, my sidebar is open not closed. So if I want to collapse my SideBar I need to resize the page again or use the close button.
const layout = document.getElementById('home-layout');
const query = window.matchMedia('(max-width: 765px)');
query.onchange = (evt) => {
if( query.matches ) {
changeMenuMinified(true);
layout.classList.add('extended-layout');
}
else {
changeMenuMinified(false);
layout.classList.remove('extended-layout');
}
};
query.onchange();
};
useEffect(() => {
window.addEventListener('resize', handleResize);
});
If I remove addEventListener it works, I can reload the page and my sidebar stays closed but if I try to open the sidebar with a button, the sidebar closes quickly
const handleResize = () => {
const layout = document.getElementById('home-layout');
const query = window.matchMedia('(max-width: 765px)');
query.onchange = (evt) => {
if( query.matches ) {
changeMenuMinified(true);
layout.classList.add('extended-layout');
}
else {
changeMenuMinified(false);
layout.classList.remove('extended-layout');
}
};
query.onchange();
};
useEffect(() => {
handleResize()
});
sideBar
Some stuff to consider here:
Initialize your state with the current matching value
Remove listener on effect cleanup function
Don't forget the useEffect dependency array to avoid your code being executed on each render.
You can find a working example here -> https://codesandbox.io/s/stack-72619755-lpwh6m?file=/src/index.js:0-613
const query = window.matchMedia('(max-width: 765px)')
const App = () => {
const [minified, changeMenuMinified] = useState(query.matches)
useEffect(() => {
const resizeHandler = () => {
if (query.matches) {
changeMenuMinified(true)
} else {
changeMenuMinified(false)
}
}
query.addEventListener("change", resizeHandler);
return () => query.removeEventListener("change", resizeHandler);
})
return <p>{minified ? 'minified' : 'expanded'}</p>
}
That's because you need to have both in order to work, on load and also on reside, for that you can just do so:
Notice I added that empty dependencies array.
useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
},[]);
Basically the same question as How to cancel a javascript function if the user scrolls the page but using react hooks.
I wrote react code that scrolls down to the end of the page after 3 seconds.
const scrollToEnd = () => { /* implementation omitted */ }
useEffect(() => {
const id = setTimeout(() => scrollToEnd(), 3000)
return () => clearTimeout(id)
}, [])
I want modify this code so that if the user manually scrolls the page before this timeout, the timeout is cleared.
I was thinking of a solution like:
const [hasScrolled, setHasScrolled] = useState(false);
const scrollToEnd = () => { /* implementation omitted */ }
useEffect(() => {
const setHasScrolledCallback = () => setHasScrolled(true)
window.addEventListener("scroll", setHasScrolledCallback);
return () => window.removeEventListener("scroll", setHasScrolledCallback);
}, []);
useEffect(() => {
const scrollCallback = () => { if (hasScrolled) scrollToEnd() }
const id = setTimeout(scrollCallback, 3000)
return () => clearTimeout(id)
}, [])
This works, but I don't think this is the correct way to approach this problem, because the scroll event is fired multiple times, even after the timeout occurs. Also the scrollCallback isn't really canceled, it runs anyway even if it does nothing.
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.
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}`
}}
/>
);
};