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">
{hoverEntity}
</span>}
{isLoading && <Spinner className={clsx(
badged && 'translate-y-[0.5px]',
)} />}
{isLoading &&
<Spinner
className={clsx(
badged && 'translate-y-[0.5px]',
contrast === 'frosted' && 'text-neutral-500',
)}
color={contrast === 'frosted' ? 'text' : undefined}
/>}
</>}
</LinkWithStatus>
</span>

View File

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

View File

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

View File

@ -6,9 +6,12 @@ import {
import { usePathname } from 'next/navigation';
import { SEARCH_PARAM_SHOW } from '@/app/paths';
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 params = useSearchParams();
@ -54,6 +57,12 @@ export default function useRecipeState() {
}
}, [pathComponents, photoId, shouldShowRecipe]);
useEffect(() => {
if (shouldShowRecipe && !isElementEntirelyInViewport(ref?.current)) {
ref?.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [ref, shouldShowRecipe]);
return {
toggleRecipe,
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;
}
};