Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
144dda6d88 | ||
|
|
0c9a406d68 | ||
|
|
75ccf14889 | ||
|
|
6a2532cb87 | ||
|
|
4c5d3de7bc | ||
|
|
5afa71ec84 | ||
|
|
d70d396670 |
No files matched your search
@@ -3,6 +3,8 @@
|
|||||||
(() => {
|
(() => {
|
||||||
if (window.ZoomzillaUpload) return;
|
if (window.ZoomzillaUpload) return;
|
||||||
const android = /Android/i.test(navigator.userAgent);
|
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();
|
let uploadQueue = Promise.resolve();
|
||||||
window.ZoomzillaUpload = {
|
window.ZoomzillaUpload = {
|
||||||
// Резерв из пяти точек задаётся CSS: слово и соседняя иконка не двигаются.
|
// Резерв из пяти точек задаётся CSS: слово и соседняя иконка не двигаются.
|
||||||
@@ -22,7 +24,6 @@
|
|||||||
output.textContent = '.'.repeat(dots);
|
output.textContent = '.'.repeat(dots);
|
||||||
},
|
},
|
||||||
bindPicker(form, mainInput, fullAccept, onFiles) {
|
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';
|
if (mobile) form.dataset.mobileUpload = '1';
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
const input = typeof mainInput === 'function' ? mainInput() : mainInput;
|
const input = typeof mainInput === 'function' ? mainInput() : mainInput;
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
/** Общая inline-логика Index/Preland: синхронная копия в обоих репозиториях. */
|
||||||
|
return <<<'JS'
|
||||||
|
window.ZoomzillaExamples = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
+23
-212
@@ -1,101 +1,39 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
$demoBasePath = rtrim($assetsUriPrefix, '/') . "/{$indpic}";
|
$demoBasePath = rtrim($assetsUriPrefix, '/') . "/{$indpic}";
|
||||||
$demoLocalPath = __DIR__ . "/../{$indpic}";
|
$demoLocalPath = __DIR__ . "/../{$indpic}";
|
||||||
|
|
||||||
$getAssetUrl = function (int $number, string $device, string $role) use ($demoBasePath, $demoLocalPath): string {
|
$getAssetUrl = function (int $number, string $device, string $role) use ($demoBasePath, $demoLocalPath): string {
|
||||||
$relativePath = sprintf('%02d', $number) . "_{$device}_{$role}.avif";
|
$relativePath = sprintf('%02d', $number) . "_{$device}_{$role}.avif";
|
||||||
$fullPath = $demoLocalPath . '/' . ltrim($relativePath, '/');
|
$fullPath = $demoLocalPath . '/' . $relativePath;
|
||||||
$version = file_exists($fullPath) ? filemtime($fullPath) : '1';
|
$version = file_exists($fullPath) ? filemtime($fullPath) : '1';
|
||||||
return $demoBasePath . '/' . ltrim($relativePath, '/') . '?v=' . $version;
|
return $demoBasePath . '/' . $relativePath . '?v=' . $version;
|
||||||
};
|
};
|
||||||
|
|
||||||
$labelBefore = Yii::t('app', 'До');
|
$labelBefore = Yii::t('app', 'До');
|
||||||
$labelAfter = Yii::t('app', 'После');
|
$labelAfter = Yii::t('app', 'После');
|
||||||
|
|
||||||
$sourceMob = $getAssetUrl(1, 'mob', '1-source');
|
|
||||||
$sourceOrig = $getAssetUrl(1, 'desk', '1-source');
|
|
||||||
|
|
||||||
$resultMob = $getAssetUrl(1, 'mob', '2-result');
|
|
||||||
$resultOrig = $getAssetUrl(1, 'desk', '2-result');
|
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
<div id="examples" data-examples-state="loading" aria-busy="true">
|
||||||
<div id="examples">
|
<div data-examples-visual style="visibility:hidden" aria-hidden="true" inert>
|
||||||
|
|
||||||
<div class="image-compare" id="image-compare">
|
<div class="image-compare" id="image-compare">
|
||||||
<img src="<?= $sourceMob ?>"
|
<img id="image-compare-source" width="728" height="395" alt="<?= htmlspecialchars($labelBefore) ?>">
|
||||||
srcset="<?= $sourceMob ?> 768w, <?= $sourceOrig ?> 1696w"
|
<img id="image-compare-result" width="728" height="395" alt="<?= htmlspecialchars($labelAfter) ?>">
|
||||||
sizes="(max-width: 767.98px) 100vw, 728px"
|
|
||||||
id="image-compare-source"
|
|
||||||
fetchpriority="high"
|
|
||||||
loading="eager"
|
|
||||||
width="728"
|
|
||||||
height="395"
|
|
||||||
alt="<?= htmlspecialchars($labelBefore) ?>">
|
|
||||||
|
|
||||||
<img src="<?= $resultMob ?>"
|
|
||||||
srcset="<?= $resultMob ?> 768w, <?= $resultOrig ?> 1696w"
|
|
||||||
sizes="(max-width: 767.98px) 100vw, 728px"
|
|
||||||
id="image-compare-result"
|
|
||||||
fetchpriority="high"
|
|
||||||
loading="eager"
|
|
||||||
width="728"
|
|
||||||
height="395"
|
|
||||||
alt="<?= htmlspecialchars($labelAfter) ?>">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="image-examples">
|
<div class="image-examples">
|
||||||
<div class="image-examples-label"><?= Yii::t('app', 'Примеры улучшения') ?></div>
|
<div class="image-examples-label"><?= Yii::t('app', 'Примеры улучшения') ?></div>
|
||||||
<div class="image-examples-list">
|
<div class="image-examples-list">
|
||||||
<?php for ($i = 1; $i <= 5; $i++):
|
<?php for ($i=1; $i<=5; $i++): ?>
|
||||||
$thumbMob = $getAssetUrl($i, 'mob', '3-thumb');
|
<div class="image-examples-item <?= $i===1 ? 'active' : '' ?>" data-example="<?= $i ?>" data-index="<?= $i ?>"
|
||||||
$thumbOrig = $getAssetUrl($i, 'desk', '3-thumb');
|
data-source="<?= $getAssetUrl($i, 'desk', '1-source') ?>" data-result="<?= $getAssetUrl($i, 'desk', '2-result') ?>"
|
||||||
?>
|
data-source-mob="<?= $getAssetUrl($i, 'mob', '1-source') ?>" data-result-mob="<?= $getAssetUrl($i, 'mob', '2-result') ?>"
|
||||||
<div class="image-examples-item <?= $i === 1 ? 'active' : '' ?>"
|
data-thumb="<?= $getAssetUrl($i, 'desk', '3-thumb') ?>" data-thumb-mob="<?= $getAssetUrl($i, 'mob', '3-thumb') ?>" aria-disabled="true" tabindex="-1">
|
||||||
data-index="<?= $i ?>"
|
<div class="image-examples-image"><picture><img width="100" height="100" alt="Example <?= $i ?>"></picture></div>
|
||||||
data-source="<?= $getAssetUrl($i, 'desk', '1-source') ?>"
|
|
||||||
data-result="<?= $getAssetUrl($i, 'desk', '2-result') ?>"
|
|
||||||
data-source-mob="<?= $getAssetUrl($i, 'mob', '1-source') ?>"
|
|
||||||
data-result-mob="<?= $getAssetUrl($i, 'mob', '2-result') ?>">
|
|
||||||
<div class="image-examples-image">
|
|
||||||
<?php if ($i === 1): ?>
|
|
||||||
<picture>
|
|
||||||
<source media="(max-width: 767.98px)" srcset="<?= $thumbMob ?>">
|
|
||||||
<img src="<?= $thumbOrig ?>" width="53" height="53" alt="Example 1">
|
|
||||||
</picture>
|
|
||||||
<?php else: ?>
|
|
||||||
<picture class="deferred-picture">
|
|
||||||
<source media="(max-width: 767.98px)" data-srcset="<?= $thumbMob ?>">
|
|
||||||
<img data-src="<?= $thumbOrig ?>" width="53" height="53" alt="Example <?= $i ?>" class="deferred-thumb">
|
|
||||||
</picture>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<?php endfor; ?>
|
<?php endfor; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn button-outline examples-retry" data-examples-retry hidden><?= Yii::t('app', 'Повторить') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const examples = {
|
<?= require __DIR__ . '/example-loader.php' ?>
|
||||||
el: document.getElementById('examples'),
|
|
||||||
titleEl: document.getElementById('form-title'),
|
|
||||||
|
|
||||||
hide() {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
||||||
if (this.el) this.el.style.display = 'none';
|
|
||||||
},
|
|
||||||
show() {
|
|
||||||
if (this.el) this.el.style.display = '';
|
|
||||||
},
|
|
||||||
setTitle(html) {
|
|
||||||
if (this.titleEl) this.titleEl.innerHTML = html;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function setupNativeCompare(element, labelBefore, labelAfter) {
|
function setupNativeCompare(element, labelBefore, labelAfter) {
|
||||||
if (!element || element.classList.contains('native-icv') || element.classList.contains('icv')) return null;
|
if (!element || element.classList.contains('native-icv') || element.classList.contains('icv')) return null;
|
||||||
|
|
||||||
@@ -132,15 +70,18 @@ $resultOrig = $getAssetUrl(1, 'desk', '2-result');
|
|||||||
element.appendChild(lblBefore);
|
element.appendChild(lblBefore);
|
||||||
element.appendChild(lblAfter);
|
element.appendChild(lblAfter);
|
||||||
|
|
||||||
|
let sliderPercent = 50;
|
||||||
|
element.addEventListener('examples:images', () => updateSlider(sliderPercent));
|
||||||
function updateSlider(percent) {
|
function updateSlider(percent) {
|
||||||
|
sliderPercent = percent;
|
||||||
percent = Math.max(0, Math.min(100, percent));
|
percent = Math.max(0, Math.min(100, percent));
|
||||||
beforeWrap.style.width = percent + '%';
|
beforeWrap.style.width = percent + '%';
|
||||||
handle.style.left = percent + '%';
|
handle.style.left = percent + '%';
|
||||||
sourceImg.style.width = element.clientWidth + 'px';
|
element.querySelector('#image-compare-source').style.width = element.clientWidth + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
sourceImg.style.width = element.clientWidth + 'px';
|
element.querySelector('#image-compare-source').style.width = element.clientWidth + 'px';
|
||||||
});
|
});
|
||||||
|
|
||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
@@ -175,140 +116,10 @@ $resultOrig = $getAssetUrl(1, 'desk', '2-result');
|
|||||||
|
|
||||||
updateSlider(50);
|
updateSlider(50);
|
||||||
|
|
||||||
return {
|
|
||||||
setImages(sourceUrl, resultUrl) {
|
|
||||||
sourceImg.removeAttribute('srcset');
|
|
||||||
resultImg.removeAttribute('srcset');
|
|
||||||
sourceImg.src = sourceUrl;
|
|
||||||
resultImg.src = resultUrl;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initCompareViewer() {
|
|
||||||
const element = document.getElementById('image-compare');
|
|
||||||
const beforeTxt = '<?= addslashes($labelBefore) ?>';
|
|
||||||
const afterTxt = '<?= addslashes($labelAfter) ?>';
|
|
||||||
|
|
||||||
if (element && typeof ImageCompare !== 'undefined') {
|
window.ZoomzillaExamples.attach(element => {
|
||||||
const options = {
|
setupNativeCompare(element, <?= json_encode($labelBefore, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, <?= json_encode($labelAfter, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
|
||||||
controlColor: "#FFFFFF",
|
});
|
||||||
controlShadow: true,
|
|
||||||
addCircle: true,
|
|
||||||
addCircleBlur: true,
|
|
||||||
showLabels: true,
|
|
||||||
labelOptions: { before: beforeTxt, after: afterTxt, onHover: false },
|
|
||||||
smoothing: true,
|
|
||||||
smoothingAmount: 100,
|
|
||||||
hoverStart: false,
|
|
||||||
verticalMode: false,
|
|
||||||
startingPoint: 50,
|
|
||||||
fluidMode: false
|
|
||||||
};
|
|
||||||
window.icvInstance = new ImageCompare(element, options).mount();
|
|
||||||
} else if (element) {
|
|
||||||
window.nativeCompareInstance = setupNativeCompare(element, beforeTxt, afterTxt);
|
|
||||||
}
|
|
||||||
|
|
||||||
const exampleItems = document.querySelectorAll('.image-examples-item');
|
|
||||||
exampleItems.forEach(item => {
|
|
||||||
item.onclick = function() {
|
|
||||||
const isMobile = window.innerWidth < 768;
|
|
||||||
// На мобильном берем source_mob, на ПК — полный оригинал
|
|
||||||
const source = (isMobile && this.getAttribute('data-source-mob'))
|
|
||||||
? this.getAttribute('data-source-mob')
|
|
||||||
: this.getAttribute('data-source');
|
|
||||||
const result = (isMobile && this.getAttribute('data-result-mob'))
|
|
||||||
? this.getAttribute('data-result-mob')
|
|
||||||
: this.getAttribute('data-result');
|
|
||||||
|
|
||||||
if (window.icvInstance) {
|
|
||||||
window.icvInstance.images.start.element.removeAttribute('srcset');
|
|
||||||
window.icvInstance.images.end.element.removeAttribute('srcset');
|
|
||||||
window.icvInstance.images.start.element.src = source;
|
|
||||||
window.icvInstance.images.end.element.src = result;
|
|
||||||
} else if (window.nativeCompareInstance) {
|
|
||||||
window.nativeCompareInstance.setImages(source, result);
|
|
||||||
} else {
|
|
||||||
const sourceEl = document.getElementById('image-compare-source');
|
|
||||||
const resultEl = document.getElementById('image-compare-result');
|
|
||||||
if (sourceEl) {
|
|
||||||
sourceEl.removeAttribute('srcset');
|
|
||||||
sourceEl.src = source;
|
|
||||||
}
|
|
||||||
if (resultEl) {
|
|
||||||
resultEl.removeAttribute('srcset');
|
|
||||||
resultEl.src = result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exampleItems.forEach(el => el.classList.remove('active'));
|
|
||||||
this.classList.add('active');
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initStrictImagePipeline() {
|
|
||||||
initCompareViewer();
|
|
||||||
|
|
||||||
const preloadUrl = (url) => {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = img.onerror = resolve;
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const deferredPics = document.querySelectorAll('.deferred-picture');
|
|
||||||
const thumbPromises = [];
|
|
||||||
|
|
||||||
deferredPics.forEach(pic => {
|
|
||||||
const source = pic.querySelector('source');
|
|
||||||
const img = pic.querySelector('img');
|
|
||||||
|
|
||||||
if (source && source.getAttribute('data-srcset')) {
|
|
||||||
source.srcset = source.getAttribute('data-srcset');
|
|
||||||
}
|
|
||||||
if (img && img.getAttribute('data-src')) {
|
|
||||||
const p = new Promise(resolve => {
|
|
||||||
img.onload = img.onerror = resolve;
|
|
||||||
img.src = img.getAttribute('data-src');
|
|
||||||
});
|
|
||||||
thumbPromises.push(p);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Promise.all(thumbPromises).then(() => {
|
|
||||||
const queue = [2, 3, 4, 5];
|
|
||||||
|
|
||||||
async function loadSequentially() {
|
|
||||||
const isMobile = window.innerWidth < 768;
|
|
||||||
for (const num of queue) {
|
|
||||||
const item = document.querySelector(`.image-examples-item[data-index="${num}"]`);
|
|
||||||
if (!item) continue;
|
|
||||||
|
|
||||||
const s = (isMobile && item.getAttribute('data-source-mob'))
|
|
||||||
? item.getAttribute('data-source-mob')
|
|
||||||
: item.getAttribute('data-source');
|
|
||||||
const r = (isMobile && item.getAttribute('data-result-mob'))
|
|
||||||
? item.getAttribute('data-result-mob')
|
|
||||||
: item.getAttribute('data-result');
|
|
||||||
|
|
||||||
await Promise.all([preloadUrl(s), preloadUrl(r)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('requestIdleCallback' in window) {
|
|
||||||
requestIdleCallback(() => loadSequentially());
|
|
||||||
} else {
|
|
||||||
setTimeout(loadSequentially, 150);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initStrictImagePipeline);
|
|
||||||
} else {
|
|
||||||
initStrictImagePipeline();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
+79
-30
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
require __DIR__ . '/browser-upload.php';
|
require_once __DIR__ . '/browser-upload.php';
|
||||||
|
echo '<script>', require __DIR__ . '/navigation-guard.php', '</script>';
|
||||||
|
echo '<script>', require __DIR__ . '/upload-recovery.php', '</script>';
|
||||||
|
|
||||||
$errorMessage = Yii::t('app', 'Возникла ошибка');
|
$errorMessage = Yii::t('app', 'Возникла ошибка');
|
||||||
$utmJson = json_encode($UTM ?? [], JSON_UNESCAPED_UNICODE);
|
$utmJson = json_encode($UTM ?? [], JSON_UNESCAPED_UNICODE);
|
||||||
@@ -16,7 +18,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
|
|
||||||
<div class="dropzone-form-inner">
|
<div class="dropzone-form-inner">
|
||||||
<div class="dropzone-form-icon">
|
<div class="dropzone-form-icon">
|
||||||
<img src="<?= $assetsUriPrefix; ?>/images/dropzone-icon.svg" alt="" height="64" width="64">
|
<img src="<?= $assetsUriPrefix; ?>/images/dropzone-icon.svg" alt="" height="65" width="64">
|
||||||
</div>
|
</div>
|
||||||
<div id="form-title" class="dropzone-form-text"><?= Yii::t('app', 'Загрузите изображение') ?> <br class="d-md-none"><?= Yii::t('app', 'для улучшения') ?></div>
|
<div id="form-title" class="dropzone-form-text"><?= Yii::t('app', 'Загрузите изображение') ?> <br class="d-md-none"><?= Yii::t('app', 'для улучшения') ?></div>
|
||||||
<div class="dropzone-form-format">
|
<div class="dropzone-form-format">
|
||||||
@@ -33,7 +35,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
</div>
|
</div>
|
||||||
<div class="dropzone-form-action">
|
<div class="dropzone-form-action">
|
||||||
<button type="button" class="dropzone-form-button">
|
<button type="button" class="dropzone-form-button">
|
||||||
<span data-upload-label><?= Yii::t('app', 'Загрузить изображение') ?></span>
|
<span data-upload-label><?= Yii::t('app', preg_match('/Android/i', $_SERVER['HTTP_USER_AGENT'] ?? '') ? 'Выбрать фото' : 'Загрузить изображение') ?></span>
|
||||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M13.3333 14.6667C15.6345 14.6667 17.5 12.8012 17.5 10.5C17.5 8.43079 15.9917 6.71387 14.0145 6.38874C14.0113 6.38821 14.0086 6.38583 14.0078 6.38267C13.4802 4.43373 11.6992 3 9.58333 3C7.05203 3 5 5.05203 5 7.58333C5 7.75477 5.00941 7.924 5.02775 8.09055C5.0282 8.0947 5.0255 8.09856 5.02145 8.09957C3.57289 8.4621 2.5 9.77242 2.5 11.3333C2.5 13.1743 3.99238 14.6667 5.83333 14.6667H6.66667M10 8.83726V18M10 8.83726L7.5 11.3333M10 8.83726L12.5 11.3333" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M13.3333 14.6667C15.6345 14.6667 17.5 12.8012 17.5 10.5C17.5 8.43079 15.9917 6.71387 14.0145 6.38874C14.0113 6.38821 14.0086 6.38583 14.0078 6.38267C13.4802 4.43373 11.6992 3 9.58333 3C7.05203 3 5 5.05203 5 7.58333C5 7.75477 5.00941 7.924 5.02775 8.09055C5.0282 8.0947 5.0255 8.09856 5.02145 8.09957C3.57289 8.4621 2.5 9.77242 2.5 11.3333C2.5 13.1743 3.99238 14.6667 5.83333 14.6667H6.66667M10 8.83726V18M10 8.83726L7.5 11.3333M10 8.83726L12.5 11.3333" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -43,6 +45,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="file" id="dropzone-file-input" name="file" accept="<?= htmlspecialchars($basePickerAccept, ENT_QUOTES, 'UTF-8') ?>" style="display: none;">
|
<input type="file" id="dropzone-file-input" name="file" accept="<?= htmlspecialchars($basePickerAccept, ENT_QUOTES, 'UTF-8') ?>" style="display: none;">
|
||||||
|
<span data-upload-markup-ready hidden></span>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -98,6 +101,11 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
}
|
}
|
||||||
|
|
||||||
let uploadPending = false;
|
let uploadPending = false;
|
||||||
|
let leavingUploadPage = false;
|
||||||
|
let uploadAttempt = 0;
|
||||||
|
const recovery = window.ZoomzillaUploadRecovery.create({scope: 'preland', target: createApiUrl});
|
||||||
|
const metadata = () => ({actionUrl: createApiUrl, redirectBase: redirectUrlBase, redirectUtm: redirectUrlUtm});
|
||||||
|
const clearRecovery = () => recovery.clear().catch(() => {});
|
||||||
let uploadDots = 1;
|
let uploadDots = 1;
|
||||||
let uploadDotsTimer = null;
|
let uploadDotsTimer = null;
|
||||||
const uploadButton = formEl.querySelector('.dropzone-form-button');
|
const uploadButton = formEl.querySelector('.dropzone-form-button');
|
||||||
@@ -111,6 +119,8 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
function beginUploadPending() {
|
function beginUploadPending() {
|
||||||
if (uploadPending) return false;
|
if (uploadPending) return false;
|
||||||
uploadPending = true;
|
uploadPending = true;
|
||||||
|
uploadAttempt++;
|
||||||
|
window.PublicNavigationGuard.activate();
|
||||||
uploadDots = 1;
|
uploadDots = 1;
|
||||||
renderUploadButton();
|
renderUploadButton();
|
||||||
if (uploadButton) {
|
if (uploadButton) {
|
||||||
@@ -185,57 +195,64 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
uploadFile(file);
|
uploadFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createClientKey() {
|
function redirectToTour(record) {
|
||||||
return crypto.randomUUID
|
let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key);
|
||||||
? crypto.randomUUID()
|
if (record.meta.redirectUtm) targetUrl += '&' + record.meta.redirectUtm;
|
||||||
: Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('');
|
window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectToTour(clientKey) {
|
async function rejectOversizedFile(file, saved = null) {
|
||||||
let targetUrl = redirectUrlBase + encodeURIComponent(clientKey);
|
|
||||||
if (redirectUrlUtm) targetUrl += '&' + redirectUrlUtm;
|
|
||||||
window.location.href = targetUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rejectOversizedFile(file) {
|
|
||||||
if (!beginUploadPending()) return;
|
if (!beginUploadPending()) return;
|
||||||
const clientKey = createClientKey();
|
const attempt = uploadAttempt;
|
||||||
|
try {
|
||||||
|
const record = saved || await recovery.remember(null, {...metadata(), rejectionCode: 105, name: file.name, size: file.size});
|
||||||
|
if (attempt !== uploadAttempt || leavingUploadPage) return;
|
||||||
|
const clientKey = record.key;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('rejection_code', '105');
|
formData.append('rejection_code', '105');
|
||||||
formData.append('original_name', file.name || 'upload');
|
formData.append('original_name', file.name || 'upload');
|
||||||
formData.append('original_size', String(file.size || 0));
|
formData.append('original_size', String(file.size || 0));
|
||||||
formData.append('client_key', clientKey);
|
formData.append('client_key', clientKey);
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', createApiUrl || formEl.getAttribute('action') || '/api/images/public/create', true);
|
xhr.open('POST', record.meta.actionUrl, true);
|
||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true;
|
||||||
xhr.setRequestHeader('Accept', 'application/json');
|
xhr.setRequestHeader('Accept', 'application/json');
|
||||||
xhr.onload = () => {
|
xhr.onload = async () => {
|
||||||
|
if (attempt !== uploadAttempt || leavingUploadPage) return;
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
redirectToTour(clientKey);
|
try { const result = JSON.parse(xhr.responseText); if (!result?.id) throw new Error(); await recovery.accepted({id: result.id}); redirectToTour(record); }
|
||||||
|
catch (_) { showUploadError(genericErrorMsg); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showUploadError(genericErrorMsg);
|
showUploadError(genericErrorMsg);
|
||||||
};
|
};
|
||||||
xhr.onerror = () => showUploadError(genericErrorMsg);
|
xhr.onerror = () => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true); };
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
|
} catch (_) { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadFile(file) {
|
function uploadFile(file, saved = null) {
|
||||||
|
const attempt = uploadAttempt;
|
||||||
// Тот же обход нестабильного Android FileProvider, что на основном сайте.
|
// Тот же обход нестабильного Android FileProvider, что на основном сайте.
|
||||||
window.ZoomzillaUpload.prepare(file, maxUploadBytes || 15 * 1024 * 1024)
|
window.ZoomzillaUpload.prepare(file, maxUploadBytes || 15 * 1024 * 1024)
|
||||||
.then(bytes => uploadPreparedFile(file, bytes))
|
.then(async bytes => {
|
||||||
.catch(() => showUploadError(genericErrorMsg));
|
if (attempt !== uploadAttempt || leavingUploadPage) return;
|
||||||
|
const record = saved || await recovery.remember(new File([bytes], file.name, {type: file.type, lastModified: file.lastModified}), metadata());
|
||||||
|
if (attempt === uploadAttempt && !leavingUploadPage) uploadPreparedFile(file, bytes, record);
|
||||||
|
})
|
||||||
|
.catch(() => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg); });
|
||||||
}
|
}
|
||||||
function uploadPreparedFile(file, bytes) {
|
function uploadPreparedFile(file, bytes, record) {
|
||||||
|
const attempt = uploadAttempt;
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
// Ключ загрузки: по нему сайт передаёт её в сессию посетителя (/api/images/public/claim, годен 30 мин).
|
// Ключ загрузки: по нему сайт передаёт её в сессию посетителя (/api/images/public/claim, годен 30 мин).
|
||||||
// randomUUID есть в HTTPS-контексте; где его нет (старый Safari), есть getRandomValues.
|
// randomUUID есть в HTTPS-контексте; где его нет (старый Safari), есть getRandomValues.
|
||||||
const clientKey = createClientKey();
|
const clientKey = record.key;
|
||||||
formData.append('file', bytes, file.name || 'upload');
|
formData.append('file', bytes, file.name || 'upload');
|
||||||
formData.append('client_key', clientKey);
|
formData.append('client_key', clientKey);
|
||||||
|
|
||||||
const actionUrl = createApiUrl || formEl.getAttribute('action') || '/api/images/public/create';
|
const actionUrl = record.meta.actionUrl;
|
||||||
xhr.open('POST', actionUrl, true);
|
xhr.open('POST', actionUrl, true);
|
||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true;
|
||||||
xhr.setRequestHeader('Accept', 'application/json');
|
xhr.setRequestHeader('Accept', 'application/json');
|
||||||
@@ -247,7 +264,8 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onload = () => {
|
xhr.onload = async () => {
|
||||||
|
if (attempt !== uploadAttempt || leavingUploadPage) return;
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
@@ -269,24 +287,55 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Переход: $redirect_url_base . client_key . ($redirect_url_utm ? '&' . $redirect_url_utm : '')
|
// Переход: $redirect_url_base . client_key . ($redirect_url_utm ? '&' . $redirect_url_utm : '')
|
||||||
redirectToTour(clientKey);
|
try { await recovery.accepted({id: imageId}); redirectToTour(record); }
|
||||||
|
catch (_) { showUploadError(genericErrorMsg); }
|
||||||
} else {
|
} else {
|
||||||
showUploadError(genericErrorMsg);
|
showUploadError(genericErrorMsg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = () => {
|
xhr.onerror = () => {
|
||||||
showUploadError(genericErrorMsg);
|
if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUploadError(msg) {
|
function showUploadError(msg, keepRecovery = false) {
|
||||||
|
if (leavingUploadPage) return;
|
||||||
|
uploadAttempt++;
|
||||||
|
if (!keepRecovery) clearRecovery();
|
||||||
resetUploadPending();
|
resetUploadPending();
|
||||||
form.show();
|
form.show();
|
||||||
if (typeof preview !== 'undefined' && preview) preview.showError(msg);
|
if (typeof preview !== 'undefined' && preview) {
|
||||||
|
preview.show(); preview.showError(msg);
|
||||||
|
if (!preview.sourceEl.getAttribute('src')) preview.sourceEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
// Release sentinel восстанавливает scroll history. Показываем сообщение
|
||||||
|
// после этого перехода, иначе браузер тут же возвращает верх страницы.
|
||||||
|
window.PublicNavigationGuard.allowNavigation(() => requestAnimationFrame(() => {
|
||||||
|
if (typeof preview !== 'undefined' && preview) preview.upscaleErrorOverlay.scrollIntoView({block: 'center', behavior: 'instant'});
|
||||||
|
}));
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
}
|
}
|
||||||
|
window.addEventListener('pagehide', () => { leavingUploadPage = true; });
|
||||||
|
window.addEventListener('pageshow', event => {
|
||||||
|
if (!event.persisted) return;
|
||||||
|
leavingUploadPage = false;
|
||||||
|
uploadAttempt++;
|
||||||
|
const wasUploading = uploadPending;
|
||||||
|
resetUploadPending(); clearRecovery(); fileInput.value = '';
|
||||||
|
if (wasUploading) window.PublicNavigationGuard.allowNavigation();
|
||||||
|
});
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const saved = await recovery.load();
|
||||||
|
if (!saved) return;
|
||||||
|
if (performance.getEntriesByType('navigation')[0]?.type !== 'reload' || saved.sourceUrl !== location.href || saved.meta.actionUrl !== createApiUrl) { clearRecovery(); return; }
|
||||||
|
if (saved.result?.id) { beginUploadPending(); redirectToTour(saved); }
|
||||||
|
else if (saved.file) { beginUploadPending(); uploadFile(saved.file, saved); }
|
||||||
|
else if (saved.meta.rejectionCode === 105) rejectOversizedFile(saved.meta, saved);
|
||||||
|
} catch (_) { clearRecovery(); }
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
// Общий inline-контракт Back для незавершённой публичной операции (api#299/preland#9).
|
||||||
|
return <<<'JS'
|
||||||
|
(() => {
|
||||||
|
let active = false;
|
||||||
|
let hasHistoryEntry = false;
|
||||||
|
let releasingHistoryEntry = false;
|
||||||
|
let returningToSentinel = false;
|
||||||
|
|
||||||
|
function pushGuardEntries() {
|
||||||
|
// Помечаем и текущую запись Tour, и sentinel сверху. Иначе первый Back
|
||||||
|
// останавливался на немаркированной записи, а второй мог уйти в about:blank.
|
||||||
|
window.history.replaceState({ publicOperationGuard: true }, '', window.location.href);
|
||||||
|
window.history.pushState({ publicOperationGuard: true }, '', window.location.href);
|
||||||
|
hasHistoryEntry = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPopState(event) {
|
||||||
|
if (releasingHistoryEntry) {
|
||||||
|
releasingHistoryEntry = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
if (returningToSentinel) {
|
||||||
|
returningToSentinel = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
returningToSentinel = true;
|
||||||
|
window.history.go(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivate(onReleased) {
|
||||||
|
if (!active) {
|
||||||
|
if (onReleased) {
|
||||||
|
if (releasingHistoryEntry) {
|
||||||
|
window.addEventListener('popstate', onReleased, { once: true });
|
||||||
|
} else {
|
||||||
|
onReleased();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
active = false;
|
||||||
|
|
||||||
|
if (hasHistoryEntry) {
|
||||||
|
hasHistoryEntry = false;
|
||||||
|
releasingHistoryEntry = true;
|
||||||
|
if (onReleased) window.addEventListener('popstate', onReleased, { once: true });
|
||||||
|
window.history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (onReleased) onReleased();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('popstate', onPopState);
|
||||||
|
window.PublicNavigationGuard = {
|
||||||
|
activate() {
|
||||||
|
if (active) return;
|
||||||
|
releasingHistoryEntry = false;
|
||||||
|
returningToSentinel = false;
|
||||||
|
active = true;
|
||||||
|
pushGuardEntries();
|
||||||
|
},
|
||||||
|
deactivate,
|
||||||
|
allowNavigation: deactivate,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
JS;
|
||||||
@@ -1,22 +1,5 @@
|
|||||||
<?php /* CSS синхронна API upload-picker-links; отдельный хост без Yii assets. */ ?>
|
<?php /* CSS синхронна API upload-picker-links; отдельный хост без Yii assets. */ ?>
|
||||||
<style>
|
|
||||||
[data-picker-or][hidden], [data-files-picker][hidden] { display: none !important; }
|
|
||||||
#dropzone-form[data-mobile-upload] { padding: 16px; }
|
|
||||||
#dropzone-form[data-mobile-upload] .dropzone-form-icon img { width: 44px; height: 44px; }
|
|
||||||
#dropzone-form[data-mobile-upload] .dropzone-form-text { margin-top: 8px; font-size: 16px; line-height: 24px; }
|
|
||||||
#dropzone-form[data-mobile-upload] .dropzone-form-format { margin-top: 10px; flex-direction: column; gap: 6px; }
|
|
||||||
#dropzone-form[data-mobile-upload] .dropzone-form-format-label { font-size: 11px; line-height: 16px; }
|
|
||||||
#dropzone-form[data-mobile-upload] .dropzone-form-format-list { flex-wrap: nowrap; gap: 4px; justify-content: center; }
|
|
||||||
#dropzone-form[data-mobile-upload] .dropzone-form-format-item { padding: 3px 5px; border-radius: 6px; font-size: 11px; line-height: 15px; white-space: nowrap; }
|
|
||||||
#dropzone-form[data-mobile-upload] .dropzone-form-action { margin-top: 12px; display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
|
||||||
#dropzone-form[data-mobile-upload] .dropzone-form-button { width: 100%; pointer-events: auto; }
|
|
||||||
#dropzone-form[data-mobile-upload] [data-picker-or] { display: block; margin: 0; font-size: 10px; line-height: 14px; color: #979aa0; }
|
|
||||||
#dropzone-form[data-mobile-upload] [data-files-picker] { display: block; position: relative; margin: -2px 0 0; font-size: 12px; line-height: 18px; text-decoration: underline; text-underline-offset: 3px; pointer-events: auto; }
|
|
||||||
#dropzone-form.theme-dark [data-files-picker], .theme-dark #dropzone-form [data-files-picker] { color: #b6caff; }
|
|
||||||
/* Расширяем область нажатия вниз, не меняя утверждённый видимый зазор4px. */
|
|
||||||
#dropzone-form [data-files-picker]::after { content: ''; position: absolute; inset: 0 -8px -14px; }
|
|
||||||
#dropzone-form[data-mobile-upload] .dropzone-form-format-item[data-format-optional] { display: none; }
|
|
||||||
</style>
|
|
||||||
<span data-picker-or hidden><?= Yii::t('app', 'или') ?></span>
|
<span data-picker-or hidden><?= Yii::t('app', 'или') ?></span>
|
||||||
<a href="#" data-files-picker hidden><?= Yii::t('app', 'Выбрать файлы (PDF и др.)') ?></a>
|
<a href="#" data-files-picker hidden><?= Yii::t('app', 'Выбрать файлы (PDF и др.)') ?></a>
|
||||||
<input type="file" data-other-files style="display:none">
|
<input type="file" data-other-files style="display:none">
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
/** Синхронная inline-CSS API/Preland: мобильный вид до инициализации picker. */
|
||||||
|
return <<<'CSS'
|
||||||
|
[data-picker-or][hidden], [data-files-picker][hidden] { display: none !important; }
|
||||||
|
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) { padding: 16px; }
|
||||||
|
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-icon img { width: 44px; height: 44px; }
|
||||||
|
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-text { margin-top: 8px; font-size: 16px; line-height: 24px; }
|
||||||
|
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format { margin-top: 10px; flex-direction: column; gap: 6px; }
|
||||||
|
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format-label { font-size: 11px; line-height: 16px; }
|
||||||
|
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format-list { flex-wrap: nowrap; gap: 4px; justify-content: center; }
|
||||||
|
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format-item { padding: 3px 5px; border-radius: 6px; font-size: 11px; line-height: 15px; white-space: nowrap; }
|
||||||
|
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-action { margin-top: 12px; display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||||
|
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-button { width: 100%; pointer-events: auto; }
|
||||||
|
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) [data-picker-or] { display: block; margin: 0; font-size: 10px; line-height: 14px; color: #979aa0; }
|
||||||
|
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) [data-files-picker] { display: block; position: relative; margin: -2px 0 0; font-size: 12px; line-height: 18px; text-decoration: underline; text-underline-offset: 3px; pointer-events: auto; }
|
||||||
|
#dropzone-form.theme-dark [data-files-picker], .theme-dark #dropzone-form [data-files-picker] { color: #b6caff; }
|
||||||
|
/* Расширяем область нажатия вниз, не меняя утверждённый видимый зазор4px. */
|
||||||
|
#dropzone-form [data-files-picker]::after { content: ''; position: absolute; inset: 0 -8px -14px; }
|
||||||
|
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format-item[data-format-optional] { display: none; }
|
||||||
|
html[data-upload-device=android] #dropzone-form [data-picker-or][hidden], html[data-upload-device=android] #dropzone-form [data-files-picker][hidden] { display: block !important; }
|
||||||
|
CSS;
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<?php
|
||||||
|
/** Общее восстановление текущей публичной загрузки; UI и навигацией управляет вызывающая форма. */
|
||||||
|
return <<<'JS'
|
||||||
|
(() => {
|
||||||
|
if (window.ZoomzillaUploadRecovery) return;
|
||||||
|
const ttl = 30 * 60 * 1000;
|
||||||
|
const prefix = 'zoomzilla-upload-recovery:';
|
||||||
|
const slots = new Map();
|
||||||
|
const randomKey = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
const failure = message => new Error('Upload recovery: ' + message);
|
||||||
|
let storageInitialized = false;
|
||||||
|
const initializeStorage = () => {
|
||||||
|
if (storageInitialized) return;
|
||||||
|
// Дублирование вкладки копирует sessionStorage даже без opener. Новый
|
||||||
|
// заход отвязывает только указатели: IndexedDB исходной вкладки не трогаем.
|
||||||
|
if (performance.getEntriesByType('navigation')[0]?.type !== 'reload') {
|
||||||
|
for (const name of Object.keys(sessionStorage)) {
|
||||||
|
if (name.startsWith(prefix)) sessionStorage.removeItem(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storageInitialized = true;
|
||||||
|
};
|
||||||
|
// Только ограниченный JSON: не теряем молча поля, NaN или функции при сериализации.
|
||||||
|
const json = value => {
|
||||||
|
const text = JSON.stringify(value, (key, item) => {
|
||||||
|
if (item === undefined || typeof item === 'function' || typeof item === 'symbol' || typeof item === 'bigint' || (typeof item === 'number' && !Number.isFinite(item))) throw failure('invalid JSON');
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
if (!text || text.length > 65536) throw failure('metadata too large');
|
||||||
|
return JSON.parse(text);
|
||||||
|
};
|
||||||
|
const valid = record => {
|
||||||
|
try {
|
||||||
|
return record && typeof record.key === 'string' && record.key.length > 0 && record.key.length <= 200
|
||||||
|
&& Number.isFinite(record.createdAt) && record.createdAt <= Date.now()
|
||||||
|
&& record.expiresAt === record.createdAt + ttl && record.expiresAt > Date.now()
|
||||||
|
&& typeof record.sourceUrl === 'string' && record.sourceUrl.length <= 16384
|
||||||
|
&& typeof record.slot === 'string' && json(record.meta) !== undefined
|
||||||
|
&& (record.result === null ? (record.blob === null || ((record.blob instanceof ArrayBuffer || record.blob instanceof Blob) && record.fileInfo && typeof record.fileInfo.name === 'string' && typeof record.fileInfo.type === 'string' && Number.isFinite(record.fileInfo.lastModified))) : (record.blob === null && record.fileInfo === null && json(record.result) !== undefined));
|
||||||
|
} catch (_) { return false; }
|
||||||
|
};
|
||||||
|
const open = () => new Promise((resolve, reject) => {
|
||||||
|
let request, settled = false;
|
||||||
|
const finish = (error, db) => {
|
||||||
|
if (settled) { if (db) db.close(); return; }
|
||||||
|
settled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
if (error) reject(error); else resolve(db);
|
||||||
|
};
|
||||||
|
const timer = window.setTimeout(() => finish(failure('open timeout')), 10000);
|
||||||
|
try { request = indexedDB.open('zoomzilla-upload-recovery', 1); } catch (error) { finish(error); return; }
|
||||||
|
request.onupgradeneeded = () => request.result.createObjectStore('uploads');
|
||||||
|
request.onerror = () => finish(request.error || failure('open failed'));
|
||||||
|
request.onblocked = () => finish(failure('database blocked'));
|
||||||
|
request.onsuccess = () => {
|
||||||
|
request.result.onversionchange = () => request.result.close();
|
||||||
|
finish(null, request.result);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const transaction = async callback => {
|
||||||
|
const db = await open();
|
||||||
|
try {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
let tx, result, settled = false;
|
||||||
|
try { tx = db.transaction('uploads', 'readwrite'); } catch (error) { reject(error); return; }
|
||||||
|
const finish = error => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
if (error) reject(error); else resolve(result);
|
||||||
|
};
|
||||||
|
const fail = error => {
|
||||||
|
finish(error);
|
||||||
|
try { tx.abort(); } catch (_) { /* Транзакция могла уже завершиться. */ }
|
||||||
|
};
|
||||||
|
const timer = window.setTimeout(() => fail(failure('transaction timeout')), 10000);
|
||||||
|
tx.oncomplete = () => finish();
|
||||||
|
tx.onabort = () => finish(tx.error || failure('transaction aborted'));
|
||||||
|
// WebKit может дать request/transaction error без последующего abort.
|
||||||
|
tx.onerror = event => fail(event.target.error || tx.error || failure('transaction failed'));
|
||||||
|
try {
|
||||||
|
const store = tx.objectStore('uploads');
|
||||||
|
// Общая уборка сирот/протухших записей, включая давно закрытые вкладки.
|
||||||
|
const cursor = store.openCursor();
|
||||||
|
cursor.onsuccess = () => {
|
||||||
|
if (settled) return;
|
||||||
|
try {
|
||||||
|
const entry = cursor.result;
|
||||||
|
if (!entry) return;
|
||||||
|
if (!valid(entry.value)) entry.delete();
|
||||||
|
entry.continue();
|
||||||
|
} catch (error) { fail(error); }
|
||||||
|
};
|
||||||
|
callback(store, value => { result = value; }, fail);
|
||||||
|
} catch (error) { fail(error); }
|
||||||
|
});
|
||||||
|
} finally { db.close(); }
|
||||||
|
};
|
||||||
|
const unpack = record => ({
|
||||||
|
key: record.key, createdAt: record.createdAt, expiresAt: record.expiresAt,
|
||||||
|
sourceUrl: record.sourceUrl, meta: json(record.meta), result: record.result === null ? null : json(record.result),
|
||||||
|
// Structured clone может вернуть Blob без File-прототипа: метаданные хранятся отдельно.
|
||||||
|
file: record.blob === null ? null : new File([record.blob], record.fileInfo.name, {type: record.fileInfo.type, lastModified: record.fileInfo.lastModified})
|
||||||
|
});
|
||||||
|
window.ZoomzillaUploadRecovery = {
|
||||||
|
create({scope, target}) {
|
||||||
|
if (typeof scope !== 'string' || !scope || typeof target !== 'string' || !target) throw failure('scope and target required');
|
||||||
|
const slot = prefix + 'slot:' + JSON.stringify([scope, target]);
|
||||||
|
if (!slots.has(slot)) slots.set(slot, {queue: Promise.resolve(), generation: 0});
|
||||||
|
const state = slots.get(slot);
|
||||||
|
const enqueue = operation => {
|
||||||
|
const pending = state.queue.then(operation);
|
||||||
|
state.queue = pending.catch(() => {});
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
const get = (store, id, done, fail) => {
|
||||||
|
if (!id) { done(null); return; }
|
||||||
|
const request = store.get(id);
|
||||||
|
request.onsuccess = () => { try { done(request.result || null); } catch (error) { fail(error); } };
|
||||||
|
};
|
||||||
|
const current = record => valid(record) && record.slot === slot;
|
||||||
|
return {
|
||||||
|
remember(file, meta, key) {
|
||||||
|
const generation = state.generation;
|
||||||
|
return enqueue(async () => {
|
||||||
|
if (generation !== state.generation) throw failure('cancelled');
|
||||||
|
initializeStorage();
|
||||||
|
const previous = sessionStorage.getItem(slot);
|
||||||
|
// Неудачная новая попытка никогда не восстанавливает прежний файл.
|
||||||
|
sessionStorage.removeItem(slot);
|
||||||
|
const metadata = json(meta);
|
||||||
|
if (file !== null && !(file instanceof File)) throw failure('prepared File required');
|
||||||
|
// ArrayBuffer не зависит от поддержки Blob-файлов в IndexedDB
|
||||||
|
// (в WebKit её отсутствие проявляется асинхронным UnknownError).
|
||||||
|
const bytes = file === null ? null : await file.arrayBuffer();
|
||||||
|
if (generation !== state.generation) throw failure('cancelled');
|
||||||
|
if (key !== undefined && (typeof key !== 'string' || !key || key.length > 200)) throw failure('invalid key');
|
||||||
|
let tab = sessionStorage.getItem(prefix + 'tab');
|
||||||
|
if (!tab) { tab = randomKey(); sessionStorage.setItem(prefix + 'tab', tab); }
|
||||||
|
const id = tab + ':' + randomKey();
|
||||||
|
let record;
|
||||||
|
await transaction((store, done, fail) => get(store, previous, old => {
|
||||||
|
const resumed = old && old.key === key;
|
||||||
|
if (resumed && !current(old)) throw failure('expired resume');
|
||||||
|
const createdAt = resumed ? old.createdAt : Date.now();
|
||||||
|
record = {slot, key: key || randomKey(), createdAt, expiresAt: createdAt + ttl,
|
||||||
|
sourceUrl: resumed ? old.sourceUrl : location.href, meta: metadata,
|
||||||
|
blob: bytes, fileInfo: file === null ? null : {name: file.name, type: file.type, lastModified: file.lastModified}, result: null};
|
||||||
|
if (previous) store.delete(previous);
|
||||||
|
store.put(record, id);
|
||||||
|
done(null);
|
||||||
|
}, fail));
|
||||||
|
if (generation !== state.generation) {
|
||||||
|
await transaction(store => store.delete(id));
|
||||||
|
throw failure('cancelled');
|
||||||
|
}
|
||||||
|
try { sessionStorage.setItem(slot, id); } catch (error) {
|
||||||
|
// Если указатель недоступен, не оставляем Blob до конца TTL.
|
||||||
|
try { await transaction(store => store.delete(id)); } catch (_) { /* TTL уберёт сироту. */ }
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return unpack(record);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
load() {
|
||||||
|
const generation = state.generation;
|
||||||
|
return enqueue(async () => {
|
||||||
|
if (generation !== state.generation) return null;
|
||||||
|
initializeStorage();
|
||||||
|
const id = sessionStorage.getItem(slot);
|
||||||
|
const record = await transaction((store, done, fail) => get(store, id, stored => {
|
||||||
|
if (stored && current(stored)) { done(stored); return; }
|
||||||
|
if (id) store.delete(id);
|
||||||
|
done(null);
|
||||||
|
}, fail));
|
||||||
|
if (generation !== state.generation) return null;
|
||||||
|
if (!record) { if (sessionStorage.getItem(slot) === id) sessionStorage.removeItem(slot); return null; }
|
||||||
|
return unpack(record);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
accepted(result) {
|
||||||
|
const generation = state.generation;
|
||||||
|
return enqueue(async () => {
|
||||||
|
initializeStorage();
|
||||||
|
const receipt = json(result);
|
||||||
|
if (receipt === null) throw failure('receipt required');
|
||||||
|
const id = sessionStorage.getItem(slot);
|
||||||
|
return transaction((store, done, fail) => get(store, id, record => {
|
||||||
|
if (generation !== state.generation || !current(record)) throw failure('missing pending upload');
|
||||||
|
record.blob = null;
|
||||||
|
record.fileInfo = null;
|
||||||
|
record.result = receipt;
|
||||||
|
store.put(record, id);
|
||||||
|
done(unpack(record));
|
||||||
|
}, fail));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
state.generation++;
|
||||||
|
let id;
|
||||||
|
try { initializeStorage(); id = sessionStorage.getItem(slot); sessionStorage.removeItem(slot); } catch (error) { return Promise.reject(error); }
|
||||||
|
return enqueue(() => transaction(store => { if (id) store.delete(id); }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
JS;
|
||||||
@@ -105,7 +105,3 @@ function renderComponent(string $name, array $params = []): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_once __DIR__ . '/template.php';
|
require_once __DIR__ . '/template.php';
|
||||||
?>
|
|
||||||
<style>
|
|
||||||
<?= file_get_contents(__DIR__ . '/style.min.css'); ?>
|
|
||||||
</style>
|
|
||||||
@@ -2808,11 +2808,11 @@ body:not(.theme-dark) .theme-light-hidden {
|
|||||||
.index-mobile-fit #image-compare .icv__img { position: absolute; top: 0; bottom: 0; left: 0; width: 100cqw; height: 100%; object-fit: cover; object-position: center; }
|
.index-mobile-fit #image-compare .icv__img { position: absolute; top: 0; bottom: 0; left: 0; width: 100cqw; height: 100%; object-fit: cover; object-position: center; }
|
||||||
.index-mobile-fit #image-compare .icv__img-b { left: auto; right: 0; }
|
.index-mobile-fit #image-compare .icv__img-b { left: auto; right: 0; }
|
||||||
.index-mobile-fit #image-compare.native-icv img { width: 100cqw !important; }
|
.index-mobile-fit #image-compare.native-icv img { width: 100cqw !important; }
|
||||||
.index-mobile-fit #dropzone-form[data-mobile-upload] { padding: 12px; margin-top: 12px; }
|
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) { padding: 12px; margin-top: 12px; }
|
||||||
.index-mobile-fit #dropzone-form[data-mobile-upload] .dropzone-form-icon img { width: 36px; height: 36px; }
|
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-icon img { width: 36px; height: 36px; }
|
||||||
.index-mobile-fit #dropzone-form[data-mobile-upload] .dropzone-form-text { margin-top: 6px; }
|
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-text { margin-top: 6px; }
|
||||||
.index-mobile-fit #dropzone-form[data-mobile-upload] .dropzone-form-format { margin-top: 8px; }
|
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format { margin-top: 8px; }
|
||||||
.index-mobile-fit #dropzone-form[data-mobile-upload] .dropzone-form-action { margin-top: 10px; }
|
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-action { margin-top: 10px; }
|
||||||
.index-mobile-fit .image-examples { max-width: min(100%, 50svh); margin: 12px auto 0; }
|
.index-mobile-fit .image-examples { max-width: min(100%, 50svh); margin: 12px auto 0; }
|
||||||
.index-mobile-fit .image-examples-label { display: none; }
|
.index-mobile-fit .image-examples-label { display: none; }
|
||||||
.index-mobile-fit .image-examples-list { gap: 8px; }
|
.index-mobile-fit .image-examples-list { gap: 8px; }
|
||||||
@@ -2914,3 +2914,28 @@ body { isolation: isolate; }
|
|||||||
}
|
}
|
||||||
@media (max-width: 767.98px) { .wrapper { --site-header-height: 68px; } }
|
@media (max-width: 767.98px) { .wrapper { --site-header-height: 68px; } }
|
||||||
/* /api#298 */
|
/* /api#298 */
|
||||||
|
|
||||||
|
/* api#301: геометрия примеров существует до загрузки изображений и viewer. */
|
||||||
|
.index-mobile-fit .dropzone-form-action { display: flex; justify-content: center; }
|
||||||
|
.index-mobile-fit #examples { position: relative; }
|
||||||
|
.index-mobile-fit #image-compare { position: relative; width: 100%; aspect-ratio: 1696 / 920; overflow: hidden; }
|
||||||
|
.index-mobile-fit #image-compare > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.index-mobile-fit #examples .examples-retry { position: absolute; top: 40%; left: 50%; transform: translate(-50%, -50%); }
|
||||||
|
.index-mobile-fit #examples .examples-retry[hidden] { display: none; }
|
||||||
|
.index-mobile-fit #examples .image-examples-item { cursor: pointer; }
|
||||||
|
.index-mobile-fit #examples .image-examples-item.active { border-color: #007bff; }
|
||||||
|
/* /api#301 */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
/* Обе колонки занимают свои ячейки и при частично полученном HTML. */
|
||||||
|
.index-mobile-fit > .container > .row { display: grid; grid-template-columns: 5fr 7fr; }
|
||||||
|
.index-mobile-fit > .container > .row > .col-md-5 { grid-column: 1; grid-row: 1; max-width: none; }
|
||||||
|
.index-mobile-fit > .container > .row > .col-md-7 { grid-column: 2; grid-row: 1; max-width: none; }
|
||||||
|
}
|
||||||
|
/* Разметка может прийти частями: показываем только полностью полученную форму. */
|
||||||
|
.index-mobile-fit #dropzone-form:not(:has([data-upload-markup-ready])) { visibility: hidden; }
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
/* Высоты существующего компактного вида: 243/201px контент + 24px padding. */
|
||||||
|
html[data-upload-device=android] .index-mobile-fit #dropzone-form { min-height: 267px; }
|
||||||
|
html[data-upload-device=mobile] .index-mobile-fit #dropzone-form { min-height: 225px; }
|
||||||
|
html[data-upload-device=android] .index-mobile-fit .dropzone-form-action { min-height: 90px; }
|
||||||
|
}
|
||||||
Vendored
+30
-5
@@ -7,11 +7,11 @@
|
|||||||
.index-mobile-fit #image-compare .icv__img { position: absolute; top: 0; bottom: 0; left: 0; width: 100cqw; height: 100%; object-fit: cover; object-position: center; }
|
.index-mobile-fit #image-compare .icv__img { position: absolute; top: 0; bottom: 0; left: 0; width: 100cqw; height: 100%; object-fit: cover; object-position: center; }
|
||||||
.index-mobile-fit #image-compare .icv__img-b { left: auto; right: 0; }
|
.index-mobile-fit #image-compare .icv__img-b { left: auto; right: 0; }
|
||||||
.index-mobile-fit #image-compare.native-icv img { width: 100cqw !important; }
|
.index-mobile-fit #image-compare.native-icv img { width: 100cqw !important; }
|
||||||
.index-mobile-fit #dropzone-form[data-mobile-upload] { padding: 12px; margin-top: 12px; }
|
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) { padding: 12px; margin-top: 12px; }
|
||||||
.index-mobile-fit #dropzone-form[data-mobile-upload] .dropzone-form-icon img { width: 36px; height: 36px; }
|
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-icon img { width: 36px; height: 36px; }
|
||||||
.index-mobile-fit #dropzone-form[data-mobile-upload] .dropzone-form-text { margin-top: 6px; }
|
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-text { margin-top: 6px; }
|
||||||
.index-mobile-fit #dropzone-form[data-mobile-upload] .dropzone-form-format { margin-top: 8px; }
|
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format { margin-top: 8px; }
|
||||||
.index-mobile-fit #dropzone-form[data-mobile-upload] .dropzone-form-action { margin-top: 10px; }
|
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-action { margin-top: 10px; }
|
||||||
.index-mobile-fit .image-examples { max-width: min(100%, 50svh); margin: 12px auto 0; }
|
.index-mobile-fit .image-examples { max-width: min(100%, 50svh); margin: 12px auto 0; }
|
||||||
.index-mobile-fit .image-examples-label { display: none; }
|
.index-mobile-fit .image-examples-label { display: none; }
|
||||||
.index-mobile-fit .image-examples-list { gap: 8px; }
|
.index-mobile-fit .image-examples-list { gap: 8px; }
|
||||||
@@ -115,3 +115,28 @@ body { isolation: isolate; }
|
|||||||
}
|
}
|
||||||
@media (max-width: 767.98px) { .wrapper { --site-header-height: 68px; } }
|
@media (max-width: 767.98px) { .wrapper { --site-header-height: 68px; } }
|
||||||
/* /api#298 */
|
/* /api#298 */
|
||||||
|
|
||||||
|
/* api#301: геометрия примеров существует до загрузки изображений и viewer. */
|
||||||
|
.index-mobile-fit .dropzone-form-action { display: flex; justify-content: center; }
|
||||||
|
.index-mobile-fit #examples { position: relative; }
|
||||||
|
.index-mobile-fit #image-compare { position: relative; width: 100%; aspect-ratio: 1696 / 920; overflow: hidden; }
|
||||||
|
.index-mobile-fit #image-compare > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.index-mobile-fit #examples .examples-retry { position: absolute; top: 40%; left: 50%; transform: translate(-50%, -50%); }
|
||||||
|
.index-mobile-fit #examples .examples-retry[hidden] { display: none; }
|
||||||
|
.index-mobile-fit #examples .image-examples-item { cursor: pointer; }
|
||||||
|
.index-mobile-fit #examples .image-examples-item.active { border-color: #007bff; }
|
||||||
|
/* /api#301 */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
/* Обе колонки занимают свои ячейки и при частично полученном HTML. */
|
||||||
|
.index-mobile-fit > .container > .row { display: grid; grid-template-columns: 5fr 7fr; }
|
||||||
|
.index-mobile-fit > .container > .row > .col-md-5 { grid-column: 1; grid-row: 1; max-width: none; }
|
||||||
|
.index-mobile-fit > .container > .row > .col-md-7 { grid-column: 2; grid-row: 1; max-width: none; }
|
||||||
|
}
|
||||||
|
/* Разметка может прийти частями: показываем только полностью полученную форму. */
|
||||||
|
.index-mobile-fit #dropzone-form:not(:has([data-upload-markup-ready])) { visibility: hidden; }
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
/* Высоты существующего компактного вида: 243/201px контент + 24px padding. */
|
||||||
|
html[data-upload-device=android] .index-mobile-fit #dropzone-form { min-height: 267px; }
|
||||||
|
html[data-upload-device=mobile] .index-mobile-fit #dropzone-form { min-height: 225px; }
|
||||||
|
html[data-upload-device=android] .index-mobile-fit .dropzone-form-action { min-height: 90px; }
|
||||||
|
}
|
||||||
+3
-1
@@ -47,7 +47,9 @@
|
|||||||
<link rel="preload" as="image" href="<?= $urlSrcOrig ?>" media="(min-width: 768px)" type="image/avif" fetchpriority="high">
|
<link rel="preload" as="image" href="<?= $urlSrcOrig ?>" media="(min-width: 768px)" type="image/avif" fetchpriority="high">
|
||||||
<link rel="preload" as="image" href="<?= $urlResOrig ?>" media="(min-width: 768px)" type="image/avif" fetchpriority="high">
|
<link rel="preload" as="image" href="<?= $urlResOrig ?>" media="(min-width: 768px)" type="image/avif" fetchpriority="high">
|
||||||
|
|
||||||
<!-- <link rel="stylesheet" href="style.min.css"> -->
|
<style><?= file_get_contents(__DIR__ . '/style.min.css'); ?></style>
|
||||||
|
<style><?= require __DIR__ . '/components/upload-picker-style.php'; ?></style>
|
||||||
|
<?php require_once __DIR__ . '/components/browser-upload.php'; ?>
|
||||||
</head>
|
</head>
|
||||||
<body class="<?= (isset($UTM['theme']) && $UTM['theme'] == 'dark') ? 'theme-dark' : '' ?>" style="<?= (isset($UTM['theme']) && $UTM['theme'] == 'dark') ? 'background: black' : '' ?>">
|
<body class="<?= (isset($UTM['theme']) && $UTM['theme'] == 'dark') ? 'theme-dark' : '' ?>" style="<?= (isset($UTM['theme']) && $UTM['theme'] == 'dark') ? 'background: black' : '' ?>">
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
|
|||||||
Reference in new issue
Block a user