Files
bentopdf/src/js/logic/pdf-layers-page.ts

464 lines
14 KiB
TypeScript

import { showLoader, hideLoader, showAlert } from '../ui.js';
import { t } from '../i18n/i18n';
import {
downloadFile,
readFileAsArrayBuffer,
formatBytes,
getPDFDocument,
} from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
interface LayerData {
number: number;
xref: number;
text: string;
on: boolean;
locked: boolean;
depth: number;
parentXref: number;
displayOrder: number;
}
let currentFile: File | null = null;
let currentDoc: any = null;
const layersMap = new Map<number, LayerData>();
let nextDisplayOrder = 0;
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const processBtnContainer = document.getElementById('process-btn-container');
const fileDisplayArea = document.getElementById('file-display-area');
const layersContainer = document.getElementById('layers-container');
const layersList = document.getElementById('layers-list');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !processBtnContainer || !processBtn) return;
if (currentFile) {
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = currentFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(currentFile.size)}${t('common.loadingPageCount')}`;
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className =
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
resetState();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
try {
const arrayBuffer = await readFileAsArrayBuffer(currentFile);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(currentFile.size)}${pdfDoc.numPages} pages`;
} catch (error) {
console.error('Error loading PDF:', error);
metaSpan.textContent = `${formatBytes(currentFile.size)} • Could not load page count`;
}
createIcons({ icons });
processBtnContainer.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
processBtnContainer.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
currentFile = null;
currentDoc = null;
layersMap.clear();
nextDisplayOrder = 0;
if (dropZone) dropZone.style.display = 'flex';
if (layersContainer) layersContainer.classList.add('hidden');
updateUI();
};
const promptForInput = (
title: string,
message: string,
defaultValue: string = ''
): Promise<string | null> => {
return new Promise((resolve) => {
const modal = document.getElementById('input-modal');
const titleEl = document.getElementById('input-title');
const messageEl = document.getElementById('input-message');
const inputEl = document.getElementById(
'input-value'
) as HTMLInputElement;
const confirmBtn = document.getElementById('input-confirm');
const cancelBtn = document.getElementById('input-cancel');
if (
!modal ||
!titleEl ||
!messageEl ||
!inputEl ||
!confirmBtn ||
!cancelBtn
) {
console.error('Input modal elements not found');
resolve(null);
return;
}
titleEl.textContent = title;
messageEl.textContent = message;
inputEl.value = defaultValue;
const closeModal = () => {
modal.classList.add('hidden');
confirmBtn.onclick = null;
cancelBtn.onclick = null;
inputEl.onkeydown = null;
};
const confirm = () => {
const val = inputEl.value.trim();
closeModal();
resolve(val);
};
const cancel = () => {
closeModal();
resolve(null);
};
confirmBtn.onclick = confirm;
cancelBtn.onclick = cancel;
inputEl.onkeydown = (e) => {
if (e.key === 'Enter') confirm();
if (e.key === 'Escape') cancel();
};
modal.classList.remove('hidden');
inputEl.focus();
});
};
const renderLayers = () => {
if (!layersList) return;
const layersArray = Array.from(layersMap.values());
if (layersArray.length === 0) {
layersList.innerHTML = `
<div class="layers-empty">
<p>This PDF has no layers (OCG).</p>
<p>Add a new layer to get started!</p>
</div>
`;
return;
}
// Sort layers by displayOrder
const sortedLayers = layersArray.sort(
(a, b) => a.displayOrder - b.displayOrder
);
layersList.innerHTML = sortedLayers
.map(
(layer: LayerData) => `
<div class="layer-item" data-number="${layer.number}" style="padding-left: ${layer.depth * 24 + 8}px;">
<label class="layer-toggle">
<input type="checkbox" ${layer.on ? 'checked' : ''} ${layer.locked ? 'disabled' : ''} data-xref="${layer.xref}" />
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${layer.text || `Layer ${layer.number}`}</span>
${layer.locked ? '<span class="layer-locked">🔒</span>' : ''}
</label>
<div class="layer-actions">
${!layer.locked ? `<button class="layer-add-child" data-xref="${layer.xref}" title="Add child layer">+</button>` : ''}
${!layer.locked ? `<button class="layer-delete" data-xref="${layer.xref}" title="Delete layer">✕</button>` : ''}
</div>
</div>
`
)
.join('');
// Attach toggle handlers
layersList
.querySelectorAll('input[type="checkbox"]')
.forEach((checkbox) => {
checkbox.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
const xref = parseInt(target.dataset.xref || '0');
const isOn = target.checked;
try {
currentDoc.setLayerVisibility(xref, isOn);
const layer = Array.from(layersMap.values()).find(
(l) => l.xref === xref
);
if (layer) {
layer.on = isOn;
}
} catch (err) {
console.error('Failed to set layer visibility:', err);
target.checked = !isOn;
showAlert('Error', 'Failed to toggle layer visibility');
}
});
});
// Attach delete handlers
layersList.querySelectorAll('.layer-delete').forEach((btn) => {
btn.addEventListener('click', (e) => {
const target = e.target as HTMLButtonElement;
const xref = parseInt(target.dataset.xref || '0');
const layer = Array.from(layersMap.values()).find(
(l) => l.xref === xref
);
if (!layer) {
showAlert('Error', 'Layer not found');
return;
}
try {
currentDoc.deleteOCG(layer.number);
layersMap.delete(layer.number);
renderLayers();
} catch (err) {
console.error('Failed to delete layer:', err);
showAlert('Error', 'Failed to delete layer');
}
});
});
layersList.querySelectorAll('.layer-add-child').forEach((btn) => {
btn.addEventListener('click', async (e) => {
const target = e.target as HTMLButtonElement;
const parentXref = parseInt(target.dataset.xref || '0');
const parentLayer = Array.from(layersMap.values()).find(
(l) => l.xref === parentXref
);
const childName = await promptForInput(
'Add Child Layer',
`Enter name for child layer under "${parentLayer?.text || 'Layer'}":`
);
if (!childName || !childName.trim()) return;
try {
const childXref = currentDoc.addOCGWithParent(
childName.trim(),
parentXref
);
const parentDisplayOrder = parentLayer?.displayOrder || 0;
layersMap.forEach((l) => {
if (l.displayOrder > parentDisplayOrder) {
l.displayOrder += 1;
}
});
layersMap.set(childXref, {
number: childXref,
xref: childXref,
text: childName.trim(),
on: true,
locked: false,
depth: (parentLayer?.depth || 0) + 1,
parentXref: parentXref,
displayOrder: parentDisplayOrder + 1,
});
renderLayers();
} catch (err) {
console.error('Failed to add child layer:', err);
showAlert('Error', 'Failed to add child layer');
}
});
});
};
const loadLayers = async () => {
if (!currentFile) {
showAlert('No File', 'Please select a PDF file.');
return;
}
try {
showLoader('Loading engine...');
const pymupdf = await loadPyMuPDF();
showLoader(`Loading layers from ${currentFile.name}...`);
currentDoc = await pymupdf.open(currentFile);
showLoader('Reading layer configuration...');
const existingLayers = currentDoc.getLayerConfig();
// Reset and populate layers map
layersMap.clear();
nextDisplayOrder = 0;
existingLayers.forEach((layer: any) => {
layersMap.set(layer.number, {
number: layer.number,
xref: layer.xref ?? layer.number,
text: layer.text,
on: layer.on,
locked: layer.locked,
depth: layer.depth ?? 0,
parentXref: layer.parentXref ?? 0,
displayOrder: layer.displayOrder ?? nextDisplayOrder++,
});
if ((layer.displayOrder ?? -1) >= nextDisplayOrder) {
nextDisplayOrder = layer.displayOrder + 1;
}
});
hideLoader();
// Hide upload zone, show layers container
if (dropZone) dropZone.style.display = 'none';
if (processBtnContainer) processBtnContainer.classList.add('hidden');
if (layersContainer) layersContainer.classList.remove('hidden');
renderLayers();
setupLayerHandlers();
} catch (error: any) {
hideLoader();
showAlert('Error', error.message || 'Failed to load PDF layers');
console.error('Layers error:', error);
}
};
const setupLayerHandlers = () => {
const addLayerBtn = document.getElementById('add-layer-btn');
const newLayerInput = document.getElementById(
'new-layer-name'
) as HTMLInputElement;
const saveLayersBtn = document.getElementById('save-layers-btn');
if (addLayerBtn && newLayerInput) {
addLayerBtn.onclick = () => {
const name = newLayerInput.value.trim();
if (!name) {
showAlert('Invalid Name', 'Please enter a layer name');
return;
}
try {
const xref = currentDoc.addOCG(name);
newLayerInput.value = '';
const newDisplayOrder = nextDisplayOrder++;
layersMap.set(xref, {
number: xref,
xref: xref,
text: name,
on: true,
locked: false,
depth: 0,
parentXref: 0,
displayOrder: newDisplayOrder,
});
renderLayers();
} catch (err: any) {
showAlert('Error', 'Failed to add layer: ' + err.message);
}
};
}
if (saveLayersBtn) {
saveLayersBtn.onclick = () => {
try {
showLoader('Saving PDF with layer changes...');
const pdfBytes = currentDoc.save();
const blob = new Blob([new Uint8Array(pdfBytes)], {
type: 'application/pdf',
});
const outName =
currentFile!.name.replace(/\.pdf$/i, '') + '_layers.pdf';
downloadFile(blob, outName);
hideLoader();
resetState();
showAlert('Success', 'PDF with layer changes saved!', 'success');
} catch (err: any) {
hideLoader();
showAlert('Error', 'Failed to save PDF: ' + err.message);
}
};
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const file = files[0];
if (
file.type === 'application/pdf' ||
file.name.toLowerCase().endsWith('.pdf')
) {
currentFile = file;
updateUI();
} else {
showAlert('Invalid File', 'Please select a PDF file.');
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (processBtn) {
processBtn.addEventListener('click', loadLayers);
}
});