MainMenu + PrintMenu временный предпросмотр

This commit is contained in:
2026-09-01 20:50:42 +03:00
parent 39250c6fa6
commit fb0b10a66a
7 changed files with 6267 additions and 92 deletions

View File

@ -1,5 +0,0 @@
<script>
let count = $state(0)
</script>
<button type="button" class="counter" onclick={() => count++}>Count is {count}</button>

169
src/lib/MainMenu.svelte Normal file
View File

@ -0,0 +1,169 @@
<script>
/** @type {Array<{ id: 'print' | 'scan' | 'photo' | 'access', label: string, grad: string, glow: string, icon: string }>} */
const actions = [
{
id: 'print',
label: 'Печать',
grad: 'linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%)',
glow: 'rgba(72, 150, 255, 0.45)',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>'
},
{
id: 'scan',
label: 'Скан',
grad: 'linear-gradient(135deg, #34d399 0%, #2dd4bf 100%)',
glow: 'rgba(52, 211, 153, 0.45)',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"></path><path d="M17 3h2a2 2 0 0 1 2 2v2"></path><path d="M21 17v2a2 2 0 0 1-2 2h-2"></path><path d="M7 21H5a2 2 0 0 1-2-2v-2"></path><line x1="7" y1="12" x2="17" y2="12"></line></svg>'
},
{
id: 'photo',
label: 'Фото на документ',
grad: 'linear-gradient(135deg, #ffb457 0%, #ff8a5c 100%)',
glow: 'rgba(255, 150, 90, 0.45)',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"></rect><circle cx="8" cy="11" r="3"></circle><path d="M14 10h4"></path><path d="M14 14h4"></path><path d="M4 19c0-2 2-3 4-3s4 1 4 3"></path></svg>'
},
{
id: 'access',
label: 'Дополнительно',
grad: 'linear-gradient(135deg, #a78bfa 0%, #f472b6 100%)',
glow: 'rgba(167, 139, 250, 0.45)',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="4" r="2"></circle><path d="M12 6v6"></path><path d="M12 12l-4 8"></path><path d="M12 12l4 8"></path><path d="M6 10h12"></path></svg>'
}
];
/** @param {'print' | 'scan' | 'photo' | 'access'} id */
export let onAction = (id) => {
console.log('Выбрано действие:', id);
};
</script>
<div class="menu-container">
<div class="content">
<header class="header">
<h1>Сервисы</h1>
<p>Выберите действие</p>
</header>
<main class="grid">
{#each actions as a}
<button
class="card"
style="--grad: {a.grad}; --glow: {a.glow}"
onclick={() => onAction(a.id)}
>
<span class="icon" aria-hidden="true">{@html a.icon}</span>
<span class="label">{a.label}</span>
</button>
{/each}
</main>
<footer class="footer">
<div class="home-bar"></div>
</footer>
</div>
</div>
<style>
/* Стили остаются здесь, но убираем глобальные сбросы body/html */
.menu-container {
width: 100%;
min-height: 100vh;
min-height: 100dvh;
padding: 28px 20px 10px;
display: flex;
flex-direction: column;
background: radial-gradient(120% 60% at 50% -10%, rgba(99, 102, 241, 0.18) 0%, transparent 60%);
}
.header {
margin: 4px 4px 26px;
}
.header h1 {
margin: 0 0 6px;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.02em;
}
.header p {
margin: 0;
font-size: 15px;
color: #8b93a1;
}
/* Сетка 2×2 */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
/* Карточка-кнопка в стиле мобильного UI */
.card {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 26px 12px 22px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #f5f7fa;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
}
@media (hover: hover) {
.card:hover {
transform: translateY(-3px);
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.08);
}
}
/* Эффект нажатия, как в мобильных приложениях */
.card:active {
transform: scale(0.96);
}
/* Иконка с градиентом и свечением */
.icon {
width: 58px;
height: 58px;
border-radius: 18px;
display: grid;
place-items: center;
background: var(--grad);
color: #fff;
box-shadow: 0 10px 22px -8px var(--glow);
}
.label {
font-size: 14px;
font-weight: 600;
line-height: 1.35;
text-align: center;
}
/* «Домашняя» полоска внизу, как на iPhone */
.footer {
margin-top: auto;
padding: 32px 0 8px;
display: flex;
justify-content: center;
}
.home-bar {
width: 120px;
height: 5px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
}
</style>

775
src/lib/PrintMenu.svelte Normal file
View File

@ -0,0 +1,775 @@
<script>
import { onDestroy, tick } from 'svelte';
let { onBack = () => {}, onPrint = () => {} } = $props();
// ── Модель данных ──
/** @typedef {{ id: number, file: File, previewUrl: string | null, fileType: 'image' | 'pdf' | 'other', expanded: boolean, format: 'A4' | 'A5', colorMode: 'bw' | 'color', qualityIndex: number, copies: number, pageRange: string, totalPages: number, selectedPages: Set<number>, pdfDoc: any }} FileEntry */
/** @type {FileEntry[]} */
let files = $state([]);
// ── Общие состояния оверлея ──
/** @type {FileEntry | null} */
let activeFile = $state(null);
/** Режим просмотра: 'gallery' (выбор страниц) или 'preview' (чтение) */
let viewMode = $state(/** @type {'gallery' | 'preview'} */ ('gallery'));
// ── Данные для галереи (выбор страниц) ──
let galleryThumbnails = $state([]);
let galleryLoading = $state(false);
// ── Данные для превью (чтение) ──
/** Кэшированные URL больших страниц для превью */
let previewPagesCache = $state({});
let previewScrollContainer;
let isPreviewRendering = $state(false);
// Флаг для предотвращения множественных вызовов preload во время одного тика скролла
let preloadScheduled = false;
let uid = 0;
const qualities = [
{ id: 'low', label: 'Эконом' },
{ id: 'medium', label: 'Стандарт' },
{ id: 'high', label: 'Максимум' }
];
const formats = ['A4', 'A5'];
const colorModes = [
{ id: 'bw', label: 'Чб' },
{ id: 'color', label: 'Цвет' }
];
// ── PDF.js Core ──
async function loadPdfDocument(entry) {
if (entry.pdfDoc) return entry.pdfDoc;
try {
const pdfjsLib = await import('https://mozilla.github.io/pdf.js/build/pdf.mjs');
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://mozilla.github.io/pdf.js/build/pdf.worker.mjs';
const arrayBuffer = await entry.file.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
entry.pdfDoc = pdf;
entry.totalPages = pdf.numPages;
entry.selectedPages = new Set(Array.from({ length: pdf.numPages }, (_, i) => i + 1));
return pdf;
} catch (err) {
console.error('PDF load error:', err);
entry.totalPages = 0;
return null;
}
}
/**
* Рендерит страницу в DataURL
*/
async function renderPageToImage(pdfDoc, pageNum, scale = 1.0) {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const outputScale = window.devicePixelRatio || 1;
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
await page.render({
canvasContext: context,
viewport,
transform
}).promise;
return canvas.toDataURL('image/jpeg', 0.85);
}
// ── Логика Галереи (Выбор страниц) ──
async function openPageGallery(entry) {
activeFile = entry;
viewMode = 'gallery';
galleryThumbnails = [];
galleryLoading = true;
previewPagesCache = {};
await tick();
if (!entry.pdfDoc) await loadPdfDocument(entry);
if (!entry.pdfDoc || entry.totalPages === 0) {
galleryLoading = false;
return;
}
const thumbs = [];
for (let i = 1; i <= entry.totalPages; i++) {
try {
const url = await renderPageToImage(entry.pdfDoc, i, 0.4);
thumbs.push(url);
galleryThumbnails = [...thumbs];
} catch (err) {
thumbs.push('');
galleryThumbnails = [...thumbs];
}
}
galleryLoading = false;
}
// ── Логика Превью (Просмотр) ──
async function openPreview(entry) {
activeFile = entry;
viewMode = 'preview';
previewPagesCache = {};
galleryThumbnails = [];
await tick();
if (entry.fileType === 'pdf') {
if (!entry.pdfDoc) await loadPdfDocument(entry);
// Загружаем первые страницы сразу с большим буфером
preloadPreviewPages(entry, 1, 5);
}
}
/**
* Ленивая подгрузка страниц для режима Preview
* @param {FileEntry} entry
* @param {number} startPage - Страница, от которой начинаем загрузку
* @param {number} count - Сколько страниц грузим
* @param {'forward' | 'around'} direction - Направление загрузки
*/
async function preloadPreviewPages(entry, startPage, count, direction = 'forward') {
if (!entry.pdfDoc || isPreviewRendering) return;
isPreviewRendering = true;
let pagesToLoad = [];
if (direction === 'around') {
// Загружаем окно вокруг текущей страницы
const half = Math.floor(count / 2);
const from = Math.max(1, startPage - half);
const to = Math.min(entry.totalPages, startPage + half);
for(let i = from; i <= to; i++) pagesToLoad.push(i);
} else {
// Загружаем вперед от startPage
const endPage = Math.min(startPage + count - 1, entry.totalPages);
for (let i = startPage; i <= endPage; i++) pagesToLoad.push(i);
}
// Фильтруем уже загруженные
pagesToLoad = pagesToLoad.filter(p => !previewPagesCache[p]);
if (pagesToLoad.length > 0) {
// Создаем локальную копию, чтобы не триггерить реактивность на каждой итерации
const newCacheEntries = {};
for (const pageNum of pagesToLoad) {
try {
const url = await renderPageToImage(entry.pdfDoc, pageNum, 1.5);
newCacheEntries[pageNum] = url;
} catch (e) {
console.error(`Failed to render preview page ${pageNum}`, e);
}
}
// Одноразовое обновление стейта
if (Object.keys(newCacheEntries).length > 0) {
previewPagesCache = { ...previewPagesCache, ...newCacheEntries };
}
}
isPreviewRendering = false;
}
// Обработчик скролла для ленивой загрузки в превью
function handlePreviewScroll() {
if (!activeFile || viewMode !== 'preview' || !previewScrollContainer) return;
// Debounce/Throttle через requestAnimationFrame
if (preloadScheduled) return;
preloadScheduled = true;
requestAnimationFrame(() => {
preloadScheduled = false;
const container = previewScrollContainer;
// Ищем ВСЕ элементы страниц: и загруженные картинки, и плейсхолдеры
const pageElements = container.querySelectorAll('[data-page]');
const buffer = window.innerHeight * 1.5; // Увеличенный буфер предзагрузки
let closestVisiblePage = 0;
let minDistance = Infinity;
let needsPreload = false;
let firstUnloadedInView = null;
const viewportCenter = window.innerHeight / 2;
pageElements.forEach((el) => {
const rect = el.getBoundingClientRect();
const pageNum = parseInt(el.dataset.page);
// Элемент находится в зоне видимости + буфер
const inView = rect.bottom > -buffer && rect.top < window.innerHeight + buffer;
if (inView) {
// Определяем ближайшую к центру экрана страницу для контекста
const elCenter = rect.top + rect.height / 2;
const dist = Math.abs(elCenter - viewportCenter);
if (dist < minDistance) {
minDistance = dist;
closestVisiblePage = pageNum;
}
// Если это плейсхолдер (нет картинки в кэше), запоминаем первую такую
if (!previewPagesCache[pageNum] && firstUnloadedInView === null) {
firstUnloadedInView = pageNum;
needsPreload = true;
}
}
});
if (needsPreload && firstUnloadedInView) {
// Загружаем пакет страниц начиная с первой ненайденной
preloadPreviewPages(activeFile, firstUnloadedInView, 4, 'forward');
} else if (closestVisiblePage > 0 && !isPreviewRendering) {
// Фоновая догрузка вокруг текущей просматриваемой страницы
// Проверяем, есть ли следующие страницы в кэше
const nextPage = closestVisiblePage + 2;
if (nextPage <= activeFile.totalPages && !previewPagesCache[nextPage]) {
preloadPreviewPages(activeFile, closestVisiblePage, 3, 'around');
}
}
});
}
// ── Общие функции закрытия ──
function closeOverlay() {
activeFile = null;
galleryThumbnails = [];
previewPagesCache = {};
}
// ── Управление выбором страниц (для Gallery) ──
function togglePageSelection(entry, pageNum) {
if (!entry.selectedPages) return;
const newSet = new Set(entry.selectedPages);
if (newSet.has(pageNum)) {
if (newSet.size > 1) newSet.delete(pageNum);
} else {
newSet.add(pageNum);
}
entry.selectedPages = newSet;
}
function selectAllPages(entry) {
if (!entry.totalPages) return;
entry.selectedPages = new Set(Array.from({ length: entry.totalPages }, (_, i) => i + 1));
}
function deselectAllPages(entry) {
entry.selectedPages = new Set([1]);
}
function pagesLabel(entry) {
if (!entry.totalPages || entry.totalPages === 0) return 'Все';
if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Все';
if (entry.selectedPages.size === entry.totalPages) return 'Все';
if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`;
return `${entry.selectedPages.size} из ${entry.totalPages}`;
}
// ── Файловые операции ──
let fileInput;
function detectFileType(file) {
if (!file) return 'other';
if (file.type.startsWith('image/')) return 'image';
if (file.type === 'application/pdf') return 'pdf';
return 'other';
}
async function handleFileSelect(event) {
const input = /** @type {HTMLInputElement} */(event.target);
const chosen = input.files;
if (!chosen || chosen.length === 0) return;
for (let i = 0; i < chosen.length; i++) {
const fType = detectFileType(chosen[i]);
const entry = {
id: ++uid,
file: chosen[i],
previewUrl: URL.createObjectURL(chosen[i]),
fileType: fType,
expanded: false,
format: 'A4',
colorMode: 'bw',
qualityIndex: 1,
copies: 1,
pageRange: 'all',
totalPages: fType === 'image' ? 1 : 0,
selectedPages: fType === 'image' ? new Set([1]) : new Set(),
pdfDoc: null,
};
files.push(entry);
if (fType === 'pdf') loadPdfDocument(entry);
}
input.value = '';
}
function triggerFileInput(event) {
event.preventDefault();
fileInput.click();
}
function toggleExpanded(file) { file.expanded = !file.expanded; }
function removeFile(file) {
if (file.previewUrl) URL.revokeObjectURL(file.previewUrl);
files = files.filter((f) => f.id !== file.id);
}
// ── Опции печати ──
function toggleFormat(file, f) { file.format = f; }
function toggleColor(file, c) { file.colorMode = c.id; }
function incrementCopies(file) { file.copies += 1; }
function decrementCopies(file) { if (file.copies > 1) file.copies -= 1; }
function qualityFillFor(file) { return Math.round((file.qualityIndex / (qualities.length - 1)) * 100); }
function pageStubFor() { return 0; }
const totalPrice = $derived(`${files.reduce((sum, f) => sum + pageStubFor(f), 0)}₽`);
const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0));
onDestroy(() => {
files.forEach((f) => { if (f.previewUrl) URL.revokeObjectURL(f.previewUrl); });
});
// ── Плюрализация ──
function pluralize(n, forms) {
const mod10 = n % 10, mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return forms[0];
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 > 14)) return forms[1];
return forms[2];
}
const plFiles = (n) => pluralize(n, ['файл', 'файла', 'файлов']);
const plCopies = (n) => pluralize(n, ['копия', 'копии', 'копий']);
const plPages = (n) => pluralize(n, ['страница', 'страницы', 'страниц']);
function handlePagesClick(file) {
if (file.fileType === 'pdf') openPageGallery(file);
else openPreview(file);
}
</script>
<div class="page-container">
<header class="header">
<button class="back-btn" onclick={onBack}> Назад</button>
<h1>Печать</h1>
</header>
<main class="options">
{#if files.length === 0}
<div class="empty-upload">
<p class="empty-hint">Загрузите файлы для печати</p>
<button class="action-btn primary" onclick={triggerFileInput}>Загрузить файл(ы)</button>
</div>
{:else}
{#each files as file (file.id)}
<section class="option-group file-card" class:expanded={file.expanded}>
<div class="file-row" onclick={() => toggleExpanded(file)}>
<span class="chevron">{#if file.expanded}{:else}{/if}</span>
<span class="file-name" title={file.file.name}>{file.file.name}</span>
<span class="file-row-actions" onclick={(e) => e.stopPropagation()}>
{#if file.previewUrl}
<button type="button" class="icon-btn" onclick={() => openPreview(file)} title="Предпросмотр">🔍</button>
{/if}
<button type="button" class="icon-btn danger" onclick={() => removeFile(file)} title="Удалить"></button>
</span>
</div>
{#if file.expanded}
<div class="file-options">
<div class="option-sub print-params-row">
<div class="pages-control">
<label class="option-title compact">Страницы:</label>
<button type="button" class="pages-btn" onclick={() => handlePagesClick(file)}>
{pagesLabel(file)}
</button>
</div>
<div class="copies-control-inline">
<label class="option-title compact">Кол-во:</label>
<div class="mini-counter">
<button type="button" class="circle-btn small" onclick={() => decrementCopies(file)} disabled={file.copies <= 1}></button>
<span class="copies-value">{file.copies} шт.</span>
<button type="button" class="circle-btn small" onclick={() => incrementCopies(file)}></button>
</div>
</div>
</div>
<div class="option-sub">
<label class="option-title">Формат</label>
<div class="toggle-group horizontal">
{#each formats as f}
<button type="button" class="toggle-btn {file.format === f ? 'active' : ''}" onclick={() => toggleFormat(file, f)}>{f}</button>
{/each}
</div>
</div>
<div class="option-sub">
<label class="option-title">Цветность</label>
<div class="toggle-group horizontal">
{#each colorModes as c}
<button type="button" class="toggle-btn {file.colorMode === c.id ? 'active' : ''}" onclick={() => toggleColor(file, c)}>{c.label}</button>
{/each}
</div>
</div>
<div class="option-sub quality-sub">
<label class="option-title">Качество {qualities[file.qualityIndex].label}</label>
<input type="range" min={0} max={qualities.length - 1} step={1} bind:value={file.qualityIndex} class="quality-slider" style="--fill: {qualityFillFor(file)}%" />
<div class="quality-marks-inline">
{#each qualities as q}<span class="quality-mark">{q.label}</span>{/each}
</div>
</div>
</div>
{/if}
</section>
{/each}
<button type="button" class="add-file-btn" onclick={triggerFileInput}> Добавить файлы</button>
{/if}
</main>
<footer class="actions">
{#if files.length > 0}
<p class="file-count">
{files.length} {plFiles(files.length)}
{#if extraCopies > 0}, {extraCopies} {plCopies(extraCopies)}{/if}
</p>
{/if}
<div class="price-summary">
<span class="summary-itogo">Итого</span>
<span class="summary-price">{totalPrice}</span>
</div>
<button type="button" class="action-btn secondary" onclick={onPrint}>Печать</button>
<div class="home-bar-spacer"><div class="home-bar"></div></div>
<input type="file" bind:this={fileInput} accept="image/*,.pdf" multiple onchange={handleFileSelect} style="display:none" />
</footer>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- УНИВЕРСАЛЬНЫЙ ОВЕРЛЕЙ (Галерея выбора ИЛИ Просмотр) -->
<!-- ═══════════════════════════════════════════════════════════ -->
{#if activeFile}
<div class="overlay-backdrop" onclick={closeOverlay} role="dialog" aria-modal="true">
<div class="overlay-window" class:is-preview-mode={viewMode === 'preview'} onclick={(e) => e.stopPropagation()}>
<!-- Шапка -->
<div class="overlay-header">
<div class="overlay-header-info">
<h2 class="overlay-title">
{#if viewMode === 'gallery'}Выбор страниц{:else}Просмотр{/if}
</h2>
{#if viewMode === 'gallery' && activeFile.selectedPages}
<span class="overlay-subtitle">
{activeFile.selectedPages.size} из {activeFile.totalPages} {plPages(activeFile.selectedPages.size)}
</span>
{/if}
</div>
<button type="button" class="overlay-close" onclick={closeOverlay} aria-label="Закрыть"></button>
</div>
<!-- Панель действий (только для галереи) -->
{#if viewMode === 'gallery'}
<div class="gallery-actions-bar">
<button type="button" class="gallery-quick-btn" class:active-quick={activeFile.selectedPages?.size === activeFile.totalPages} onclick={() => selectAllPages(activeFile)}>Выбрать все</button>
<button type="button" class="gallery-quick-btn" onclick={() => deselectAllPages(activeFile)}>Снять все</button>
</div>
{/if}
<!-- КОНТЕНТ: ГАЛЕРЕЯ (Сетка 3 колонки) -->
{#if viewMode === 'gallery'}
<div class="gallery-grid">
{#if galleryLoading && galleryThumbnails.length === 0}
<div class="gallery-loading"><div class="spinner"></div><span>Загрузка…</span></div>
{/if}
{#each galleryThumbnails as thumb, i}
{@const pageNum = i + 1}
{@const isSelected = activeFile.selectedPages?.has(pageNum)}
<button type="button" class="gallery-thumb" class:selected={isSelected} onclick={() => togglePageSelection(activeFile, pageNum)}>
<div class="thumb-image-wrap">
{#if thumb}
<img src={thumb} alt="Стр. {pageNum}" class="thumb-img" loading="lazy" />
{:else}
<div class="thumb-placeholder">?</div>
{/if}
{#if isSelected}
<div class="thumb-check">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</div>
{/if}
</div>
<span class="thumb-label">{pageNum}</span>
</button>
{/each}
{#if galleryLoading && galleryThumbnails.length > 0}
<div class="gallery-thumb loading-placeholder"><div class="thumb-image-wrap"><div class="thumb-placeholder"><div class="spinner small"></div></div></div></div>
{/if}
</div>
{/if}
<!-- КОНТЕНТ: ПРЕВЬЮ (Вертикальный скролл, одна страница в строке) -->
{#if viewMode === 'preview'}
<!-- passive: true улучшает производительность скролла -->
<div class="preview-scroll-area" bind:this={previewScrollContainer} onscroll={handlePreviewScroll} onscrollcapture={handlePreviewScroll}>
{#if activeFile.fileType === 'image'}
<div class="preview-single-item">
<img src={activeFile.previewUrl} alt={activeFile.file.name} class="preview-full-img" />
</div>
{:else if activeFile.fileType === 'pdf'}
{#each Array(activeFile.totalPages) as _, i}
{@const pageNum = i + 1}
<div class="preview-page-item" data-page={pageNum}>
{#if previewPagesCache[pageNum]}
<img
src={previewPagesCache[pageNum]}
class="preview-page-img"
data-page={pageNum}
alt="Страница {pageNum}"
/>
{:else}
<!-- ВАЖНО: data-page должен быть и на плейсхолдере, чтобы скролл-хендлер мог его найти -->
<div class="preview-page-placeholder" data-page={pageNum}>
<div class="placeholder-content">
{#if pageNum <= 5 || isPreviewRendering}
<div class="spinner"></div>
{:else}
<span class="placeholder-text">Стр. {pageNum}</span>
{/if}
</div>
</div>
{/if}
</div>
{/each}
<div class="preview-end-marker">
Конец документа ({activeFile.totalPages} {plPages(activeFile.totalPages)})
</div>
{:else}
<div class="preview-fallback">Предпросмотр недоступен</div>
{/if}
</div>
{/if}
<!-- Подвал оверлея -->
<div class="overlay-footer">
<button type="button" class="action-btn primary" onclick={closeOverlay}>
{#if viewMode === 'gallery'}Готово{:else}Закрыть{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
.page-container { width: 100%; min-height: 100dvh; display: flex; flex-direction: column; background: radial-gradient(120% 60% at 50% -10%, rgba(99, 102, 241, 0.18) 0%, transparent 60%); font-family: system-ui, -apple-system, sans-serif; color: #f5f7fa; }
.header { padding: 24px 20px 8px; }
.back-btn { background: none; border: none; color: #6366f1; font-size: 15px; font-weight: 600; cursor: pointer; padding: 8px 4px; margin-bottom: 12px; }
.header h1 { margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -0.02em; }
.options { flex: 1; padding: 24px 20px 16px; display: flex; flex-direction: column; gap: 14px; }
.option-title { font-size: 13px; font-weight: 600; color: #8b93a1; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; display: block; }
.option-title.compact { margin-bottom: 0; font-size: 12px; white-space: nowrap; margin-right: 8px; }
.empty-upload { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; }
.empty-hint { color: #8b93a1; font-size: 15px; margin: 0; }
.file-card { border-radius: 16px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); overflow: hidden; padding-bottom: 6px; }
.file-row { width: 100%; box-sizing: border-box; display: flex; align-items: center; gap: 12px; padding: 14px; background: none; border: none; color: #f5f7fa; cursor: pointer; user-select: none; text-align: left; }
.file-row:hover { background: rgba(255, 255, 255, 0.03); }
.chevron { color: #6366f1; font-size: 12px; transition: transform 0.15s; }
.file-name { flex: 1; font-size: 15px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-row-actions { display: flex; gap: 6px; }
.icon-btn { width: 34px; height: 34px; border-radius: 10px; border: none; background: rgba(255, 255, 255, 0.08); color: #f5f7fa; font-size: 14px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.icon-btn.danger { color: #ff6b6b; }
.file-options { padding: 8px 14px 14px; display: flex; flex-direction: column; gap: 18px; }
.option-sub { display: flex; flex-direction: column; gap: 8px; }
.print-params-row { flex-direction: row; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); margin-bottom: 4px; }
.pages-control { display: flex; align-items: center; flex: 1; min-width: 0; }
.pages-btn { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; border-radius: 8px; padding: 6px 16px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; }
.pages-btn:hover { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.2); }
.copies-control-inline { display: flex; align-items: center; flex-shrink: 0; }
.mini-counter { display: flex; align-items: center; gap: 8px; }
.circle-btn { width: 32px; height: 32px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); color: #f5f7fa; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; padding: 0; line-height: 1; }
.circle-btn.small { width: 28px; height: 28px; font-size: 14px; }
.circle-btn:hover:not(:disabled) { background: rgba(99, 102, 241, 0.2); border-color: #6366f1; }
.circle-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.copies-value { font-size: 14px; font-weight: 600; color: #fff; min-width: 36px; text-align: center; font-variant-numeric: tabular-nums; }
.toggle-group { display: flex; gap: 10px; }
.toggle-btn { flex: 1; padding: 12px 8px; border-radius: 14px; border: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.05); color: #f5f7fa; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.18s ease; }
.toggle-btn.active { background: rgba(99, 102, 241, 0.25); border-color: #6366f1; color: #fff; }
.quality-slider { -webkit-appearance: none; width: 100%; height: 6px; border-radius: 4px; background: rgba(255, 255, 255, 0.12); outline: none; }
.quality-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #6366f1; cursor: pointer; border: 2px solid #fff; }
.quality-marks-inline { display: flex; justify-content: space-between; font-size: 11px; color: #8b93a1; margin-top: 4px; }
.add-file-btn { align-self: center; background: none; border: 1px dashed rgba(255, 255, 255, 0.18); color: #a9b0c0; font-size: 14px; font-weight: 600; padding: 12px 18px; border-radius: 14px; cursor: pointer; margin-top: 8px; }
.actions { padding: 16px 20px 32px; display: flex; flex-direction: column; gap: 12px; }
.file-count { color: #8b93a1; font-size: 13px; margin: 0; text-align: center; line-height: 1.4; }
.action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; }
.action-btn.primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 8px 24px -6px rgba(99, 102, 241, 0.5); }
.action-btn.secondary { background: linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%); box-shadow: 0 8px 24px -6px rgba(72, 150, 255, 0.4); }
.price-summary { display: flex; justify-content: space-between; align-items: baseline; width: 100%; }
.summary-itogo { font-size: 30px; font-weight: 700; color: #8b93a1; }
.summary-price { font-size: 38px; font-weight: 800; color: #fff; }
.home-bar-spacer { margin-top: auto; padding: 24px 0 8px; display: flex; justify-content: center; }
.home-bar { width: 134px; height: 5px; border-radius: 3px; background: rgba(255, 255, 255, 0.3); }
/* ═══════════════════════════════════════ */
/* ОБЩИЙ ОВЕРЛЕЙ */
/* ═══════════════════════════════════════ */
.overlay-backdrop {
position: fixed; inset: 0; z-index: 1000;
background: rgba(10, 12, 18, 0.92); backdrop-filter: blur(12px);
display: flex; align-items: center; justify-content: center; padding: 0;
}
.overlay-window {
width: 100%; height: 100%;
background: #13151b;
display: flex; flex-direction: column;
overflow: hidden;
animation: slide-up 0.3s ease-out;
}
@keyframes slide-up {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.overlay-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; flex-shrink: 0;
background: rgba(19, 21, 27, 0.95);
border-bottom: 1px solid rgba(255,255,255,0.06);
z-index: 10;
}
.overlay-title { margin: 0; font-size: 18px; font-weight: 700; color: #f5f7fa; }
.overlay-subtitle { font-size: 13px; color: #8b93a1; font-weight: 500; margin-top: 2px; display: block;}
.overlay-close {
width: 36px; height: 36px; border-radius: 50%; border: none;
background: rgba(255, 255, 255, 0.08); color: #fff; font-size: 18px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.overlay-footer {
padding: 16px 20px; flex-shrink: 0;
background: rgba(19, 21, 27, 0.95);
border-top: 1px solid rgba(255,255,255,0.06);
padding-bottom: max(16px, env(safe-area-inset-bottom));
}
/* ═══════════════════════════════════════ */
/* РЕЖИМ: ГАЛЕРЕЯ (Выбор страниц) */
/* ═══════════════════════════════════════ */
.gallery-actions-bar { display: flex; gap: 8px; padding: 12px 20px; flex-shrink: 0; }
.gallery-quick-btn {
flex: 1; padding: 10px 12px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.05);
color: #c0c5d0; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s ease;
}
.gallery-quick-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
.gallery-quick-btn.active-quick { background: rgba(99, 102, 241, 0.2); border-color: #6366f1; color: #a5b4fc; }
.gallery-grid {
flex: 1; overflow-y: auto; padding: 12px 16px 20px;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
-webkit-overflow-scrolling: touch;
}
.gallery-thumb {
display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 0; border: none; background: none; cursor: pointer;
}
.thumb-image-wrap {
position: relative; width: 100%; aspect-ratio: 210 / 297;
border-radius: 8px; overflow: hidden;
background: rgba(255, 255, 255, 0.06);
border: 2px solid transparent; transition: all 0.2s ease;
}
.gallery-thumb.selected .thumb-image-wrap { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
.gallery-thumb:not(.selected) .thumb-image-wrap { opacity: 0.5; }
.thumb-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.thumb-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #555; font-size: 20px; }
.thumb-check {
position: absolute; top: 4px; right: 4px;
width: 20px; height: 20px; border-radius: 50%;
background: #6366f1; display: flex; align-items: center; justify-content: center;
}
.thumb-check svg { width: 12px; height: 12px; color: #fff; }
.thumb-label { font-size: 11px; font-weight: 600; color: #8b93a1; }
.gallery-thumb.selected .thumb-label { color: #a5b4fc; }
.gallery-loading { grid-column: 1 / -1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 40px 0; color: #8b93a1; }
/* ═══════════════════════════════════════ */
/* РЕЖИМ: ПРЕВЬЮ (Вертикальный скролл) */
/* ═══════════════════════════════════════ */
.preview-scroll-area {
flex: 1; overflow-y: auto; overflow-x: hidden;
background: #0d0f14;
padding: 20px 0;
display: flex; flex-direction: column; align-items: center; gap: 16px;
-webkit-overflow-scrolling: touch;
}
.preview-single-item {
width: 100%; display: flex; justify-content: center; padding: 0 16px; box-sizing: border-box;
}
.preview-full-img {
max-width: 100%; max-height: 80vh; border-radius: 4px;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
}
.preview-page-item {
width: 100%; max-width: 600px;
padding: 0 16px; box-sizing: border-box;
flex-shrink: 0;
}
.preview-page-img {
width: 100%; height: auto; display: block;
border-radius: 4px;
box-shadow: 0 8px 30px rgba(0,0,0,0.6);
background: #fff;
}
.preview-page-placeholder {
width: 100%; aspect-ratio: 210 / 297;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 4px;
display: flex; align-items: center; justify-content: center;
}
.placeholder-content { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.placeholder-text { color: #444; font-size: 14px; font-weight: 600; }
.preview-end-marker {
padding: 40px 0; color: #555; font-size: 13px; text-align: center;
}
.preview-fallback { padding: 40px; color: #8b93a1; text-align: center; }
.spinner {
width: 32px; height: 32px;
border: 3px solid rgba(255, 255, 255, 0.1); border-top-color: #6366f1;
border-radius: 50%; animation: spin 0.8s linear infinite;
}
.spinner.small { width: 20px; height: 20px; border-width: 2px; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (min-width: 768px) {
.overlay-window {
max-width: 800px; max-height: 90vh;
border-radius: 20px; height: auto;
border: 1px solid rgba(255,255,255,0.1);
}
.gallery-grid { grid-template-columns: repeat(4, 1fr); }
}
</style>