Change hover cards to not appear until the mouse stops in web UI

Eugen Rochko 2024-06-27 00:33:52 +02:00
parent 3939352e92
commit 20852257d1
4 changed files with 111 additions and 54 deletions

View File

@ -2,19 +2,34 @@ import { useRef, useCallback, useEffect } from 'react';
export const useTimeout = () => {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const callbackRef = useRef<() => void>();
const set = useCallback((callback: () => void, delay: number) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
callbackRef.current = callback;
timeoutRef.current = setTimeout(callback, delay);
}, []);
const delay = useCallback((delay: number) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (!callbackRef.current) {
return;
}
timeoutRef.current = setTimeout(callbackRef.current, delay);
}, []);
const cancel = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = undefined;
callbackRef.current = undefined;
}
}, []);
@ -25,5 +40,5 @@ export const useTimeout = () => {
[cancel],
);
return [set, cancel] as const;
return [set, cancel, delay] as const;
};

View File

@ -25,17 +25,19 @@ const messages = defineMessages({
});
export const FollowButton: React.FC<{
accountId: string;
accountId?: string;
}> = ({ accountId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
accountId ? state.relationships.get(accountId) : undefined,
);
const following = relationship?.following || relationship?.requested;
useEffect(() => {
dispatch(fetchRelationships([accountId]));
if (accountId) {
dispatch(fetchRelationships([accountId]));
}
}, [dispatch, accountId]);
const handleClick = useCallback(() => {

View File

@ -17,7 +17,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store';
export const HoverCardAccount = forwardRef<
HTMLDivElement,
{ accountId: string }
{ accountId?: string }
>(({ accountId }, ref) => {
const dispatch = useAppDispatch();

View File

@ -12,7 +12,7 @@ import { useTimeout } from 'mastodon/../hooks/useTimeout';
import { HoverCardAccount } from 'mastodon/components/hover_card_account';
const offset = [-12, 4] as OffsetValue;
const enterDelay = 650;
const enterDelay = 500;
const leaveDelay = 250;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
@ -23,50 +23,14 @@ export const HoverCardController: React.FC = () => {
const [open, setOpen] = useState(false);
const [accountId, setAccountId] = useState<string | undefined>();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const cardRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement | null>(null);
const anchorRef = useRef<HTMLElement | null>(null);
const scrollingRef = useRef<boolean>(false);
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
const [setEnterTimeout, cancelEnterTimeout] = useTimeout();
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
const [setScrollTimeout] = useTimeout();
const location = useLocation();
const handleAnchorMouseEnter = useCallback(
(e: MouseEvent) => {
const { target } = e;
if (target instanceof HTMLElement && isHoverCardAnchor(target)) {
cancelLeaveTimeout();
setEnterTimeout(() => {
target.setAttribute('aria-describedby', 'hover-card');
setAnchor(target);
setOpen(true);
setAccountId(
target.getAttribute('data-hover-card-account') ?? undefined,
);
}, enterDelay);
}
if (target === cardRef.current?.parentNode) {
cancelLeaveTimeout();
}
},
[cancelLeaveTimeout, setEnterTimeout, setOpen, setAccountId, setAnchor],
);
const handleAnchorMouseLeave = useCallback(
(e: MouseEvent) => {
if (e.target === anchor || e.target === cardRef.current?.parentNode) {
cancelEnterTimeout();
setLeaveTimeout(() => {
anchor?.removeAttribute('aria-describedby');
setOpen(false);
setAnchor(null);
}, leaveDelay);
}
},
[cancelEnterTimeout, setLeaveTimeout, setOpen, setAnchor, anchor],
);
const handleClose = useCallback(() => {
cancelEnterTimeout();
cancelLeaveTimeout();
@ -79,22 +43,98 @@ export const HoverCardController: React.FC = () => {
}, [handleClose, location]);
useEffect(() => {
document.body.addEventListener('mouseenter', handleAnchorMouseEnter, {
const handleMouseEnter = (e: MouseEvent) => {
const { target } = e;
if (scrollingRef.current) {
return;
}
if (target instanceof HTMLElement && isHoverCardAnchor(target)) {
cancelLeaveTimeout();
setEnterTimeout(() => {
anchorRef.current = target;
target.setAttribute('aria-describedby', 'hover-card');
setAnchor(target);
setOpen(true);
setAccountId(
target.getAttribute('data-hover-card-account') ?? undefined,
);
}, enterDelay);
}
if (target === cardRef.current?.parentNode) {
cancelLeaveTimeout();
}
};
const handleMouseLeave = (e: MouseEvent) => {
if (
e.target === anchorRef.current ||
e.target === cardRef.current?.parentNode
) {
cancelEnterTimeout();
setLeaveTimeout(() => {
anchorRef.current?.removeAttribute('aria-describedby');
setOpen(false);
setAnchor(null);
}, leaveDelay);
}
};
const handleScrollEnd = () => {
scrollingRef.current = false;
};
const handleScroll = () => {
scrollingRef.current = true;
cancelEnterTimeout();
setScrollTimeout(handleScrollEnd, 100);
};
const handleMouseMove = () => {
delayEnterTimeout(enterDelay);
};
document.body.addEventListener('mouseenter', handleMouseEnter, {
passive: true,
capture: true,
});
document.body.addEventListener('mouseleave', handleAnchorMouseLeave, {
document.body.addEventListener('mousemove', handleMouseMove, {
passive: true,
capture: false,
});
document.body.addEventListener('mouseleave', handleMouseLeave, {
passive: true,
capture: true,
});
document.addEventListener('scroll', handleScroll, {
passive: true,
capture: true,
});
return () => {
document.body.removeEventListener('mouseenter', handleAnchorMouseEnter);
document.body.removeEventListener('mouseleave', handleAnchorMouseLeave);
document.body.removeEventListener('mouseenter', handleMouseEnter);
document.body.removeEventListener('mousemove', handleMouseMove);
document.body.removeEventListener('mouseleave', handleMouseLeave);
document.removeEventListener('scroll', handleScroll);
};
}, [handleAnchorMouseEnter, handleAnchorMouseLeave]);
if (!accountId) return null;
}, [
setEnterTimeout,
setLeaveTimeout,
setScrollTimeout,
cancelEnterTimeout,
cancelLeaveTimeout,
delayEnterTimeout,
setOpen,
setAccountId,
setAnchor,
]);
return (
<Overlay