Skip to content

Commit

Permalink
Merge pull request #387 from aVileBroker/360-spotlight-scroll-resize
Browse files Browse the repository at this point in the history
Fixes #360 Stop spotlight from animating during scroll and resize events
  • Loading branch information
aVileBroker committed May 4, 2022
2 parents a5c7820 + bc57ab7 commit 2312d7a
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 56 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ module.exports = {
rules: {
'@typescript-eslint/ban-types': 1, // StyledComponentBase<any, {}> failed every time
'@typescript-eslint/no-empty-function': 0,
'@typescript-eslint/no-inferrable-types': 1,
'arrow-body-style': 0,
'arrow-parens': 0,
'comma-dangle': 1,
Expand Down
4 changes: 2 additions & 2 deletions src/components/Spotlight/Spotlight.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,11 @@ export const AnimatedSpotlight: Story = (args: Partial<SpotlightProps>) => {
containerRef={setButtonRef}
color={colors.tertiary}
>
Start the tour!
Start tour
</Button>
}
>
There are a few items in this card we can talk about
There are a few items in this card we can talk about!
</Card>
{tourStarted && (
<Spotlight {...args} StyledAnnotation={Annotation} targetElement={stepOptions[currStep]}>
Expand Down
89 changes: 35 additions & 54 deletions src/components/Spotlight/Spotlight.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo } from 'react';
import styled from 'styled-components';
import { animated, useSpring } from '@react-spring/web';
import { Portal } from 'react-portal';

import { SubcomponentPropsType, StyledSubcomponentType } from '../commonTypes';
import { useScrollObserver, useWindowSizeObserver } from '../../utils/hooks';
import { useAnalytics } from '../../context';
import { AnimatedDiv } from '../../htmlElements';

Expand Down Expand Up @@ -65,6 +66,8 @@ export type SpotlightProps = {
// onAnimationEnd?: ControllerProps['onRest'];
onAnimationEnd?: () => void;
animationSpringConfig?: Record<string, unknown>;
resizeUpdateInterval?: number;
scrollUpdateInterval?: number;
};

const Spotlight = ({
Expand All @@ -87,24 +90,27 @@ const Spotlight = ({
animateTargetChanges = true,
onAnimationEnd,
animationSpringConfig,
resizeUpdateInterval = 0,
scrollUpdateInterval = 0,
}: SpotlightProps): JSX.Element | null => {
const handleEventWithAnalytics = useAnalytics();
const [windowDimensions, setWindowDimensions] = useState<{ width: number; height: number }>({
width: window.innerWidth,
height: window.innerHeight,
});
const [scrollTop, setScrollTop] = useState<number>(0);
const {
width: windowWidth,
height: windowHeight,
isResizing,
} = useWindowSizeObserver(resizeUpdateInterval);
const { scrollY, isScrolling } = useScrollObserver(scrollUpdateInterval);

const rect = useMemo<Pick<DOMRect, 'x' | 'y' | 'width' | 'height' | 'bottom' | 'right'>>(() => {
const defaultVal = {
x: windowDimensions.width / 2,
y: windowDimensions.height / 2,
x: windowWidth / 2,
y: windowHeight / 2,
width: 0,
height: 0,
bottom: windowDimensions.height / 2,
left: windowDimensions.width / 2,
top: windowDimensions.height / 2,
right: windowDimensions.width / 2,
bottom: windowHeight / 2,
left: windowWidth / 2,
top: windowHeight / 2,
right: windowWidth / 2,
};

if (targetElement) {
Expand All @@ -130,7 +136,7 @@ const Spotlight = ({
}
return defaultVal;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetElement, padding, windowDimensions, shape, scrollTop]);
}, [targetElement, padding, windowWidth, windowHeight, shape, scrollY]);

const radii = [Math.min(cornerRadius, rect.width / 2), Math.min(cornerRadius, rect.height / 2)];
if (shape === SpotlightShapes.round) {
Expand All @@ -141,7 +147,7 @@ const Spotlight = ({
radii[1] = 0;
}

const outerRectPath = `M 0 0 h${windowDimensions.width} v${windowDimensions.height} h-${windowDimensions.width}`;
const outerRectPath = `M 0 0 h${windowWidth} v${windowHeight} h-${windowWidth}`;
const innerShapePath = `
M ${rect.x} ${rect.y + radii[1]}
Q ${rect.x} ${rect.y}, ${rect.x + radii[0]} ${rect.y}
Expand Down Expand Up @@ -196,25 +202,25 @@ const Spotlight = ({

annotationTransform: `translate(${rect.x}px, ${rect.y}px) translate(0%, -100%)`,

topBlurWidth: windowDimensions.width,
topBlurWidth: windowWidth,
topBlurHeight: rect.y - 1,

bottomBlurY: rect.bottom,
bottomBlurWidth: windowDimensions.width,
bottomBlurHeight: windowDimensions.height - rect.bottom - 1,
bottomBlurWidth: windowWidth,
bottomBlurHeight: windowHeight - rect.bottom - 1,

leftBlurY: rect.y,
leftBlurWidth: rect.x,
leftBlurHeight: rect.height + 2,

rightBlurY: rect.y,
rightBlurWidth: windowDimensions.width - rect.right,
rightBlurWidth: windowWidth - rect.right,
rightBlurHeight: rect.height + 2,

friction: 75,
tension: 550,
mass: 5,
immediate: !animateTargetChanges,
immediate: !animateTargetChanges || isScrolling || isResizing,
onRest: onAnimationEnd,
...animationSpringConfig,
}));
Expand All @@ -230,25 +236,25 @@ const Spotlight = ({

annotationTransform: `translate(${rect.x}px, ${rect.y}px) translate(0%, -100%)`,

topBlurWidth: windowDimensions.width,
topBlurWidth: windowWidth,
topBlurHeight: rect.y - 1,

bottomBlurY: rect.bottom,
bottomBlurWidth: windowDimensions.width,
bottomBlurHeight: windowDimensions.height - rect.bottom - 1,
bottomBlurWidth: windowWidth,
bottomBlurHeight: windowHeight - rect.bottom - 1,

leftBlurY: rect.y,
leftBlurWidth: rect.x,
leftBlurHeight: rect.height + 2,

rightBlurY: rect.y,
rightBlurWidth: windowDimensions.width - rect.right,
rightBlurWidth: windowWidth - rect.right,
rightBlurHeight: rect.height + 2,

friction: 75,
tension: 550,
mass: 5,
immediate: !animateTargetChanges,
immediate: !animateTargetChanges || isScrolling || isResizing,

onRest: onAnimationEnd,
...animationSpringConfig,
Expand All @@ -260,7 +266,10 @@ const Spotlight = ({
backgroundDarkness,
padding,
cornerRadius,
windowDimensions,
windowWidth,
windowHeight,
isScrolling,
isResizing,
setSpring,
finalRectangularPath,
circularPath,
Expand All @@ -274,34 +283,6 @@ const Spotlight = ({
onAnimationEnd,
]);

// TODO: use a resize observer to detect when the bounds of the target change
const updateWindowBounds = () => {
setWindowDimensions({
width: window.innerWidth,
height: window.innerHeight,
});
};

const updateScrollPosition = (e: Event) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore documentElement does exist on target element
setScrollTop(e?.target?.documentElement.scrollTop);
};

useEffect(() => {
window.addEventListener('scroll', updateScrollPosition);

return () => window.removeEventListener('scroll', updateScrollPosition);
}, []);

useEffect(() => {
window.addEventListener('resize', updateWindowBounds);

return () => {
window.removeEventListener('resize', updateWindowBounds);
};
}, []);

const handleClick = (evt: React.MouseEvent) => {
handleEventWithAnalytics('Spotlight', onClick, 'onClick', evt, containerProps);
};
Expand Down Expand Up @@ -355,7 +336,7 @@ const Spotlight = ({
</StyledContainer>
<svg
style={{ position: 'fixed', top: 0, left: 0 }}
viewBox={`0 0 ${windowDimensions.width} ${windowDimensions.height}`}
viewBox={`0 0 ${windowWidth} ${windowHeight}`}
width={0}
height={0}
>
Expand Down
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,20 @@ import TextInput from './components/TextInput';
import Progress from './components/Progress';
import Skeleton from './components/Skeleton';
import { FoundryProvider, FoundryContext, useTheme } from './context';
import { useStateWithPrevious, useWindowSizeObserver, useScrollObserver } from './utils/hooks';
import { clamp } from './utils/math';

import colors from './enums/colors';
import timings from './enums/timings';
import fonts from './enums/fonts';
import variants from './enums/variants';
import feedbackTypes from './enums/feedbackTypes';
import checkboxTypes from './enums/checkboxTypes';
import {
disabledStyles,
getBackgroundColorFromVariant,
getFontColorFromVariant,
} from './utils/color';

export {
Button,
Expand All @@ -46,6 +53,13 @@ export {
FoundryProvider,
FoundryContext,
useTheme,
clamp,
getFontColorFromVariant,
getBackgroundColorFromVariant,
disabledStyles,
useStateWithPrevious,
useWindowSizeObserver,
useScrollObserver,
colors,
timings,
fonts,
Expand Down
121 changes: 121 additions & 0 deletions src/utils/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { debounce, throttle } from 'lodash';
import React, { useCallback, useEffect, useRef, useState } from 'react';

export const useStateWithPrevious = <Type>(
defaultValue: Type,
): [Type, Type, React.Dispatch<Type>] => {
const [currentValue, setInternalCurrentValue] = useState<Type>(defaultValue);
const currentValueRef = useRef<Type>(defaultValue);
const previous = useRef<Type>(defaultValue);

useEffect(() => {
currentValueRef.current = currentValue;
}, [currentValue]);

const setCurrent = (value: Type): void => {
previous.current = currentValueRef.current;
currentValueRef.current = value;
setInternalCurrentValue(value);
};

return [currentValue, previous.current, setCurrent];
};

// TODO: Generalize observer to observe any attribute of any element on when an event happens, with previous values, "onComplete" callback func, and an "isComplete" flag
export const useWindowSizeObserver = (
reportInterval = 0, // only cause rerenders in the component using the hook every X milliseconds
resizeEndReportDelay = 50, // wait this long after the last resize event to update curr/prev values
): {
width: number;
height: number;
previousWidth: number;
previousHeight: number;
isResizing: boolean;
} => {
const [width, previousWidth, setWidth] = useStateWithPrevious(window.innerWidth);
const [height, previousHeight, setHeight] = useStateWithPrevious(window.innerHeight);
const [isResizing, setIsResizing] = useState(false);

// if the resizing events stop for more than 100ms, set the previous widths/heights to the current
// because this is based on async timing of debounce,
// // the current/previous values will be delivered on the next render of the component using this hook.
const debouncedEndResize = debounce(
() => {
setWidth(window.innerWidth);
setHeight(window.innerHeight);
setIsResizing(false);
},
resizeEndReportDelay,
{ leading: false, trailing: true },
);

const updateWindowBounds = useCallback(() => {
setWidth(window.innerWidth);
setHeight(window.innerHeight);
if (!isResizing) {
setIsResizing(true);
}
debouncedEndResize();
}, [debouncedEndResize, isResizing, setHeight, setWidth]);

const throttledResizeHandler = throttle(updateWindowBounds, reportInterval);

useEffect(() => {
window.addEventListener('resize', throttledResizeHandler);

return () => {
window.removeEventListener('resize', throttledResizeHandler);
};
}, [throttledResizeHandler]);

return { width, height, previousWidth, previousHeight, isResizing };
};

export const useScrollObserver = (
reportInterval = 0, // only cause rerenders in the component using the hook every X milliseconds
scrollEndReportDelay = 50, // wait this long after the last resize event to update curr/prev values
): {
scrollX: number;
scrollY: number;
previousScrollX: number;
previousScrollY: number;
isScrolling: boolean;
} => {
const [scrollX, previousScrollX, setScrollX] = useStateWithPrevious(window.scrollX);
const [scrollY, previousScrollY, setScrollY] = useStateWithPrevious(window.scrollY);
const [isScrolling, setIsScrolling] = useState(false);

// if the scrolling events stop for more than 100ms, set the previous X/Y to the current
// because this is based on async timing of debounce,
// // the current/previous values will be delivered on the next render of the component using this hook.
const debouncedEndScroll = debounce(
() => {
setScrollX(window.scrollX);
setScrollY(window.scrollY);
setIsScrolling(false);
},
scrollEndReportDelay,
{ leading: false, trailing: true },
);

const updateWindowBounds = useCallback(() => {
setScrollX(window.scrollX);
setScrollY(window.scrollY);
if (!isScrolling) {
setIsScrolling(true);
}
debouncedEndScroll();
}, [debouncedEndScroll, isScrolling, setScrollY, setScrollX]);

const throttledScrollHandler = throttle(updateWindowBounds, reportInterval);

useEffect(() => {
window.addEventListener('scroll', throttledScrollHandler);

return () => {
window.removeEventListener('scroll', throttledScrollHandler);
};
}, [throttledScrollHandler]);

return { scrollX, scrollY, previousScrollX, previousScrollY, isScrolling };
};
1 change: 1 addition & 0 deletions src/utils/math.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// eslint-disable-next-line import/prefer-default-export
export const clamp = (val: number, min: number, max: number): number => {
return Math.max(Math.min(val, max), min);
};

0 comments on commit 2312d7a

Please sign in to comment.