Scroll recipe card into view

This commit is contained in:
Sam Becker 2025-02-23 16:12:56 -06:00
parent 0872834db5
commit 22d94e1b4b
5 changed files with 72 additions and 40 deletions

View File

@ -107,9 +107,14 @@ export default function EntityLink({
<span className="hidden group-hover:inline text-dim"> <span className="hidden group-hover:inline text-dim">
{hoverEntity} {hoverEntity}
</span>} </span>}
{isLoading && <Spinner className={clsx( {isLoading &&
badged && 'translate-y-[0.5px]', <Spinner
)} />} className={clsx(
badged && 'translate-y-[0.5px]',
contrast === 'frosted' && 'text-neutral-500',
)}
color={contrast === 'frosted' ? 'text' : undefined}
/>}
</>} </>}
</LinkWithStatus> </LinkWithStatus>
</span> </span>

View File

@ -40,7 +40,7 @@ import ZoomControls, { ZoomControlsRef } from '@/components/image/ZoomControls';
import PhotoRecipe from './PhotoRecipe'; import PhotoRecipe from './PhotoRecipe';
import { TbChecklist } from 'react-icons/tb'; import { TbChecklist } from 'react-icons/tb';
import { IoCloseSharp } from 'react-icons/io5'; import { IoCloseSharp } from 'react-icons/io5';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import useRecipeState from './useRecipeState'; import useRecipeState from './useRecipeState';
export default function PhotoLarge({ export default function PhotoLarge({
@ -98,11 +98,12 @@ export default function PhotoLarge({
const showZoomControls = showZoomControlsProp && areZoomControlsShown; const showZoomControls = showZoomControlsProp && areZoomControlsShown;
const recipeRef = useRef<HTMLDivElement>(null);
const { const {
shouldShowRecipe, shouldShowRecipe,
toggleRecipe, toggleRecipe,
recipeButtonRef, recipeButtonRef,
} = useRecipeState(); } = useRecipeState(recipeRef);
const tags = sortTags(photo.tags, primaryTag); const tags = sortTags(photo.tags, primaryTag);
@ -173,22 +174,21 @@ export default function PhotoLarge({
/> />
</ZoomControls> </ZoomControls>
<AnimatePresence> <AnimatePresence>
{shouldShowRecipe && photo.fujifilmRecipe && photo.filmSimulation && <div className={clsx(
<motion.div 'absolute inset-0',
className={clsx( 'flex items-center justify-center',
'absolute inset-0', )}>
'flex items-center justify-center', {shouldShowRecipe && photo.fujifilmRecipe && photo.filmSimulation &&
)}
>
<PhotoRecipe <PhotoRecipe
ref={recipeRef}
recipe={photo.fujifilmRecipe} recipe={photo.fujifilmRecipe}
simulation={photo.filmSimulation} simulation={photo.filmSimulation}
iso={photo.isoFormatted} iso={photo.isoFormatted}
exposure={photo.exposureCompensationFormatted} exposure={photo.exposureCompensationFormatted}
onClose={toggleRecipe} onClose={toggleRecipe}
externalTriggerRef={recipeButtonRef} externalTriggerRef={recipeButtonRef}
/> />}
</motion.div>} </div>
</AnimatePresence> </AnimatePresence>
</div>; </div>;

View File

@ -12,10 +12,12 @@ import useClickInsideOutside from '@/utility/useClickInsideOutside';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import { ReactNode, useRef, RefObject } from 'react'; import { ReactNode, useRef, RefObject } from 'react';
import { IoCloseCircle } from 'react-icons/io5'; import { IoCloseCircle } from 'react-icons/io5';
import { motion } from 'framer-motion';
const addSign = (value = 0) => value < 0 ? value : `+${value}`; const addSign = (value = 0) => value < 0 ? value : `+${value}`;
export default function PhotoRecipe({ export default function PhotoRecipe({
ref: refExternal,
recipe: { recipe: {
dynamicRange, dynamicRange,
whiteBalance = DEFAULT_WHITE_BALANCE, whiteBalance = DEFAULT_WHITE_BALANCE,
@ -38,6 +40,7 @@ export default function PhotoRecipe({
onClose, onClose,
externalTriggerRef, externalTriggerRef,
}: { }: {
ref?: RefObject<HTMLDivElement | null>
recipe: FujifilmRecipe recipe: FujifilmRecipe
simulation: FilmSimulation simulation: FilmSimulation
iso?: string iso?: string
@ -48,7 +51,7 @@ export default function PhotoRecipe({
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useClickInsideOutside({ useClickInsideOutside({
htmlElements: [ref, externalTriggerRef], htmlElements: [ref, refExternal, externalTriggerRef],
onClickOutside: onClose, onClickOutside: onClose,
}); });
@ -67,11 +70,12 @@ export default function PhotoRecipe({
<div className={clsx( <div className={clsx(
'flex flex-col items-center justify-center gap-0.5 rounded-md min-w-0', 'flex flex-col items-center justify-center gap-0.5 rounded-md min-w-0',
'rounded-md border', 'rounded-md border',
'bg-neutral-100/30 border-neutral-200/40', 'border-neutral-200/40',
'bg-neutral-100/30 hover:bg-neutral-100/50',
label && 'p-1', label && 'p-1',
className, className,
)}> )}>
<div className="truncate max-w-full"> <div className="truncate max-w-full tracking-wide">
{typeof value === 'number' ? addSign(value) : value} {typeof value === 'number' ? addSign(value) : value}
</div> </div>
{label && <div className={clsx( {label && <div className={clsx(
@ -84,14 +88,17 @@ export default function PhotoRecipe({
); );
return ( return (
<div <motion.div
ref={ref} ref={refExternal ?? ref}
initial={{ opacity: 0, translateY: -10 }}
animate={{ opacity: 1, translateY: 0 }}
exit={{ opacity: 0, translateY: -10 }}
className={clsx( className={clsx(
'z-10', 'z-10',
'w-[18rem] p-3 space-y-3', 'w-[19rem] p-3 space-y-3',
'rounded-lg shadow-2xl', 'rounded-lg shadow-2xl',
'text-[13px] text-black', 'text-[13.5px] text-black',
'bg-white/60 border border-neutral-200/30', 'bg-white/65 border border-neutral-200/30',
'backdrop-blur-xl saturate-200', 'backdrop-blur-xl saturate-200',
)} )}
> >
@ -149,26 +156,17 @@ export default function PhotoRecipe({
</>)} </>)}
{renderRow(<> {renderRow(<>
{renderDataSquare( {renderDataSquare(
grainEffect.roughness === 'off' grainEffect.roughness.toLocaleUpperCase(),
? 'NONE' grainEffect.size === 'large'
: <> ? 'Large Grain'
{grainEffect.roughness === 'strong' : grainEffect.size === 'small'
? 'STR' ? 'Small Grain'
: grainEffect.roughness === 'weak' : 'Grain',
? 'WK'
: 'OFF'}
{'/'}
{grainEffect.size === 'large'
? 'LG'
: grainEffect.size === 'small'
? 'SM' : 'OFF'}
</>,
'Grain',
)} )}
{renderDataSquare(bwAdjustment ?? 0, 'BW ADJ')} {renderDataSquare(bwAdjustment ?? 0, 'BW ADJ')}
{renderDataSquare(bwMagentaGreen ?? 0, 'BW M/G')} {renderDataSquare(bwMagentaGreen ?? 0, 'BW M/G')}
</>)} </>)}
</div> </div>
</div> </motion.div>
); );
} }

View File

@ -6,9 +6,12 @@ import {
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { SEARCH_PARAM_SHOW } from '@/app/paths'; import { SEARCH_PARAM_SHOW } from '@/app/paths';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useCallback, useRef, useState } from 'react'; import { RefObject, useCallback, useEffect, useRef, useState } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom';
export default function useRecipeState() { export default function useRecipeState(
ref?: RefObject<HTMLDivElement | null>,
) {
const pathname = usePathname(); const pathname = usePathname();
const params = useSearchParams(); const params = useSearchParams();
@ -54,6 +57,12 @@ export default function useRecipeState() {
} }
}, [pathComponents, photoId, shouldShowRecipe]); }, [pathComponents, photoId, shouldShowRecipe]);
useEffect(() => {
if (shouldShowRecipe && !isElementEntirelyInViewport(ref?.current)) {
ref?.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [ref, shouldShowRecipe]);
return { return {
toggleRecipe, toggleRecipe,
recipeButtonRef, recipeButtonRef,

20
src/utility/dom.ts Normal file
View File

@ -0,0 +1,20 @@
export const isElementEntirelyInViewport = (
element?: HTMLElement | null,
) => {
if (element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (
window.innerHeight ||
document.documentElement.clientHeight
) &&
rect.right <= (
window.innerWidth || document.documentElement.clientWidth
)
);
} else {
return false;
}
};