1712 lines
74 KiB
HTML
1712 lines
74 KiB
HTML
<!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-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="page-grid-hint" id="rotateHint">Загрузите файл для просмотра страниц</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="page-grid-hint" id="deleteHint">Загрузите файл для просмотра страниц</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="page-grid-hint" id="reorderHint">Загрузите файл для просмотра страниц</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);
|
||
});
|
||
});
|
||
}
|
||
|
||
function escHtml(str) {
|
||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ─── 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 === '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 = mode === 'delete' ? 'Нажмите на страницы для выбора' : mode === 'rotate' ? 'Нажмите для выбора страниц' : 'Перетаскивайте для изменения порядка';
|
||
|
||
grid.innerHTML = '';
|
||
for (let i = 1; i <= pages; i++) {
|
||
const thumb = document.createElement('div');
|
||
thumb.className = 'page-thumb';
|
||
thumb.dataset.page = i;
|
||
thumb.innerHTML = `<img src="/pdf/preview/${fileId}/${i}" alt="Page ${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' || mode === 'rotate') {
|
||
thumb.addEventListener('click', () => {
|
||
thumb.classList.toggle('selected');
|
||
});
|
||
}
|
||
grid.appendChild(thumb);
|
||
}
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
// Re-render grids when file selection changes
|
||
const origRenderSingle = renderSingleFileSelectors;
|
||
renderSingleFileSelectors = function() {
|
||
origRenderSingle();
|
||
const sel = state.files.find(f => f.id === state.selectedFileId);
|
||
if (sel) {
|
||
renderPageGrid('deletePageGrid', 'deleteHint', sel.id, sel.pages, 'delete');
|
||
renderPageGrid('rotatePageGrid', 'rotateHint', sel.id, sel.pages, 'rotate');
|
||
renderPageGrid('reorderPageGrid', 'reorderHint', sel.id, sel.pages, 'reorder');
|
||
}
|
||
};
|
||
|
||
})();
|
||
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|