Scroll recipe card into view
This commit is contained in:
parent
0872834db5
commit
22d94e1b4b
@ -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>
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
20
src/utility/dom.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user