Files
preland/components/browser-upload.php
T
2026-10-20 01:41:30 +03:00

99 lines
5.2 KiB
PHP

<?php /* Лёгкая inline-копия API browser-upload: единый контракт, без внешнего запроса. */ ?>
<script>
(() => {
if (window.ZoomzillaUpload) return;
const android = /Android/i.test(navigator.userAgent);
let uploadQueue = Promise.resolve();
window.ZoomzillaUpload = {
// Резерв из пяти точек задаётся CSS: слово и соседняя иконка не двигаются.
renderLoadingLabel(label, dots, text) {
if (!label) return;
let output = label.querySelector('[data-upload-dots]');
if (!output) {
label.textContent = text + ' ';
const slot = document.createElement('span');
slot.className = 'upload-loading-dots';
slot.setAttribute('aria-hidden', 'true');
output = document.createElement('span');
output.dataset.uploadDots = '';
slot.append(output);
label.append(slot);
}
output.textContent = '.'.repeat(dots);
},
bindPicker(form, mainInput, fullAccept, onFiles) {
const mobile = android || /iPhone|iPad|iPod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (mobile) form.dataset.mobileUpload = '1';
const sync = () => {
const input = typeof mainInput === 'function' ? mainInput() : mainInput;
if (input) input.accept = android ? 'image/*' : (typeof fullAccept === 'function' ? fullAccept() : fullAccept);
};
sync();
// Dropzone пересоздаёт input после каждого выбора: обновляем до native click.
form.addEventListener('click', sync, true);
if (!android) return;
const link = form.querySelector('[data-files-picker]');
const other = form.querySelector('[data-other-files]');
if (!link || !other) return;
form.querySelector('[data-picker-or]').hidden = false;
link.hidden = false;
const label = form.querySelector('[data-upload-label]');
if (label) {
label.textContent = form.dataset.photoLabel;
label.dataset.pickerLabel = form.dataset.photoLabel;
}
other.addEventListener('click', event => event.stopPropagation());
link.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
if (form.querySelector('.dropzone-form-button')?.disabled) return;
other.value = '';
other.click();
});
other.addEventListener('change', event => {
event.stopPropagation();
const files = Array.from(other.files || []);
other.value = '';
if (files.length) onFiles(files);
});
},
run(file, maxBytes, send) {
const upload = () => this.prepare(file, maxBytes).then(send);
if (!android) return upload();
// В кабинете до10 файлов: удерживаем в памяти только одну копию.
const pending = uploadQueue.then(upload);
uploadQueue = pending.catch(() => {});
return pending;
},
prepare(file, maxBytes) {
// FileProvider может отдавать несовпадающие lastModified для сетевого
// процесса Chromium. Blob из прочитанных байтов от него не зависит.
if (!android || !file.size || file.size > maxBytes) return Promise.resolve(file);
return new Promise((resolve, reject) => {
const reader = new FileReader();
const timer = window.setTimeout(() => reader.abort(), 30000);
const failed = error => {
window.clearTimeout(timer);
window.UploadDiagnostics?.record('buffer_error', { error_name: error.name, message: error.message });
reject(error);
};
window.UploadDiagnostics?.record('buffer_start', { size: file.size });
reader.onload = () => {
window.clearTimeout(timer);
if (reader.result.byteLength !== file.size) {
failed(new Error('Размер файла изменился при чтении.'));
return;
}
const blob = new Blob([reader.result], { type: file.type });
window.UploadDiagnostics?.record('buffer_ok', { size: blob.size, mime: blob.type });
resolve(blob);
};
reader.onerror = () => failed(reader.error || new Error('Не удалось прочитать файл.'));
reader.onabort = () => failed(new Error('Чтение файла прервано.'));
try { reader.readAsArrayBuffer(file.slice(0, file.size, file.type)); } catch (error) { failed(error); }
});
}
};
})();
</script>