Preview preload pages count

This commit is contained in:
2026-09-02 16:52:54 +03:00
parent f03c321c38
commit cd8549daa3

View File

@ -1,488 +1,494 @@
<script> <script>
import { onDestroy, tick } from 'svelte'; import { onDestroy, tick } from 'svelte';
import SelectionPage from './preview/SelectionPage.svelte'; import SelectionPage from './preview/SelectionPage.svelte';
import PreviewPage from './preview/PreviewPage.svelte'; import PreviewPage from './preview/PreviewPage.svelte';
let { onBack = () => {}, onPrint = () => {} } = $props(); 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 */ /** @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[]} */ /** @type {FileEntry[]} */
let files = $state([]); let files = $state([]);
// ── Общие состояния оверлея ── // ── Общие состояния оверлея ──
/** @type {FileEntry | null} */ /** @type {FileEntry | null} */
let activeFile = $state(null); let activeFile = $state(null);
/** Режим просмотра: 'gallery' (выбор страниц) или 'preview' (чтение) */
let viewMode = $state(/** @type {'gallery' | 'preview'} */ ('gallery'));
/** Режим просмотра: 'gallery' (выбор страниц) или 'preview' (чтение) */ // ── Данные для галереи (выбор страниц) ──
let viewMode = $state(/** @type {'gallery' | 'preview'} */ ('gallery')); let galleryThumbnails = $state([]);
let galleryLoading = $state(false);
// ── Данные для галереи (выбор страниц) ── // ── Данные для превью (чтение) ──
let galleryThumbnails = $state([]); /** Кэшированные URL больших страниц для превью */
let galleryLoading = $state(false); let previewPagesCache = $state({});
let isPreviewRendering = $state(false);
// ── Данные для превью (чтение) ── let uid = 0;
/** Кэшированные URL больших страниц для превью */
let previewPagesCache = $state({});
let isPreviewRendering = $state(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: 'Цвет' }
];
const qualities = [ // ── PDF.js Core ──
{ id: 'low', label: 'Эконом' }, async function loadPdfDocument(entry) {
{ id: 'medium', label: 'Стандарт' }, if (entry.pdfDoc) return entry.pdfDoc;
{ id: 'high', label: 'Максимум' } try {
]; const pdfjsLib = await import('https://mozilla.github.io/pdf.js/build/pdf.mjs');
const formats = ['A4', 'A5']; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://mozilla.github.io/pdf.js/build/pdf.worker.mjs';
const colorModes = [
{ id: 'bw', label: 'Чб' },
{ id: 'color', label: 'Цвет' }
];
// ── PDF.js Core (Остается в родителе для управления памятью) ── const arrayBuffer = await entry.file.arrayBuffer();
async function loadPdfDocument(entry) { const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
if (entry.pdfDoc) return entry.pdfDoc; const pdf = await loadingTask.promise;
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;
}
}
async function renderPageToImage(pdfDoc, pageNum, scale = 1.0) { entry.pdfDoc = pdf;
const page = await pdfDoc.getPage(pageNum); entry.totalPages = pdf.numPages;
const viewport = page.getViewport({ scale }); // Инициализируем выбранные страницы только если они еще не заданы
const canvas = document.createElement('canvas'); if (!entry.selectedPages || entry.selectedPages.size === 0) {
const context = canvas.getContext('2d'); 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;
}
}
const outputScale = window.devicePixelRatio || 1; async function renderPageToImage(pdfDoc, pageNum, scale = 1.0) {
canvas.width = Math.floor(viewport.width * outputScale); const page = await pdfDoc.getPage(pageNum);
canvas.height = Math.floor(viewport.height * outputScale); const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const outputScale = window.devicePixelRatio || 1;
canvas.style.width = `${Math.floor(viewport.width)}px`; canvas.width = Math.floor(viewport.width * outputScale);
canvas.style.height = `${Math.floor(viewport.height)}px`; 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; const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
await page.render({ await page.render({
canvasContext: context, canvasContext: context,
viewport, viewport,
transform transform
}).promise; }).promise;
return canvas.toDataURL('image/jpeg', 0.85); return canvas.toDataURL('image/jpeg', 0.85);
} }
// ── Логика Галереи ── // ── Логика Галереи ──
async function openPageGallery(entry) { async function openPageGallery(entry) {
activeFile = entry; activeFile = entry;
viewMode = 'gallery'; viewMode = 'gallery';
galleryThumbnails = []; galleryThumbnails = [];
galleryLoading = true; galleryLoading = true;
previewPagesCache = {}; previewPagesCache = {};
await tick(); await tick();
if (!entry.pdfDoc) await loadPdfDocument(entry);
if (!entry.pdfDoc || entry.totalPages === 0) { // Используем уже загруженный документ или загружаем при необходимости
galleryLoading = false; if (!entry.pdfDoc) await loadPdfDocument(entry);
return;
}
const thumbs = []; if (!entry.pdfDoc || entry.totalPages === 0) {
for (let i = 1; i <= entry.totalPages; i++) { galleryLoading = false;
try { return;
const url = await renderPageToImage(entry.pdfDoc, i, 0.4); }
thumbs.push(url);
galleryThumbnails = [...thumbs];
} catch (err) {
thumbs.push('');
galleryThumbnails = [...thumbs];
}
}
galleryLoading = false;
}
// ── Логика Превью ── const thumbs = [];
async function openPreview(entry) { for (let i = 1; i <= entry.totalPages; i++) {
activeFile = entry; try {
viewMode = 'preview'; const url = await renderPageToImage(entry.pdfDoc, i, 0.4);
previewPagesCache = {}; thumbs.push(url);
galleryThumbnails = []; galleryThumbnails = [...thumbs];
} catch (err) {
thumbs.push('');
galleryThumbnails = [...thumbs];
}
}
galleryLoading = false;
}
await tick(); // ── Логика Превью ──
async function openPreview(entry) {
activeFile = entry;
viewMode = 'preview';
previewPagesCache = {};
galleryThumbnails = [];
if (entry.fileType === 'pdf') { await tick();
if (!entry.pdfDoc) await loadPdfDocument(entry);
preloadPreviewPages(entry, 1, 5);
}
}
async function preloadPreviewPages(entry, startPage, count, direction = 'forward') { if (entry.fileType === 'pdf') {
if (!entry.pdfDoc || isPreviewRendering) return; // Документ должен быть уже загружен при добавлении файла,
isPreviewRendering = true; // но на всякий случай проверяем
if (!entry.pdfDoc) await loadPdfDocument(entry);
let pagesToLoad = []; // Берем totalPages из entry, которое было получено при добавлении файла
if (entry.totalPages > 0) {
preloadPreviewPages(entry, 1, 5);
}
}
}
if (direction === 'around') { async function preloadPreviewPages(entry, startPage, count, direction = 'forward') {
const half = Math.floor(count / 2); if (!entry.pdfDoc || isPreviewRendering) return;
const from = Math.max(1, startPage - half); isPreviewRendering = true;
const to = Math.min(entry.totalPages, startPage + half);
for(let i = from; i <= to; i++) pagesToLoad.push(i);
} else {
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]); 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 {
const endPage = Math.min(startPage + count - 1, entry.totalPages);
for (let i = startPage; i <= endPage; i++) pagesToLoad.push(i);
}
if (pagesToLoad.length > 0) { pagesToLoad = pagesToLoad.filter(p => !previewPagesCache[p]);
const newCacheEntries = {};
for (const pageNum of pagesToLoad) { if (pagesToLoad.length > 0) {
try { const newCacheEntries = {};
const url = await renderPageToImage(entry.pdfDoc, pageNum, 1.5); for (const pageNum of pagesToLoad) {
newCacheEntries[pageNum] = url; try {
} catch (e) { const url = await renderPageToImage(entry.pdfDoc, pageNum, 1.5);
console.error(`Failed to render preview page ${pageNum}`, e); 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;
}
if (Object.keys(newCacheEntries).length > 0) { function handlePreviewRequestPages(start, count, direction) {
previewPagesCache = { ...previewPagesCache, ...newCacheEntries }; if (activeFile) preloadPreviewPages(activeFile, start, count, direction);
} }
}
isPreviewRendering = false; // ── Общие функции закрытия ──
} function closeOverlay() {
activeFile = null;
galleryThumbnails = [];
previewPagesCache = {};
}
// Обработчик запроса страниц от дочернего PreviewPage // ── Управление выбором страниц ──
function handlePreviewRequestPages(start, count, direction) { function togglePageSelection(pageNum) {
if (activeFile) preloadPreviewPages(activeFile, start, count, direction); if (!activeFile?.selectedPages) return;
} const newSet = new Set(activeFile.selectedPages);
if (newSet.has(pageNum)) {
if (newSet.size > 1) newSet.delete(pageNum);
} else {
newSet.add(pageNum);
}
activeFile.selectedPages = newSet;
}
// ── Общие функции закрытия ── function selectAllPages() {
function closeOverlay() { if (!activeFile?.totalPages) return;
activeFile = null; activeFile.selectedPages = new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1));
galleryThumbnails = []; }
previewPagesCache = {};
}
// ── Управление выбором страниц ── function deselectAllPages() {
function togglePageSelection(pageNum) { if (!activeFile) return;
if (!activeFile?.selectedPages) return; activeFile.selectedPages = new Set([1]);
const newSet = new Set(activeFile.selectedPages); }
if (newSet.has(pageNum)) {
if (newSet.size > 1) newSet.delete(pageNum);
} else {
newSet.add(pageNum);
}
activeFile.selectedPages = newSet;
}
function selectAllPages() { function pagesLabel(entry) {
if (!activeFile?.totalPages) return; if (!entry.totalPages || entry.totalPages === 0) return 'Все';
activeFile.selectedPages = new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1)); 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}`;
}
function deselectAllPages() { // ── Файловые операции ──
if (!activeFile) return; let fileInput;
activeFile.selectedPages = new Set([1]);
}
function pagesLabel(entry) { function detectFileType(file) {
if (!entry.totalPages || entry.totalPages === 0) return 'Все'; if (!file) return 'other';
if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Все'; if (file.type.startsWith('image/')) return 'image';
if (entry.selectedPages.size === entry.totalPages) return 'Все'; if (file.type === 'application/pdf') return 'pdf';
if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`; return 'other';
return `${entry.selectedPages.size} из ${entry.totalPages}`; }
}
// ── Файловые операции ── async function handleFileSelect(event) {
let fileInput; const input = /** @type {HTMLInputElement} */(event.target);
const chosen = input.files;
if (!chosen || chosen.length === 0) return;
function detectFileType(file) { for (let i = 0; i < chosen.length; i++) {
if (!file) return 'other'; const fType = detectFileType(chosen[i]);
if (file.type.startsWith('image/')) return 'image'; const entry = {
if (file.type === 'application/pdf') return 'pdf'; id: ++uid,
return 'other'; 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,
};
async function handleFileSelect(event) { files.push(entry);
const input = /** @type {HTMLInputElement} */(event.target);
const chosen = input.files;
if (!chosen || chosen.length === 0) return;
for (let i = 0; i < chosen.length; i++) { // ВАЖНО: Загружаем PDF сразу при добавлении, чтобы сохранить totalPages
const fType = detectFileType(chosen[i]); if (fType === 'pdf') {
const entry = { loadPdfDocument(entry);
id: ++uid, }
file: chosen[i], }
previewUrl: URL.createObjectURL(chosen[i]), input.value = '';
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) { function triggerFileInput(event) {
event.preventDefault(); event.preventDefault();
fileInput.click(); fileInput.click();
} }
function toggleExpanded(file) { file.expanded = !file.expanded; } 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 removeFile(file) {
function toggleFormat(file, f) { file.format = f; } if (file.previewUrl) URL.revokeObjectURL(file.previewUrl);
function toggleColor(file, c) { file.colorMode = c.id; } files = files.filter((f) => f.id !== file.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)); 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; }
onDestroy(() => { const totalPrice = $derived(`${files.reduce((sum, f) => sum + pageStubFor(f), 0)}₽`);
files.forEach((f) => { if (f.previewUrl) URL.revokeObjectURL(f.previewUrl); }); const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0));
});
// ── Плюрализация ── onDestroy(() => {
function pluralize(n, forms) { files.forEach((f) => { if (f.previewUrl) URL.revokeObjectURL(f.previewUrl); });
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, ['копия', 'копии', 'копий']);
function handlePagesClick(file) { // ── Плюрализация ──
if (file.fileType === 'pdf') openPageGallery(file); function pluralize(n, forms) {
else openPreview(file); 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, ['копия', 'копии', 'копий']);
function handlePagesClick(file) {
if (file.fileType === 'pdf') openPageGallery(file);
else openPreview(file);
}
</script> </script>
{#if !activeFile} {#if !activeFile}
<div class="page-container"> <div class="page-container">
<header class="header"> <header class="header">
<button class="back-btn" onclick={onBack}> Назад</button> <button class="back-btn" onclick={onBack}> Назад</button>
<h1>Печать</h1> <h1>Печать</h1>
</header> </header>
<main class="options"> <main class="options">
{#if files.length === 0} {#if files.length === 0}
<div class="empty-upload"> <div class="empty-upload">
<p class="empty-hint">Загрузите файлы для печати</p> <p class="empty-hint">Загрузите файлы для печати</p>
<button class="action-btn primary" onclick={triggerFileInput}>Загрузить файл(ы)</button> <button class="action-btn primary" onclick={triggerFileInput}>Загрузить файл(ы)</button>
</div> </div>
{:else} {:else}
{#each files as file (file.id)} {#each files as file (file.id)}
<section class="option-group file-card" class:expanded={file.expanded}> <section class="option-group file-card" class:expanded={file.expanded}>
<div class="file-row" onclick={() => toggleExpanded(file)}> <div class="file-row" onclick={() => toggleExpanded(file)}>
<span class="chevron">{#if file.expanded}{:else}{/if}</span> <span class="chevron">{#if file.expanded}{:else}{/if}</span>
<span class="file-name" title={file.file.name}>{file.file.name}</span> <span class="file-name" title={file.file.name}>{file.file.name}</span>
<span class="file-row-actions" onclick={(e) => e.stopPropagation()}> <span class="file-row-actions" onclick={(e) => e.stopPropagation()}>
{#if file.previewUrl} {#if file.previewUrl}
<button type="button" class="icon-btn" onclick={() => openPreview(file)} title="Предпросмотр">🔍</button> <button type="button" class="icon-btn" onclick={() => openPreview(file)} title="Предпросмотр">🔍</button>
{/if} {/if}
<button type="button" class="icon-btn danger" onclick={() => removeFile(file)} title="Удалить"></button> <button type="button" class="icon-btn danger" onclick={() => removeFile(file)} title="Удалить"></button>
</span> </span>
</div> </div>
{#if file.expanded} {#if file.expanded}
<div class="file-options"> <div class="file-options">
<div class="option-sub print-params-row"> <div class="option-sub print-params-row">
<div class="pages-control"> <div class="pages-control">
<label class="option-title compact">Страницы:</label> <label class="option-title compact">Страницы:</label>
<button type="button" class="pages-btn" onclick={() => handlePagesClick(file)}> <button type="button" class="pages-btn" onclick={() => handlePagesClick(file)}>
{pagesLabel(file)} {pagesLabel(file)}
</button> </button>
</div> </div>
<div class="copies-control-inline"> <div class="copies-control-inline">
<label class="option-title compact">Кол-во:</label> <label class="option-title compact">Кол-во:</label>
<div class="mini-counter"> <div class="mini-counter">
<button type="button" class="circle-btn small" onclick={() => decrementCopies(file)} disabled={file.copies <= 1}></button> <button type="button" class="circle-btn small" onclick={() => decrementCopies(file)} disabled={file.copies <= 1}></button>
<span class="copies-value">{file.copies} шт.</span> <span class="copies-value">{file.copies} шт.</span>
<button type="button" class="circle-btn small" onclick={() => incrementCopies(file)}></button> <button type="button" class="circle-btn small" onclick={() => incrementCopies(file)}></button>
</div> </div>
</div> </div>
</div> </div>
<div class="option-sub"> <div class="option-sub">
<label class="option-title">Формат</label> <label class="option-title">Формат</label>
<div class="toggle-group horizontal"> <div class="toggle-group horizontal">
{#each formats as f} {#each formats as f}
<button type="button" class="toggle-btn {file.format === f ? 'active' : ''}" onclick={() => toggleFormat(file, f)}>{f}</button> <button type="button" class="toggle-btn {file.format === f ? 'active' : ''}" onclick={() => toggleFormat(file, f)}>{f}</button>
{/each} {/each}
</div> </div>
</div> </div>
<div class="option-sub"> <div class="option-sub">
<label class="option-title">Цветность</label> <label class="option-title">Цветность</label>
<div class="toggle-group horizontal"> <div class="toggle-group horizontal">
{#each colorModes as c} {#each colorModes as c}
<button type="button" class="toggle-btn {file.colorMode === c.id ? 'active' : ''}" onclick={() => toggleColor(file, c)}>{c.label}</button> <button type="button" class="toggle-btn {file.colorMode === c.id ? 'active' : ''}" onclick={() => toggleColor(file, c)}>{c.label}</button>
{/each} {/each}
</div> </div>
</div> </div>
<div class="option-sub quality-sub"> <div class="option-sub quality-sub">
<label class="option-title">Качество {qualities[file.qualityIndex].label}</label> <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)}%" /> <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"> <div class="quality-marks-inline">
{#each qualities as q}<span class="quality-mark">{q.label}</span>{/each} {#each qualities as q}<span class="quality-mark">{q.label}</span>{/each}
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
</section> </section>
{/each} {/each}
<button type="button" class="add-file-btn" onclick={triggerFileInput}> Добавить файлы</button>
{/if}
</main>
<footer class="actions"> <button type="button" class="add-file-btn" onclick={triggerFileInput}> Добавить файлы</button>
{#if files.length > 0} {/if}
<p class="file-count"> </main>
{files.length} {plFiles(files.length)}
{#if extraCopies > 0}, {extraCopies} {plCopies(extraCopies)}{/if} <footer class="actions">
</p> {#if files.length > 0}
{/if} <p class="file-count">
<div class="price-summary"> {files.length} {plFiles(files.length)}
<span class="summary-itogo">Итого</span> {#if extraCopies > 0}, {extraCopies} {plCopies(extraCopies)}{/if}
<span class="summary-price">{totalPrice}</span> </p>
</div> {/if}
<button type="button" class="action-btn secondary" onclick={onPrint}>Печать</button> <div class="price-summary">
<input type="file" bind:this={fileInput} accept="image/*,.pdf" multiple onchange={handleFileSelect} style="display:none" /> <span class="summary-itogo">Итого</span>
</footer> <span class="summary-price">{totalPrice}</span>
</div> </div>
<button type="button" class="action-btn secondary" onclick={onPrint}>Печать</button>
<input type="file" bind:this={fileInput} accept="image/*,.pdf" multiple onchange={handleFileSelect} style="display:none" />
</footer>
</div>
{/if} {/if}
<!-- ═══════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════ -->
<!-- ДЕЛЕГИРОВАНИЕ В ДОЧЕРНИЕ КОМПОНЕНТЫ --> <!-- ДЕЛЕГИРОВАНИЕ В ДОЧЕРНИЕ КОМПОНЕНТЫ -->
<!-- ═══════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════ -->
{#if activeFile} {#if activeFile}
<div class="overlay-backdrop" onclick={closeOverlay} role="dialog" aria-modal="true"> <div class="overlay-backdrop" onclick={closeOverlay} role="dialog" aria-modal="true">
<!-- stopPropagation чтобы клики внутри окна не закрывали его --> <div class="overlay-window-wrapper" onclick={(e) => e.stopPropagation()}>
<div class="overlay-window-wrapper" onclick={(e) => e.stopPropagation()}> {#if viewMode === 'gallery'}
<SelectionPage
{#if viewMode === 'gallery'} file={activeFile}
<SelectionPage thumbnails={galleryThumbnails}
file={activeFile} loading={galleryLoading}
thumbnails={galleryThumbnails} onTogglePage={togglePageSelection}
loading={galleryLoading} onSelectAll={selectAllPages}
onTogglePage={togglePageSelection} onDeselectAll={deselectAllPages}
onSelectAll={selectAllPages} onClose={closeOverlay}
onDeselectAll={deselectAllPages} />
onClose={closeOverlay} {:else}
/> <PreviewPage
{:else} file={activeFile}
<PreviewPage pagesCache={previewPagesCache}
file={activeFile} isRendering={isPreviewRendering}
pagesCache={previewPagesCache} onRequestPages={handlePreviewRequestPages}
isRendering={isPreviewRendering} onClose={closeOverlay}
onRequestPages={handlePreviewRequestPages} />
onClose={closeOverlay} {/if}
/> </div>
{/if} </div>
</div>
</div>
{/if} {/if}
<style> <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; }
.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; }
.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; }
.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; }
.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; }
.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 { 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; }
.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-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; }
.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-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 { 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); }
.file-row:hover { background: rgba(255, 255, 255, 0.03); } .chevron { color: #6366f1; font-size: 12px; transition: transform 0.15s; }
.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-name { flex: 1; font-size: 15px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .file-row-actions { display: flex; gap: 6px; }
.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 { 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; }
.icon-btn.danger { color: #ff6b6b; } .file-options { padding: 8px 14px 14px; display: flex; flex-direction: column; gap: 18px; }
.file-options { padding: 8px 14px 14px; display: flex; flex-direction: column; gap: 18px; } .option-sub { display: flex; flex-direction: column; gap: 8px; }
.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; }
.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-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 { 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); }
.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; }
.copies-control-inline { display: flex; align-items: center; flex-shrink: 0; } .mini-counter { display: flex; align-items: center; gap: 8px; }
.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 { 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.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:hover:not(:disabled) { background: rgba(99, 102, 241, 0.2); border-color: #6366f1; } .circle-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.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; }
.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-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 { 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; }
.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-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-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; }
.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; }
.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; }
.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; }
.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 { 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.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); }
.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%; }
.price-summary { display: flex; justify-content: space-between; align-items: baseline; width: 100%; } .summary-itogo { font-size: 30px; font-weight: 700; color: #8b93a1; }
.summary-itogo { font-size: 30px; font-weight: 700; color: #8b93a1; } .summary-price { font-size: 38px; font-weight: 800; color: #fff; }
.summary-price { font-size: 38px; font-weight: 800; color: #fff; }
/* Обертка оверлея */ .overlay-backdrop {
.overlay-backdrop { position: fixed; inset: 0; z-index: 1000;
position: fixed; inset: 0; z-index: 1000; background: rgba(10, 12, 18, 0.92); backdrop-filter: blur(12px);
background: rgba(10, 12, 18, 0.92); backdrop-filter: blur(12px); display: flex; align-items: center; justify-content: center; padding: 0;
display: flex; align-items: center; justify-content: center; padding: 0; }
} .overlay-window-wrapper {
width: 100%; height: 100%;
.overlay-window-wrapper { display: flex; flex-direction: column;
width: 100%; height: 100%; overflow: hidden;
display: flex; flex-direction: column; }
overflow: hidden; @media (min-width: 768px) {
} .overlay-window-wrapper {
max-width: 800px; max-height: 90vh;
@media (min-width: 768px) { border-radius: 20px; height: auto;
.overlay-window-wrapper { border: 1px solid rgba(255,255,255,0.1);
max-width: 800px; max-height: 90vh; background: #13151b;
border-radius: 20px; height: auto; }
border: 1px solid rgba(255,255,255,0.1); }
background: #13151b; /* Fallback bg for wrapper on desktop */
}
}
</style> </style>