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">
|
<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>
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
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