Preview preload pages count

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

View File

@ -1,74 +1,77 @@
<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 = [
const qualities = [
{ id: 'low', label: 'Эконом' }, { id: 'low', label: 'Эконом' },
{ id: 'medium', label: 'Стандарт' }, { id: 'medium', label: 'Стандарт' },
{ id: 'high', label: 'Максимум' } { id: 'high', label: 'Максимум' }
]; ];
const formats = ['A4', 'A5']; const formats = ['A4', 'A5'];
const colorModes = [ const colorModes = [
{ id: 'bw', label: 'Чб' }, { id: 'bw', label: 'Чб' },
{ id: 'color', label: 'Цвет' } { id: 'color', label: 'Цвет' }
]; ];
// ── PDF.js Core (Остается в родителе для управления памятью) ── // ── PDF.js Core ──
async function loadPdfDocument(entry) { async function loadPdfDocument(entry) {
if (entry.pdfDoc) return entry.pdfDoc; if (entry.pdfDoc) return entry.pdfDoc;
try { try {
const pdfjsLib = await import('https://mozilla.github.io/pdf.js/build/pdf.mjs'); 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'; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://mozilla.github.io/pdf.js/build/pdf.worker.mjs';
const arrayBuffer = await entry.file.arrayBuffer(); const arrayBuffer = await entry.file.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise; const pdf = await loadingTask.promise;
entry.pdfDoc = pdf; entry.pdfDoc = pdf;
entry.totalPages = pdf.numPages; entry.totalPages = pdf.numPages;
// Инициализируем выбранные страницы только если они еще не заданы
if (!entry.selectedPages || entry.selectedPages.size === 0) {
entry.selectedPages = new Set(Array.from({ length: pdf.numPages }, (_, i) => i + 1)); entry.selectedPages = new Set(Array.from({ length: pdf.numPages }, (_, i) => i + 1));
}
return pdf; return pdf;
} catch (err) { } catch (err) {
console.error('PDF load error:', err); console.error('PDF load error:', err);
entry.totalPages = 0; entry.totalPages = 0;
return null; return null;
} }
} }
async function renderPageToImage(pdfDoc, pageNum, scale = 1.0) { async function renderPageToImage(pdfDoc, pageNum, scale = 1.0) {
const page = await pdfDoc.getPage(pageNum); const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale }); const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
const outputScale = window.devicePixelRatio || 1; const outputScale = window.devicePixelRatio || 1;
canvas.width = Math.floor(viewport.width * outputScale); canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale); canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = `${Math.floor(viewport.width)}px`; canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`; canvas.style.height = `${Math.floor(viewport.height)}px`;
@ -81,10 +84,10 @@
}).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 = [];
@ -92,6 +95,8 @@
previewPagesCache = {}; previewPagesCache = {};
await tick(); await tick();
// Используем уже загруженный документ или загружаем при необходимости
if (!entry.pdfDoc) await loadPdfDocument(entry); if (!entry.pdfDoc) await loadPdfDocument(entry);
if (!entry.pdfDoc || entry.totalPages === 0) { if (!entry.pdfDoc || entry.totalPages === 0) {
@ -111,10 +116,10 @@
} }
} }
galleryLoading = false; galleryLoading = false;
} }
// ── Логика Превью ── // ── Логика Превью ──
async function openPreview(entry) { async function openPreview(entry) {
activeFile = entry; activeFile = entry;
viewMode = 'preview'; viewMode = 'preview';
previewPagesCache = {}; previewPagesCache = {};
@ -123,17 +128,22 @@
await tick(); await tick();
if (entry.fileType === 'pdf') { if (entry.fileType === 'pdf') {
// Документ должен быть уже загружен при добавлении файла,
// но на всякий случай проверяем
if (!entry.pdfDoc) await loadPdfDocument(entry); if (!entry.pdfDoc) await loadPdfDocument(entry);
// Берем totalPages из entry, которое было получено при добавлении файла
if (entry.totalPages > 0) {
preloadPreviewPages(entry, 1, 5); preloadPreviewPages(entry, 1, 5);
} }
} }
}
async function preloadPreviewPages(entry, startPage, count, direction = 'forward') { async function preloadPreviewPages(entry, startPage, count, direction = 'forward') {
if (!entry.pdfDoc || isPreviewRendering) return; if (!entry.pdfDoc || isPreviewRendering) return;
isPreviewRendering = true; isPreviewRendering = true;
let pagesToLoad = []; let pagesToLoad = [];
if (direction === 'around') { if (direction === 'around') {
const half = Math.floor(count / 2); const half = Math.floor(count / 2);
const from = Math.max(1, startPage - half); const from = Math.max(1, startPage - half);
@ -148,7 +158,6 @@
if (pagesToLoad.length > 0) { if (pagesToLoad.length > 0) {
const newCacheEntries = {}; const newCacheEntries = {};
for (const pageNum of pagesToLoad) { for (const pageNum of pagesToLoad) {
try { try {
const url = await renderPageToImage(entry.pdfDoc, pageNum, 1.5); const url = await renderPageToImage(entry.pdfDoc, pageNum, 1.5);
@ -157,29 +166,26 @@
console.error(`Failed to render preview page ${pageNum}`, e); console.error(`Failed to render preview page ${pageNum}`, e);
} }
} }
if (Object.keys(newCacheEntries).length > 0) { if (Object.keys(newCacheEntries).length > 0) {
previewPagesCache = { ...previewPagesCache, ...newCacheEntries }; previewPagesCache = { ...previewPagesCache, ...newCacheEntries };
} }
} }
isPreviewRendering = false; isPreviewRendering = false;
} }
// Обработчик запроса страниц от дочернего PreviewPage function handlePreviewRequestPages(start, count, direction) {
function handlePreviewRequestPages(start, count, direction) {
if (activeFile) preloadPreviewPages(activeFile, start, count, direction); if (activeFile) preloadPreviewPages(activeFile, start, count, direction);
} }
// ── Общие функции закрытия ── // ── Общие функции закрытия ──
function closeOverlay() { function closeOverlay() {
activeFile = null; activeFile = null;
galleryThumbnails = []; galleryThumbnails = [];
previewPagesCache = {}; previewPagesCache = {};
} }
// ── Управление выбором страниц ── // ── Управление выбором страниц ──
function togglePageSelection(pageNum) { function togglePageSelection(pageNum) {
if (!activeFile?.selectedPages) return; if (!activeFile?.selectedPages) return;
const newSet = new Set(activeFile.selectedPages); const newSet = new Set(activeFile.selectedPages);
if (newSet.has(pageNum)) { if (newSet.has(pageNum)) {
@ -188,37 +194,37 @@
newSet.add(pageNum); newSet.add(pageNum);
} }
activeFile.selectedPages = newSet; activeFile.selectedPages = newSet;
} }
function selectAllPages() { function selectAllPages() {
if (!activeFile?.totalPages) return; if (!activeFile?.totalPages) return;
activeFile.selectedPages = new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1)); activeFile.selectedPages = new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1));
} }
function deselectAllPages() { function deselectAllPages() {
if (!activeFile) return; if (!activeFile) return;
activeFile.selectedPages = new Set([1]); activeFile.selectedPages = new Set([1]);
} }
function pagesLabel(entry) { function pagesLabel(entry) {
if (!entry.totalPages || entry.totalPages === 0) return 'Все'; if (!entry.totalPages || entry.totalPages === 0) return 'Все';
if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Все'; if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Все';
if (entry.selectedPages.size === entry.totalPages) return 'Все'; if (entry.selectedPages.size === entry.totalPages) return 'Все';
if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`; if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`;
return `${entry.selectedPages.size} из ${entry.totalPages}`; return `${entry.selectedPages.size} из ${entry.totalPages}`;
} }
// ── Файловые операции ── // ── Файловые операции ──
let fileInput; let fileInput;
function detectFileType(file) { function detectFileType(file) {
if (!file) return 'other'; if (!file) return 'other';
if (file.type.startsWith('image/')) return 'image'; if (file.type.startsWith('image/')) return 'image';
if (file.type === 'application/pdf') return 'pdf'; if (file.type === 'application/pdf') return 'pdf';
return 'other'; return 'other';
} }
async function handleFileSelect(event) { async function handleFileSelect(event) {
const input = /** @type {HTMLInputElement} */(event.target); const input = /** @type {HTMLInputElement} */(event.target);
const chosen = input.files; const chosen = input.files;
if (!chosen || chosen.length === 0) return; if (!chosen || chosen.length === 0) return;
@ -240,56 +246,62 @@
selectedPages: fType === 'image' ? new Set([1]) : new Set(), selectedPages: fType === 'image' ? new Set([1]) : new Set(),
pdfDoc: null, pdfDoc: null,
}; };
files.push(entry); files.push(entry);
if (fType === 'pdf') loadPdfDocument(entry);
// ВАЖНО: Загружаем PDF сразу при добавлении, чтобы сохранить totalPages
if (fType === 'pdf') {
loadPdfDocument(entry);
}
} }
input.value = ''; 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) {
function removeFile(file) {
if (file.previewUrl) URL.revokeObjectURL(file.previewUrl); if (file.previewUrl) URL.revokeObjectURL(file.previewUrl);
files = files.filter((f) => f.id !== file.id); files = files.filter((f) => f.id !== file.id);
} }
// ── Опции печати ── // ── Опции печати ──
function toggleFormat(file, f) { file.format = f; } function toggleFormat(file, f) { file.format = f; }
function toggleColor(file, c) { file.colorMode = c.id; } function toggleColor(file, c) { file.colorMode = c.id; }
function incrementCopies(file) { file.copies += 1; } function incrementCopies(file) { file.copies += 1; }
function decrementCopies(file) { if (file.copies > 1) 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 qualityFillFor(file) { return Math.round((file.qualityIndex / (qualities.length - 1)) * 100); }
function pageStubFor() { return 0; } function pageStubFor() { return 0; }
const totalPrice = $derived(`${files.reduce((sum, f) => sum + pageStubFor(f), 0)}₽`); const totalPrice = $derived(`${files.reduce((sum, f) => sum + pageStubFor(f), 0)}₽`);
const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0)); const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0));
onDestroy(() => { onDestroy(() => {
files.forEach((f) => { if (f.previewUrl) URL.revokeObjectURL(f.previewUrl); }); files.forEach((f) => { if (f.previewUrl) URL.revokeObjectURL(f.previewUrl); });
}); });
// ── Плюрализация ── // ── Плюрализация ──
function pluralize(n, forms) { function pluralize(n, forms) {
const mod10 = n % 10, mod100 = n % 100; const mod10 = n % 10, mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return forms[0]; if (mod10 === 1 && mod100 !== 11) return forms[0];
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 > 14)) return forms[1]; if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 > 14)) return forms[1];
return forms[2]; return forms[2];
} }
const plFiles = (n) => pluralize(n, ['файл', 'файла', 'файлов']); const plFiles = (n) => pluralize(n, ['файл', 'файла', 'файлов']);
const plCopies = (n) => pluralize(n, ['копия', 'копии', 'копий']); const plCopies = (n) => pluralize(n, ['копия', 'копии', 'копий']);
function handlePagesClick(file) { function handlePagesClick(file) {
if (file.fileType === 'pdf') openPageGallery(file); if (file.fileType === 'pdf') openPageGallery(file);
else openPreview(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>
@ -363,6 +375,7 @@
{/if} {/if}
</section> </section>
{/each} {/each}
<button type="button" class="add-file-btn" onclick={triggerFileInput}> Добавить файлы</button> <button type="button" class="add-file-btn" onclick={triggerFileInput}> Добавить файлы</button>
{/if} {/if}
</main> </main>
@ -381,7 +394,7 @@
<button type="button" class="action-btn secondary" onclick={onPrint}>Печать</button> <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" /> <input type="file" bind:this={fileInput} accept="image/*,.pdf" multiple onchange={handleFileSelect} style="display:none" />
</footer> </footer>
</div> </div>
{/if} {/if}
<!-- ═══════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════ -->
@ -389,9 +402,7 @@
<!-- ═══════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════ -->
{#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'} {#if viewMode === 'gallery'}
<SelectionPage <SelectionPage
file={activeFile} file={activeFile}
@ -411,13 +422,11 @@
onClose={closeOverlay} onClose={closeOverlay}
/> />
{/if} {/if}
</div> </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; }
@ -464,25 +473,22 @@
.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 { .overlay-window-wrapper {
width: 100%; height: 100%; width: 100%; height: 100%;
display: flex; flex-direction: column; display: flex; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.overlay-window-wrapper { .overlay-window-wrapper {
max-width: 800px; max-height: 90vh; max-width: 800px; max-height: 90vh;
border-radius: 20px; height: auto; border-radius: 20px; height: auto;
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
background: #13151b; /* Fallback bg for wrapper on desktop */ background: #13151b;
} }
} }
</style> </style>