VSEARCH / src /PhotoViewer.jsx
bep40's picture
add timeList prop: page interiors by date (newest first) with prev/next arrows
216548e verified
Raw History Blame Contribute Delete
17.5 kB
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { imageURL, shareURL } from './assets.js';
import { detailOf, GENERIC_HEADING } from './detail.js';
/**
* Full-screen viewer shared by the interior field and the product catalogue.
*
* A `photo` is either an interior record ({ id, url, ar, blur, source }) or
* a product photo ({ sku, src, images, name, price, specs }). Interior records
* carry no `images` field, but keying the gallery off product identity anyway
* keeps the two paths unambiguous rather than merely incidental.
*/
function resolveSrc(photo, width) {
if (photo.src) return photo.src;
if (typeof photo.url === 'string' && /^https?:\/\//i.test(photo.url)) return photo.url;
return imageURL(photo, width);
}
export default function PhotoViewer({ photo, timeList, onClose }) {
const dialog = useRef(null);
const frame = useRef(null);
const closing = useRef(false);
const animations = useRef([]);
const zoomOrigin = useRef(null);
const [loaded, setLoaded] = useState(false);
const [zoomed, setZoomed] = useState(false);
// Measured from the decoded image, so a product photo is not letterboxed
// against an assumed ratio on first paint.
const [measured, setMeasured] = useState(0);
const [position, setPosition] = useState(0); // product gallery index
const [timePosition, setTimePosition] = useState(0); // time-ordered navigation index
/**
* A catalogue product, or a photograph?
*
* Not `Boolean(photo.sku)`: a photograph published on vai-avatar carries a
* namespaced `sku` too (`avatarimg:<image_id>`), which is what lets it share
* this viewer and the field with a product. Keying on it announced every such
* photograph as "Sản phẩm (avatarimg:...)" and rendered the product price and
* spec caption underneath it. `avatarImageId` is present only on records read
* from the vai-avatar interior index -- and detail.js derives the flag from the
* same field, so the two cannot disagree.
*/
const isProduct = Boolean(photo.sku) && !photo.avatarImageId;
// Only a product has a gallery worth paging through.
const gallery = useMemo(
() => (isProduct && Array.isArray(photo.images) ? photo.images.filter(Boolean) : null),
[isProduct, photo.images],
);
/**
* Time-ordered list of interiors (newest → oldest) handed down from the
* field. When active, the arrows step through photographs by `addedAt`, and
* the counter shows both the slot and the date of the current photo.
*
* If absent, the viewer falls back to the existing behaviour: a single
* photograph or a product's own gallery.
*/
const steppableList = useMemo(() => {
if (timeList && Array.isArray(timeList) && timeList.length > 1) {
return timeList;
}
return gallery;
}, [timeList, gallery]);
/**
* The frame actually on screen.
*
* This has to read `position`, not just `photo`: resolving the URL straight
* from the record pins the viewer to the first image, so the arrows would move
* the counter while the photograph stayed frozen.
*/
const src = useMemo(
() => {
const list = steppableList;
const idx = timeList && timeList.length > 1 ? timePosition : position;
return (list && list[idx]) ? resolveSrc(list[idx], 2400) : resolveSrc(photo, 2400);
},
[steppableList, timePosition, position, photo],
);
const preview = steppableList
? resolveSrc(steppableList[timeList && timeList.length > 1 ? timePosition : position], 640)
: (photo.previewURL || resolveSrc(photo, 640));
const ar = photo.ar || measured || 4 / 3;
const sourceTransform = useCallback(() => {
const rect = frame.current.getBoundingClientRect();
const origin = photo.origin;
if (!origin || !rect.width || !rect.height) return 'scale(.96)';
return `translate(${origin.x + origin.width / 2 - rect.x - rect.width / 2}px, ${origin.y + origin.height / 2 - rect.y - rect.height / 2}px) scale(${origin.width / rect.width}, ${origin.height / rect.height})`;
}, [photo.origin]);
// A new frame re-arms the loading fade, the measured ratio and the zoom. This
// deliberately does NOT touch `position`: `src` moves while paging, so
// resetting the index here would snap every arrow press back to photo 1.
useEffect(() => { setLoaded(false); setMeasured(0); setZoomed(false); }, [src]);
// Opening a different photo starts that product's gallery from the beginning.
useEffect(() => { setPosition(0); setTimePosition(0); }, [photo]);
useEffect(() => {
const previous = document.activeElement;
dialog.current.showModal();
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
animations.current = [frame.current.animate([
{ transform: sourceTransform(), opacity: 0.7 },
{ transform: 'none', opacity: 1 },
], { duration: 560, easing: 'cubic-bezier(.22,1,.36,1)' })];
}
return () => {
animations.current.forEach(animation => animation.cancel());
if (previous?.isConnected) previous.focus({ preventScroll: true });
};
}, [sourceTransform]);
useLayoutEffect(() => {
const before = zoomOrigin.current;
zoomOrigin.current = null;
if (!before || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const after = frame.current.getBoundingClientRect();
animations.current = [frame.current.animate([
{ transform: `translate(${before.x + before.width / 2 - after.x - after.width / 2}px, ${before.y + before.height / 2 - after.y - after.height / 2}px) scale(${before.width / after.width}, ${before.height / after.height})` },
{ transform: 'none' },
], { duration: 460, easing: 'cubic-bezier(.22,1,.36,1)' })];
}, [zoomed]);
const close = useCallback(async () => {
if (closing.current) return;
closing.current = true;
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
const from = getComputedStyle(frame.current).transform;
animations.current.forEach(animation => animation.cancel());
const animation = frame.current.animate([
{ transform: from === 'none' ? 'none' : from, opacity: 1 },
{ transform: sourceTransform(), opacity: 0 },
], { duration: 320, easing: 'cubic-bezier(.4,0,.2,1)', fill: 'forwards' });
animations.current = [animation];
dialog.current.classList.add('is-closing');
await animation.finished.catch(() => {});
}
onClose();
}, [onClose, sourceTransform]);
/**
* Step through the steppable list (product gallery OR time-ordered interiors),
* clamped at the ends.
*
* The clamp lives inside the updater so rapid arrow presses cannot drift the
* index past the end and land on an empty frame.
*/
const step = useCallback(delta => {
const total = steppableList?.length ?? 0;
if (total < 2) return;
const setTime = timeList && timeList.length > 1 ? setTimePosition : setPosition;
setTime(current => {
const next = current + delta;
return next < 0 || next >= total ? current : next;
});
}, [steppableList, timeList]);
useEffect(() => {
if (!steppableList || steppableList.length < 2) return;
const onKey = event => {
if (event.key === 'ArrowRight') { event.preventDefault(); step(1); }
else if (event.key === 'ArrowLeft') { event.preventDefault(); step(-1); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [steppableList, step]);
// Escape stays with the dialog's own cancel event; only arrows are added here.
const alt = isProduct
? `${photo.name || 'Sản phẩm'}${photo.sku ? ` (${photo.sku})` : ''}`
: 'Ảnh nội thất đã phóng to';
/**
* A plain label, never a link.
*
* This used to render "View source ↗" pointed at photo.source. Nothing in the
* viewer names or links to the scraped origin any more -- not the button, not
* a "Nguồn" row in the fact table. The share link in the toolbar is the single
* way out of the app, and the photograph's address is the only URL it needs.
*/
const caption = <span className="photo-origin">
{isProduct ? (photo.name || photo.sku) : ((photo.categories || [])[0] || photo.title || 'Interior design')}
</span>;
// Paging controls appear only when there is somewhere to go.
const stepping = Boolean(steppableList && steppableList.length > 1);
const usingTimeList = Boolean(timeList && timeList.length > 1);
/**
* What the share sheet shows above the URL.
*
* The record's own name, falling back to the heading the panel uses so the
* sheet is never blank. Nothing scraped: a share card titled after daibau.vn
* or noithatphuphat.com would carry somebody else's site name into the
* reader's own messages, which is the same reason the panel stopped naming
* them.
*/
const shareTitle = (isProduct
? [photo.name, photo.n, photo.sku].find(value => typeof value === 'string' && value.trim())
: [photo.title, detail.heading].find(value => typeof value === 'string' && value.trim())
) || 'Ảnh nội thất V.AI STUDIO';
const share = shareURL(photo);
const specs = isProduct && Array.isArray(photo.specs) ? photo.specs : null;
const [shareLabel, setShareLabel] = useState('');
const onShare = useCallback(async ({ url, title, label }) => {
const announce = text => {
label(text);
window.clearTimeout(announce.timer);
announce.timer = window.setTimeout(() => label(''), 1800);
};
if (navigator.share) {
try {
await navigator.share({ title, url });
announce('Đã chia sẻ');
} catch {
// AbortError here means the sheet was swiped away. Nothing to report.
}
return;
}
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(url);
announce('Đã sao chép liên kết');
return;
} catch { /* fall through to the legacy path */ }
}
// Last resort, for a non-secure context or a blocked clipboard: a temporary
// input selected and copied synchronously, which still works where the async
// API above does not.
const field = document.createElement('input');
field.value = url;
field.setAttribute('aria-hidden', 'true');
document.body.append(field);
field.select();
const copied = document.execCommand?.('copy');
field.remove();
announce(copied ? 'Đã sao chép liên kết' : 'Không chia sẻ được');
}, []);
const detail = useMemo(() => detailOf(photo), [photo]);
const generic = detail.description.startsWith('Ảnh trong bộ sưu tập');
const hasDetail = Boolean(
detail.room
|| detail.tags.length > 1
|| (detail.heading && detail.heading !== GENERIC_HEADING)
|| (detail.description && !generic),
);
const nowIdx = timeList && timeList.length > 1 ? timePosition : position;
const currentItem = steppableList ? steppableList[nowIdx] : null;
const dateLabel = (currentItem?.addedAt || photo.createdAt || '').slice(0, 10);
return <dialog ref={dialog} className="photo-viewer" aria-label="Ảnh toàn màn hình"
onCancel={event => { event.preventDefault(); close(); }}
onClick={event => { if (event.target === dialog.current) close(); }}>
<div className="photo-toolbar">
{share ? <>
{/*
Two different jobs, two different controls.
This button hands the photograph to whatever the reader already uses --
Messages, Zalo, Telegram, the system sheet on iOS and Android. That is
the gesture the icon already implied, and the old <a target="_blank">
could not offer it: it opened a browser tab, not the reader's own apps.
navigator.share is where the platform's share sheet lives; where it is
absent or the user dismisses it, the clipboard takes over so there is
always something to paste.
A dismissal is not an error -- navigator.share rejects with AbortError
every time the sheet is swiped away -- so a refusal is swallowed
silently and nothing is announced.
*/}
<button type="button" className="photo-share" onClick={() => onShare({ url: share, title: shareTitle, label: setShareLabel })}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg>
<span>{shareLabel || (isProduct ? 'Chia sẻ sản phẩm' : 'Chia sẻ ảnh')}</span>
</button>
<a className="photo-open" href={share} target="_blank" rel="noopener noreferrer"
title={isProduct ? 'Xem sản phẩm trên V.AI STUDIO' : 'Xem ảnh nội thất trên V.AI STUDIO'}>
<span>Xem trang</span>
</a>
</> : null}
{caption}
<button type="button" aria-label="Đóng ảnh" autoFocus onClick={close}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6" /></svg>
</button>
</div>
<div className={`photo-viewport ${zoomed ? 'is-zoomed' : ''}`} onClick={event => { if (event.target === event.currentTarget) close(); }}>
{stepping && nowIdx > 0 && <button type="button" className="photo-step is-prev" aria-label="Ảnh trước"
onClick={event => { event.stopPropagation(); step(-1); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m14 6-6 6 6 6" /></svg>
</button>}
<button ref={frame} type="button" className="photo-frame" aria-label={zoomed ? 'Thu nhỏ' : 'Phóng to ảnh'} aria-pressed={zoomed}
style={{ '--photo-ar': ar, backgroundImage: `url("${preview}")` }}
onClick={() => {
zoomOrigin.current = frame.current.getBoundingClientRect();
animations.current.forEach(animation => animation.cancel());
setZoomed(value => !value);
}}>
<img src={src} alt={alt} className={loaded ? 'loaded' : ''}
onLoad={event => {
setLoaded(true);
const image = event.currentTarget;
if (image.naturalWidth && image.naturalHeight) {
setMeasured(image.naturalWidth / image.naturalHeight);
}
}} />
</button>
{stepping && nowIdx < steppableList.length - 1 && <button type="button" className="photo-step is-next" aria-label="Ảnh tiếp theo"
onClick={event => { event.stopPropagation(); step(1); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m10 6 6 6-6 6" /></svg>
</button>}
</div>
{isProduct && (photo.price || (specs && specs.length)) ? <div className="photo-caption">
{photo.price && <p className="photo-caption-price">{photo.price}</p>}
{specs && specs.length > 0 ? <dl className="photo-caption-specs">
{specs.slice(0, 6).map(([key, value], index) => (
<div key={index}><dt>{key}</dt><dd>{value}</dd></div>
))}
</dl> : null}
</div> : null}
{/*
Detail panel, for BOTH kinds of record.
Collapsible rather than always-open because the viewer is a photo first:
on a phone there is no room for both. Closed by default keeps the first
impression the photograph, while the summary line still shows the title
and the price.
<details>/<summary> rather than a button plus state: keyboard- and
screen-reader-correct for free, and it survives a re-render mid-read.
*/}
{hasDetail ? (
<details className="photo-detail">
<summary className="photo-detail-summary">
<span className="photo-detail-title">{detail.heading}</span>
{photo.price ? <span className="photo-detail-price">{photo.price}</span> : null}
<span className="photo-detail-toggle" aria-hidden="true">Chi tiết</span>
</summary>
<div className="photo-detail-body">
{detail.description ? <p className="photo-detail-desc">{detail.description}</p> : null}
{detail.tags.length > 0 ? <ul className="photo-detail-tags">
{detail.tags.map(tag => <li key={tag}>{tag}</li>)}
</ul> : null}
{detail.facts.length > 0 ? <dl className="photo-detail-facts">
{detail.facts.map(([key, value], index) => (
<div key={index}><dt>{key}</dt><dd>{value}</dd></div>
))}
</dl> : null}
{/*
No "Xem bài gốc" link here, and no "Nguồn" row either. The panel
answers "what am I looking at", and the share link in the toolbar
above already carries the photograph's public address. Naming the
scraped origin -- daibau.vn, noithatphuphat.com -- in the table put
somebody else's domain in the middle of our own interface, on 1,113
of the 1,222 photographs, for no gain to a reader.
*/}
</div>
</details>
) : null}
{stepping ? (
<p className="photo-counter" role="status" aria-live="polite"
title={usingTimeList ? `Duyệt theo thời gian: ${dateLabel}` : ''}>
{usingTimeList && dateLabel
? <span className="photo-date" title={currentItem.addedAt}>{dateLabel}</span>
: `${nowIdx + 1} / ${steppableList.length}`}
</p>
) : null}
</dialog>;
}