Spaces:
Running
Running
Download src/PhotoViewer.jsx from bep40/VSEARCH: direct link, hf CLI and curl.
- Browser
- Download file 17.5 kB
-
https://huggingface.co/spaces/bep40/VSEARCH/resolve/main/src/PhotoViewer.jsx
- Command line
-
hf download hf://spaces/bep40/VSEARCH/src/PhotoViewer.jsx
-
curl -L -o PhotoViewer.jsx https://huggingface.co/spaces/bep40/VSEARCH/resolve/main/src/PhotoViewer.jsx
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>; | |
| } | |