diff --git a/components/browser-upload.php b/components/browser-upload.php index 69c575a..26d9b92 100644 --- a/components/browser-upload.php +++ b/components/browser-upload.php @@ -3,6 +3,8 @@ (() => { if (window.ZoomzillaUpload) return; const android = /Android/i.test(navigator.userAgent); + const mobile = android || /iPhone|iPad|iPod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + document.documentElement.dataset.uploadDevice = android ? 'android' : (mobile ? 'mobile' : 'desktop'); let uploadQueue = Promise.resolve(); window.ZoomzillaUpload = { // Резерв из пяти точек задаётся CSS: слово и соседняя иконка не двигаются. @@ -22,7 +24,6 @@ output.textContent = '.'.repeat(dots); }, bindPicker(form, mainInput, fullAccept, onFiles) { - const mobile = android || /iPhone|iPad|iPod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); if (mobile) form.dataset.mobileUpload = '1'; const sync = () => { const input = typeof mainInput === 'function' ? mainInput() : mainInput; diff --git a/components/example-loader.php b/components/example-loader.php new file mode 100644 index 0000000..cf8b0e9 --- /dev/null +++ b/components/example-loader.php @@ -0,0 +1,227 @@ + 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; + } + + async function loadCandidates(item, role, variant, priority) { + const suffix = variant === 'mob' ? 'Mob' : ''; + const candidates = [item.dataset[role + suffix]]; + if (role === 'thumb') { + for (const format of ['Webp', 'Jpg']) { + const fallback = item.dataset['thumb' + suffix + format]; + if (fallback) candidates.push(fallback); + } + } + let failure; + for (const url of candidates) { + try { return await loadImage(url, priority); } catch (error) { failure = error; } + } + throw failure; + } + + 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); + } + } + + 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; + 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; + visual.style.visibility = 'visible'; + visual.inert = false; + visual.removeAttribute('aria-hidden'); + 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.addEventListener('click', run); + window.addEventListener('online', () => { + if (root.dataset.examplesState === 'error') run(); + else { attemptedAt.clear(); items.forEach(retryFailed); } + }); + media.addEventListener('change', run); + refreshItems(); + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, {once: true}); + else run(); + } +}; +JS; diff --git a/components/examples.php b/components/examples.php index 7c9d739..2da4308 100644 --- a/components/examples.php +++ b/components/examples.php @@ -1,101 +1,39 @@ - -
- -
- <?= htmlspecialchars($labelBefore) ?> - - <?= htmlspecialchars($labelAfter) ?> -
- -
-
-
- -
-
- - - - Example 1 - - - - - Example <?= $i ?> - - -
+
+ - +
- diff --git a/components/form.php b/components/form.php index 7dccb51..803682e 100644 --- a/components/form.php +++ b/components/form.php @@ -1,5 +1,5 @@ ', require __DIR__ . '/navigation-guard.php', ''; echo ''; @@ -18,7 +18,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
- +

@@ -35,7 +35,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
+