import { useEffect, useRef, useState } from 'react';
import {
type LightboxImage,
Lightbox as Lb,
type LightboxTapInfo,
useLightbox,
useLightboxState,
} from '@oomfware/lightbox';
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import { clsx } from 'clsx';
import { downloadImage } from '#/lib/media/download-image';
import type { LightboxPayload } from '#/components/dialogs/handles';
import * as Menu from '#/components/Menu';
import { Text } from '#/components/Text';
import * as Toast from '#/components/Toast';
import DownloadIcon from '#/icons/central/ArrowInbox_round_outlined_radius1_stroke2.svg';
import ArrowLeftIcon from '#/icons/central/ArrowLeft_round_outlined_radius1_stroke2.svg';
import ShareIcon from '#/icons/central/ArrowOutOfBox_round_outlined_radius1_stroke2.svg';
import ArrowRightIcon from '#/icons/central/ArrowRight_round_outlined_radius1_stroke2.svg';
import XIcon from '#/icons/central/CrossLarge_round_outlined_radius1_stroke2.svg';
import EllipsisIcon from '#/icons/central/DotGrid1x3Horizontal_round_outlined_radius1_stroke2.svg';
import { m } from '#/paraglide/messages';
import * as styles from './Lightbox.css';
import { LightboxLoading } from './LightboxLoading';
export function LightboxContents({
payload,
open,
close,
}: {
payload: LightboxPayload;
open: boolean;
close: () => void;
}) {
const viewportRef = useRef<HTMLDivElement>(null);
const [chromeVisible, setChromeVisible] = useState(true);
// each open starts with the chrome shown; Base UI keeps this content mounted after close, so reopening
// the same instance would otherwise inherit whatever the last session toggled it to. reset during render
// (against the previous open value) so the reopened lightbox never commits a stale hidden-chrome frame.
const [prevOpen, setPrevOpen] = useState(open);
if (prevOpen !== open) {
setPrevOpen(open);
if (open) {
setChromeVisible(true);
}
}
// keep keyboard paging active when transient chrome drops focus
useEffect(() => {
const el = viewportRef.current;
if (!open || !el) {
return undefined;
}
const onFocusOut = (e: FocusEvent) => {
// do not steal focus when the window loses it
if (e.relatedTarget === null && document.hasFocus()) {
el.focus({ preventScroll: true });
}
};
el.focus({ preventScroll: true });
el.addEventListener('focusout', onFocusOut);
return () => el.removeEventListener('focusout', onFocusOut);
}, [open]);
const toggleTimer = useRef<number | null>(null);
useEffect(
() => () => {
if (toggleTimer.current !== null) {
clearTimeout(toggleTimer.current);
}
},
[],
);
const onTap = (info: LightboxTapInfo) => {
if (!info.onImage && info.pointerType !== 'touch') {
return;
}
// the lib fires onTap on the *first* tap of a double-tap-to-zoom too, so defer the toggle past the
// engine's double-tap window (300ms): a second tap inside it cancels the pending toggle, leaving the
// zoom unflickered.
if (toggleTimer.current !== null) {
clearTimeout(toggleTimer.current);
toggleTimer.current = null;
return;
}
toggleTimer.current = window.setTimeout(() => {
toggleTimer.current = null;
setChromeVisible((v) => !v);
}, DOUBLE_TAP_MS);
};
return (
<Lb.Provider active={open} images={payload.images} defaultIndex={payload.index} onDismiss={close}>
<Lb.Viewport ref={viewportRef} className={styles.viewport} onTap={onTap}>
<Lb.Track>{renderSlide}</Lb.Track>
<div className={clsx(styles.chrome, !chromeVisible && styles.chromeHidden)}>
<Chrome />
</div>
</Lb.Viewport>
</Lb.Provider>
);
}
/**
* the lib's double-tap-to-zoom window (`DOUBLE_TAP_MS` in its engine); single-tap chrome toggles wait this
* out.
*/
const DOUBLE_TAP_MS = 300;
/** custom slide renderer for `Lb.Track` */
const renderSlide = (image: LightboxImage, index: number) => (
<Slide key={image.src} image={image} index={index} />
);
/** A carousel cell that overlays a spinner on the lib's image until the full-size source resolves. */
function Slide({ image, index }: { image: LightboxImage; index: number }) {
const loading = useImageLoading(image.src);
return (
<Lb.Slide index={index}>
<Lb.Image index={index} />
{loading ? <LightboxLoading /> : null}
</Lb.Slide>
);
}
/**
* track whether a full-size image is still loading via preload. resolves on both load and error to prevent
* infinite loading states.
*/
function useImageLoading(src: string) {
const [loading, setLoading] = useState(true);
useEffect(() => {
const img = new window.Image();
const controller = new AbortController();
let active = true;
const done = () => {
if (active) {
setLoading(false);
}
};
img.addEventListener('load', done, { signal: controller.signal });
img.addEventListener('error', done, { signal: controller.signal });
img.src = src;
// a cached source can already be complete before the handlers attach
if (img.complete) {
done();
}
return () => {
active = false;
controller.abort();
};
}, [src]);
return loading;
}
function Chrome() {
const { images, next, prev } = useLightbox();
const index = useLightboxState((state) => state.index);
const img = images[index];
const canLeft = index > 0;
const canRight = index < images.length - 1;
const onShare = async () => {
const url = img?.src;
if (!url) {
return;
}
if (typeof navigator !== 'undefined' && 'share' in navigator && navigator.share) {
try {
await navigator.share({ url });
} catch {
// user cancelled or share failed; no-op
}
} else if (typeof navigator !== 'undefined' && navigator.clipboard) {
try {
await navigator.clipboard.writeText(url);
Toast.show(m['components.lightbox.share.copiedToast']());
} catch {
Toast.show(m['components.lightbox.share.error'](), { type: 'error' });
}
}
};
const onDownload = () => {
const url = img?.src;
if (!url) {
return;
}
downloadImage({ uri: url }).then(
() => Toast.show(m['components.lightbox.download.savedToast']()),
() => Toast.show(m['components.lightbox.download.error'](), { type: 'error' }),
);
};
return (
<>
<button
type="button"
className={clsx(styles.navButton, styles.navLeft)}
aria-label={m['components.lightbox.a11y.previous']()}
disabled={!canLeft}
tabIndex={-1}
onClick={prev}
>
<ArrowLeftIcon className={styles.controlIcon} />
</button>
<button
type="button"
className={clsx(styles.navButton, styles.navRight)}
aria-label={m['components.lightbox.a11y.next']()}
disabled={!canRight}
tabIndex={-1}
onClick={next}
>
<ArrowRightIcon className={styles.controlIcon} />
</button>
<div className={styles.topLeft}>
<Menu.Root>
<Menu.Trigger className={styles.circle} aria-label={m['components.lightbox.a11y.options']()}>
<EllipsisIcon className={styles.controlIconRotated} />
</Menu.Trigger>
<Menu.Popup label={m['components.lightbox.a11y.options']()}>
<Menu.Item label={m['components.lightbox.share.label']()} onClick={() => void onShare()}>
<Menu.ItemText>{m['components.lightbox.share.label']()}</Menu.ItemText>
<Menu.ItemIcon icon={ShareIcon} position="right" />
</Menu.Item>
<Menu.Item label={m['components.lightbox.download.label']()} onClick={onDownload}>
<Menu.ItemText>{m['components.lightbox.download.label']()}</Menu.ItemText>
<Menu.ItemIcon icon={DownloadIcon} position="right" />
</Menu.Item>
</Menu.Popup>
</Menu.Root>
</div>
<BaseDialog.Close
aria-label={m['components.lightbox.a11y.close']()}
className={clsx(styles.circle, styles.topRight)}
>
<XIcon className={styles.controlIcon} />
</BaseDialog.Close>
{img?.alt ? (
// let long alt text bypass viewport gestures and receive keyboard focus
<div className={styles.altPanel} tabIndex={0} data-lightbox-passthrough>
<Text size="md" selectable className={styles.altText}>
{img.alt}
</Text>
</div>
) : null}
{images.length > 1 && (
<>
<div className={styles.pagerDots}>
<div className={styles.dotPill}>
{images.map((image, i) => (
<span key={image.src} className={i === index ? styles.dotActive : styles.dotInactive} />
))}
</div>
</div>
<div aria-live="polite" aria-atomic="true" className={styles.srOnly}>
<Text>
{m['components.lightbox.a11y.imagePosition']({ index: index + 1, count: images.length })}
</Text>
</div>
</>
)}
</>
);
}