media.matches ? 'mob' : 'desk'; const itemNumber = item => Number(item.dataset.example); const pairKey = (number, variant) => variant + ':' + number; window.examples = { el: root, hide() { window.scrollTo(0, 0); root.style.display = 'none'; }, show() { root.style.display = ''; }, setTitle(html) { const title = document.getElementById('form-title'); if (title) title.innerHTML = html; } }; function loadImage(url, priority = 'low') { if (images.has(url)) return images.get(url); const promise = new Promise((resolve, reject) => { const image = new Image(); image.decoding = 'async'; image.fetchPriority = priority; const timer = window.setTimeout(() => finish(new Error('example_timeout')), 15000); let finished = false; function finish(error) { if (finished) return; finished = true; window.clearTimeout(timer); image.onload = image.onerror = null; if (error) { imageAttempts.set(url, (imageAttempts.get(url) || 0) + 1); reject(error); } else resolve(image); } image.onerror = () => finish(new Error('example_image_failed')); image.onload = async () => { try { if (!image.naturalWidth) throw new Error('example_image_empty'); if (image.decode) await image.decode(); finish(); } catch (error) { finish(error); } }; // WebKit может запомнить сетевой отказ URL: повтору нужен новый запрос, // при этом успешно декодированные изображения остаются в нашем кеше. const attempt = imageAttempts.get(url) || 0; image.src = attempt ? url + (url.includes('?') ? '&' : '?') + '_example_retry=' + attempt : url; }); images.set(url, promise); promise.catch(() => { if (images.get(url) === promise) images.delete(url); }); return promise; } const codecProbes = {"avif":"AAAAHGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZgAAAOptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABwaWN0AAAAAAAAAAAAAAAAAAAAAA5waXRtAAAAAAABAAAAImlsb2MAAAAAREAAAQABAAAAAAEOAAEAAAAAAAAAHgAAACNpaW5mAAAAAAABAAAAFWluZmUCAAAAAAEAAGF2MDEAAAAAamlwcnAAAABLaXBjbwAAABNjb2xybmNseAACAAIABoAAAAAMYXYxQ4EADAAAAAAUaXNwZQAAAAAAAAAQAAAAEAAAABBwaXhpAAAAAAMICAgAAAAXaXBtYQAAAAAAAAABAAEEgYIDhAAAACZtZGF0EgAKBhgM/9oQgDISF/AAAEgAAACpjltftKtLwWis","webp":"UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAgA0JaACdLoB+AADsAD+8MQL/yC5YXXI1/8gP+QH/ID/+PIAAAA="}; function supportsCodec(url) { const match = /\.(avif|webp)(?:[?#]|$)/i.exec(url); const format = match ? match[1].toLowerCase() : null; if (!format) return Promise.resolve(true); const cache = window.ZoomzillaExamples.codecSupport; if (!cache.has(format)) cache.set(format, new Promise(resolve => { const image = new Image(); const timer = window.setTimeout(() => finish(false), 1000); let finished = false; function finish(supported) { if (finished) return; finished = true; window.clearTimeout(timer); image.onload = image.onerror = null; resolve(supported); } image.onerror = () => finish(false); image.onload = async () => { try { if (image.decode) await image.decode(); finish(image.naturalWidth === (format === 'avif' ? 16 : 1)); } catch (_) { finish(false); } }; image.src = 'data:image/' + format + ';base64,' + codecProbes[format]; })); return cache.get(format); } async function loadCandidates(item, role, variant, priority) { const suffix = variant === 'mob' ? 'Mob' : ''; const candidates = [item.dataset[role + suffix]]; for (const format of ['Webp', 'Jpg']) { const fallback = item.dataset[role + suffix + format]; if (fallback && !candidates.includes(fallback)) candidates.push(fallback); } let failure; for (const url of candidates) { if (!await supportsCodec(url)) continue; try { return await loadImage(url, priority); } catch (error) { failure = error; } } throw failure || new Error('example_codec_unavailable'); } async function preparePair(item, variant, priority = 'low') { const key = pairKey(itemNumber(item), variant); if (pairs.has(key)) return pairs.get(key); const pair = await Promise.all([ loadCandidates(item, 'source', variant, priority), loadCandidates(item, 'result', variant, priority) ]); pairs.set(key, pair); failed.delete(key); return pair; } function replaceImage(previous, next) { if (previous === next) return; for (const attribute of [...previous.attributes]) { if (!['src', 'srcset', 'sizes'].includes(attribute.name)) next.setAttribute(attribute.name, attribute.value); } previous.replaceWith(next); } function refreshItems() { for (const item of items) { const ready = !locked && pairs.has(pairKey(itemNumber(item), device())); item.setAttribute('aria-disabled', String(!ready)); item.tabIndex = ready ? 0 : -1; item.classList.toggle('active', itemNumber(item) === active); } } // inert — улучшение. Скрытая/переключаемая область блокируется и в старых движках. const savedTabIndexes = new Map(); function lockVisual(value) { visual.style.visibility = value ? 'hidden' : 'visible'; visual.style.pointerEvents = value ? 'none' : ''; if (value) visual.setAttribute('inert', ''); else visual.removeAttribute('inert'); if (value) { visual.setAttribute('aria-hidden', 'true'); for (const node of visual.querySelectorAll('a,button,input,select,textarea,[tabindex]')) { if (!savedTabIndexes.has(node)) savedTabIndexes.set(node, node.getAttribute('tabindex')); node.tabIndex = -1; } if (visual.contains(document.activeElement)) document.activeElement.blur(); } else { visual.removeAttribute('aria-hidden'); for (const [node, index] of savedTabIndexes) { if (index === null) node.removeAttribute('tabindex'); else node.setAttribute('tabindex', index); } savedTabIndexes.clear(); } } for (const name of ['click', 'mousedown', 'touchstart', 'pointerdown', 'keydown', 'keyup']) { visual.addEventListener(name, event => { if (locked) { event.preventDefault(); event.stopImmediatePropagation(); } }, true); } function commitPair(number, pair) { // Обе декодированные DOM-картинки заменяются в одном JS-такте, без промежуточного paint. replaceImage(element.querySelector('#image-compare-source'), pair[0]); replaceImage(element.querySelector('#image-compare-result'), pair[1]); active = number; if (!mounted) { mount(element); mounted = true; } element.dispatchEvent(new Event('examples:images')); refreshItems(); } function select(item) { const number = itemNumber(item); const pair = pairs.get(pairKey(number, device())); if (locked) return; if (!pair) { retryFailed(item); return; } if (number === active) return; commitPair(number, pair); } async function retryFailed(item) { const variant = device(); const key = pairKey(itemNumber(item), variant); if (locked || backgroundGeneration === generation || !failed.has(key) || retrying.has(key) || Date.now() - (attemptedAt.get(key) || 0) < 1000) return; const token = generation; retrying.add(key); attemptedAt.set(key, Date.now()); const request = retryQueue.then(() => { if (token === generation && !locked) return preparePair(item, variant); }); retryQueue = request.catch(() => {}); try { await request; } catch (_) { /* Сеть может всё ещё быть недоступна. */ } finally { retrying.delete(key); if (!locked && device() === variant) refreshItems(); } } for (const item of items) { item.setAttribute('role', 'button'); item.addEventListener('click', event => { event.preventDefault(); select(item); }); // Повтор не выбирает неготовую пару и не меняет оформление миниатюры. item.addEventListener('pointerenter', () => retryFailed(item)); item.addEventListener('touchstart', () => retryFailed(item), {passive: true}); item.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); select(item); } }); } // Сравнение заблокировано также при смене device-варианта, пока новая пара не готова. for (const name of ['mousedown', 'mousemove', 'touchstart', 'touchmove', 'pointerdown', 'pointermove', 'keydown']) { element.addEventListener(name, event => { if (locked) event.stopImmediatePropagation(); }, true); } async function run() { const token = ++generation; const variant = device(); locked = true; root.dataset.examplesState = 'loading'; root.setAttribute('aria-busy', 'true'); retry.hidden = true; retry.disabled = true; lockVisual(true); refreshItems(); try { const item = items.find(item => itemNumber(item) === active); const pair = await preparePair(item, variant, 'high'); if (token !== generation) return; // Только после выбранной пары разрешены запросы всех миниатюр. const thumbs = await Promise.all(items.map(item => loadCandidates(item, 'thumb', variant, 'auto'))); if (document.fonts) await new Promise((resolve, reject) => { const timer = window.setTimeout(() => reject(new Error('example_font_timeout')), 15000); document.fonts.ready.then(() => { window.clearTimeout(timer); resolve(); }, error => { window.clearTimeout(timer); reject(error); }); }); if (token !== generation) return; items.forEach((item, index) => replaceImage(item.querySelector('.image-examples-image img'), thumbs[index])); commitPair(active, pair); locked = false; lockVisual(false); root.dataset.examplesState = 'ready'; root.setAttribute('aria-busy', 'false'); refreshItems(); // Остальные пары последовательно; две стороны одной пары — параллельно. backgroundGeneration = token; for (const next of items) { if (token !== generation) return; if (itemNumber(next) === active) continue; try { await preparePair(next, variant); } catch (_) { failed.add(pairKey(itemNumber(next), variant)); } if (token === generation) refreshItems(); } if (backgroundGeneration === token) backgroundGeneration = 0; // Один тихий повтор после очереди; затем — по возврату сети или обращению к миниатюре. if (token === generation && items.some(item => failed.has(pairKey(itemNumber(item), variant)))) { await new Promise(resolve => window.setTimeout(resolve, 1000)); for (const next of items) { if (token !== generation) return; await retryFailed(next); } } } catch (_) { if (token !== generation) return; root.dataset.examplesState = 'error'; root.setAttribute('aria-busy', 'false'); retry.hidden = false; retry.disabled = false; } } retry.addEventListener('click', run); window.addEventListener('online', () => { if (root.dataset.examplesState === 'error') run(); else { attemptedAt.clear(); items.forEach(retryFailed); } }); if (media.addEventListener) media.addEventListener('change', run); else media.addListener(run); refreshItems(); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, {once: true}); else run(); } }; JS;