add more documents format support
This commit is contained in:
@ -3,13 +3,24 @@
|
|||||||
/**
|
/**
|
||||||
* Определение типа файла
|
* Определение типа файла
|
||||||
* @param {File} file
|
* @param {File} file
|
||||||
* @returns {'image' | 'pdf' | 'other'}
|
* @returns {'image' | 'pdf' | 'office' | 'other'}
|
||||||
*/
|
*/
|
||||||
export function detectFileType(file) {
|
export 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';
|
|
||||||
|
// Офисные документы — определяем по расширению,
|
||||||
|
// т.к. MIME может быть пустым или некорректным
|
||||||
|
const ext = (file.name || '').toLowerCase().split('.').pop() || '';
|
||||||
|
const officeExts = [
|
||||||
|
'doc', 'docx', 'odt', // текст
|
||||||
|
'xls', 'xlsx', 'ods', // таблицы
|
||||||
|
'ppt', 'pptx', 'odp', // презентации (на всякий случай)
|
||||||
|
];
|
||||||
|
if (officeExts.includes(ext)) return 'office';
|
||||||
|
|
||||||
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,9 +28,9 @@ export function detectFileType(file) {
|
|||||||
* @param {import('$lib/pages/print/stores/print.store.svelte').FileEntry} entry
|
* @param {import('$lib/pages/print/stores/print.store.svelte').FileEntry} entry
|
||||||
*/
|
*/
|
||||||
export function pagesLabel(entry) {
|
export 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} стр.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,89 +1,97 @@
|
|||||||
<!-- lib/pages/print/PrintMenu.svelte -->
|
<!-- lib/pages/print/PrintMenu.svelte -->
|
||||||
<script>
|
<script>
|
||||||
import { createPrintStore } from './store.svelte.js';
|
import { createPrintStore } from './store.svelte.js';
|
||||||
import PrintUI from './PrintUI.svelte';
|
import PrintUI from './PrintUI.svelte';
|
||||||
import PreviewOverlay from './PreviewOverlay.svelte';
|
import PreviewOverlay from './PreviewOverlay.svelte';
|
||||||
import PaymentPage from '$lib/common/ui/PaymentPage.svelte';
|
import PaymentPage from '$lib/common/ui/PaymentPage.svelte';
|
||||||
import { plFiles, plCopies } from '$lib/common/utils/pricing.util.js';
|
import { plFiles, plCopies } from '$lib/common/utils/pricing.util.js';
|
||||||
|
|
||||||
let { onBack = () => {} } = $props();
|
let { onBack = () => {} } = $props();
|
||||||
const store = createPrintStore();
|
const store = createPrintStore();
|
||||||
let fileInput = $state(null);
|
let fileInput = $state(null);
|
||||||
|
|
||||||
function handleFileSelect(event) {
|
function handleFileSelect(event) {
|
||||||
store.addFiles(event.target.files);
|
store.addFiles(event.target.files);
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasTierSmall = $derived(store.files.some((f) => (f.selectedPages?.size || 0) < 25));
|
const hasTierSmall = $derived(store.files.some((f) => (f.selectedPages?.size || 0) < 25));
|
||||||
const hasTierMedium = $derived(store.files.some((f) => {
|
const hasTierMedium = $derived(store.files.some((f) => {
|
||||||
const s = f.selectedPages?.size || 0;
|
const s = f.selectedPages?.size || 0;
|
||||||
return s >= 25 && s < 1000;
|
return s >= 25 && s < 1000;
|
||||||
}));
|
}));
|
||||||
const hasTierLarge = $derived(store.files.some((f) => (f.selectedPages?.size || 0) >= 1000));
|
const hasTierLarge = $derived(store.files.some((f) => (f.selectedPages?.size || 0) >= 1000));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if store.showPayment}
|
{#if store.showPayment}
|
||||||
<PaymentPage
|
<PaymentPage
|
||||||
totalPrice={store.totalPrice}
|
totalPrice={store.totalPrice}
|
||||||
filesCount={store.files.length}
|
filesCount={store.files.length}
|
||||||
onBack={store.hidePayment}
|
onBack={store.hidePayment}
|
||||||
onConfirmPayment={store.submitPrint}
|
onConfirmPayment={store.submitPrint}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !store.activeFile && !store.showPayment}
|
{#if !store.activeFile && !store.showPayment}
|
||||||
<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 store.files.length === 0}
|
{#if store.files.length === 0}
|
||||||
<PrintUI as="upload-zone" onSelect={() => fileInput.click()} />
|
<PrintUI as="upload-zone" onSelect={() => fileInput.click()} />
|
||||||
{:else}
|
{:else}
|
||||||
{#each store.files as file (file.id)}
|
{#each store.files as file (file.id)}
|
||||||
<PrintUI
|
<PrintUI
|
||||||
as="file-card"
|
as="file-card"
|
||||||
{file}
|
{file}
|
||||||
onToggle={store.toggleExpand}
|
onToggle={store.toggleExpand}
|
||||||
onRemove={store.removeFile}
|
onRemove={store.removeFile}
|
||||||
onUpdate={store.updateFile}
|
onUpdate={store.updateFile}
|
||||||
onPreview={(f) => store.openPreview(f)}
|
onPreview={(f) => store.openPreview(f)}
|
||||||
onPageSelect={(f) => (f.fileType === 'pdf' ? store.openGallery(f) : store.openPreview(f))}
|
onPageSelect={(f) => (f.fileType === 'pdf' ? store.openGallery(f) : store.openPreview(f))}
|
||||||
pagesLabel={store.pagesLabel}
|
pagesLabel={store.pagesLabel}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
<button class="add-file-btn" onclick={() => fileInput.click()}>+ Добавить файлы</button>
|
<button class="add-file-btn" onclick={() => fileInput.click()}>+ Добавить файлы</button>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<PrintUI
|
<PrintUI
|
||||||
as="footer"
|
as="footer"
|
||||||
totalPrice={store.totalPrice}
|
totalPrice={store.totalPrice}
|
||||||
filesCount={store.files.length}
|
filesCount={store.files.length}
|
||||||
extraCopies={store.extraCopies}
|
extraCopies={store.extraCopies}
|
||||||
isPrinting={store.isPrinting}
|
isPrinting={store.isPrinting}
|
||||||
onSubmit={store.openPayment}
|
onSubmit={store.openPayment}
|
||||||
hasTierSmall={hasTierSmall}
|
hasTierSmall={hasTierSmall}
|
||||||
hasTierMedium={hasTierMedium}
|
hasTierMedium={hasTierMedium}
|
||||||
hasTierLarge={hasTierLarge}
|
hasTierLarge={hasTierLarge}
|
||||||
plFiles={plFiles}
|
plFiles={plFiles}
|
||||||
plCopies={plCopies}
|
plCopies={plCopies}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input type="file" bind:this={fileInput} accept="image/*,.pdf" multiple onchange={handleFileSelect} hidden />
|
<!-- ✅ Расширенный accept -->
|
||||||
</div>
|
<input
|
||||||
|
type="file"
|
||||||
|
bind:this={fileInput}
|
||||||
|
accept="image/*,.pdf,.doc,.docx,.odt,.xls,.xlsx,.ods,.ppt,.pptx,.odp"
|
||||||
|
multiple
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<PreviewOverlay {store} />
|
<PreviewOverlay {store} />
|
||||||
|
|
||||||
<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; display: flex; align-items: center; position: relative; }
|
.header { padding: 24px 20px 8px; display: flex; align-items: center; position: relative; }
|
||||||
.back-btn { background: none; border: none; color: #6366f1; font-size: 15px; font-weight: 600; cursor: pointer; padding: 8px 4px; margin-right: auto; min-height: 44px; }
|
.back-btn { background: none; border: none; color: #6366f1; font-size: 15px; font-weight: 600; cursor: pointer; padding: 8px 4px; margin-right: auto; min-height: 44px; }
|
||||||
.header h1 { margin: 0; font-size: 28px; font-weight: 700; position: absolute; left: 50%; transform: translateX(-50%); white-space: nowrap; }
|
.header h1 { margin: 0; font-size: 28px; font-weight: 700; position: absolute; left: 50%; transform: translateX(-50%); white-space: nowrap; }
|
||||||
.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; }
|
||||||
.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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,230 +1,308 @@
|
|||||||
<!-- svelte-ignore a11y_click_1vents_have_key_events a11y_no_static_element_interactions a11y_label_has_associated_control slot_element_deprecated svelte_self_deprecated -->
|
<!-- svelte-ignore a11y_click_1vents_have_key_events a11y_no_static_element_interactions a11y_label_has_associated_control slot_element_deprecated svelte_self_deprecated -->
|
||||||
<!-- lib/pages/print/PrintUI.svelte -->
|
<!-- lib/pages/print/PrintUI.svelte -->
|
||||||
<script>
|
<script>
|
||||||
import { QUALITIES, COLOR_MODES } from '$lib/common/utils/pricing.util.js';
|
import { QUALITIES, COLOR_MODES } from '$lib/common/utils/pricing.util.js';
|
||||||
import SliderOption from './SliderOption.svelte';
|
import SliderOption from './SliderOption.svelte';
|
||||||
import ToggleOption from './ToggleOption.svelte';
|
import ToggleOption from './ToggleOption.svelte';
|
||||||
import GearOption from './GearOption.svelte';
|
import GearOption from './GearOption.svelte';
|
||||||
import FormatPicker from './FormatPicker.svelte';
|
import FormatPicker from './FormatPicker.svelte';
|
||||||
|
|
||||||
const QUALITY_LABELS = QUALITIES.map((q) => q.label);
|
const QUALITY_LABELS = QUALITIES.map((q) => q.label);
|
||||||
const COLOR_LABELS = COLOR_MODES.map((c) => c.label);
|
const COLOR_LABELS = COLOR_MODES.map((c) => c.label);
|
||||||
const DPI_OPTIONS = ['300', '600', 'Авто', '1200', '2400'];
|
const DPI_OPTIONS = ['300', '600', 'Авто', '1200', '2400'];
|
||||||
const SIDES_OPTIONS = ['Односторонняя', 'Двусторонняя'];
|
const SIDES_OPTIONS = ['Односторонняя', 'Двусторонняя'];
|
||||||
const FORMAT_OPTIONS = ['Авто', 'A2', 'A3', 'A4', 'A5', '10x15'];
|
const FORMAT_OPTIONS = ['Авто', 'A2', 'A3', 'A4', 'A5', '10x15'];
|
||||||
|
|
||||||
/** @type {'button' | 'icon-button' | 'counter' | 'toggle-group' | 'file-card' | 'upload-zone' | 'footer'} */
|
/** @type {'button' | 'icon-button' | 'counter' | 'toggle-group' | 'file-card' | 'upload-zone' | 'footer'} */
|
||||||
let { as, ...props } = $props();
|
let { as, ...props } = $props();
|
||||||
|
|
||||||
// локально на экземпляр карточки файла
|
let formatOpen = $state(false);
|
||||||
let formatOpen = $state(false);
|
$effect(() => {
|
||||||
|
if (!props.file?.expanded) formatOpen = false;
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
function pagesText(file) {
|
||||||
if (!props.file?.expanded) formatOpen = false;
|
const t = props.pagesLabel?.(file);
|
||||||
});
|
if (!t || t === 'Все') return 'Все страницы';
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
function pagesText(file) {
|
function formatText(file) {
|
||||||
const t = props.pagesLabel?.(file);
|
if (!file.format || file.format === 'Авто') return `Авто (${file.autoFormat ?? 'A4'})`;
|
||||||
if (!t || t === 'Все') return 'Все страницы';
|
return file.format;
|
||||||
return t;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function formatText(file) {
|
/** Является ли файл офисным документом */
|
||||||
if (!file.format || file.format === 'Авто') return `Авто (${file.autoFormat ?? 'A4'})`;
|
function isOffice(file) {
|
||||||
return file.format;
|
return file?.fileType === 'office';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if as === 'button'}
|
{#if as === 'button'}
|
||||||
<button
|
<button
|
||||||
class="action-btn {props.variant ?? 'primary'}"
|
class="action-btn {props.variant ?? 'primary'}"
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
onclick={props.onclick}
|
onclick={props.onclick}
|
||||||
type={props.type ?? 'button'}
|
type={props.type ?? 'button'}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{:else if as === 'icon-button'}
|
{:else if as === 'icon-button'}
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
class:danger={props.variant === 'danger'}
|
class:danger={props.variant === 'danger'}
|
||||||
onclick={props.onclick}
|
onclick={props.onclick}
|
||||||
title={props.title}
|
title={props.title}
|
||||||
type="button"
|
type="button"
|
||||||
><slot /></button>
|
><slot /></button>
|
||||||
|
|
||||||
{:else if as === 'counter'}
|
{:else if as === 'counter'}
|
||||||
<div class="mini-counter">
|
<div class="mini-counter">
|
||||||
<button class="circle-btn small" onclick={props.onDecrement} disabled={props.value <= (props.min ?? 1)} type="button">−</button>
|
<button class="circle-btn small" onclick={props.onDecrement} disabled={props.value <= (props.min ?? 1)} type="button">−</button>
|
||||||
<span class="copies-value">{props.value} шт.</span>
|
<span class="copies-value">{props.value} шт.</span>
|
||||||
<button class="circle-btn small" onclick={props.onIncrement} type="button">+</button>
|
<button class="circle-btn small" onclick={props.onIncrement} type="button">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if as === 'toggle-group'}
|
{:else if as === 'toggle-group'}
|
||||||
<div class="option-sub">
|
<div class="option-sub">
|
||||||
{#if props.label}<label class="option-title">{props.label}</label>{/if}
|
{#if props.label}<label class="option-title">{props.label}</label>{/if}
|
||||||
<div class="toggle-group horizontal">
|
<div class="toggle-group horizontal">
|
||||||
{#each props.options as opt}
|
{#each props.options as opt}
|
||||||
{@const val = props.valueKey ? opt[props.valueKey] : opt}
|
{@const val = props.valueKey ? opt[props.valueKey] : opt}
|
||||||
{@const lbl = props.labelKey ? opt[props.labelKey] : opt}
|
{@const lbl = props.labelKey ? opt[props.labelKey] : opt}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="toggle-btn"
|
class="toggle-btn"
|
||||||
class:active={props.selected === val}
|
class:active={props.selected === val}
|
||||||
onclick={() => props.onSelect?.(val)}
|
onclick={() => props.onSelect?.(val)}
|
||||||
>{lbl}</button>
|
>{lbl}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if as === 'file-card'}
|
{:else if as === 'file-card'}
|
||||||
{@const file = props.file}
|
{@const file = props.file}
|
||||||
<section class="file-card" class:expanded={file.expanded}>
|
<section class="file-card" class:expanded={file.expanded}>
|
||||||
<div class="file-row" onclick={() => props.onToggle?.(file.id)}>
|
<div class="file-row" onclick={() => props.onToggle?.(file.id)}>
|
||||||
<span class="chevron">{file.expanded ? '▼' : '►'}</span>
|
<span class="chevron">{file.expanded ? '▼' : '►'}</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}
|
|
||||||
<svelte:self as="icon-button" onclick={() => props.onPreview?.(file)} title="Предпросмотр">🔍</svelte:self>
|
|
||||||
{/if}
|
|
||||||
<svelte:self as="icon-button" variant="danger" onclick={() => props.onRemove?.(file.id)} title="Удалить">✕</svelte:self>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if file.expanded}
|
|
||||||
<div class="file-options">
|
|
||||||
<!-- 1. Кнопки с настройками -->
|
|
||||||
<!-- 2. Переключатели — сразу под кнопками настроек -->
|
|
||||||
<div class="duo-group">
|
|
||||||
<div class="duo-row">
|
|
||||||
<GearOption value={pagesText(file)} onclick={() => props.onPageSelect?.(file)} />
|
|
||||||
<GearOption value={formatText(file)} open={formatOpen} onclick={() => (formatOpen = !formatOpen)} />
|
|
||||||
</div>
|
|
||||||
<div class="duo-row">
|
|
||||||
<ToggleOption
|
|
||||||
label="Печать"
|
|
||||||
options={SIDES_OPTIONS}
|
|
||||||
value={file.sides ?? 'Односторонняя'}
|
|
||||||
onchange={(v) => props.onUpdate?.(file.id, { sides: v })}
|
|
||||||
/>
|
|
||||||
<ToggleOption
|
|
||||||
label="Цветность"
|
|
||||||
options={COLOR_LABELS}
|
|
||||||
value={COLOR_LABELS[Math.max(0, COLOR_MODES.findIndex((c) => c.id === file.colorMode))]}
|
|
||||||
onchange={(v, i) => props.onUpdate?.(file.id, { colorMode: COLOR_MODES[i]?.id })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 3. Ползунки — ниже -->
|
<!-- Бейдж типа файла -->
|
||||||
<div class="slider-options">
|
{#if isOffice(file)}
|
||||||
<SliderOption
|
<span class="type-badge">
|
||||||
label="Качество"
|
{file.file.name.split('.').pop()?.toUpperCase() ?? 'DOC'}
|
||||||
options={QUALITY_LABELS}
|
</span>
|
||||||
hint="Качество печати: влияет на расход тонера и время прогрева."
|
{/if}
|
||||||
value={QUALITIES[file.qualityIndex]?.label ?? QUALITY_LABELS[1] ?? QUALITY_LABELS[0]}
|
|
||||||
onchange={(v) => props.onUpdate?.(file.id, { qualityIndex: QUALITY_LABELS.indexOf(v) })}
|
|
||||||
/>
|
|
||||||
<SliderOption
|
|
||||||
label="DPI"
|
|
||||||
options={DPI_OPTIONS}
|
|
||||||
hint="Разрешение печати. «Авто» подбирает DPI по содержимому страницы."
|
|
||||||
value={file.dpi ?? 'Авто'}
|
|
||||||
onchange={(v) => props.onUpdate?.(file.id, { dpi: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 4. Количество — в конце -->
|
<span class="file-name" title={file.file.name}>{file.file.name}</span>
|
||||||
<div class="copies-row">
|
|
||||||
<label class="option-title compact">Кол-во:</label>
|
|
||||||
<svelte:self
|
|
||||||
as="counter"
|
|
||||||
value={file.copies}
|
|
||||||
min={1}
|
|
||||||
onIncrement={() => props.onUpdate?.(file.id, { copies: file.copies + 1 })}
|
|
||||||
onDecrement={() => props.onUpdate?.(file.id, { copies: Math.max(1, file.copies - 1) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormatPicker
|
<span class="file-row-actions" onclick={(e) => e.stopPropagation()}>
|
||||||
bind:open={formatOpen}
|
<!-- Превью только для image/pdf -->
|
||||||
options={FORMAT_OPTIONS}
|
{#if file.previewUrl && !isOffice(file)}
|
||||||
selected={file.format ?? 'Авто'}
|
<svelte:self as="icon-button" onclick={() => props.onPreview?.(file)} title="Предпросмотр">🔍</svelte:self>
|
||||||
onselect={(v) => props.onUpdate?.(file.id, { format: v })}
|
{/if}
|
||||||
/>
|
<svelte:self as="icon-button" variant="danger" onclick={() => props.onRemove?.(file.id)} title="Удалить">✕</svelte:self>
|
||||||
{/if}
|
</span>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
|
{#if file.expanded}
|
||||||
|
<div class="file-options">
|
||||||
|
|
||||||
|
{#if isOffice(file)}
|
||||||
|
<!-- ═══ Офисный документ: упрощённые настройки ═══ -->
|
||||||
|
<p class="office-hint">
|
||||||
|
Документ будет распечатан как есть — конвертация выполняется на сервере.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="duo-group">
|
||||||
|
<div class="duo-row">
|
||||||
|
<ToggleOption
|
||||||
|
label="Печать"
|
||||||
|
options={SIDES_OPTIONS}
|
||||||
|
value={file.sides ?? 'Односторонняя'}
|
||||||
|
onchange={(v) => props.onUpdate?.(file.id, { sides: v })}
|
||||||
|
/>
|
||||||
|
<ToggleOption
|
||||||
|
label="Цветность"
|
||||||
|
options={COLOR_LABELS}
|
||||||
|
value={COLOR_LABELS[Math.max(0, COLOR_MODES.findIndex((c) => c.id === file.colorMode))]}
|
||||||
|
onchange={(v, i) => props.onUpdate?.(file.id, { colorMode: COLOR_MODES[i]?.id })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-options">
|
||||||
|
<SliderOption
|
||||||
|
label="Качество"
|
||||||
|
options={QUALITY_LABELS}
|
||||||
|
hint="Качество печати: влияет на расход тонера и время прогрева."
|
||||||
|
value={QUALITIES[file.qualityIndex]?.label ?? QUALITY_LABELS[1] ?? QUALITY_LABELS[0]}
|
||||||
|
onchange={(v) => props.onUpdate?.(file.id, { qualityIndex: QUALITY_LABELS.indexOf(v) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<!-- ═══ Image / PDF: полные настройки ═══ -->
|
||||||
|
<div class="duo-group">
|
||||||
|
<div class="duo-row">
|
||||||
|
<GearOption value={pagesText(file)} onclick={() => props.onPageSelect?.(file)} />
|
||||||
|
<GearOption value={formatText(file)} open={formatOpen} onclick={() => (formatOpen = !formatOpen)} />
|
||||||
|
</div>
|
||||||
|
<div class="duo-row">
|
||||||
|
<ToggleOption
|
||||||
|
label="Печать"
|
||||||
|
options={SIDES_OPTIONS}
|
||||||
|
value={file.sides ?? 'Односторонняя'}
|
||||||
|
onchange={(v) => props.onUpdate?.(file.id, { sides: v })}
|
||||||
|
/>
|
||||||
|
<ToggleOption
|
||||||
|
label="Цветность"
|
||||||
|
options={COLOR_LABELS}
|
||||||
|
value={COLOR_LABELS[Math.max(0, COLOR_MODES.findIndex((c) => c.id === file.colorMode))]}
|
||||||
|
onchange={(v, i) => props.onUpdate?.(file.id, { colorMode: COLOR_MODES[i]?.id })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-options">
|
||||||
|
<SliderOption
|
||||||
|
label="Качество"
|
||||||
|
options={QUALITY_LABELS}
|
||||||
|
hint="Качество печати: влияет на расход тонера и время прогрева."
|
||||||
|
value={QUALITIES[file.qualityIndex]?.label ?? QUALITY_LABELS[1] ?? QUALITY_LABELS[0]}
|
||||||
|
onchange={(v) => props.onUpdate?.(file.id, { qualityIndex: QUALITY_LABELS.indexOf(v) })}
|
||||||
|
/>
|
||||||
|
<SliderOption
|
||||||
|
label="DPI"
|
||||||
|
options={DPI_OPTIONS}
|
||||||
|
hint="Разрешение печати. «Авто» подбирает DPI по содержимому страницы."
|
||||||
|
value={file.dpi ?? 'Авто'}
|
||||||
|
onchange={(v) => props.onUpdate?.(file.id, { dpi: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Количество копий — для всех типов -->
|
||||||
|
<div class="copies-row">
|
||||||
|
<label class="option-title compact">Кол-во:</label>
|
||||||
|
<svelte:self
|
||||||
|
as="counter"
|
||||||
|
value={file.copies}
|
||||||
|
min={1}
|
||||||
|
onIncrement={() => props.onUpdate?.(file.id, { copies: file.copies + 1 })}
|
||||||
|
onDecrement={() => props.onUpdate?.(file.id, { copies: Math.max(1, file.copies - 1) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FormatPicker только для image/pdf -->
|
||||||
|
{#if !isOffice(file)}
|
||||||
|
<FormatPicker
|
||||||
|
bind:open={formatOpen}
|
||||||
|
options={FORMAT_OPTIONS}
|
||||||
|
selected={file.format ?? 'Авто'}
|
||||||
|
onselect={(v) => props.onUpdate?.(file.id, { format: v })}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
{:else if as === 'upload-zone'}
|
{:else if as === 'upload-zone'}
|
||||||
<div class="empty-upload">
|
<div class="empty-upload">
|
||||||
<p class="empty-hint">Загрузите файлы для печати</p>
|
<p class="empty-hint">Загрузите файлы для печати</p>
|
||||||
<svelte:self as="button" variant="primary" onclick={props.onSelect}>Загрузить файл(ы)</svelte:self>
|
<svelte:self as="button" variant="primary" onclick={props.onSelect}>Загрузить файл(ы)</svelte:self>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if as === 'footer'}
|
{:else if as === 'footer'}
|
||||||
<footer class="actions">
|
<footer class="actions">
|
||||||
{#if props.filesCount > 0}
|
{#if props.filesCount > 0}
|
||||||
<p class="file-count">
|
<p class="file-count">
|
||||||
{props.filesCount} {props.plFiles(props.filesCount)}
|
{props.filesCount} {props.plFiles(props.filesCount)}
|
||||||
{#if props.extraCopies > 0}, {props.extraCopies} {props.plCopies(props.extraCopies)}{/if}
|
{#if props.extraCopies > 0}, {props.extraCopies} {props.plCopies(props.extraCopies)}{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="pricing-info">
|
<div class="pricing-info">
|
||||||
<span class="price-tier" class:active={props.hasTierSmall}>10р/л от 1</span>
|
<span class="price-tier" class:active={props.hasTierSmall}>10р/л от 1</span>
|
||||||
<span class="price-tier" class:active={props.hasTierMedium}>9р/л от 25</span>
|
<span class="price-tier" class:active={props.hasTierMedium}>9р/л от 25</span>
|
||||||
<span class="price-tier" class:active={props.hasTierLarge}>4р/л от 1000</span>
|
<span class="price-tier" class:active={props.hasTierLarge}>4р/л от 1000</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="price-summary">
|
<div class="price-summary">
|
||||||
<span class="summary-itogo">Итого</span>
|
<span class="summary-itogo">Итого</span>
|
||||||
<span class="summary-price">{props.totalPrice}₽</span>
|
<span class="summary-price">{props.totalPrice}₽</span>
|
||||||
</div>
|
</div>
|
||||||
<svelte:self as="button" variant="secondary" disabled={props.isPrinting || props.filesCount === 0} onclick={props.onSubmit}>
|
<svelte:self as="button" variant="secondary" disabled={props.isPrinting || props.filesCount === 0} onclick={props.onSubmit}>
|
||||||
{props.isPrinting ? 'Отправка...' : 'Далее'}
|
{props.isPrinting ? 'Отправка...' : 'Далее'}
|
||||||
</svelte:self>
|
</svelte:self>
|
||||||
</footer>
|
</footer>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.duo-group + .slider-options { margin-top: -1px; }
|
.duo-group + .slider-options { margin-top: -1px; }
|
||||||
.duo-row { display: flex; gap: 5px; width: 100%; }
|
.duo-row { display: flex; gap: 5px; width: 100%; }
|
||||||
.duo-group { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
.duo-group { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
||||||
.action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; transition: opacity 0.2s; }
|
.action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; transition: opacity 0.2s; }
|
||||||
.action-btn:disabled { opacity: 0.6; cursor: wait; }
|
.action-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||||
.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); }
|
||||||
.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; }
|
||||||
.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; }
|
||||||
.option-sub { display: flex; flex-direction: column; gap: 8px; }
|
.option-sub { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.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; }
|
||||||
.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; }
|
||||||
.slider-options { display: flex; flex-direction: column; gap: 7px; width: 100%; }
|
.slider-options { display: flex; flex-direction: column; gap: 7px; width: 100%; }
|
||||||
.cycle-row { display: flex; gap: 10px; width: 100%; }
|
.cycle-row { display: flex; gap: 10px; width: 100%; }
|
||||||
.copies-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.06); }
|
.copies-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.06); }
|
||||||
.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; }
|
||||||
.file-options { padding: 8px 14px 14px; display: flex; flex-direction: column; gap: 12px; }
|
.file-options { padding: 8px 14px 14px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
.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; }
|
||||||
.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; }
|
||||||
.pricing-info { display: flex; justify-content: center; gap: 12px; font-size: 10px; line-height: 1.2; color: rgba(255,255,255,0.25); margin-bottom: 4px; user-select: none; flex-wrap: wrap; }
|
.pricing-info { display: flex; justify-content: center; gap: 12px; font-size: 10px; line-height: 1.2; color: rgba(255,255,255,0.25); margin-bottom: 4px; user-select: none; flex-wrap: wrap; }
|
||||||
.price-tier { transition: color 0.2s ease; }
|
.price-tier { transition: color 0.2s ease; }
|
||||||
.price-tier.active { color: rgba(255,255,255,0.6); }
|
.price-tier.active { color: rgba(255,255,255,0.6); }
|
||||||
.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; }
|
||||||
|
|
||||||
|
/* ── Бейдж типа файла (DOC, XLS, ODT…) ── */
|
||||||
|
.type-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||||
|
color: #a5b4fc;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
line-height: 1.2;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Подсказка для офисных файлов ── */
|
||||||
|
.office-hint {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(251, 191, 36, 0.08);
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||||
|
color: #d4a94e;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,243 +1,274 @@
|
|||||||
// lib/pages/print/store.svelte.js
|
// lib/pages/print/store.svelte.js
|
||||||
|
|
||||||
import { onDestroy, tick } from 'svelte';
|
import { onDestroy, tick } from 'svelte';
|
||||||
import { getPricePerPage } from '$lib/common/utils/pricing.util.js';
|
import { getPricePerPage } from '$lib/common/utils/pricing.util.js';
|
||||||
import { detectFileType, pagesLabel as _pagesLabel } from '$lib/common/utils/file.util.js';
|
import { detectFileType, pagesLabel as _pagesLabel } from '$lib/common/utils/file.util.js';
|
||||||
import { loadPdfDocument, renderPageToImage } from '$lib/common/services/pdf.service.js';
|
import { loadPdfDocument, renderPageToImage } from '$lib/common/services/pdf.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{ id: number, file: File, previewUrl: string | null, fileType: 'image' | 'pdf' | 'other', expanded: boolean, format: 'A4' | 'A5', colorMode: 'bw' | 'color', qualityIndex: number, copies: number, totalPages: number, selectedPages: Set<number>, pdfDoc: any }} FileEntry
|
* @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<number>, pdfDoc: any
|
||||||
|
* }} FileEntry
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function createPrintStore() {
|
export function createPrintStore() {
|
||||||
let files = $state(/** @type {FileEntry[]} */ ([]));
|
let files = $state(/** @type {FileEntry[]} */ ([]));
|
||||||
let activeFile = $state(/** @type {FileEntry | null} */ (null));
|
let activeFile = $state(/** @type {FileEntry | null} */ (null));
|
||||||
let viewMode = $state(/** @type {'gallery' | 'preview'} */ ('gallery'));
|
let viewMode = $state(/** @type {'gallery' | 'preview'} */ ('gallery'));
|
||||||
let showPayment = $state(false);
|
let showPayment = $state(false);
|
||||||
let isPrinting = $state(false);
|
let isPrinting = $state(false);
|
||||||
|
let galleryThumbnails = $state([]);
|
||||||
|
let galleryLoading = $state(false);
|
||||||
|
let previewPagesCache = $state({});
|
||||||
|
let isPreviewRendering = $state(false);
|
||||||
|
let uid = 0;
|
||||||
|
|
||||||
let galleryThumbnails = $state([]);
|
const totalPrice = $derived(
|
||||||
let galleryLoading = $state(false);
|
files.reduce((sum, f) => {
|
||||||
let previewPagesCache = $state({});
|
const pages = f.selectedPages?.size || 0;
|
||||||
let isPreviewRendering = $state(false);
|
return sum + pages * f.copies * getPricePerPage(pages);
|
||||||
|
}, 0)
|
||||||
|
);
|
||||||
|
|
||||||
let uid = 0;
|
const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0));
|
||||||
|
|
||||||
const totalPrice = $derived(
|
function updateFile(id, changes) {
|
||||||
files.reduce((sum, f) => {
|
files = files.map((f) => (f.id === id ? { ...f, ...changes } : f));
|
||||||
const pages = f.selectedPages?.size || 0;
|
if (activeFile?.id === id) {
|
||||||
return sum + pages * f.copies * getPricePerPage(pages);
|
activeFile = files.find((f) => f.id === id);
|
||||||
}, 0)
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0));
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function updateFile(id, changes) {
|
async function addFiles(fileList) {
|
||||||
files = files.map((f) => (f.id === id ? { ...f, ...changes } : f));
|
if (!fileList || fileList.length === 0) return;
|
||||||
if (activeFile?.id === id) {
|
const newFiles = [];
|
||||||
activeFile = files.find((f) => f.id === id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensurePdfLoaded(entry) {
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
if (entry.pdfDoc) return entry.pdfDoc;
|
const file = fileList[i];
|
||||||
const pdf = await loadPdfDocument(entry.file);
|
const fType = detectFileType(file);
|
||||||
if (pdf) {
|
const isOffice = fType === 'office';
|
||||||
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) {
|
newFiles.push({
|
||||||
if (!fileList || fileList.length === 0) return;
|
id: ++uid,
|
||||||
const newFiles = [];
|
file,
|
||||||
for (let i = 0; i < fileList.length; i++) {
|
// Для офисных файлов превью не создаём
|
||||||
const file = fileList[i];
|
previewUrl: isOffice ? null : URL.createObjectURL(file),
|
||||||
const fType = detectFileType(file);
|
fileType: fType,
|
||||||
newFiles.push({
|
expanded: false,
|
||||||
id: ++uid,
|
format: 'Авто',
|
||||||
file,
|
colorMode: 'bw',
|
||||||
previewUrl: URL.createObjectURL(file),
|
qualityIndex: 1,
|
||||||
fileType: fType,
|
copies: 1,
|
||||||
expanded: false,
|
sides: 'Односторонняя',
|
||||||
format: 'A4',
|
dpi: 'Авто',
|
||||||
colorMode: 'bw',
|
// Для офисных файлов: 1 «страница» для расчёта цены,
|
||||||
qualityIndex: 1,
|
// реальное кол-во определит CUPS
|
||||||
copies: 1,
|
totalPages: (fType === 'image' || isOffice) ? 1 : 0,
|
||||||
totalPages: fType === 'image' ? 1 : 0,
|
selectedPages: (fType === 'image' || isOffice) ? new Set([1]) : new Set(),
|
||||||
selectedPages: fType === 'image' ? new Set([1]) : new Set(),
|
pdfDoc: null
|
||||||
pdfDoc: null
|
});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
files = [...files, ...newFiles];
|
|
||||||
for (const entry of newFiles) {
|
|
||||||
if (entry.fileType === 'pdf') ensurePdfLoaded(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFile(id) {
|
files = [...files, ...newFiles];
|
||||||
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) {
|
for (const entry of newFiles) {
|
||||||
updateFile(id, { expanded: !files.find((f) => f.id === id)?.expanded });
|
if (entry.fileType === 'pdf') ensurePdfLoaded(entry);
|
||||||
}
|
// office и image — ничего дополнительно не загружаем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openGallery(entry) {
|
function removeFile(id) {
|
||||||
activeFile = entry;
|
const f = files.find((x) => x.id === id);
|
||||||
viewMode = 'gallery';
|
if (f?.previewUrl) URL.revokeObjectURL(f.previewUrl);
|
||||||
galleryThumbnails = [];
|
files = files.filter((x) => x.id !== id);
|
||||||
galleryLoading = true;
|
}
|
||||||
previewPagesCache = {};
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
const pdf = await ensurePdfLoaded(entry);
|
function toggleExpand(id) {
|
||||||
if (!pdf || entry.totalPages === 0) {
|
updateFile(id, { expanded: !files.find((f) => f.id === id)?.expanded });
|
||||||
galleryLoading = false;
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const thumbs = [];
|
async function openGallery(entry) {
|
||||||
for (let i = 1; i <= entry.totalPages; i++) {
|
activeFile = entry;
|
||||||
try {
|
viewMode = 'gallery';
|
||||||
thumbs.push(await renderPageToImage(pdf, i, 0.4));
|
galleryThumbnails = [];
|
||||||
} catch {
|
galleryLoading = true;
|
||||||
thumbs.push('');
|
previewPagesCache = {};
|
||||||
}
|
await tick();
|
||||||
galleryThumbnails = [...thumbs];
|
|
||||||
}
|
|
||||||
galleryLoading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openPreview(entry) {
|
const pdf = await ensurePdfLoaded(entry);
|
||||||
activeFile = entry;
|
if (!pdf || entry.totalPages === 0) {
|
||||||
viewMode = 'preview';
|
galleryLoading = false;
|
||||||
previewPagesCache = {};
|
return;
|
||||||
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 thumbs = [];
|
||||||
const currentFile = files.find((f) => f.id === entry.id);
|
for (let i = 1; i <= entry.totalPages; i++) {
|
||||||
const pdf = currentFile?.pdfDoc || entry.pdfDoc;
|
try {
|
||||||
if (!pdf || isPreviewRendering) return;
|
thumbs.push(await renderPageToImage(pdf, i, 0.4));
|
||||||
|
} catch {
|
||||||
|
thumbs.push('');
|
||||||
|
}
|
||||||
|
galleryThumbnails = [...thumbs];
|
||||||
|
}
|
||||||
|
galleryLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
isPreviewRendering = true;
|
async function openPreview(entry) {
|
||||||
const endPage = Math.min(startPage + count - 1, entry.totalPages);
|
activeFile = entry;
|
||||||
const newCache = {};
|
viewMode = 'preview';
|
||||||
|
previewPagesCache = {};
|
||||||
|
galleryThumbnails = [];
|
||||||
|
await tick();
|
||||||
|
|
||||||
for (let i = startPage; i <= endPage; i++) {
|
if (entry.fileType === 'pdf') {
|
||||||
if (previewPagesCache[i]) continue;
|
const pdf = await ensurePdfLoaded(entry);
|
||||||
try {
|
if (pdf && entry.totalPages > 0) {
|
||||||
newCache[i] = await renderPageToImage(pdf, i, 1.5);
|
preloadPreviewPages(entry, 1, 5);
|
||||||
} catch (e) {
|
}
|
||||||
console.error(`Failed to render preview page ${i}`, e);
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(newCache).length > 0) {
|
async function preloadPreviewPages(entry, startPage, count) {
|
||||||
previewPagesCache = { ...previewPagesCache, ...newCache };
|
const currentFile = files.find((f) => f.id === entry.id);
|
||||||
}
|
const pdf = currentFile?.pdfDoc || entry.pdfDoc;
|
||||||
isPreviewRendering = false;
|
if (!pdf || isPreviewRendering) return;
|
||||||
}
|
|
||||||
|
|
||||||
function togglePageSelection(pageNum) {
|
isPreviewRendering = true;
|
||||||
if (!activeFile?.selectedPages) return;
|
const endPage = Math.min(startPage + count - 1, entry.totalPages);
|
||||||
const newSet = new Set(activeFile.selectedPages);
|
const newCache = {};
|
||||||
if (newSet.has(pageNum)) {
|
|
||||||
if (newSet.size > 1) newSet.delete(pageNum);
|
|
||||||
} else {
|
|
||||||
newSet.add(pageNum);
|
|
||||||
}
|
|
||||||
updateFile(activeFile.id, { selectedPages: newSet });
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectAllPages() {
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
if (!activeFile?.totalPages) return;
|
if (previewPagesCache[i]) continue;
|
||||||
updateFile(activeFile.id, {
|
try {
|
||||||
selectedPages: new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1))
|
newCache[i] = await renderPageToImage(pdf, i, 1.5);
|
||||||
});
|
} catch (e) {
|
||||||
}
|
console.error(`Failed to render preview page ${i}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function deselectAllPages() {
|
if (Object.keys(newCache).length > 0) {
|
||||||
if (!activeFile) return;
|
previewPagesCache = { ...previewPagesCache, ...newCache };
|
||||||
updateFile(activeFile.id, { selectedPages: new Set([1]) });
|
}
|
||||||
}
|
isPreviewRendering = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function submitPrint() {
|
function togglePageSelection(pageNum) {
|
||||||
if (files.length === 0 || isPrinting) return;
|
if (!activeFile?.selectedPages) return;
|
||||||
isPrinting = true;
|
const newSet = new Set(activeFile.selectedPages);
|
||||||
const formData = new FormData();
|
if (newSet.has(pageNum)) {
|
||||||
for (const f of files) {
|
if (newSet.size > 1) newSet.delete(pageNum);
|
||||||
formData.append('files', f.file, f.file.name);
|
} else {
|
||||||
formData.append(
|
newSet.add(pageNum);
|
||||||
'settings',
|
}
|
||||||
JSON.stringify({
|
updateFile(activeFile.id, { selectedPages: newSet });
|
||||||
filename: f.file.name,
|
}
|
||||||
pages: f.selectedPages ? Array.from(f.selectedPages).sort((a, b) => a - b).join(',') : 'all',
|
|
||||||
copies: f.copies,
|
|
||||||
colorMode: f.colorMode,
|
|
||||||
format: f.format
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/print', { method: 'POST', body: formData });
|
|
||||||
const text = await res.text();
|
|
||||||
alert(res.ok ? `✅ Успешно!\n${text}` : `❌ Ошибка (${res.status}):\n${text}`);
|
|
||||||
} catch {
|
|
||||||
alert('⚠️ Не удалось связаться с сервером печати.');
|
|
||||||
} finally {
|
|
||||||
isPrinting = false;
|
|
||||||
showPayment = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => {
|
function selectAllPages() {
|
||||||
files.forEach((f) => f.previewUrl && URL.revokeObjectURL(f.previewUrl));
|
if (!activeFile?.totalPages) return;
|
||||||
});
|
updateFile(activeFile.id, {
|
||||||
|
selectedPages: new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
function deselectAllPages() {
|
||||||
get files() { return files; },
|
if (!activeFile) return;
|
||||||
get activeFile() { return activeFile; },
|
updateFile(activeFile.id, { selectedPages: new Set([1]) });
|
||||||
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; },
|
|
||||||
|
|
||||||
addFiles,
|
async function submitPrint() {
|
||||||
removeFile,
|
if (files.length === 0 || isPrinting) return;
|
||||||
toggleExpand,
|
isPrinting = true;
|
||||||
updateFile,
|
|
||||||
openGallery,
|
const formData = new FormData();
|
||||||
openPreview,
|
for (const f of files) {
|
||||||
closeOverlay: () => { activeFile = null; galleryThumbnails = []; previewPagesCache = {}; },
|
formData.append('files', f.file, f.file.name);
|
||||||
openPayment: () => { if (files.length > 0 && !isPrinting) showPayment = true; },
|
|
||||||
hidePayment: () => { showPayment = false; },
|
// Для офисных файлов всегда отправляем все страницы —
|
||||||
submitPrint,
|
// CUPS сам разберётся
|
||||||
togglePageSelection,
|
const pagesValue =
|
||||||
selectAllPages,
|
f.fileType === 'office'
|
||||||
deselectAllPages,
|
? 'all'
|
||||||
requestPreviewPages: (start, count) => { if (activeFile) preloadPreviewPages(activeFile, start, count); },
|
: f.selectedPages && f.selectedPages.size > 0
|
||||||
pagesLabel: _pagesLabel
|
? 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();
|
||||||
|
alert(res.ok ? `✅ Успешно!\n${text}` : `❌ Ошибка (${res.status}):\n${text}`);
|
||||||
|
} catch {
|
||||||
|
alert('⚠️ Не удалось связаться с сервером печати.');
|
||||||
|
} finally {
|
||||||
|
isPrinting = false;
|
||||||
|
showPayment = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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; },
|
||||||
|
addFiles,
|
||||||
|
removeFile,
|
||||||
|
toggleExpand,
|
||||||
|
updateFile,
|
||||||
|
openGallery,
|
||||||
|
openPreview,
|
||||||
|
closeOverlay: () => { activeFile = null; galleryThumbnails = []; previewPagesCache = {}; },
|
||||||
|
openPayment: () => { if (files.length > 0 && !isPrinting) showPayment = true; },
|
||||||
|
hidePayment: () => { showPayment = false; },
|
||||||
|
submitPrint,
|
||||||
|
togglePageSelection,
|
||||||
|
selectAllPages,
|
||||||
|
deselectAllPages,
|
||||||
|
requestPreviewPages: (start, count) => { if (activeFile) preloadPreviewPages(activeFile, start, count); },
|
||||||
|
pagesLabel: _pagesLabel
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user