wa-dev-tools/public/pdf.html
treamz 816753c925 PDF pages: visual rotate/delete/reorder with action buttons
- Delete: click pages (red highlight), counter, Удалить выбранные (N) button
- Rotate: click pages (visual rotation preview 90/180/270), Повернуть button
- Reorder: drag-and-drop thumbnails, visual number update, Применить button
- CSS: rotation transforms, delete/drag states, action bar with buttons
- Direct execute functions (bypass main execute flow)
2026-03-22 01:42:26 +03:00

1853 lines
82 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WA Dev Tools — PDF инструменты</title>
<script src="/vendor/tailwind.js"></script>
<script>window.WA_TOOL_ID = 'pdf';</script>
<script src="/shared.js"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<style>
/* Drop zone */
.drop-zone {
border: 2px dashed var(--surface-600);
border-radius: 16px;
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: border-color .2s, background .2s;
background: var(--surface-700);
}
.drop-zone:hover,
.drop-zone.drag-over {
border-color: var(--accent);
background: var(--accent-bg);
}
.drop-zone.drag-over .drop-icon {
color: var(--accent);
}
.drop-icon {
color: var(--text-muted);
transition: color .2s;
margin-bottom: 16px;
display: flex;
justify-content: center;
}
.drop-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.drop-hint {
font-size: 12px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
/* Upload overlay */
.uploading-overlay {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--surface-600);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Progress */
.progress-wrap {
background: var(--surface-700);
border: 1px solid var(--surface-600);
border-radius: 12px;
padding: 24px;
}
.progress-bar-track {
background: var(--surface-600);
border-radius: 99px;
height: 8px;
overflow: hidden;
margin: 14px 0 10px;
}
.progress-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 99px;
transition: width .3s ease;
width: 0%;
}
.progress-status {
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
color: var(--text-secondary);
}
.progress-percent {
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
color: var(--accent);
font-weight: 700;
}
/* Section labels */
.section-title {
font-size: 13px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
font-family: 'JetBrains Mono', monospace;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.section-num {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent-bg);
color: var(--accent);
font-size: 11px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* File list */
.file-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--surface-700);
border: 1px solid var(--surface-600);
border-radius: 10px;
padding: 12px 14px;
transition: border-color .15s;
}
.file-card.selected-file {
border-color: var(--accent);
background: var(--accent-bg);
}
.file-card-icon {
flex-shrink: 0;
color: var(--accent);
width: 48px;
height: 48px;
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-600);
}
.file-card-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-card-info {
flex: 1;
min-width: 0;
}
.file-card-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-card-meta {
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
margin-top: 2px;
}
.file-card-remove {
flex-shrink: 0;
width: 26px;
height: 26px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: color .15s, background .15s;
}
/* Page thumbnails grid */
.page-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(80px,1fr)); gap:8px; margin-top:10px; max-height:400px; overflow-y:auto; }
.page-thumb { position:relative; border:2px solid var(--surface-600); border-radius:8px; overflow:hidden; cursor:pointer; transition:all .15s; aspect-ratio:3/4; background:var(--surface-700); }
.page-thumb:hover { border-color:var(--text-muted); }
.page-thumb.selected { border-color:var(--accent); box-shadow:0 0 0 2px rgba(0,84,230,.3); }
.page-thumb img { width:100%; height:100%; object-fit:cover; }
.page-thumb-num { position:absolute; bottom:0; left:0; right:0; background:rgba(0,0,0,.6); color:#fff; font-size:10px; text-align:center; padding:2px 0; font-family:'JetBrains Mono',monospace; }
.page-thumb-check { position:absolute; top:4px; right:4px; width:18px; height:18px; border-radius:50%; border:2px solid rgba(255,255,255,.5); background:rgba(0,0,0,.3); display:none; align-items:center; justify-content:center; }
.page-thumb.selected .page-thumb-check { display:flex; background:var(--accent); border-color:var(--accent); }
.page-thumb-check svg { width:12px; height:12px; }
.page-thumb.rotate-90 img { transform: rotate(90deg); }
.page-thumb.rotate-180 img { transform: rotate(180deg); }
.page-thumb.rotate-270 img { transform: rotate(270deg); }
.page-thumb.dragging { opacity: 0.4; border-color: var(--accent); }
.page-thumb.drag-over { border-color: var(--accent); transform: scale(1.05); }
.page-thumb.marked-delete { border-color: #ef4444; opacity: 0.6; }
.page-thumb.marked-delete .page-thumb-check { display: flex; background: #ef4444; border-color: #ef4444; }
.page-thumb.marked-delete .page-thumb-num { background: rgba(239,68,68,.8); }
.grid-action-bar { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.grid-action-btn { padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; border: none; cursor: pointer; font-family: 'Manrope', sans-serif; transition: all .15s; }
.grid-action-btn.danger { background: rgba(239,68,68,.1); color: #ef4444; border: 1px solid rgba(239,68,68,.3); }
.grid-action-btn.danger:hover { background: rgba(239,68,68,.2); }
.grid-action-btn.primary { background: var(--accent); color: #fff; }
.grid-action-btn.primary:hover { background: var(--accent-dim); }
.grid-action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.page-grid-hint { font-size:11px; color:var(--text-muted); margin-top:6px; font-family:'JetBrains Mono',monospace; }
.file-card-remove:hover {
color: #ff5252;
background: rgba(255,82,82,.1);
}
.file-card-select {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid var(--surface-600);
cursor: pointer;
transition: border-color .15s, background .15s;
display: flex;
align-items: center;
justify-content: center;
}
.file-card.selected-file .file-card-select {
border-color: var(--accent);
background: var(--accent);
}
.file-card-select-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #fff;
display: none;
}
.file-card.selected-file .file-card-select-dot {
display: block;
}
/* Mode tabs — horizontal scrollable */
.op-tabs-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.op-tabs-wrap::-webkit-scrollbar { display: none; }
.op-tabs {
display: flex;
gap: 4px;
background: var(--surface-700);
border: 1px solid var(--surface-600);
border-radius: 10px;
padding: 4px;
min-width: max-content;
}
.op-tab {
padding: 9px 14px;
border-radius: 7px;
border: none;
background: transparent;
color: var(--text-muted);
font-family: 'Manrope', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
white-space: nowrap;
}
.op-tab:hover {
color: var(--text-secondary);
background: var(--surface-600);
}
.op-tab.active {
background: var(--accent);
color: #fff;
}
/* Op panels */
.op-panel { display: none; }
.op-panel.active { display: block; }
/* Option group */
.option-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-family: 'JetBrains Mono', monospace;
}
/* Button group (format selector) */
.btn-group {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.btn-option {
padding: 8px 18px;
border-radius: 8px;
border: 1px solid var(--surface-600);
background: var(--surface-700);
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.btn-option:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.btn-option.selected {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* Sub-tabs (inside a panel) */
.sub-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
}
.sub-tab {
padding: 7px 14px;
border-radius: 7px;
border: 1px solid var(--surface-600);
background: transparent;
color: var(--text-muted);
font-family: 'Manrope', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.sub-tab:hover {
color: var(--text-secondary);
background: var(--surface-600);
}
.sub-tab.active {
background: var(--accent-bg);
border-color: var(--accent);
color: var(--accent);
}
.sub-panel { display: none; }
.sub-panel.active { display: block; }
/* Slider */
.slider-wrap {
display: flex;
flex-direction: column;
gap: 6px;
}
.slider-row {
display: flex;
align-items: center;
gap: 12px;
}
input[type="range"] {
flex: 1;
height: 6px;
accent-color: var(--accent);
cursor: pointer;
}
.slider-val {
font-size: 13px;
font-weight: 700;
color: var(--accent);
font-family: 'JetBrains Mono', monospace;
min-width: 36px;
text-align: right;
}
/* Compress quality options */
.quality-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid var(--surface-600);
background: var(--surface-700);
cursor: pointer;
transition: all .2s;
}
.quality-option:hover {
border-color: var(--accent);
}
.quality-option.selected {
border-color: var(--accent);
background: var(--accent-bg);
}
.quality-option input[type="radio"] { display: none; }
.quality-label {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.quality-desc {
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
margin-top: 2px;
}
/* Position grid (3x3) */
.pos-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
max-width: 200px;
}
.pos-cell {
aspect-ratio: 1;
border: 1px solid var(--surface-600);
border-radius: 6px;
background: var(--surface-700);
cursor: pointer;
transition: all .15s;
display: flex;
align-items: center;
justify-content: center;
}
.pos-cell:hover {
border-color: var(--accent);
}
.pos-cell.selected {
border-color: var(--accent);
background: var(--accent-bg);
}
.pos-cell-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
transition: background .15s;
}
.pos-cell.selected .pos-cell-dot {
background: var(--accent);
}
/* Angle selector */
.angle-btn {
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--surface-600);
background: var(--surface-700);
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.angle-btn:hover { border-color: var(--accent); color: var(--text-primary); }
.angle-btn.selected { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Result card */
.result-card {
background: var(--surface-700);
border: 1px solid var(--surface-600);
border-radius: 12px;
padding: 24px;
}
.result-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.result-stat { text-align: center; }
.result-stat-val {
font-size: 20px;
font-weight: 800;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
}
.result-stat-val.accent { color: var(--accent); }
.result-stat-val.green { color: #22c55e; }
.result-stat-label {
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
margin-top: 4px;
}
/* Extracted text area */
.text-output {
width: 100%;
min-height: 200px;
background: var(--select-bg);
border: 1px solid var(--surface-600);
color: var(--select-color);
border-radius: 8px;
padding: 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
resize: vertical;
box-sizing: border-box;
line-height: 1.6;
}
/* Error */
.error-box {
background: rgba(255, 82, 82, 0.08);
border: 1px solid rgba(255, 82, 82, 0.25);
border-radius: 10px;
padding: 14px 16px;
font-size: 13px;
color: #ff5252;
font-family: 'JetBrains Mono', monospace;
}
/* Hint note */
.hint-note {
font-size: 12px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
background: var(--surface-700);
border: 1px solid var(--surface-600);
border-radius: 8px;
padding: 10px 14px;
}
/* Color picker row */
.color-picker-row {
display: flex;
align-items: center;
gap: 10px;
}
input[type="color"] {
width: 36px;
height: 36px;
border: 1px solid var(--surface-600);
border-radius: 6px;
background: none;
cursor: pointer;
padding: 2px;
}
.color-hex {
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
color: var(--text-secondary);
}
/* Add more button */
.btn-add-more {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 10px;
border: 1px dashed var(--surface-600);
border-radius: 8px;
background: transparent;
color: var(--text-muted);
font-family: 'Manrope', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.btn-add-more:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-bg);
}
/* Images drop zone (smaller) */
.drop-zone-sm {
border: 2px dashed var(--surface-600);
border-radius: 12px;
padding: 28px 16px;
text-align: center;
cursor: pointer;
transition: border-color .2s, background .2s;
background: var(--surface-700);
}
.drop-zone-sm:hover,
.drop-zone-sm.drag-over {
border-color: var(--accent);
background: var(--accent-bg);
}
/* Merge order drag indicator */
.file-card-order {
flex-shrink: 0;
width: 20px;
text-align: center;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
@media (max-width: 480px) {
.result-stats { grid-template-columns: 1fr; }
}
</style>
</head>
<body class="font-sans antialiased">
<div class="main-content">
<div class="tool-container max-w-3xl mx-auto">
<!-- Page header -->
<div class="page-header">
<h1>PDF <span class="text-accent">инструменты</span></h1>
<p class="description">объединение, разделение, сжатие, конвертация и защита</p>
</div>
<!-- Error display -->
<div id="errorBox" class="error-box" style="display:none; margin-bottom:20px;"></div>
<!-- Step 1: Upload -->
<div id="stepUpload" style="margin-bottom:24px;">
<div class="section-title">
<span class="section-num">1</span>
Загрузка файлов
</div>
<div id="dropZone" class="drop-zone" tabindex="0" role="button" aria-label="Загрузить PDF">
<div id="dropZoneContent">
<div class="drop-icon">
<svg width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
</svg>
</div>
<div class="drop-label">Перетащите PDF-файлы или нажмите для выбора</div>
<div class="drop-hint" style="margin-top:6px;">PDF — можно несколько файлов сразу</div>
</div>
<input type="file" id="fileInput" accept=".pdf,application/pdf" multiple style="display:none;" />
</div>
</div>
<!-- Step 2+: shown after at least one file uploaded -->
<div id="stepConfig" style="display:none;">
<!-- Uploaded files list -->
<div style="margin-bottom:24px;">
<div class="section-title">
<span class="section-num">1</span>
Загруженные файлы
</div>
<div class="file-list" id="fileList"></div>
<div style="margin-top:8px;">
<button id="btnAddMore" class="btn-add-more">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
Добавить ещё файлы
</button>
<input type="file" id="fileInputMore" accept=".pdf,application/pdf" multiple style="display:none;" />
</div>
</div>
<!-- Operation tabs -->
<div style="margin-bottom:20px;">
<div class="section-title">
<span class="section-num">2</span>
Операция
</div>
<div class="op-tabs-wrap">
<div class="op-tabs" role="tablist">
<button class="op-tab active" data-op="merge" role="tab">Объединить</button>
<button class="op-tab" data-op="split" role="tab">Разделить</button>
<button class="op-tab" data-op="pages" role="tab">Страницы</button>
<button class="op-tab" data-op="watermark" role="tab">Водяной знак</button>
<button class="op-tab" data-op="pagenums" role="tab">Нумерация</button>
<button class="op-tab" data-op="compress" role="tab">Сжатие</button>
<button class="op-tab" data-op="convert" role="tab">Конвертация</button>
<button class="op-tab" data-op="security" role="tab">Безопасность</button>
</div>
</div>
</div>
<!-- ── Panel: Объединить ── -->
<div id="panel-merge" class="op-panel active" style="margin-bottom:24px;">
<div class="wa-card">
<div class="option-group">
<span class="option-label">Порядок файлов</span>
<div class="hint-note">Файлы будут объединены в порядке списка выше. Нужно минимум 2 файла.</div>
</div>
</div>
</div>
<!-- ── Panel: Разделить ── -->
<div id="panel-split" class="op-panel" style="margin-bottom:24px;">
<div class="wa-card">
<div class="option-group">
<span class="option-label">Файл для разделения</span>
<div id="splitFileSelector" class="file-list" style="margin-bottom:12px;"></div>
<span class="option-label">Диапазон страниц</span>
<input type="text" id="splitRanges" class="wa-input" placeholder="Например: 1-3, 5, 7-9" style="width:100%;box-sizing:border-box;" />
<div class="hint-note">Используйте запятые для разделения диапазонов. Тире для диапазона страниц.</div>
</div>
</div>
</div>
<!-- ── Panel: Страницы ── -->
<div id="panel-pages" class="op-panel" style="margin-bottom:24px;">
<div class="wa-card" style="margin-bottom:12px;">
<div class="option-group">
<span class="option-label">Файл</span>
<div id="pagesFileSelector" class="file-list"></div>
</div>
</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="rotate">Повернуть</button>
<button class="sub-tab" data-sub="delete">Удалить</button>
<button class="sub-tab" data-sub="reorder">Изменить порядок</button>
</div>
<!-- Rotate sub-panel -->
<div id="subpanel-rotate" class="sub-panel active wa-card">
<div class="option-group">
<span class="option-label">Угол поворота</span>
<div class="btn-group" id="angleGroup">
<button class="angle-btn selected" data-angle="90">90°</button>
<button class="angle-btn" data-angle="180">180°</button>
<button class="angle-btn" data-angle="270">270° (90°)</button>
</div>
<span class="option-label" style="margin-top:8px;">Нажмите на страницы для поворота</span>
<div class="page-grid" id="rotatePageGrid"></div>
<div class="grid-action-bar">
<button class="grid-action-btn primary" id="btnRotateSelected" disabled onclick="executeRotatePages()">Повернуть выбранные (<span id="rotateCount">0</span>)</button>
<span class="page-grid-hint" id="rotateHint"></span>
</div>
</div>
</div>
<!-- Delete sub-panel -->
<div id="subpanel-delete" class="sub-panel wa-card">
<div class="option-group">
<span class="option-label">Нажмите на страницы для удаления</span>
<div class="page-grid" id="deletePageGrid"></div>
<div class="grid-action-bar">
<button class="grid-action-btn danger" id="btnDeleteSelected" disabled onclick="executeDeletePages()">Удалить выбранные (<span id="deleteCount">0</span>)</button>
<span class="page-grid-hint" id="deleteHint"></span>
</div>
</div>
</div>
<!-- Reorder sub-panel -->
<div id="subpanel-reorder" class="sub-panel wa-card">
<div class="option-group">
<span class="option-label">Перетаскивайте страницы мышкой для нового порядка</span>
<div class="page-grid" id="reorderPageGrid"></div>
<div class="grid-action-bar">
<button class="grid-action-btn primary" id="btnReorderApply" onclick="executeReorderPages()">Применить порядок</button>
<span class="page-grid-hint" id="reorderHint"></span>
</div>
</div>
</div>
</div>
<!-- ── Panel: Водяной знак ── -->
<div id="panel-watermark" class="op-panel" style="margin-bottom:24px;">
<div class="wa-card" style="margin-bottom:12px;">
<div class="option-group">
<span class="option-label">Файл</span>
<div id="watermarkFileSelector" class="file-list"></div>
</div>
</div>
<div class="wa-card">
<div class="option-group">
<span class="option-label">Текст водяного знака</span>
<input type="text" id="wmText" class="wa-input" placeholder="КОНФИДЕНЦИАЛЬНО" style="width:100%;box-sizing:border-box;" />
<span class="option-label" style="margin-top:8px;">Размер шрифта</span>
<div class="slider-wrap">
<div class="slider-row">
<input type="range" id="wmFontSize" min="12" max="72" value="36" />
<span class="slider-val" id="wmFontSizeVal">36</span>
</div>
</div>
<span class="option-label" style="margin-top:8px;">Прозрачность</span>
<div class="slider-wrap">
<div class="slider-row">
<input type="range" id="wmOpacity" min="0.1" max="1.0" step="0.05" value="0.3" />
<span class="slider-val" id="wmOpacityVal">0.3</span>
</div>
</div>
<span class="option-label" style="margin-top:8px;">Цвет</span>
<div class="color-picker-row">
<input type="color" id="wmColor" value="#ff0000" />
<span class="color-hex" id="wmColorHex">#ff0000</span>
</div>
</div>
</div>
</div>
<!-- ── Panel: Нумерация страниц ── -->
<div id="panel-pagenums" class="op-panel" style="margin-bottom:24px;">
<div class="wa-card" style="margin-bottom:12px;">
<div class="option-group">
<span class="option-label">Файл</span>
<div id="pagenumFileSelector" class="file-list"></div>
</div>
</div>
<div class="wa-card">
<div class="option-group">
<span class="option-label">Положение</span>
<div style="display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap;">
<div class="pos-grid" id="posGrid">
<div class="pos-cell" data-pos="top-left"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="top-center"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="top-right"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="mid-left"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="mid-center"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="mid-right"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell selected" data-pos="bot-center"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="bot-left"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="bot-right"> <div class="pos-cell-dot"></div></div>
</div>
<div style="font-size:11px;color:var(--text-muted);font-family:'JetBrains Mono',monospace;line-height:1.8;">
Выбрано:<br>
<span id="posLabel" style="color:var(--accent);font-weight:700;">Снизу по центру</span>
</div>
</div>
<span class="option-label" style="margin-top:8px;">Начинать с номера</span>
<input type="number" id="pageNumStart" class="wa-input" min="1" value="1" style="width:120px;" />
</div>
</div>
</div>
<!-- ── Panel: Сжатие ── -->
<div id="panel-compress" class="op-panel" style="margin-bottom:24px;">
<div class="wa-card" style="margin-bottom:12px;">
<div class="option-group">
<span class="option-label">Файл</span>
<div id="compressFileSelector" class="file-list"></div>
</div>
</div>
<div class="wa-card">
<div class="option-group">
<span class="option-label">Качество сжатия</span>
<label class="quality-option selected" data-quality="screen">
<input type="radio" name="compressQuality" value="screen" checked />
<div>
<div class="quality-label">Экран</div>
<div class="quality-desc">минимальный размер, для экранного просмотра</div>
</div>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" style="color:var(--accent);flex-shrink:0;display:none;" class="q-check">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
</label>
<label class="quality-option" data-quality="ebook">
<input type="radio" name="compressQuality" value="ebook" />
<div>
<div class="quality-label">Электронная книга</div>
<div class="quality-desc">баланс размера и качества</div>
</div>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" style="color:var(--accent);flex-shrink:0;display:none;" class="q-check">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
</label>
<label class="quality-option" data-quality="printer">
<input type="radio" name="compressQuality" value="printer" />
<div>
<div class="quality-label">Печать</div>
<div class="quality-desc">максимальное качество, для печати</div>
</div>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" style="color:var(--accent);flex-shrink:0;display:none;" class="q-check">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
</label>
</div>
</div>
</div>
<!-- ── Panel: Конвертация ── -->
<div id="panel-convert" class="op-panel" style="margin-bottom:24px;">
<div class="wa-card" style="margin-bottom:12px;">
<div class="option-group">
<span class="option-label">Файл</span>
<div id="convertFileSelector" class="file-list"></div>
</div>
</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="toimg">PDF → Картинки</button>
<button class="sub-tab" data-sub="fromimg">Картинки → PDF</button>
</div>
<!-- PDF → Images -->
<div id="subpanel-toimg" class="sub-panel active wa-card">
<div class="option-group">
<span class="option-label">Формат</span>
<div class="btn-group" id="imgFormatGroup">
<button class="btn-option selected" data-value="png">PNG</button>
<button class="btn-option" data-value="jpg">JPG</button>
</div>
<span class="option-label" style="margin-top:8px;">Разрешение (DPI)</span>
<div class="btn-group" id="dpiGroup">
<button class="btn-option selected" data-value="150">150 dpi</button>
<button class="btn-option" data-value="300">300 dpi</button>
</div>
</div>
</div>
<!-- Images → PDF -->
<div id="subpanel-fromimg" class="sub-panel wa-card">
<div class="option-group">
<span class="option-label">Загрузить изображения</span>
<div id="imgDropZone" class="drop-zone-sm" tabindex="0" role="button">
<div class="drop-icon" style="margin-bottom:8px;">
<svg width="32" height="32" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3 9.75h.375a2.25 2.25 0 012.25 2.25v.375"/>
</svg>
</div>
<div class="drop-label" style="font-size:13px;">Перетащите изображения или нажмите</div>
<div class="drop-hint">PNG, JPG, JPEG, WebP</div>
<input type="file" id="imgFileInput" accept="image/png,image/jpeg,image/webp" multiple style="display:none;" />
</div>
<div id="imgFileList" class="file-list" style="margin-top:8px;"></div>
</div>
</div>
</div>
<!-- ── Panel: Безопасность ── -->
<div id="panel-security" class="op-panel" style="margin-bottom:24px;">
<div class="wa-card" style="margin-bottom:12px;">
<div class="option-group">
<span class="option-label">Файл</span>
<div id="securityFileSelector" class="file-list"></div>
</div>
</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="protect">Защитить</button>
<button class="sub-tab" data-sub="extract">Извлечь текст</button>
</div>
<!-- Protect -->
<div id="subpanel-protect" class="sub-panel active wa-card">
<div class="option-group">
<span class="option-label">Пароль</span>
<input type="password" id="protectPassword" class="wa-input" placeholder="Введите пароль" style="width:100%;box-sizing:border-box;" />
<div class="hint-note">Файл будет защищён от открытия без пароля.</div>
</div>
</div>
<!-- Extract text -->
<div id="subpanel-extract" class="sub-panel wa-card">
<div class="hint-note">Текст будет извлечён из PDF и показан ниже для копирования.</div>
</div>
</div>
<!-- Step 3: Action + Progress -->
<div style="margin-bottom:24px;">
<div class="section-title">
<span class="section-num">3</span>
Обработка
</div>
<div id="actionWrap">
<button id="btnProcess" class="wa-btn" style="width:100%;padding:14px 24px;font-size:15px;">
Выполнить
</button>
</div>
<div id="progressWrap" class="progress-wrap" style="display:none;margin-top:0;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="progress-status" id="progressStatus">Обработка...</span>
<span class="progress-percent" id="progressPercent">0%</span>
</div>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="progressFill"></div>
</div>
<div style="font-size:11px;color:var(--text-muted);font-family:'JetBrains Mono',monospace;">
Не закрывайте вкладку во время обработки
</div>
</div>
</div>
<!-- Step 4: Result -->
<div id="stepResult" style="display:none;margin-bottom:24px;">
<div class="section-title">
<span class="section-num">4</span>
Результат
</div>
<div class="result-card">
<div id="resultStats" class="result-stats">
<div class="result-stat">
<div class="result-stat-val accent" id="resultSize"></div>
<div class="result-stat-label">размер файла</div>
</div>
<div class="result-stat">
<div class="result-stat-val green" id="resultSavings" style="display:none;"></div>
<div class="result-stat-label" id="resultSavingsLabel" style="display:none;">экономия</div>
</div>
</div>
<!-- Extracted text output -->
<div id="resultTextWrap" style="display:none;margin-bottom:16px;">
<div class="option-label" style="margin-bottom:8px;">Извлечённый текст</div>
<textarea id="resultText" class="text-output" readonly></textarea>
<div style="margin-top:8px;">
<button id="btnCopyText" class="wa-btn" style="background:var(--surface-600);color:var(--text-secondary);font-size:12px;padding:8px 16px;">
Копировать текст
</button>
</div>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<a id="downloadLink" href="#" class="wa-btn" style="flex:1;text-align:center;text-decoration:none;padding:12px 24px;display:none;">
Скачать файл
</a>
<button id="btnBack" class="wa-btn" style="background:var(--surface-600);color:var(--text-secondary);flex:0 0 auto;padding:12px 18px;">
Назад
</button>
</div>
</div>
</div>
</div><!-- /stepConfig -->
</div>
</div>
<script>
(function () {
'use strict';
// ─── State ────────────────────────────────────────────────────────────────
const state = {
files: [], // [{ id, name, size, pages }]
selectedFileId: null,
currentOp: 'merge',
// pages sub-ops
pagesSubOp: 'rotate',
rotateAngle: 90,
// convert sub-ops
convertSub: 'toimg',
imgFormat: 'png',
imgDpi: '150',
// images for fromImages
imgFiles: [],
// compress
compressQuality: 'screen',
// pagenums
pageNumPosition: 'bot-center',
// watermark color
wmColor: '#ff0000',
};
// ─── DOM refs ─────────────────────────────────────────────────────────────
const dropZone = document.getElementById('dropZone');
const dropContent = document.getElementById('dropZoneContent');
const fileInput = document.getElementById('fileInput');
const fileInputMore = document.getElementById('fileInputMore');
const stepUpload = document.getElementById('stepUpload');
const stepConfig = document.getElementById('stepConfig');
const stepResult = document.getElementById('stepResult');
const errorBox = document.getElementById('errorBox');
const fileList = document.getElementById('fileList');
const btnAddMore = document.getElementById('btnAddMore');
const btnProcess = document.getElementById('btnProcess');
const actionWrap = document.getElementById('actionWrap');
const progressWrap = document.getElementById('progressWrap');
const progressFill = document.getElementById('progressFill');
const progressStatus = document.getElementById('progressStatus');
const progressPercent = document.getElementById('progressPercent');
const downloadLink = document.getElementById('downloadLink');
const btnBack = document.getElementById('btnBack');
// ─── Helpers ──────────────────────────────────────────────────────────────
function formatSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
function showError(msg) {
errorBox.textContent = msg;
errorBox.style.display = 'block';
errorBox.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function clearError() {
errorBox.style.display = 'none';
errorBox.textContent = '';
}
function pdfIcon(size = 18) {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>
</svg>`;
}
// ─── Render file list ─────────────────────────────────────────────────────
function renderFileList() {
fileList.innerHTML = '';
state.files.forEach((f, idx) => {
const card = document.createElement('div');
card.className = 'file-card';
card.dataset.id = f.id;
card.innerHTML = `
<div class="file-card-order">${idx + 1}</div>
<div class="file-card-icon"><img src="/pdf/preview/${f.id}" alt="" onerror="this.parentNode.innerHTML=pdfIcon(20)"></div>
<div class="file-card-info">
<div class="file-card-name">${escHtml(f.name)}</div>
<div class="file-card-meta">${formatSize(f.size)} · ${f.pages} стр.</div>
</div>
<button class="file-card-remove" data-id="${f.id}" title="Удалить">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>`;
fileList.appendChild(card);
});
// Remove buttons
fileList.querySelectorAll('.file-card-remove').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const id = btn.dataset.id;
state.files = state.files.filter(f => f.id !== id);
if (state.selectedFileId === id) state.selectedFileId = state.files[0]?.id || null;
renderFileList();
renderSingleFileSelectors();
if (state.files.length === 0) resetToUpload();
});
});
renderSingleFileSelectors();
}
// Render file selector for single-file operations
function renderSingleFileSelectors() {
const containers = [
'splitFileSelector',
'pagesFileSelector',
'watermarkFileSelector',
'pagenumFileSelector',
'compressFileSelector',
'convertFileSelector',
'securityFileSelector',
];
containers.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.innerHTML = '';
state.files.forEach(f => {
const card = document.createElement('div');
const isSelected = f.id === state.selectedFileId;
card.className = 'file-card' + (isSelected ? ' selected-file' : '');
card.dataset.id = f.id;
card.style.cursor = 'pointer';
card.innerHTML = `
<div class="file-card-select"><div class="file-card-select-dot"></div></div>
<div class="file-card-icon">${pdfIcon(16)}</div>
<div class="file-card-info">
<div class="file-card-name">${escHtml(f.name)}</div>
<div class="file-card-meta">${formatSize(f.size)} · ${f.pages} стр.</div>
</div>`;
card.addEventListener('click', () => {
state.selectedFileId = f.id;
renderSingleFileSelectors();
});
el.appendChild(card);
});
});
// Render page thumbnails for selected file
const _sel = state.files.find(f => f.id === state.selectedFileId);
if (_sel && typeof renderPageGrid === 'function') {
renderPageGrid('deletePageGrid', 'deleteHint', _sel.id, _sel.pages, 'delete');
renderPageGrid('rotatePageGrid', 'rotateHint', _sel.id, _sel.pages, 'rotate');
renderPageGrid('reorderPageGrid', 'reorderHint', _sel.id, _sel.pages, 'reorder');
}
}
function escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ─── Drop zone ────────────────────────────────────────────────────────────
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); });
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
handleFiles(Array.from(e.dataTransfer.files));
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) handleFiles(Array.from(fileInput.files));
});
btnAddMore.addEventListener('click', () => fileInputMore.click());
fileInputMore.addEventListener('change', () => {
if (fileInputMore.files.length) handleFiles(Array.from(fileInputMore.files));
});
// ─── Images drop zone (fromImages) ────────────────────────────────────────
const imgDropZone = document.getElementById('imgDropZone');
const imgFileInput = document.getElementById('imgFileInput');
const imgFileList = document.getElementById('imgFileList');
imgDropZone.addEventListener('click', () => imgFileInput.click());
imgDropZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') imgFileInput.click(); });
imgDropZone.addEventListener('dragover', e => { e.preventDefault(); imgDropZone.classList.add('drag-over'); });
imgDropZone.addEventListener('dragleave', () => imgDropZone.classList.remove('drag-over'));
imgDropZone.addEventListener('drop', e => {
e.preventDefault();
imgDropZone.classList.remove('drag-over');
state.imgFiles = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
renderImgFileList();
});
imgFileInput.addEventListener('change', () => {
state.imgFiles = Array.from(imgFileInput.files);
renderImgFileList();
});
function renderImgFileList() {
imgFileList.innerHTML = '';
state.imgFiles.forEach((f, idx) => {
const card = document.createElement('div');
card.className = 'file-card';
card.innerHTML = `
<div class="file-card-order">${idx + 1}</div>
<div class="file-card-icon" style="color:var(--text-muted);">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909"/>
</svg>
</div>
<div class="file-card-info">
<div class="file-card-name">${escHtml(f.name)}</div>
<div class="file-card-meta">${formatSize(f.size)}</div>
</div>`;
imgFileList.appendChild(card);
});
}
// ─── Upload files ─────────────────────────────────────────────────────────
async function handleFiles(files) {
clearError();
const pdfFiles = files.filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
if (pdfFiles.length === 0) {
showError('Выберите PDF-файлы.');
return;
}
const MAX = 100 * 1024 * 1024;
const oversized = pdfFiles.find(f => f.size > MAX);
if (oversized) {
showError(`Файл "${oversized.name}" слишком большой. Максимальный размер — 100 МБ.`);
return;
}
// Show upload progress in drop zone (only if step still visible)
if (stepUpload.style.display !== 'none') {
dropContent.innerHTML = `
<div class="uploading-overlay">
<div class="spinner"></div>
<div class="drop-label">Загрузка файлов...</div>
<div style="font-size:24px;font-weight:800;color:var(--accent);font-family:'JetBrains Mono',monospace;" id="uploadPercent">0%</div>
<div class="progress-bar-track" style="width:100%;max-width:300px;">
<div class="progress-bar-fill" id="uploadFill" style="width:0%"></div>
</div>
<div class="drop-hint" style="margin-top:8px;">${pdfFiles.length} файл(ов)</div>
</div>`;
}
const formData = new FormData();
pdfFiles.forEach(f => formData.append('files', f));
try {
const data = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/pdf/upload');
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
const el = document.getElementById('uploadPercent');
const fill = document.getElementById('uploadFill');
if (el) el.textContent = pct + '%';
if (fill) fill.style.width = pct + '%';
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); }
catch { reject(new Error('Ошибка разбора ответа')); }
} else {
try { reject(new Error(JSON.parse(xhr.responseText).error)); }
catch { reject(new Error('Ошибка загрузки: ' + xhr.status)); }
}
};
xhr.onerror = () => reject(new Error('Сеть недоступна'));
xhr.send(formData);
});
onUploadSuccess(data);
} catch (err) {
restoreDropZone();
showError(err.message || 'Не удалось загрузить файлы');
}
}
function onUploadSuccess(data) {
const uploaded = data.files || [];
uploaded.forEach(f => {
if (!state.files.find(ex => ex.id === f.id)) {
state.files.push(f);
}
});
if (!state.selectedFileId && state.files.length > 0) {
state.selectedFileId = state.files[0].id;
}
renderFileList();
stepUpload.style.display = 'none';
stepConfig.style.display = 'block';
fileInput.value = '';
fileInputMore.value = '';
}
function restoreDropZone() {
dropContent.innerHTML = `
<div class="drop-icon">
<svg width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
</svg>
</div>
<div class="drop-label">Перетащите PDF-файлы или нажмите для выбора</div>
<div class="drop-hint" style="margin-top:6px;">PDF — можно несколько файлов сразу</div>`;
}
// ─── Operation tabs ───────────────────────────────────────────────────────
document.querySelectorAll('.op-tab').forEach(tab => {
tab.addEventListener('click', () => {
state.currentOp = tab.dataset.op;
document.querySelectorAll('.op-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('.op-panel').forEach(p => p.classList.remove('active'));
const panel = document.getElementById('panel-' + tab.dataset.op);
if (panel) panel.classList.add('active');
stepResult.style.display = 'none';
actionWrap.style.display = 'block';
progressWrap.style.display = 'none';
});
});
// ─── Sub-tabs (pages, convert, security) ─────────────────────────────────
function initSubTabs(panelId, stateKey) {
const panel = document.getElementById(panelId);
if (!panel) return;
panel.querySelectorAll('.sub-tab').forEach(tab => {
tab.addEventListener('click', () => {
state[stateKey] = tab.dataset.sub;
panel.querySelectorAll('.sub-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
panel.querySelectorAll('.sub-panel').forEach(p => p.classList.remove('active'));
const sp = document.getElementById('subpanel-' + tab.dataset.sub);
if (sp) sp.classList.add('active');
});
});
}
initSubTabs('panel-pages', 'pagesSubOp');
initSubTabs('panel-convert', 'convertSub');
initSubTabs('panel-security', 'securitySub');
// ─── Angle buttons ────────────────────────────────────────────────────────
document.getElementById('angleGroup').addEventListener('click', e => {
const btn = e.target.closest('.angle-btn');
if (!btn) return;
document.querySelectorAll('.angle-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
state.rotateAngle = parseInt(btn.dataset.angle, 10);
});
// ─── Img format + DPI groups ──────────────────────────────────────────────
function initBtnGroup(id, stateKey) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('click', e => {
const btn = e.target.closest('.btn-option');
if (!btn) return;
el.querySelectorAll('.btn-option').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
state[stateKey] = btn.dataset.value;
});
}
initBtnGroup('imgFormatGroup', 'imgFormat');
initBtnGroup('dpiGroup', 'imgDpi');
// ─── Compress quality ─────────────────────────────────────────────────────
document.querySelectorAll('.quality-option').forEach(label => {
label.addEventListener('click', () => {
document.querySelectorAll('.quality-option').forEach(l => l.classList.remove('selected'));
label.classList.add('selected');
state.compressQuality = label.dataset.quality;
});
});
// ─── Sliders (watermark) ──────────────────────────────────────────────────
const wmFontSize = document.getElementById('wmFontSize');
const wmOpacity = document.getElementById('wmOpacity');
wmFontSize.addEventListener('input', () => {
document.getElementById('wmFontSizeVal').textContent = wmFontSize.value;
});
wmOpacity.addEventListener('input', () => {
document.getElementById('wmOpacityVal').textContent = parseFloat(wmOpacity.value).toFixed(2);
});
// ─── Color picker ─────────────────────────────────────────────────────────
document.getElementById('wmColor').addEventListener('input', e => {
state.wmColor = e.target.value;
document.getElementById('wmColorHex').textContent = e.target.value;
});
// ─── Position grid ────────────────────────────────────────────────────────
const posLabels = {
'top-left': 'Вверху слева',
'top-center': 'Вверху по центру',
'top-right': 'Вверху справа',
'mid-left': 'По центру слева',
'mid-center': 'По центру',
'mid-right': 'По центру справа',
'bot-left': 'Внизу слева',
'bot-center': 'Внизу по центру',
'bot-right': 'Внизу справа',
};
document.getElementById('posGrid').addEventListener('click', e => {
const cell = e.target.closest('.pos-cell');
if (!cell) return;
document.querySelectorAll('.pos-cell').forEach(c => c.classList.remove('selected'));
cell.classList.add('selected');
state.pageNumPosition = cell.dataset.pos;
document.getElementById('posLabel').textContent = posLabels[cell.dataset.pos] || cell.dataset.pos;
});
// ─── Process ──────────────────────────────────────────────────────────────
btnProcess.addEventListener('click', startProcess);
async function startProcess() {
clearError();
const op = state.currentOp;
// Validation
if (op === 'merge' && state.files.length < 2) {
showError('Для объединения нужно минимум 2 файла.');
return;
}
const needsOne = ['split','pages','watermark','pagenums','compress','convert','security'];
if (needsOne.includes(op) && !state.selectedFileId) {
showError('Выберите файл для обработки.');
return;
}
if (op === 'watermark' && !document.getElementById('wmText').value.trim()) {
showError('Введите текст водяного знака.');
return;
}
if (op === 'pages' && state.pagesSubOp === 'delete' && getSelectedPages('deletePageGrid').length === 0) {
showError('Выберите страницы для удаления');
return;
}
if (op === 'security' && state.securitySub === 'protect' && !document.getElementById('protectPassword').value) {
showError('Введите пароль для защиты файла.');
return;
}
if (op === 'convert' && state.convertSub === 'fromimg' && state.imgFiles.length === 0) {
showError('Добавьте изображения для конвертации в PDF.');
return;
}
actionWrap.style.display = 'none';
progressWrap.style.display = 'block';
stepResult.style.display = 'none';
setProgress(10, 'Отправка запроса...');
try {
const result = await sendRequest(op);
onProcessDone(op, result);
} catch (err) {
showProcessError(err.message || 'Ошибка обработки');
}
}
async function sendRequest(op) {
let url, body, isMultipart = false;
const fileId = state.selectedFileId;
switch (op) {
case 'merge':
url = '/pdf/merge';
body = JSON.stringify({ fileIds: state.files.map(f => f.id) });
break;
case 'split':
url = '/pdf/split';
body = JSON.stringify({ fileId, ranges: document.getElementById('splitRanges').value.trim() });
break;
case 'pages':
if (state.pagesSubOp === 'rotate') {
url = '/pdf/rotate';
body = JSON.stringify({ fileId, pages: (getSelectedPages('rotatePageGrid').length ? getSelectedPages('rotatePageGrid').join(',') : 'all'), angle: state.rotateAngle });
} else if (state.pagesSubOp === 'delete') {
url = '/pdf/delete';
body = JSON.stringify({ fileId, pages: getSelectedPages('deletePageGrid').join(',') });
} else {
url = '/pdf/reorder';
body = JSON.stringify({ fileId, order: Array.from(document.getElementById('reorderPageGrid').querySelectorAll('.page-thumb')).map(t => parseInt(t.dataset.page)) });
}
break;
case 'watermark':
url = '/pdf/watermark';
body = JSON.stringify({
fileId,
text: document.getElementById('wmText').value.trim(),
fontSize: parseInt(document.getElementById('wmFontSize').value, 10),
opacity: parseFloat(document.getElementById('wmOpacity').value),
color: state.wmColor,
});
break;
case 'pagenums':
url = '/pdf/pagenumbers';
body = JSON.stringify({
fileId,
position: state.pageNumPosition,
startFrom: parseInt(document.getElementById('pageNumStart').value, 10) || 1,
});
break;
case 'compress':
url = '/pdf/compress';
body = JSON.stringify({ fileId, quality: state.compressQuality });
break;
case 'convert':
if (state.convertSub === 'toimg') {
url = '/pdf/toImages';
body = JSON.stringify({ fileId, format: state.imgFormat, dpi: parseInt(state.imgDpi, 10) });
} else {
url = '/pdf/fromImages';
isMultipart = true;
const fd = new FormData();
state.imgFiles.forEach(f => fd.append('files', f));
body = fd;
}
break;
case 'security':
if (state.securitySub === 'protect') {
url = '/pdf/protect';
body = JSON.stringify({ fileId, password: document.getElementById('protectPassword').value });
} else {
url = '/pdf/extract-text';
body = JSON.stringify({ fileId });
}
break;
default:
throw new Error('Неизвестная операция: ' + op);
}
setProgress(40, 'Обработка на сервере...');
const headers = isMultipart ? {} : { 'Content-Type': 'application/json' };
const res = await fetch(url, { method: 'POST', headers, body });
setProgress(90, 'Получение результата...');
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Ошибка сервера ' + res.status }));
throw new Error(err.error || 'Ошибка сервера ' + res.status);
}
return res.json();
}
function setProgress(pct, text) {
progressFill.style.width = pct + '%';
progressPercent.textContent = pct + '%';
progressStatus.textContent = text;
}
function onProcessDone(op, data) {
setProgress(100, 'Готово!');
// Reset result UI
document.getElementById('resultTextWrap').style.display = 'none';
downloadLink.style.display = 'none';
document.getElementById('resultSavings').style.display = 'none';
document.getElementById('resultSavingsLabel').style.display = 'none';
// Fill stats
if (data.size) {
document.getElementById('resultSize').textContent = formatSize(data.size);
} else {
document.getElementById('resultSize').textContent = '—';
}
if (data.savings) {
const savingsEl = document.getElementById('resultSavings');
const savingsLbl = document.getElementById('resultSavingsLabel');
savingsEl.textContent = '' + data.savings + '%';
savingsEl.style.display = '';
savingsLbl.style.display = '';
}
// Extract text: show textarea
if (op === 'security' && state.securitySub === 'extract') {
document.getElementById('resultTextWrap').style.display = 'block';
document.getElementById('resultText').value = data.text || '';
}
// Download link
if (data.downloadUrl) {
downloadLink.href = data.downloadUrl;
downloadLink.setAttribute('download', '');
downloadLink.style.display = '';
}
stepResult.style.display = 'block';
stepResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function showProcessError(msg) {
progressWrap.style.display = 'none';
actionWrap.style.display = 'block';
showError(msg);
}
// ─── Copy extracted text ──────────────────────────────────────────────────
document.getElementById('btnCopyText').addEventListener('click', () => {
const ta = document.getElementById('resultText');
ta.select();
try {
navigator.clipboard.writeText(ta.value).catch(() => document.execCommand('copy'));
} catch {
document.execCommand('copy');
}
const btn = document.getElementById('btnCopyText');
const orig = btn.textContent;
btn.textContent = 'Скопировано!';
setTimeout(() => { btn.textContent = orig; }, 2000);
});
// ─── Back button ──────────────────────────────────────────────────────────
btnBack.addEventListener('click', () => {
stepResult.style.display = 'none';
actionWrap.style.display = 'block';
progressWrap.style.display = 'none';
setProgress(0, 'Обработка...');
clearError();
});
// ─── Reset to upload ──────────────────────────────────────────────────────
function resetToUpload() {
state.files = [];
state.selectedFileId = null;
state.imgFiles = [];
stepConfig.style.display = 'none';
stepResult.style.display = 'none';
stepUpload.style.display = 'block';
actionWrap.style.display = 'block';
progressWrap.style.display = 'none';
restoreDropZone();
clearError();
fileInput.value = '';
fileInputMore.value = '';
}
// ─── Security sub-op default ──────────────────────────────────────────────
state.securitySub = 'protect';
// ─── Page Grid Thumbnails ─────────────────────────────────────────────────
function renderPageGrid(gridId, hintId, fileId, pages, mode) {
const grid = document.getElementById(gridId);
const hint = document.getElementById(hintId);
if (!grid) return;
if (!fileId || pages < 1) {
grid.innerHTML = '';
if (hint) hint.textContent = '';
return;
}
if (hint) hint.textContent = '';
grid.innerHTML = '';
for (let i = 1; i <= Math.min(pages, 100); i++) {
const thumb = document.createElement('div');
thumb.className = 'page-thumb';
thumb.dataset.page = i;
thumb.innerHTML = '<img src="/pdf/preview/' + fileId + '/' + i + '" alt="' + i + '" loading="lazy"><div class="page-thumb-num">' + i + '</div><div class="page-thumb-check"><svg fill="none" stroke="#fff" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg></div>';
if (mode === 'delete') {
thumb.addEventListener('click', () => {
thumb.classList.toggle('selected');
thumb.classList.toggle('marked-delete');
updateDeleteCount();
});
} else if (mode === 'rotate') {
thumb.addEventListener('click', () => {
thumb.classList.toggle('selected');
// Visual rotation preview
if (thumb.classList.contains('selected')) {
const angle = state.rotateAngle || 90;
thumb.classList.add('rotate-' + angle);
} else {
thumb.classList.remove('rotate-90', 'rotate-180', 'rotate-270');
}
updateRotateCount();
});
} else if (mode === 'reorder') {
// Drag and drop
thumb.draggable = true;
thumb.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', i);
thumb.classList.add('dragging');
});
thumb.addEventListener('dragend', () => thumb.classList.remove('dragging'));
thumb.addEventListener('dragover', e => { e.preventDefault(); thumb.classList.add('drag-over'); });
thumb.addEventListener('dragleave', () => thumb.classList.remove('drag-over'));
thumb.addEventListener('drop', e => {
e.preventDefault();
thumb.classList.remove('drag-over');
const fromPage = e.dataTransfer.getData('text/plain');
const fromEl = grid.querySelector('[data-page="' + fromPage + '"]');
if (fromEl && fromEl !== thumb) {
// Swap positions in DOM
const parent = thumb.parentNode;
const thumbNext = thumb.nextSibling;
if (fromEl.nextSibling === thumb) {
parent.insertBefore(thumb, fromEl);
} else {
parent.insertBefore(fromEl, thumb);
parent.insertBefore(thumb, thumbNext);
}
// Update visual numbers
Array.from(grid.children).forEach((el, idx) => {
el.querySelector('.page-thumb-num').textContent = idx + 1;
});
}
});
}
grid.appendChild(thumb);
}
}
function updateDeleteCount() {
const count = getSelectedPages('deletePageGrid').length;
const el = document.getElementById('deleteCount');
const btn = document.getElementById('btnDeleteSelected');
if (el) el.textContent = count;
if (btn) btn.disabled = count === 0;
}
function updateRotateCount() {
const count = getSelectedPages('rotatePageGrid').length;
const el = document.getElementById('rotateCount');
const btn = document.getElementById('btnRotateSelected');
if (el) el.textContent = count;
if (btn) btn.disabled = count === 0;
}
function getSelectedPages(gridId) {
const grid = document.getElementById(gridId);
if (!grid) return [];
return Array.from(grid.querySelectorAll('.page-thumb.selected')).map(t => parseInt(t.dataset.page));
}
// Direct execute for page operations (from grid action buttons)
window.executeDeletePages = async function() {
const pages = getSelectedPages('deletePageGrid');
if (!pages.length) { showError('Выберите страницы'); return; }
const fileId = state.selectedFileId;
if (!fileId) { showError('Выберите файл'); return; }
clearError();
try {
const res = await fetch('/pdf/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ fileId, pages: pages.join(',') }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
showResult(data);
} catch(e) { showError(e.message); }
};
window.executeRotatePages = async function() {
const pages = getSelectedPages('rotatePageGrid');
const fileId = state.selectedFileId;
if (!fileId) { showError('Выберите файл'); return; }
clearError();
try {
const res = await fetch('/pdf/rotate', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ fileId, pages: pages.length ? pages.join(',') : 'all', angle: state.rotateAngle || 90 }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
showResult(data);
} catch(e) { showError(e.message); }
};
window.executeReorderPages = async function() {
const grid = document.getElementById('reorderPageGrid');
if (!grid) return;
const order = Array.from(grid.querySelectorAll('.page-thumb')).map(t => parseInt(t.dataset.page));
const fileId = state.selectedFileId;
if (!fileId) { showError('Выберите файл'); return; }
clearError();
try {
const res = await fetch('/pdf/reorder', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ fileId, order }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
showResult(data);
} catch(e) { showError(e.message); }
};
function showResult(data) {
if (data.downloadUrl) {
document.getElementById('downloadLink').href = data.downloadUrl;
document.getElementById('downloadLink').style.display = '';
if (data.size) document.getElementById('resultSize').textContent = formatSize(data.size);
stepConfig.style.display = 'none';
stepResult.style.display = 'block';
}
}
})();
</script>
</body>
</html>