You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
185 lines
5.6 KiB
185 lines
5.6 KiB
import { randomString } from '@/lib/random' |
|
import { cn } from '@/lib/utils' |
|
import logger from '@/lib/logger' |
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider' |
|
import modalManager from '@/services/modal-manager.service' |
|
import { TImetaInfo } from '@/types' |
|
import { useCallback, useEffect, useMemo, useState } from 'react' |
|
import { createPortal } from 'react-dom' |
|
import { preferBlossomPrimalDisplayUrl } from '@/lib/url' |
|
import { useTranslation } from 'react-i18next' |
|
import Lightbox from 'yet-another-react-lightbox' |
|
import Captions from 'yet-another-react-lightbox/plugins/captions' |
|
import Zoom from 'yet-another-react-lightbox/plugins/zoom' |
|
import 'yet-another-react-lightbox/plugins/captions.css' |
|
import Image from '../Image' |
|
|
|
export default function ImageWithLightbox({ |
|
image, |
|
className, |
|
classNames = {} |
|
}: { |
|
image: TImetaInfo |
|
className?: string |
|
classNames?: { |
|
wrapper?: string |
|
} |
|
}) { |
|
const id = useMemo(() => `image-with-lightbox-${randomString()}`, []) |
|
const { t } = useTranslation() |
|
const { autoLoadMedia } = useContentPolicy() |
|
const [display, setDisplay] = useState(autoLoadMedia) |
|
const [index, setIndex] = useState(-1) |
|
|
|
useEffect(() => { |
|
setDisplay(autoLoadMedia) |
|
if (!autoLoadMedia) { |
|
setIndex(-1) |
|
} |
|
}, [autoLoadMedia]) |
|
|
|
const logLightboxEvent = useCallback((stage: string, details?: Record<string, unknown>) => { |
|
logger.info('[LightboxTrace]', { |
|
stage, |
|
id, |
|
imageUrl: image.url, |
|
index, |
|
pathname: window.location.pathname, |
|
search: window.location.search, |
|
hash: window.location.hash, |
|
...details |
|
}) |
|
}, [id, image.url, index]) |
|
|
|
useEffect(() => { |
|
if (index >= 0) { |
|
logLightboxEvent('modal-register') |
|
modalManager.register(id, () => { |
|
logLightboxEvent('modal-callback-close') |
|
setIndex(-1) |
|
}) |
|
} else { |
|
logLightboxEvent('modal-unregister') |
|
modalManager.unregister(id) |
|
} |
|
}, [id, index, logLightboxEvent]) |
|
|
|
useEffect(() => { |
|
if (index < 0) return |
|
|
|
const onCaptureKeydown = (event: KeyboardEvent) => { |
|
if (event.key === 'Escape') { |
|
logLightboxEvent('escape-keydown-capture', { |
|
defaultPrevented: event.defaultPrevented, |
|
eventPhase: event.eventPhase |
|
}) |
|
} |
|
} |
|
const onPopState = (event: PopStateEvent) => { |
|
logLightboxEvent('window-popstate-while-open', { |
|
hasState: !!event.state, |
|
state: event.state |
|
}) |
|
} |
|
|
|
window.addEventListener('keydown', onCaptureKeydown, true) |
|
window.addEventListener('popstate', onPopState) |
|
|
|
return () => { |
|
window.removeEventListener('keydown', onCaptureKeydown, true) |
|
window.removeEventListener('popstate', onPopState) |
|
} |
|
}, [index, logLightboxEvent]) |
|
|
|
const handlePhotoClick = (event: React.MouseEvent) => { |
|
logLightboxEvent('thumbnail-click', { |
|
defaultPreventedBefore: event.defaultPrevented |
|
}) |
|
event.stopPropagation() |
|
event.preventDefault() |
|
logLightboxEvent('set-open-index') |
|
setIndex(0) |
|
} |
|
|
|
// The portal is always mounted (not conditional on `index >= 0`) so that React |
|
// never removes it while yet-another-react-lightbox is mid-cleanup, which would |
|
// otherwise cause "Node.removeChild: The node to be removed is not a child of |
|
// this node". Visibility is controlled via the `open` prop instead. |
|
return ( |
|
<div className="max-w-[400px]"> |
|
{display ? ( |
|
<Image |
|
key={0} |
|
className={className} |
|
classNames={{ |
|
wrapper: cn('rounded-lg cursor-zoom-in', classNames.wrapper), |
|
errorPlaceholder: 'aspect-square h-[30vh]' |
|
}} |
|
image={image} |
|
onClick={(e) => handlePhotoClick(e)} |
|
/> |
|
) : ( |
|
<span |
|
className="text-primary hover:underline truncate w-fit cursor-pointer inline-block" |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
setDisplay(true) |
|
}} |
|
> |
|
[{t('Click to load image')}] |
|
</span> |
|
)} |
|
{createPortal( |
|
<div |
|
data-lightbox-overlay |
|
onClick={(e) => { |
|
logLightboxEvent('overlay-click', { target: (e.target as HTMLElement)?.tagName }) |
|
e.stopPropagation() |
|
}} |
|
onPointerDown={(e) => { |
|
logLightboxEvent('overlay-pointerdown', { target: (e.target as HTMLElement)?.tagName }) |
|
e.stopPropagation() |
|
}} |
|
onMouseDown={(e) => { |
|
logLightboxEvent('overlay-mousedown', { target: (e.target as HTMLElement)?.tagName }) |
|
e.stopPropagation() |
|
}} |
|
onTouchStart={(e) => { |
|
logLightboxEvent('overlay-touchstart', { target: (e.target as HTMLElement)?.tagName }) |
|
e.stopPropagation() |
|
}} |
|
> |
|
<Lightbox |
|
index={index} |
|
slides={[ |
|
{ |
|
src: preferBlossomPrimalDisplayUrl(image.url), |
|
alt: image.alt || image.url, |
|
title: image.alt || undefined |
|
} |
|
]} |
|
plugins={[Zoom, Captions]} |
|
open={index >= 0} |
|
close={() => { |
|
logLightboxEvent('lightbox-close-callback') |
|
setIndex(-1) |
|
}} |
|
controller={{ |
|
closeOnBackdropClick: false, |
|
closeOnPullUp: true, |
|
closeOnPullDown: true |
|
}} |
|
render={{ |
|
buttonPrev: () => null, |
|
buttonNext: () => null |
|
}} |
|
styles={{ |
|
toolbar: { paddingTop: '2.25rem' } |
|
}} |
|
/> |
|
</div>, |
|
document.body |
|
)} |
|
</div> |
|
) |
|
}
|
|
|