288 lines
15 KiB
PHP
288 lines
15 KiB
PHP
<?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;
|