Spaces:
Running
Running
Download src/ImageField.jsx from bep40/VSEARCH: direct link, hf CLI and curl.
- Browser
- Download file 20.7 kB
-
https://huggingface.co/spaces/bep40/VSEARCH/resolve/main/src/ImageField.jsx
- Command line
-
hf download hf://spaces/bep40/VSEARCH/src/ImageField.jsx
-
curl -L -o ImageField.jsx https://huggingface.co/spaces/bep40/VSEARCH/resolve/main/src/ImageField.jsx
20.7 kB
| import { useEffect, useRef } from 'react'; | |
| import collection from './collection.json'; | |
| import { dimensions, fieldPosition, fieldCount, scatter, edgePosition, seed } from './layout.js'; | |
| import { sampleMotion, transition } from './motion.js'; | |
| import { relevanceAppearance } from './relevance.js'; | |
| import { imageURL, tileURL, shareURL } from './assets.js'; | |
| import { averageColor, createImageLoader, createPlaceholderLoader, thumbnailWidth } from './image-loader.js'; | |
| /** | |
| * z-index given to the tile the pointer is over. | |
| * | |
| * Clears every seeded value above (1 + floor(seed(id) * 100), so 1..100) and | |
| * every other z-index the field uses -- vignette 300, grain 301, search pill | |
| * 400, footer 401 -- so the lifted tile sits above its neighbours without | |
| * pushing the search box under the user's photographs. | |
| */ | |
| const LIFTED_Z = 200; | |
| /** A single population of photographs, one coordinate plane, one animation clock. */ | |
| export default function ImageField({ results, onSelect, paused, query = '', products = [] }) { | |
| const surface = useRef(null); | |
| const engine = useRef(null); | |
| const selection = useRef(onSelect); | |
| selection.current = onSelect; | |
| // The query and products are read inside the long-lived effect, so they are | |
| // mirrored into a ref rather than becoming effect dependencies. Making them | |
| // dependencies would tear down and rebuild every tile -- and re-request every | |
| // thumbnail -- on every keystroke. | |
| const controls = useRef({ query, products }); | |
| controls.current = { query, products }; | |
| useEffect(() => { | |
| const root = surface.current; | |
| const nodes = new Map(); | |
| const thumbnails = createImageLoader(); | |
| const placeholders = createPlaceholderLoader(); | |
| const motion = matchMedia('(prefers-reduced-motion: reduce)'); | |
| const state = { width: root.clientWidth, height: root.clientHeight, results: [], pool: [], paused: false, pointer: { x: 0, y: 0 }, x: 0, y: 0 }; | |
| let frame; | |
| let previous = performance.now(), time = 0; | |
| function create(image, target, initial = false) { | |
| const button = document.createElement('button'); | |
| // `featured` is set here, at creation, rather than toggled per frame like | |
| // `exact`: it comes from the record itself and does not change for a | |
| // given tile while it is on screen, so there is nothing to re-evaluate. | |
| button.className = image.featured ? 'image-tile featured' : 'image-tile'; | |
| button.type = 'button'; | |
| button.dataset.imageId = image.id; | |
| button.style.backgroundColor = averageColor(image.blur); | |
| /** | |
| * A share link on the tile itself, so a photograph can be sent without | |
| * opening it first. | |
| * | |
| * The tile stays a <button> -- it has to, for keyboard activation and for | |
| * aria-haspopup="dialog" to mean anything -- and the anchor is appended | |
| * inside it. Nesting an <a> in a <button> is not valid HTML, so the anchor | |
| * is aria-hidden and tabIndex -1: it is a pointer affordance, and the | |
| * reachable way to share is the link in the viewer, which is a real link | |
| * with a real name. A keyboard user is not offered something that behaves | |
| * differently from its accessible name. | |
| * | |
| * Clicking it must not also open the viewer, so its handler stops | |
| * propagation; the tile's own onclick never sees the event. | |
| */ | |
| const share = document.createElement('a'); | |
| share.className = 'image-share'; | |
| share.tabIndex = -1; | |
| share.setAttribute('aria-hidden', 'true'); | |
| /** | |
| * Opens in a new tab, like the viewer's own share link. Navigating the | |
| * current tab away would destroy the search the user is standing in, and | |
| * the field cannot be rebuilt from history -- there is no route to come | |
| * back to. | |
| */ | |
| share.target = '_blank'; | |
| share.rel = 'noopener noreferrer'; | |
| share.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" 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>'; | |
| share.addEventListener('click', event => event.stopPropagation()); | |
| button.append(share); | |
| button._share = share; | |
| // Stack order belongs to the photograph, never to its search rank -- EXCEPT | |
| // that a featured tile -- the exact answer, a top keyword hit, or a just | |
| // added avatar photograph -- must sit visibly ON TOP of the field around | |
| // it, which is the whole point of enlarging it. So featured tiles draw in | |
| // a layer above every ordinary tile (which can at most reach 100; 300 is | |
| // safely clear of the 1..100 seed range and below the 400 reserved for the | |
| // search pill glass in index.css). | |
| const baseZ = image.featured ? 300 + Math.floor(seed(image.id) * 40) : 1 + Math.floor(seed(image.id) * 100); | |
| button.style.zIndex = baseZ; | |
| root.append(button); | |
| const home = initial ? target : edgePosition(image, state.width, state.height, target); | |
| // baseZ is kept on the node so liftUnderPointer can restore it verbatim | |
| // when the pointer moves on. | |
| const node = { image, button, baseZ, lifted: false, ...home, target: home, motion: null, exit: false, phase: seed(image.id) * Math.PI * 2 }; | |
| resize(node); | |
| button.style.transform = `translate3d(${home.x - node.width / 2}px,${home.y - node.height / 2}px,0) scale(${home.scale})`; | |
| nodes.set(image.id, node); | |
| node.cancelPlaceholder = placeholders.request(image.blur, url => { | |
| if (url && nodes.get(image.id) === node && !node.photo) button.style.backgroundImage = `url("${url}")`; | |
| }); | |
| button.onclick = event => { | |
| /** | |
| * The share anchor is a child of this button, so its click reaches here | |
| * too. stopPropagation on the anchor is not enough on its own: the tile | |
| * drifts under the pointer every frame, so by the time the click lands | |
| * the browser may have re-targeted the event at the button itself, and | |
| * the photograph opens behind a tab that was supposed to be shared. | |
| * Testing the composed path covers both cases. | |
| */ | |
| const path = event.composedPath ? event.composedPath() : [event.target]; | |
| if (path.includes(share)) return; | |
| selection.current( | |
| // A product carries its full gallery so the viewer can page through it, | |
| // exactly as the old product strip did. Interiors have no `images` and | |
| // are unchanged. | |
| // | |
| // Read `node.image` at click time rather than closing over `image`: a | |
| // node is created once and then reused across searches, and the record | |
| // it was created from may have been an ambient tile from | |
| // collection.json -- id/url/ar/blur only. Closing over the original | |
| // meant a photograph that later matched the query kept opening with | |
| // none of its title, categories or source, because the richer search | |
| // result was never written back to the node. layout() assigns | |
| // node.image on every pass, so the click always sees the current one. | |
| { ...node.image, previewURL: node.photo?.currentSrc || node.photo?.src }, | |
| button.getBoundingClientRect().toJSON(), | |
| ); | |
| }; | |
| return node; | |
| } | |
| function resize(node) { | |
| // A featured tile -- the exact answer to the query, or a photograph just | |
| // added on vai-avatar -- carries its own scale multiplier. Read from the | |
| // record rather than passed down, so a tile that becomes featured keeps | |
| // the same identity (and therefore the same place in the layout) it had | |
| // as an ordinary tile. | |
| const scale = node.image?.featuredScale || 1; | |
| const { width, height } = dimensions(node.image, state.width, scale); | |
| if (node.width === width && node.height === height) return; | |
| node.width = width; node.height = height; | |
| node.button.style.width = `${width}px`; | |
| node.button.style.height = `${height}px`; | |
| } | |
| function remove(node) { | |
| node.load?.cancel(); | |
| node.cancelPlaceholder?.(); | |
| node.button.remove(); | |
| if (node.photo) { node.photo.remove(); thumbnails.release(node.photo); } | |
| nodes.delete(node.image.id); | |
| } | |
| function loadThumbnail(node, priority) { | |
| const url = tileURL(node.image, thumbnailWidth(node.width, node.target.scale, window.devicePixelRatio || 1)); | |
| node.load = thumbnails.request(url, photo => { | |
| node.load = null; | |
| if (node.exit || nodes.get(node.image.id) !== node) { thumbnails.release(photo); return; } | |
| photo.alt = ''; photo.draggable = false; photo.className = ''; | |
| node.photo = photo; | |
| /** | |
| * insertBefore rather than append, so the share anchor stays the tile's | |
| * last child. | |
| * | |
| * The tile has no stacking context of its own, and both children are | |
| * absolutely positioned -- so with the <img> appended last, it paints | |
| * over the anchor and every click on the share button opens the viewer | |
| * instead of sharing. z-index cannot fix it either: it is set in a | |
| * stylesheet, and re-appending the photo reorders the DOM, so the two | |
| * swap paint order on each cached thumbnail. Keeping the anchor last in | |
| * the DOM fixes it by construction, for every image, loaded or not. | |
| */ | |
| node.button.insertBefore(photo, node.button._share); | |
| if (!motion.matches) photo.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 180, easing: 'ease-out' }); | |
| photo.classList.add('loaded'); | |
| }, priority); | |
| } | |
| function layout(initial = false) { | |
| const now = performance.now(); | |
| const active = new Set(state.pool.map(image => image.id)); | |
| const ranks = new Map(state.results.map((image, rank) => [image.id, rank])); | |
| // Read the controls once per pass: every tile in a pass must agree on | |
| // where the plane sits, or the collection visibly tears apart mid-drag. | |
| const { offsetX: shiftX, offsetY: shiftY } = controls.current; | |
| // fieldPosition() whenever a query is active OR anything is featured, and | |
| // scatter() only for a plain, featureless browse. | |
| // | |
| // The reason is that scatter() lays tiles out on a jittered grid with no | |
| // notion of the search pill, so index 0 lands in a corner and a featured | |
| // tile can end up underneath the pill with the pill's own glass sitting on | |
| // top of it. fieldPosition() spirals out from the centre and already has | |
| // the clearance term that pushes tiles around the pill. Using it for the | |
| // idle state too is what puts the ten newest photographs in a readable | |
| // arc below the search box instead of wherever the grid happens to put | |
| // them. | |
| const ordered = state.results.length || state.pool.some(image => image.featured); | |
| const targets = state.pool.map((image, index) => ordered | |
| ? fieldPosition(image, index, state.pool.length, state.width, state.height, shiftX, shiftY) | |
| : scatter(image, index, state.pool.length, state.width, state.height, shiftX, shiftY)); | |
| // The query and the product hits go in so relevance.js can decide which | |
| // tiles are EXACT matches (see exactMatches) and get the neon outline. | |
| const appearance = relevanceAppearance(state.results, controls.current.query, controls.current.products); | |
| state.pool.forEach((image, index) => { | |
| const target = targets[index]; | |
| const node = nodes.get(image.id) || create(image, target, initial); | |
| // Keep the node's record current, not just its position: `image` here is | |
| // whatever the pool now holds, which for a photograph that has become a | |
| // search result is the full row (title, categories, source) rather than | |
| // the metadata-free collection entry the node was first built from. | |
| // Without this, opening a matched photograph shows nothing about it. | |
| node.image = image; | |
| /** | |
| * Point the tile's share link at the current record, and hide it when | |
| * there is nothing to share. Updated alongside node.image for the same | |
| * reason: the node may have been built from a record that carried no | |
| * shareable identity, and a photograph's address is derived from it. | |
| */ | |
| const share = node.button._share; | |
| if (share) { | |
| const href = shareURL(image); | |
| if (href) { | |
| share.href = href; | |
| share.dataset.shareFor = String(image.id); | |
| share.removeAttribute('hidden'); | |
| } else { | |
| share.removeAttribute('href'); | |
| share.removeAttribute('data-share-for'); | |
| share.hidden = true; | |
| } | |
| } | |
| node.target = target; node.exit = false; | |
| node.button.removeAttribute('aria-hidden'); | |
| const rank = ranks.get(image.id); | |
| resize(node); | |
| if (!node.photo && !node.load) loadThumbnail(node, rank ?? 200 + index); | |
| node.load?.prioritize(rank ?? 200 + index); | |
| node.button.setAttribute('aria-label', rank == null ? 'Phóng to ảnh' : `Phóng to kết quả ${rank + 1}`); | |
| node.button.setAttribute('aria-haspopup', 'dialog'); | |
| // `exact` comes back on the same appearance object as the fade, so the | |
| // outline and the dimming can never disagree about which tile is the | |
| // answer. classList.toggle (not add) because a tile keeps its node | |
| // across searches and would otherwise keep a stale outline. | |
| const { opacity, blur, exact } = appearance(image.id); | |
| node.button.style.setProperty('--relevance-opacity', opacity); | |
| node.button.style.setProperty('--relevance-blur', `${blur}px`); | |
| node.button.classList.toggle('exact', !!exact); | |
| // An exact hit is never allowed to fade with the tail, whatever its | |
| // cosine: if the outline says "this is the one", it has to be readable. | |
| if (exact) { | |
| node.button.style.setProperty('--relevance-opacity', 1); | |
| node.button.style.setProperty('--relevance-blur', '0px'); | |
| } | |
| node.button.dataset.rank = rank == null ? '' : rank + 1; | |
| node.button.dataset.exact = exact ? 'true' : ''; | |
| node.button.tabIndex = state.results.length ? (rank != null && rank < 12 ? 0 : -1) : (index < 12 ? 0 : -1); | |
| transition(node, target, now, initial || motion.matches); | |
| }); | |
| for (const [id, node] of nodes) { | |
| if (active.has(id) || node.exit) continue; | |
| node.exit = true; | |
| node.load?.cancel(); node.load = null; | |
| node.button.setAttribute('aria-hidden', 'true'); | |
| node.button.dataset.rank = ''; | |
| // Clear the outline on the way out: a tile drifting off-screen must not | |
| // keep advertising itself as the answer to the previous query. | |
| node.button.dataset.exact = ''; | |
| node.button.classList.remove('exact'); | |
| node.button.tabIndex = -1; | |
| transition(node, edgePosition(node.image, state.width, state.height, node), now, motion.matches); | |
| } | |
| } | |
| function update(results) { | |
| const count = fieldCount(state.width); | |
| const seen = new Set(); | |
| // Retain existing photographs between searches instead of restoring a background. | |
| // `results` already carries the merged interiors + products, so the pool | |
| // is filled from it first; the ambient collection only backs the idle | |
| // field and the tail beyond the current results. | |
| const candidates = results.length ? [...results, ...state.pool, ...collection] : collection; | |
| const pool = []; | |
| for (const image of candidates) { | |
| if (seen.has(image.id)) continue; | |
| seen.add(image.id); | |
| pool.push(image); | |
| if (pool.length === count) break; | |
| } | |
| // Move immediately. BlurHash covers image downloads without holding up the field. | |
| state.results = results; state.pool = pool; | |
| layout(); | |
| } | |
| function animate(now) { | |
| const elapsed = now - previous; | |
| const dt = Math.min(elapsed / 1000, 0.04); | |
| previous = now; | |
| if (state.paused) { | |
| for (const node of nodes.values()) if (node.motion) node.motion.started += elapsed; | |
| frame = requestAnimationFrame(animate); | |
| return; | |
| } | |
| if (!document.hidden) time += dt; | |
| const blend = 1 - Math.exp(-4 * dt); | |
| state.x += (state.pointer.x - state.x) * blend; | |
| state.y += (state.pointer.y - state.y) * blend; | |
| for (const node of nodes.values()) { | |
| let done = true; | |
| if (node.motion) { | |
| const sampled = sampleMotion(node.motion, motion.matches ? Infinity : now); | |
| Object.assign(node, sampled.value); | |
| done = sampled.done; | |
| if (done) node.motion = null; | |
| } | |
| if (node.exit && done) { | |
| remove(node); continue; | |
| } | |
| // Shared ambient flow: equal parallax keeps everything on the same visual plane. | |
| const phase = node.phase; | |
| const dx = motion.matches ? 0 : Math.sin(time * 0.22 + phase) * 3 + state.x * 7; | |
| const dy = motion.matches ? 0 : Math.cos(time * 0.18 + phase) * 3 + state.y * 7; | |
| node.button.style.transform = `translate3d(${(node.x - node.width / 2 + dx).toFixed(2)}px,${(node.y - node.height / 2 + dy).toFixed(2)}px,0) scale(${node.scale.toFixed(4)})`; | |
| node.button.style.pointerEvents = node.exit ? 'none' : ''; | |
| } | |
| frame = requestAnimationFrame(animate); | |
| } | |
| state.pool = collection.slice(0, fieldCount(state.width)); | |
| layout(true); | |
| const observer = new ResizeObserver(([entry]) => { | |
| const { width, height } = entry.contentRect; | |
| if (width === state.width && height === state.height) return; | |
| state.width = width; state.height = height; | |
| update(state.results); | |
| }); | |
| observer.observe(root); | |
| const move = event => { | |
| if (event.pointerType === 'touch') return; | |
| state.pointer.x = (event.clientX / state.width - 0.5) * 2; | |
| state.pointer.y = (event.clientY / state.height - 0.5) * 2; | |
| liftUnderPointer(event.target); | |
| }; | |
| /** | |
| * Raise the photograph under the pointer above its neighbours. | |
| * | |
| * The field overlaps tiles deliberately and each tile's z-index is a stable | |
| * seed of its id, so a tile can end up permanently buried under whichever | |
| * neighbour hashed higher. CSS :hover cannot rescue it, because a buried | |
| * element never matches :hover -- which is the chicken-and-egg the share | |
| * button was stuck in. Measured on the idle field: of 79 in-viewport share | |
| * buttons, not one was the topmost element at its own centre; 13 were | |
| * covered by another tile's photograph and 8 by another tile outright. | |
| * | |
| * Lifting whichever tile the pointer is over makes the next hover land on | |
| * the tile the user is aiming at, and then its own :hover reveals the | |
| * share button normally. The seeded resting order is restored when the | |
| * pointer leaves, so the composition stays deterministic across reloads. | |
| */ | |
| function liftUnderPointer(target) { | |
| const tile = target?.closest?.('.image-tile'); | |
| for (const node of nodes.values()) { | |
| if (node.lifted && node.button !== tile) restoreZ(node); | |
| } | |
| if (!tile) return; | |
| const node = [...nodes.values()].find(n => n.button === tile); | |
| if (node && !node.lifted) { | |
| node.lifted = true; | |
| tile.style.zIndex = LIFTED_Z; | |
| } | |
| } | |
| function restoreZ(node) { | |
| node.lifted = false; | |
| node.button.style.zIndex = node.baseZ; | |
| } | |
| const leave = () => { | |
| state.pointer.x = state.pointer.y = 0; | |
| // Restore the seeded resting order. Leaving the field is the only | |
| // unambiguous signal that the user is done arranging it, and doing it | |
| // here is what keeps the composition identical on every reload. | |
| for (const node of nodes.values()) if (node.lifted) restoreZ(node); | |
| }; | |
| root.addEventListener('pointermove', move); root.addEventListener('pointerleave', leave); | |
| frame = requestAnimationFrame(animate); | |
| engine.current = { state, update }; | |
| return () => { | |
| cancelAnimationFrame(frame); observer.disconnect(); | |
| root.removeEventListener('pointermove', move); root.removeEventListener('pointerleave', leave); | |
| thumbnails.dispose(); placeholders.dispose(); | |
| root.replaceChildren(); engine.current = null; | |
| }; | |
| }, []); | |
| useEffect(() => { engine.current?.update(results); }, [results]); | |
| useEffect(() => { | |
| if (!engine.current) return; | |
| engine.current.state.paused = paused; | |
| }, [paused]); | |
| return <div className="image-field" ref={surface} aria-label="Photograph collection" />; | |
| } | |