fix(ui): согласовать первый экран и pending #16

This commit is contained in:
Panfilov Konstantin committed 2026-10-23 19:40:47 +03:00
1 parent 01933546a7
commit 198ba573f9
8 files changed
+187 -8

No files matched your search

+43
View File
@@ -23,6 +23,19 @@
} }
output.textContent = '.'.repeat(dots); output.textContent = '.'.repeat(dots);
}, },
setPending(form, pending) {
if (pending) form.dataset.uploadPending = 'true';
else delete form.dataset.uploadPending;
const link = form.querySelector('[data-files-picker]');
if (!link) return;
if (pending) {
link.setAttribute('aria-disabled', 'true');
link.setAttribute('tabindex', '-1');
} else {
link.removeAttribute('aria-disabled');
link.removeAttribute('tabindex');
}
},
bindPicker(form, mainInput, fullAccept, onFiles) { bindPicker(form, mainInput, fullAccept, onFiles) {
if (mobile) form.dataset.mobileUpload = '1'; if (mobile) form.dataset.mobileUpload = '1';
const sync = () => { const sync = () => {
@@ -32,6 +45,36 @@
sync(); sync();
// Dropzone пересоздаёт input после каждого выбора: обновляем до native click. // Dropzone пересоздаёт input после каждого выбора: обновляем до native click.
form.addEventListener('click', sync, true); form.addEventListener('click', sync, true);
// svh не меняется при движении панелей браузера. На старых движках
// сохраняем минимальную высоту до смены ширины/ориентации.
const index = form.closest('.index-mobile-fit');
if (index) {
const probe = document.createElement('span');
probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;height:100vh;height:100svh;width:0';
index.append(probe);
let lastWidth = 0, fallbackHeight = Infinity;
const fit = () => {
if (innerWidth !== lastWidth) fallbackHeight = Infinity;
lastWidth = innerWidth;
const height = probe.getBoundingClientRect().height;
fallbackHeight = Math.min(fallbackHeight, height);
const budget = window.CSS?.supports('height', '100svh') ? height : fallbackHeight;
index.style.setProperty('--index-small-height', budget + 'px');
index.dataset.compactUpload = budget < 650 ? 'true' : 'false';
const hero = index.querySelector('#image-compare');
if (hero) hero.style.setProperty('--compare-width', hero.getBoundingClientRect().width + 'px');
};
fit();
window.addEventListener('resize', fit);
if (window.ResizeObserver) new ResizeObserver(fit).observe(index);
}
if (mobile && form.dataset.photoLabel && !form.querySelector('[data-other-files][multiple]')) {
const label = form.querySelector('[data-upload-label]');
if (label) {
label.textContent = form.dataset.photoLabel;
label.dataset.pickerLabel = form.dataset.photoLabel;
}
}
if (!android) return; if (!android) return;
const link = form.querySelector('[data-files-picker]'); const link = form.querySelector('[data-files-picker]');
const other = form.querySelector('[data-other-files]'); const other = form.querySelector('[data-other-files]');
+10 -5
View File
@@ -14,13 +14,13 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
?> ?>
<form action="<?= htmlspecialchars($createApiUrl, ENT_QUOTES, 'UTF-8') ?>" class="dropzone-form gradient-dashed-border" id="dropzone-form" data-photo-label="<?= htmlspecialchars(Yii::t('app', 'Выбрать фото'), ENT_QUOTES, 'UTF-8') ?>" method="POST" style="background: transparent; border: unset; display: <?= ($show ?? true) ? '' : 'none'; ?>; cursor: pointer;"> <form action="<?= htmlspecialchars($createApiUrl, ENT_QUOTES, 'UTF-8') ?>" class="dropzone-form gradient-dashed-border" id="dropzone-form" data-photo-label="<?= htmlspecialchars(Yii::t('app', 'Выберите фото'), ENT_QUOTES, 'UTF-8') ?>" method="POST" style="background: transparent; border: unset; display: <?= ($show ?? true) ? '' : 'none'; ?>; cursor: pointer;">
<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="65" 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 index-upload-heading"><?= Yii::t('app', 'Улучшить изображение') ?></div>
<div class="dropzone-form-format"> <div class="dropzone-form-format">
<div class="dropzone-form-format-label"><?= Yii::t('app', 'Поддерживаемые форматы') ?>:</div> <div class="dropzone-form-format-label"><?= Yii::t('app', 'Поддерживаемые форматы') ?>:</div>
<div class="dropzone-form-format-list"> <div class="dropzone-form-format-list">
@@ -35,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', preg_match('/Android/i', $_SERVER['HTTP_USER_AGENT'] ?? '') ? 'Выбрать фото' : 'Загрузить изображение') ?></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>
@@ -62,7 +62,10 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
if (this.el) this.el.style.display = ''; if (this.el) this.el.style.display = '';
}, },
setTitle(html) { setTitle(html) {
if (this.titleEl) this.titleEl.innerHTML = html; if (this.titleEl) {
this.titleEl.classList.remove('index-upload-heading');
this.titleEl.innerHTML = html;
}
} }
}; };
@@ -92,7 +95,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
pickerAccept = policy.picker_accept; pickerAccept = policy.picker_accept;
fileInput.accept = /Android/i.test(navigator.userAgent) ? 'image/*' : pickerAccept; fileInput.accept = /Android/i.test(navigator.userAgent) ? 'image/*' : pickerAccept;
} }
if (policy?.pdf_observation_enabled === false) formEl.querySelector('[data-files-picker]').textContent = <?= json_encode(Yii::t('app', 'Выбрать файлы (другие форматы)'), JSON_UNESCAPED_UNICODE) ?>; if (policy?.pdf_observation_enabled === false) formEl.querySelector('[data-files-picker]').textContent = <?= json_encode(Yii::t('app', 'Файлы (другие форматы)'), JSON_UNESCAPED_UNICODE) ?>;
if (Number.isSafeInteger(policy?.max_file_size_bytes) && policy.max_file_size_bytes > 0) { if (Number.isSafeInteger(policy?.max_file_size_bytes) && policy.max_file_size_bytes > 0) {
maxUploadBytes = policy.max_file_size_bytes; maxUploadBytes = policy.max_file_size_bytes;
} }
@@ -119,6 +122,7 @@ $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;
window.ZoomzillaUpload.setPending(formEl, true);
uploadAttempt++; uploadAttempt++;
window.PublicNavigationGuard.activate(); window.PublicNavigationGuard.activate();
uploadDots = 1; uploadDots = 1;
@@ -136,6 +140,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
function resetUploadPending() { function resetUploadPending() {
uploadPending = false; uploadPending = false;
window.ZoomzillaUpload.setPending(formEl, false);
if (uploadDotsTimer !== null) window.clearInterval(uploadDotsTimer); if (uploadDotsTimer !== null) window.clearInterval(uploadDotsTimer);
uploadDotsTimer = null; uploadDotsTimer = null;
if (uploadButtonLabel) uploadButtonLabel.textContent = uploadButtonLabel.dataset.pickerLabel || uploadButtonDefaultLabel; if (uploadButtonLabel) uploadButtonLabel.textContent = uploadButtonLabel.dataset.pickerLabel || uploadButtonDefaultLabel;
+1 -1
View File
@@ -1,5 +1,5 @@
<?php /* CSS синхронна API upload-picker-links; отдельный хост без Yii assets. */ ?> <?php /* CSS синхронна API upload-picker-links; отдельный хост без Yii assets. */ ?>
<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">
+4
View File
@@ -137,6 +137,10 @@ return [
'Загрузите изображение' => 'Upload an image', 'Загрузите изображение' => 'Upload an image',
'Загрузить' => 'Upload', 'Загрузить' => 'Upload',
'Загрузить изображение' => 'Upload Image', 'Загрузить изображение' => 'Upload Image',
'Улучшить изображение' => 'Enhance image',
'Выберите фото' => 'Choose photo',
'Файлы (PDF и др.)' => 'Files (PDF, etc.)',
'Файлы (другие форматы)' => 'Files (other formats)',
'Выбрать фото' => 'Choose photo', 'Выбрать фото' => 'Choose photo',
'или' => 'or', 'или' => 'or',
'Выбрать файлы (PDF и др.)' => 'Choose files (PDF, etc.)', 'Выбрать файлы (PDF и др.)' => 'Choose files (PDF, etc.)',
+35 -1
View File
@@ -2886,7 +2886,7 @@ body { isolation: isolate; }
.index-mobile-fit > .container > .row > .col-md-7 { grid-column: 2; 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) { @media (max-width: 767.98px) {
/* Высоты существующего компактного вида: 243/201px контент + 24px padding. */ /* Высоты существующего компактного вида: 243/201px контент + 24px padding. */
html[data-upload-device=android] .index-mobile-fit #dropzone-form { min-height: 267px; } html[data-upload-device=android] .index-mobile-fit #dropzone-form { min-height: 267px; }
@@ -2899,3 +2899,37 @@ body { isolation: isolate; }
/* preland#12: восстановлен пропущенный в critical CSS Bootstrap-контракт navbar. */ /* preland#12: восстановлен пропущенный в critical CSS Bootstrap-контракт navbar. */
.navbar>.container{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between;} .navbar>.container{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between;}
/* api#313 / preland#16: первый экран, стабильный бюджет высоты и явное состояние загрузки. */
#dropzone-form[data-upload-pending="true"] .dropzone-form-button {
background: #979aa0; opacity: 1; filter: none; cursor: progress;
}
#dropzone-form[data-upload-pending="true"] .dropzone-form-action [data-files-picker] {
color: #979aa0; pointer-events: none; cursor: default;
}
.wrapper { min-height: 100vh; min-height: 100svh; }
.wrapper .main-section:not(.main-section-alt),
.wrapper .main-section.main-section-alt2,
.wrapper .main-content-wrapper { min-height: calc(100vh - var(--site-header-height)); min-height: calc(100svh - var(--site-header-height)); }
@media (min-width: 768px) {
.index-mobile-fit .index-upload-heading { font-size: 24px; font-weight: 600; }
}
@media (max-width: 767.98px) {
.index-mobile-fit { --index-small-height: 100vh; }
.index-mobile-fit #image-compare { max-height: clamp(120px, calc(var(--index-small-height) - 360px), 240px); }
.index-mobile-fit #image-compare .icv__img,
.index-mobile-fit #image-compare.native-icv img { width: var(--compare-width, 100%) !important; }
html[data-upload-device=android] .index-mobile-fit #dropzone-form { min-height: 243px; }
html[data-upload-device=mobile] .index-mobile-fit #dropzone-form { min-height: 201px; }
.index-mobile-fit[data-compact-upload=true] .dropzone-form-icon { display: none; }
.index-mobile-fit[data-compact-upload=true] #dropzone-form #form-title { margin-top: 0; }
html[data-upload-device] .index-mobile-fit[data-compact-upload=true] #dropzone-form { min-height: 0; }
}
@supports (height: 100svh) {
.index-mobile-fit { --index-small-height: 100svh; }
}
/* Без :has форма остаётся доступной; современные движки сохраняют partial-HTML gate. */
@supports selector(:has(*)) {
.index-mobile-fit #dropzone-form:not(:has([data-upload-markup-ready])) { visibility: hidden; }
}
/* /api#313 / preland#16 */
+34
View File
@@ -847,3 +847,37 @@ padding-top: 2svh !important;
/* preland#12: восстановлен пропущенный в critical CSS Bootstrap-контракт navbar. */ /* preland#12: восстановлен пропущенный в critical CSS Bootstrap-контракт navbar. */
.navbar>.container{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between;} .navbar>.container{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between;}
/* api#313 / preland#16: первый экран, стабильный бюджет высоты и явное состояние загрузки. */
#dropzone-form[data-upload-pending="true"] .dropzone-form-button {
background: #979aa0; opacity: 1; filter: none; cursor: progress;
}
#dropzone-form[data-upload-pending="true"] .dropzone-form-action [data-files-picker] {
color: #979aa0; pointer-events: none; cursor: default;
}
.wrapper { min-height: 100vh; min-height: 100svh; }
.wrapper .main-section:not(.main-section-alt),
.wrapper .main-section.main-section-alt2,
.wrapper .main-content-wrapper { min-height: calc(100vh - var(--site-header-height)); min-height: calc(100svh - var(--site-header-height)); }
@media (min-width: 768px) {
.index-mobile-fit .index-upload-heading { font-size: 24px; font-weight: 600; }
}
@media (max-width: 767.98px) {
.index-mobile-fit { --index-small-height: 100vh; }
.index-mobile-fit #image-compare { max-height: clamp(120px, calc(var(--index-small-height) - 360px), 240px); }
.index-mobile-fit #image-compare .icv__img,
.index-mobile-fit #image-compare.native-icv img { width: var(--compare-width, 100%) !important; }
html[data-upload-device=android] .index-mobile-fit #dropzone-form { min-height: 243px; }
html[data-upload-device=mobile] .index-mobile-fit #dropzone-form { min-height: 201px; }
.index-mobile-fit[data-compact-upload=true] .dropzone-form-icon { display: none; }
.index-mobile-fit[data-compact-upload=true] #dropzone-form #form-title { margin-top: 0; }
html[data-upload-device] .index-mobile-fit[data-compact-upload=true] #dropzone-form { min-height: 0; }
}
@supports (height: 100svh) {
.index-mobile-fit { --index-small-height: 100svh; }
}
/* Без :has форма остаётся доступной; современные движки сохраняют partial-HTML gate. */
@supports selector(:has(*)) {
.index-mobile-fit #dropzone-form:not(:has([data-upload-markup-ready])) { visibility: hidden; }
}
/* /api#313 / preland#16 */
+35 -1
View File
@@ -133,7 +133,7 @@ body { isolation: isolate; }
.index-mobile-fit > .container > .row > .col-md-7 { grid-column: 2; 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) { @media (max-width: 767.98px) {
/* Высоты существующего компактного вида: 243/201px контент + 24px padding. */ /* Высоты существующего компактного вида: 243/201px контент + 24px padding. */
html[data-upload-device=android] .index-mobile-fit #dropzone-form { min-height: 267px; } html[data-upload-device=android] .index-mobile-fit #dropzone-form { min-height: 267px; }
@@ -146,3 +146,37 @@ body { isolation: isolate; }
/* preland#12: восстановлен пропущенный в critical CSS Bootstrap-контракт navbar. */ /* preland#12: восстановлен пропущенный в critical CSS Bootstrap-контракт navbar. */
.navbar>.container{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between;} .navbar>.container{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between;}
/* api#313 / preland#16: первый экран, стабильный бюджет высоты и явное состояние загрузки. */
#dropzone-form[data-upload-pending="true"] .dropzone-form-button {
background: #979aa0; opacity: 1; filter: none; cursor: progress;
}
#dropzone-form[data-upload-pending="true"] .dropzone-form-action [data-files-picker] {
color: #979aa0; pointer-events: none; cursor: default;
}
.wrapper { min-height: 100vh; min-height: 100svh; }
.wrapper .main-section:not(.main-section-alt),
.wrapper .main-section.main-section-alt2,
.wrapper .main-content-wrapper { min-height: calc(100vh - var(--site-header-height)); min-height: calc(100svh - var(--site-header-height)); }
@media (min-width: 768px) {
.index-mobile-fit .index-upload-heading { font-size: 24px; font-weight: 600; }
}
@media (max-width: 767.98px) {
.index-mobile-fit { --index-small-height: 100vh; }
.index-mobile-fit #image-compare { max-height: clamp(120px, calc(var(--index-small-height) - 360px), 240px); }
.index-mobile-fit #image-compare .icv__img,
.index-mobile-fit #image-compare.native-icv img { width: var(--compare-width, 100%) !important; }
html[data-upload-device=android] .index-mobile-fit #dropzone-form { min-height: 243px; }
html[data-upload-device=mobile] .index-mobile-fit #dropzone-form { min-height: 201px; }
.index-mobile-fit[data-compact-upload=true] .dropzone-form-icon { display: none; }
.index-mobile-fit[data-compact-upload=true] #dropzone-form #form-title { margin-top: 0; }
html[data-upload-device] .index-mobile-fit[data-compact-upload=true] #dropzone-form { min-height: 0; }
}
@supports (height: 100svh) {
.index-mobile-fit { --index-small-height: 100svh; }
}
/* Без :has форма остаётся доступной; современные движки сохраняют partial-HTML gate. */
@supports selector(:has(*)) {
.index-mobile-fit #dropzone-form:not(:has([data-upload-markup-ready])) { visibility: hidden; }
}
/* /api#313 / preland#16 */
+25
View File
@@ -0,0 +1,25 @@
<?php
/** preland#16: публичная форма отдаёт согласованные подписи до выполнения JavaScript. */
require dirname(__DIR__) . '/i18n.php';
Yii::init('ru');
$_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Linux; Android 10)';
$assetsUriPrefix = '';
ob_start();
require dirname(__DIR__) . '/components/form.php';
$html = ob_get_clean();
$expected = [
'/id="form-title"[^>]*>(.*?)<\/div>/s' => 'Улучшить изображение',
'/data-upload-label>(.*?)<\/span>/s' => 'Выберите фото',
'/data-files-picker hidden>(.*?)<\/a>/s' => 'Файлы (PDF и др.)',
];
$failed = 0;
foreach ($expected as $pattern => $label) {
preg_match($pattern, $html, $match);
$actual = trim(strip_tags($match[1] ?? ''));
if ($actual !== $label) {
fwrite(STDERR, "FAIL: ожидалось «{$label}», получено «{$actual}»\n");
$failed++;
}
}
if ($failed) exit(1);
echo "OK: публичные подписи SSR (3 проверки)\n";