Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wip fix test: Implement glowing cursor effect #1010

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions ui/src/components/containers/base-container.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,40 @@
'use client';

import React from 'react';
import { twMerge } from 'tailwind-merge';
import { BackgroundColor } from '../../interfaces/color';
import { useGlowHover } from '../use-glow-hover';

type BaseContainerProps = {
backgroundColor?: BackgroundColor;
wrapperClassName?: string;
wrapperRef?: React.Ref<HTMLDivElement>;
} & React.HTMLAttributes<HTMLDivElement>;

export const BaseContainer = React.forwardRef<HTMLDivElement, BaseContainerProps>(
({ children, className, backgroundColor, ...props }, ref) => {
({ children, className, backgroundColor, wrapperClassName, wrapperRef, ...props }, ref) => {
return (
<div className={backgroundColor}>
<div className={twMerge(wrapperClassName, backgroundColor)} ref={wrapperRef}>
<div className="mx-auto max-w-6xl px-3 md:px-6">
<div className={twMerge(backgroundColor, className)} ref={ref} {...props}>
<div className={className} ref={ref} {...props}>
{children}
</div>
</div>
</div>
);
},
);
export const GLowHoverContainer = React.forwardRef<HTMLDivElement, Omit<BaseContainerProps, 'wrapperRef'>>(
({ className, ...props }, ref) => {
const refCard = useGlowHover({ lightColor: '#CEFF00' });

return (
<BaseContainer
wrapperClassName={twMerge('theme-blue', className)}
ref={ref}
{...props}
wrapperRef={refCard as React.Ref<HTMLDivElement>}
/>
);
},
);
266 changes: 266 additions & 0 deletions ui/src/components/use-glow-hover/glow-hover-effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
'use client';

import { linearAnimation } from './linear-animation';
import { presets } from './presets';

export type GlowHoverOptions = {
hoverBg?: string;
lightSize?: number;
lightSizeEnterAnimationTime?: number;
lightSizeLeaveAnimationTime?: number;
isElementMovable?: boolean;
customStaticBg?: string;
enableBurst?: boolean;
} & (
| {
preset: keyof typeof presets;
lightColor?: string;
}
| {
preset?: undefined;
lightColor: string;
}
);

type Coords = {
x: number;
y: number;
};

const BURST_TIME = 300;

export function parseColor(colorToParse: string) {
const div = document.createElement('div');
div.style.color = colorToParse;
div.style.position = 'absolute';
div.style.display = 'none';
document.body.appendChild(div);
const colorFromEl = getComputedStyle(div).color;
document.body.removeChild(div);
const parsedColor = colorFromEl.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i);
if (parsedColor) {
const alpha = typeof parsedColor[4] === 'undefined' ? 1 : parsedColor[4];
return [parsedColor[1], parsedColor[2], parsedColor[3], alpha];
} else {
console.error(`Color ${colorToParse} could not be parsed.`);
return [0, 0, 0, 0];
}
}

export const glowHoverEffect = (el: HTMLElement, { preset, ...options }: GlowHoverOptions) => {
if (!el) {
return () => {};
}

const lightColor = options.lightColor ?? '#CEFF00';
const lightSize = options.lightSize ?? 130;
const lightSizeEnterAnimationTime = options.lightSizeEnterAnimationTime ?? 100;
const lightSizeLeaveAnimationTime = options.lightSizeLeaveAnimationTime ?? 50;
const isElementMovable = options.isElementMovable ?? false;
const customStaticBg = options.customStaticBg ?? null;

const enableBurst = options.enableBurst ?? false;

const getResolvedHoverBg = () => getComputedStyle(el).backgroundColor;

let resolvedHoverBg = getResolvedHoverBg();

// default bg (if not defined) is rgba(0, 0, 0, 0) which is bugged in gradients in Safari
// so we use transparent lightColor instead
const parsedLightColor = parseColor(lightColor);
const parsedLightColorRGBString = parsedLightColor.slice(0, 3).join(',');
const resolvedGradientBg = `rgba(${parsedLightColorRGBString},0)`;

let isMouseInside = false;
let currentLightSize = 0;
let blownSize = 0;
let lightSizeEnterAnimationId: number | undefined = undefined;
let lightSizeLeaveAnimationId: number | undefined = undefined;
let blownSizeIncreaseAnimationId: number | undefined = undefined;
let blownSizeDecreaseAnimationId: number | undefined = undefined;
let lastMousePos: Coords;
const defaultBox = el.getBoundingClientRect();
let lastElPos: Coords = { x: defaultBox.left, y: defaultBox.top };

const updateGlowEffect = () => {
if (!lastMousePos) {
return;
}
const gradientXPos = lastMousePos.x - lastElPos.x;
const gradientYPos = lastMousePos.y - lastElPos.y;
// we do not use transparent color here because of dirty gradient in Safari (more info: https://stackoverflow.com/questions/38391457/linear-gradient-to-transparent-bug-in-latest-safari)
const gradient = `radial-gradient(circle at ${gradientXPos}px ${gradientYPos}px, ${lightColor} 0%, ${resolvedGradientBg} calc(${
blownSize * 2.5
}% + ${currentLightSize}px)) no-repeat`;

// we duplicate resolvedHoverBg layer here because of transition "blinking" without it
el.style.background = `${gradient} border-box border-box ${resolvedHoverBg}`;
};

const updateEffectWithPosition = () => {
if (isMouseInside) {
const curBox = el.getBoundingClientRect();
lastElPos = { x: curBox.left, y: curBox.top };
updateGlowEffect();
}
};

const onMouseEnter = (e: MouseEvent) => {
resolvedHoverBg = getResolvedHoverBg();
lastMousePos = { x: e.clientX, y: e.clientY };
const curBox = el.getBoundingClientRect();
lastElPos = { x: curBox.left, y: curBox.top };
isMouseInside = true;
if (lightSizeEnterAnimationId !== undefined) {
window.cancelAnimationFrame(lightSizeEnterAnimationId);
}
if (lightSizeLeaveAnimationId !== undefined) {
window.cancelAnimationFrame(lightSizeLeaveAnimationId);
}

// animate currentLightSize from 0 to lightSize
linearAnimation({
onProgress: (progress) => {
currentLightSize = lightSize * progress;
updateGlowEffect();
},
time: lightSizeEnterAnimationTime,
initialProgress: currentLightSize / lightSize,
onIdUpdate: (newId) => (lightSizeEnterAnimationId = newId),
});
};

const onMouseMove = (e: MouseEvent) => {
lastMousePos = { x: e.clientX, y: e.clientY };
if (isElementMovable) {
updateEffectWithPosition();
} else {
updateGlowEffect();
}
};

const onMouseLeave = () => {
isMouseInside = false;
if (lightSizeEnterAnimationId !== undefined) {
window.cancelAnimationFrame(lightSizeEnterAnimationId);
}
if (lightSizeLeaveAnimationId !== undefined) {
window.cancelAnimationFrame(lightSizeLeaveAnimationId);
}
if (blownSizeIncreaseAnimationId !== undefined) {
window.cancelAnimationFrame(blownSizeIncreaseAnimationId);
}
if (blownSizeDecreaseAnimationId !== undefined) {
window.cancelAnimationFrame(blownSizeDecreaseAnimationId);
}

// animate currentLightSize from lightSize to 0
linearAnimation({
onProgress: (progress) => {
currentLightSize = lightSize * (1 - progress);
blownSize = Math.min(blownSize, (1 - progress) * 100);

if (progress < 1) {
updateGlowEffect();
} else {
el.style.background = customStaticBg ? customStaticBg : '';
}
},
time: lightSizeLeaveAnimationTime,
initialProgress: 1 - currentLightSize / lightSize,
onIdUpdate: (newId) => (lightSizeLeaveAnimationId = newId),
});
};

const onMouseDown = (e: MouseEvent) => {
lastMousePos = { x: e.clientX, y: e.clientY };
const curBox = el.getBoundingClientRect();
lastElPos = { x: curBox.left, y: curBox.top };
if (blownSizeIncreaseAnimationId !== undefined) {
window.cancelAnimationFrame(blownSizeIncreaseAnimationId);
}
if (blownSizeDecreaseAnimationId !== undefined) {
window.cancelAnimationFrame(blownSizeDecreaseAnimationId);
}

// animate blownSize from 0 to 100
linearAnimation({
onProgress: (progress) => {
blownSize = 100 * progress;
updateGlowEffect();
},
time: BURST_TIME,
initialProgress: blownSize / 100,
onIdUpdate: (newId) => (blownSizeIncreaseAnimationId = newId),
});
};

const onMouseUp = (e: MouseEvent) => {
lastMousePos = { x: e.clientX, y: e.clientY };
const curBox = el.getBoundingClientRect();
lastElPos = { x: curBox.left, y: curBox.top };
if (blownSizeIncreaseAnimationId !== undefined) {
window.cancelAnimationFrame(blownSizeIncreaseAnimationId);
}
if (blownSizeDecreaseAnimationId !== undefined) {
window.cancelAnimationFrame(blownSizeDecreaseAnimationId);
}

// animate blownSize from 100 to 0
linearAnimation({
onProgress: (progress) => {
blownSize = (1 - progress) * 100;
updateGlowEffect();
},
time: BURST_TIME,
initialProgress: 1 - blownSize / 100,
onIdUpdate: (newId) => (blownSizeDecreaseAnimationId = newId),
});
};

document.addEventListener('scroll', updateEffectWithPosition);
window.addEventListener('resize', updateEffectWithPosition);
el.addEventListener('mouseenter', onMouseEnter);
el.addEventListener('mousemove', onMouseMove);
el.addEventListener('mouseleave', onMouseLeave);
if (enableBurst) {
el.addEventListener('mousedown', onMouseDown);
el.addEventListener('mouseup', onMouseUp);
}

let resizeObserver: ResizeObserver;
if (window.ResizeObserver) {
resizeObserver = new ResizeObserver(updateEffectWithPosition);
resizeObserver.observe(el);
}

return () => {
if (lightSizeEnterAnimationId !== undefined) {
window.cancelAnimationFrame(lightSizeEnterAnimationId);
}
if (lightSizeLeaveAnimationId !== undefined) {
window.cancelAnimationFrame(lightSizeLeaveAnimationId);
}
if (blownSizeIncreaseAnimationId !== undefined) {
window.cancelAnimationFrame(blownSizeIncreaseAnimationId);
}
if (blownSizeDecreaseAnimationId !== undefined) {
window.cancelAnimationFrame(blownSizeDecreaseAnimationId);
}

document.removeEventListener('scroll', updateEffectWithPosition);
window.removeEventListener('resize', updateEffectWithPosition);
el.removeEventListener('mouseenter', onMouseEnter);
el.removeEventListener('mousemove', onMouseMove);
el.removeEventListener('mouseleave', onMouseLeave);
if (enableBurst) {
el.removeEventListener('mousedown', onMouseDown);
el.removeEventListener('mouseup', onMouseUp);
}

if (resizeObserver) {
resizeObserver.unobserve(el);
resizeObserver.disconnect();
}
};
};
4 changes: 4 additions & 0 deletions ui/src/components/use-glow-hover/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { glowHoverEffect } from './glow-hover-effect';
export type { GlowHoverOptions } from './glow-hover-effect';
export { useGlowHover } from './use-glow-hover';
export type { GlowHoverHookOptions } from './use-glow-hover';
38 changes: 38 additions & 0 deletions ui/src/components/use-glow-hover/linear-animation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use client';

interface LinearAnimationParams {
onProgress: (progress: number) => void;
onIdUpdate?: (id: number | undefined) => void;
time: number;
initialProgress?: number;
}

export const linearAnimation = ({
onProgress,
onIdUpdate = () => {},
time,
initialProgress = 0,
}: LinearAnimationParams) => {
if (time === 0) {
onProgress(1);
onIdUpdate(undefined);
return;
}

let start: number | undefined = undefined;
const step = (timestamp: number) => {
if (start === undefined) start = timestamp;
const progress = Math.min((timestamp - start) / time + initialProgress, 1);

onProgress(progress);

if (progress < 1) {
const id = window.requestAnimationFrame(step);
onIdUpdate(id);
} else {
onIdUpdate(undefined);
}
};
const id = window.requestAnimationFrame(step);
onIdUpdate(id);
};
11 changes: 11 additions & 0 deletions ui/src/components/use-glow-hover/presets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const presets = {
default: {
lightColor: '#CEFF00',
lightSize: 130,
lightSizeEnterAnimationTime: 100,
lightSizeLeaveAnimationTime: 50,
isElementMovable: false,
customStaticBg: null,
enableBurst: false,
},
};
16 changes: 16 additions & 0 deletions ui/src/components/use-glow-hover/use-glow-hover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use client';

import { useEffect, useRef } from 'react';

import { glowHoverEffect, GlowHoverOptions } from './glow-hover-effect';

export type GlowHoverHookOptions = GlowHoverOptions & { disabled?: boolean };
export const useGlowHover = ({ disabled = false, ...options }: GlowHoverHookOptions) => {
const ref = useRef<HTMLElement>(null);

useEffect(
() => (!disabled && ref.current ? glowHoverEffect(ref.current, options) : () => {}),
[disabled, ...Object.values(options)],
);
return ref;
};
1 change: 1 addition & 0 deletions ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ export * from './components/table';
export * from './components/tabs';
export * from './components/tooltip';
export * from './components/typography';
export * from './components/use-glow-hover';
Loading
Loading