Files
preland/components/example-loader.php
T
2026-10-23 20:10:51 +03:00

288 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/** Общая inline-логика Index/Preland: синхронная копия в обоих репозиториях. */
return <<<'JS'
window.ZoomzillaExamples = {
// Маленькие dataURI (AVIF 16×16, WebP 1×1) проверяют декодер один раз, без сетевой пробы каждого файла.
codecSupport: new Map(),
attach(mount) {
const root = document.getElementById('examples');
if (!root) return;
const element = root.querySelector('#image-compare');
const visual = root.querySelector('[data-examples-visual]');
const retry = root.querySelector('[data-examples-retry]');
const items = [...root.querySelectorAll('.image-examples-item')];
const media = window.matchMedia('(max-width: 767.98px)');
const images = new Map();
const imageAttempts = new Map();
const pairs = new Map();
const failed = new Set();
const retrying = new Set();
const attemptedAt = new Map();
let generation = 0;
let active = 1;
let locked = true;
let mounted = false;
let backgroundGeneration = 0;
let retryQueue = Promise.resolve();
const device = () => 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;