// lib/pages/print/store.svelte.js import { onDestroy, tick } from 'svelte'; import { getPricePerPage } from '$lib/common/utils/pricing.util.js'; import { detectFileType, pagesLabel as _pagesLabel } from '$lib/common/utils/file.util.js'; import { loadPdfDocument, renderPageToImage } from '$lib/common/services/pdf.service.js'; /** * @typedef {{ * id: number, file: File, previewUrl: string | null, * fileType: 'image' | 'pdf' | 'office' | 'other', * expanded: boolean, format: string, colorMode: string, * qualityIndex: number, copies: number, sides: string, * dpi: string, totalPages: number, * selectedPages: Set, pdfDoc: any * }} FileEntry * @typedef {{ name: string, status: 'queued'|'printing'|'sent'|'awaiting_clear_output'|'awaiting_flip'|'awaiting_pickup'|'done'|'error'|'cancelled', error: (string|null) }} JobFileView * @typedef {{ job_id: string, phase: 'waiting_start'|'printing'|'awaiting_clear_output'|'awaiting_flip'|'awaiting_pickup'|'finished'|'cancelled', files: JobFileView[] }} JobView */ export function createPrintStore() { let files = $state(/** @type {FileEntry[]} */ ([])); let activeFile = $state(/** @type {FileEntry | null} */ (null)); let viewMode = $state(/** @type {'gallery' | 'preview'} */ ('gallery')); let showPayment = $state(false); let isPrinting = $state(false); let galleryThumbnails = $state([]); let galleryLoading = $state(false); let previewPagesCache = $state({}); let isPreviewRendering = $state(false); let job = $state(/** @type {JobView | null} */ (null)); let autoContinue = $state(false); let advancing = false; const jobActive = $derived(!!job && job.phase !== 'finished' && job.phase !== 'cancelled'); let uid = 0; const totalPrice = $derived( files.reduce((sum, f) => { const pages = f.selectedPages?.size || 0; return sum + pages * f.copies * getPricePerPage(pages); }, 0) ); const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0)); function updateFile(id, changes) { files = files.map((f) => (f.id === id ? { ...f, ...changes } : f)); if (activeFile?.id === id) { activeFile = files.find((f) => f.id === id); } } async function ensurePdfLoaded(entry) { if (entry.pdfDoc) return entry.pdfDoc; const pdf = await loadPdfDocument(entry.file); if (pdf) { const numPages = pdf.numPages; updateFile(entry.id, { pdfDoc: pdf, totalPages: numPages, selectedPages: new Set(Array.from({ length: numPages }, (_, i) => i + 1)) }); } else { updateFile(entry.id, { totalPages: 0 }); } return pdf; } async function addFiles(fileList) { if (!fileList || fileList.length === 0) return; const newFiles = []; for (let i = 0; i < fileList.length; i++) { const file = fileList[i]; const fType = detectFileType(file); const isOffice = fType === 'office'; newFiles.push({ id: ++uid, file, previewUrl: isOffice ? null : URL.createObjectURL(file), fileType: fType, expanded: false, format: 'Авто', colorMode: 'bw', qualityIndex: 1, copies: 1, sides: 'Односторонняя', dpi: 'Авто', totalPages: (fType === 'image' || isOffice) ? 1 : 0, selectedPages: (fType === 'image' || isOffice) ? new Set([1]) : new Set(), pdfDoc: null }); } files = [...files, ...newFiles]; for (const entry of newFiles) { if (entry.fileType === 'pdf') ensurePdfLoaded(entry); } } function removeFile(id) { const f = files.find((x) => x.id === id); if (f?.previewUrl) URL.revokeObjectURL(f.previewUrl); files = files.filter((x) => x.id !== id); } function toggleExpand(id) { updateFile(id, { expanded: !files.find((f) => f.id === id)?.expanded }); } async function openGallery(entry) { activeFile = entry; viewMode = 'gallery'; galleryThumbnails = []; galleryLoading = true; previewPagesCache = {}; await tick(); const pdf = await ensurePdfLoaded(entry); if (!pdf || entry.totalPages === 0) { galleryLoading = false; return; } const thumbs = []; for (let i = 1; i <= entry.totalPages; i++) { try { thumbs.push(await renderPageToImage(pdf, i, 0.4)); } catch { thumbs.push(''); } } galleryThumbnails = [...thumbs]; galleryLoading = false; } async function openPreview(entry) { activeFile = entry; viewMode = 'preview'; previewPagesCache = {}; galleryThumbnails = []; await tick(); if (entry.fileType === 'pdf') { const pdf = await ensurePdfLoaded(entry); if (pdf && entry.totalPages > 0) { preloadPreviewPages(entry, 1, 5); } } } async function preloadPreviewPages(entry, startPage, count) { const currentFile = files.find((f) => f.id === entry.id); const pdf = currentFile?.pdfDoc || entry.pdfDoc; if (!pdf || isPreviewRendering) return; isPreviewRendering = true; const endPage = Math.min(startPage + count - 1, entry.totalPages); const newCache = {}; for (let i = startPage; i <= endPage; i++) { if (previewPagesCache[i]) continue; try { newCache[i] = await renderPageToImage(pdf, i, 1.5); } catch (e) { console.error(`Failed to render preview page ${i}`, e); } } if (Object.keys(newCache).length > 0) { previewPagesCache = { ...previewPagesCache, ...newCache }; } isPreviewRendering = false; } function togglePageSelection(pageNum) { 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); } updateFile(activeFile.id, { selectedPages: newSet }); } function selectAllPages() { if (!activeFile?.totalPages) return; updateFile(activeFile.id, { selectedPages: new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1)) }); } function deselectAllPages() { if (!activeFile) return; updateFile(activeFile.id, { selectedPages: new Set([1]) }); } async function submitPrint() { if (files.length === 0 || isPrinting) return; isPrinting = true; const formData = new FormData(); for (const f of files) { formData.append('files', f.file, f.file.name); const pagesValue = f.fileType === 'office' ? 'all' : f.selectedPages && f.selectedPages.size > 0 ? Array.from(f.selectedPages).sort((a, b) => a - b).join(',') : 'all'; formData.append( 'settings', JSON.stringify({ filename: f.file.name, pages: pagesValue, copies: f.copies, colorMode: f.colorMode, format: f.fileType === 'office' ? 'auto' : f.format, sides: f.sides ?? 'Односторонняя', quality: f.qualityIndex, dpi: f.dpi ?? 'Авто' }) ); } try { const res = await fetch('/api/print', { method: 'POST', body: formData }); const text = await res.text(); if (!res.ok) { alert(`❌ Ошибка (${res.status}):\n${text}`); return; } let data; try { data = JSON.parse(text); } catch { alert('⚠️ Некорректный ответ сервера печати.'); return; } job = data; showPayment = false; await advanceJob(); } catch { alert('⚠️ Не удалось связаться с сервером печати.'); } finally { isPrinting = false; } } async function advanceJob() { if (!job || advancing) return; advancing = true; try { const res = await fetch(`/api/print/${job.job_id}/advance`, { method: 'POST' }); if (res.ok) job = await res.json(); } catch (e) { console.error('advance error:', e); } finally { advancing = false; } } async function refreshJob() { if (!job) return; try { const res = await fetch(`/api/print/${job.job_id}`); if (!res.ok) return; job = await res.json(); // Автопродолжение срабатывает ТОЛЬКО для awaiting_pickup if (job.phase === 'awaiting_pickup' && autoContinue) { advanceJob(); } } catch (e) { console.error('refresh error:', e); } } async function cancelJob() { if (!job) return; try { const res = await fetch(`/api/print/${job.job_id}/cancel`, { method: 'POST' }); if (res.ok) job = await res.json(); } catch (e) { console.error('cancel error:', e); } } function resetJob() { job = null; autoContinue = false; files.forEach((f) => f.previewUrl && URL.revokeObjectURL(f.previewUrl)); files = []; activeFile = null; galleryThumbnails = []; previewPagesCache = {}; showPayment = false; } function setAutoContinue(v) { autoContinue = !!v; } onDestroy(() => { files.forEach((f) => f.previewUrl && URL.revokeObjectURL(f.previewUrl)); }); return { get files() { return files; }, get activeFile() { return activeFile; }, get viewMode() { return viewMode; }, get showPayment() { return showPayment; }, get isPrinting() { return isPrinting; }, get totalPrice() { return totalPrice; }, get extraCopies() { return extraCopies; }, get galleryThumbnails() { return galleryThumbnails; }, get galleryLoading() { return galleryLoading; }, get previewPagesCache() { return previewPagesCache; }, get isPreviewRendering() { return isPreviewRendering; }, get job() { return job; }, get jobActive() { return jobActive; }, get autoContinue() { return autoContinue; }, addFiles, removeFile, toggleExpand, updateFile, openGallery, openPreview, closeOverlay: () => { activeFile = null; galleryThumbnails = []; previewPagesCache = {}; }, openPayment: () => { if (files.length > 0 && !isPrinting) showPayment = true; }, hidePayment: () => { showPayment = false; }, submitPrint, advanceJob, refreshJob, cancelJob, resetJob, setAutoContinue, togglePageSelection, selectAllPages, deselectAllPages, requestPreviewPages: (start, count) => { if (activeFile) preloadPreviewPages(activeFile, start, count); }, pagesLabel: _pagesLabel }; }