// @ts-nocheck // TODO: @ALAM - remove ts-nocheck and fix types later, possibly convert this into an npm package import { PDFDocument, PDFName, PDFString, PDFNumber, PDFArray } from 'pdf-lib'; import * as pdfjsLib from 'pdfjs-dist'; import Sortable from 'sortablejs'; import { createIcons, icons } from 'lucide'; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url ).toString(); const modalContainer = document.getElementById('modal-container'); // Destination picking state let isPickingDestination = false; let currentPickingCallback = null; let destinationMarker = null; let savedModalOverlay = null; let savedModal = null; let currentViewport = null; let currentZoom = 1.0; function showInputModal(title, fields = [], defaultValues = {}) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'active-modal-overlay'; const modal = document.createElement('div'); modal.className = 'modal-content'; modal.id = 'active-modal'; const fieldsHTML = fields .map((field) => { if (field.type === 'text') { return `
`; } else if (field.type === 'select') { return `
${field.name === 'color' ? '' : ''}
`; } else if (field.type === 'destination') { const hasDestination = defaultValues.destX !== null && defaultValues.destX !== undefined; return `

Click the button above, then click on the PDF where you want the bookmark to jump to

`; } else if (field.type === 'preview') { return `
`; } return ''; }) .join(''); modal.innerHTML = `

${title}

${fieldsHTML}
`; overlay.appendChild(modal); modalContainer.appendChild(overlay); function updatePreview() { const previewText = modal.querySelector('#preview-text'); if (previewText) { const titleInput = modal.querySelector('#modal-title'); const colorSelect = modal.querySelector('#modal-color'); const styleSelect = modal.querySelector('#modal-style'); const colorPicker = modal.querySelector('#modal-color-picker'); const title = titleInput ? titleInput.value : 'Preview Text'; const color = colorSelect ? colorSelect.value : ''; const style = styleSelect ? styleSelect.value : ''; previewText.textContent = title || 'Preview Text'; const colorMap = { red: '#dc2626', blue: '#2563eb', green: '#16a34a', yellow: '#ca8a04', purple: '#9333ea', }; // Handle custom color if (color === 'custom' && colorPicker) { previewText.style.color = colorPicker.value; } else { previewText.style.color = colorMap[color] || '#000'; } previewText.style.fontWeight = style === 'bold' || style === 'bold-italic' ? 'bold' : 'normal'; previewText.style.fontStyle = style === 'italic' || style === 'bold-italic' ? 'italic' : 'normal'; } } const titleInput = modal.querySelector('#modal-title'); const colorSelect = modal.querySelector('#modal-color'); const styleSelect = modal.querySelector('#modal-style'); if (titleInput) titleInput.addEventListener('input', updatePreview); if (colorSelect) { colorSelect.addEventListener('change', (e) => { const colorPicker = modal.querySelector('#modal-color-picker'); if (e.target.value === 'custom' && colorPicker) { colorPicker.classList.remove('hidden'); setTimeout(() => colorPicker.click(), 100); } else if (colorPicker) { colorPicker.classList.add('hidden'); } updatePreview(); }); } const colorPicker = modal.querySelector('#modal-color-picker'); if (colorPicker) { colorPicker.addEventListener('input', updatePreview); } if (styleSelect) styleSelect.addEventListener('change', updatePreview); // Destination toggle handler const useDestCheckbox = modal.querySelector('#modal-use-destination'); const destControls = modal.querySelector('#destination-controls'); const pickDestBtn = modal.querySelector('#modal-pick-destination'); if (useDestCheckbox && destControls) { useDestCheckbox.addEventListener('change', (e) => { destControls.classList.toggle('hidden', !e.target.checked); }); // Populate existing destination values if (defaultValues.destX !== null && defaultValues.destX !== undefined) { const destPageInput = modal.querySelector('#modal-dest-page'); const destXInput = modal.querySelector('#modal-dest-x'); const destYInput = modal.querySelector('#modal-dest-y'); const destZoomSelect = modal.querySelector('#modal-dest-zoom'); if (destPageInput && defaultValues.destPage !== undefined) { destPageInput.value = defaultValues.destPage; } if (destXInput && defaultValues.destX !== null) { destXInput.value = Math.round(defaultValues.destX); } if (destYInput && defaultValues.destY !== null) { destYInput.value = Math.round(defaultValues.destY); } if (destZoomSelect && defaultValues.zoom !== null) { destZoomSelect.value = defaultValues.zoom || ''; } } } // Visual destination picker if (pickDestBtn) { pickDestBtn.addEventListener('click', () => { // Store modal references savedModalOverlay = overlay; savedModal = modal; // Hide modal completely overlay.style.display = 'none'; startDestinationPicking((page, pdfX, pdfY) => { const destPageInput = modal.querySelector('#modal-dest-page'); const destXInput = modal.querySelector('#modal-dest-x'); const destYInput = modal.querySelector('#modal-dest-y'); if (destPageInput) destPageInput.value = page; if (destXInput) destXInput.value = Math.round(pdfX); if (destYInput) destYInput.value = Math.round(pdfY); // Restore modal overlay.style.display = ''; // Update preview to show the destination after a short delay to ensure modal is visible setTimeout(() => { updateDestinationPreview(); }, 100); }); }); } // Add validation for page input const destPageInput = modal.querySelector('#modal-dest-page'); if (destPageInput) { destPageInput.addEventListener('input', (e) => { const value = parseInt(e.target.value); const maxPages = parseInt(e.target.max) || 1; if (isNaN(value) || value < 1) { e.target.value = 1; } else if (value > maxPages) { e.target.value = maxPages; } else { e.target.value = Math.floor(value); } updateDestinationPreview(); }); destPageInput.addEventListener('blur', (e) => { const value = parseInt(e.target.value); const maxPages = parseInt(e.target.max) || 1; if (isNaN(value) || value < 1) { e.target.value = 1; } else if (value > maxPages) { e.target.value = maxPages; } else { e.target.value = Math.floor(value); } updateDestinationPreview(); }); } // Function to update destination preview function updateDestinationPreview() { if (!pdfJsDoc) return; const destPageInput = modal.querySelector('#modal-dest-page'); const destXInput = modal.querySelector('#modal-dest-x'); const destYInput = modal.querySelector('#modal-dest-y'); const destZoomSelect = modal.querySelector('#modal-dest-zoom'); const pageNum = destPageInput ? parseInt(destPageInput.value) : currentPage; const x = destXInput ? parseFloat(destXInput.value) : null; const y = destYInput ? parseFloat(destYInput.value) : null; const zoom = destZoomSelect ? destZoomSelect.value : null; if (pageNum >= 1 && pageNum <= pdfJsDoc.numPages) { // Render the page with zoom if specified renderPageWithDestination(pageNum, x, y, zoom); } } // Add listeners for X, Y, and zoom changes const destXInput = modal.querySelector('#modal-dest-x'); const destYInput = modal.querySelector('#modal-dest-y'); const destZoomSelect = modal.querySelector('#modal-dest-zoom'); if (destXInput) { destXInput.addEventListener('input', updateDestinationPreview); } if (destYInput) { destYInput.addEventListener('input', updateDestinationPreview); } if (destZoomSelect) { destZoomSelect.addEventListener('change', updateDestinationPreview); } updatePreview(); modal.querySelector('#modal-cancel').addEventListener('click', () => { cancelDestinationPicking(); modalContainer.removeChild(overlay); resolve(null); }); modal.querySelector('#modal-confirm').addEventListener('click', () => { const result = {}; fields.forEach((field) => { if (field.type !== 'preview' && field.type !== 'destination') { const input = modal.querySelector(`#modal-${field.name}`); result[field.name] = input.value; } }); // Handle custom color const colorSelect = modal.querySelector('#modal-color'); const colorPicker = modal.querySelector('#modal-color-picker'); if (colorSelect && colorSelect.value === 'custom' && colorPicker) { result.color = colorPicker.value; } // Handle destination const useDestCheckbox = modal.querySelector('#modal-use-destination'); if (useDestCheckbox && useDestCheckbox.checked) { const destPageInput = modal.querySelector('#modal-dest-page'); const destXInput = modal.querySelector('#modal-dest-x'); const destYInput = modal.querySelector('#modal-dest-y'); const destZoomSelect = modal.querySelector('#modal-dest-zoom'); result.destPage = destPageInput ? parseInt(destPageInput.value) : null; result.destX = destXInput ? parseFloat(destXInput.value) : null; result.destY = destYInput ? parseFloat(destYInput.value) : null; result.zoom = destZoomSelect && destZoomSelect.value ? destZoomSelect.value : null; } else { result.destPage = null; result.destX = null; result.destY = null; result.zoom = null; } cancelDestinationPicking(); modalContainer.removeChild(overlay); resolve(result); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) { cancelDestinationPicking(); modalContainer.removeChild(overlay); resolve(null); } }); setTimeout(() => { const firstInput = modal.querySelector('input, select'); if (firstInput) firstInput.focus(); }, 0); createIcons({ icons }); }); } // Destination picking functions function startDestinationPicking(callback) { isPickingDestination = true; currentPickingCallback = callback; const canvasWrapper = document.getElementById('pdf-canvas-wrapper'); const pickingBanner = document.getElementById('picking-mode-banner'); canvasWrapper.classList.add('picking-mode'); pickingBanner.classList.remove('hidden'); // Switch to viewer on mobile if (window.innerWidth < 1024) { document.getElementById('show-viewer-btn').click(); } createIcons({ icons }); } function cancelDestinationPicking() { isPickingDestination = false; currentPickingCallback = null; const canvasWrapper = document.getElementById('pdf-canvas-wrapper'); const pickingBanner = document.getElementById('picking-mode-banner'); canvasWrapper.classList.remove('picking-mode'); pickingBanner.classList.add('hidden'); // Remove any existing marker if (destinationMarker) { destinationMarker.remove(); destinationMarker = null; } // Remove coordinate display const coordDisplay = document.getElementById('destination-coord-display'); if (coordDisplay) { coordDisplay.remove(); } // Restore modal if it was hidden if (savedModalOverlay) { savedModalOverlay.style.display = ''; savedModalOverlay = null; savedModal = null; } } // Setup canvas click handler for destination picking document.addEventListener('DOMContentLoaded', () => { const canvas = document.getElementById('pdf-canvas'); const canvasWrapper = document.getElementById('pdf-canvas-wrapper'); const cancelPickingBtn = document.getElementById('cancel-picking-btn'); // Coordinate tooltip let coordTooltip = null; canvasWrapper.addEventListener('mousemove', (e) => { if (!isPickingDestination) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Create or update tooltip if (!coordTooltip) { coordTooltip = document.createElement('div'); coordTooltip.className = 'coordinate-tooltip'; canvasWrapper.appendChild(coordTooltip); } coordTooltip.textContent = `X: ${Math.round(x)}, Y: ${Math.round(y)}`; coordTooltip.style.left = e.clientX - rect.left + 15 + 'px'; coordTooltip.style.top = e.clientY - rect.top + 15 + 'px'; }); canvasWrapper.addEventListener('mouseleave', () => { if (coordTooltip) { coordTooltip.remove(); coordTooltip = null; } }); canvas.addEventListener('click', async (e) => { if (!isPickingDestination || !currentPickingCallback) return; const rect = canvas.getBoundingClientRect(); const canvasX = e.clientX - rect.left; const canvasY = e.clientY - rect.top; // Get viewport for coordinate conversion let viewport = currentViewport; if (!viewport) { const page = await pdfJsDoc.getPage(currentPage); viewport = page.getViewport({ scale: currentZoom }); } // Convert canvas pixel coordinates to PDF coordinates // The canvas CSS size matches viewport dimensions, so coordinates map directly // PDF uses bottom-left origin, canvas uses top-left const scaleX = viewport.width / rect.width; const scaleY = viewport.height / rect.height; const pdfX = canvasX * scaleX; const pdfY = viewport.height - (canvasY * scaleY); // Remove old marker and coordinate display if (destinationMarker) { destinationMarker.remove(); } const oldCoordDisplay = document.getElementById('destination-coord-display'); if (oldCoordDisplay) { oldCoordDisplay.remove(); } // Create visual marker destinationMarker = document.createElement('div'); destinationMarker.className = 'destination-marker'; destinationMarker.innerHTML = ` `; const canvasRect = canvas.getBoundingClientRect(); const wrapperRect = canvasWrapper.getBoundingClientRect(); destinationMarker.style.position = 'absolute'; destinationMarker.style.left = (canvasX + canvasRect.left - wrapperRect.left) + 'px'; destinationMarker.style.top = (canvasY + canvasRect.top - wrapperRect.top) + 'px'; canvasWrapper.appendChild(destinationMarker); // Create persistent coordinate display const coordDisplay = document.createElement('div'); coordDisplay.id = 'destination-coord-display'; coordDisplay.className = 'absolute bg-blue-500 text-white px-2 py-1 rounded text-xs font-mono z-50 pointer-events-none'; coordDisplay.style.left = (canvasX + canvasRect.left - wrapperRect.left + 20) + 'px'; coordDisplay.style.top = (canvasY + canvasRect.top - wrapperRect.top - 30) + 'px'; coordDisplay.textContent = `X: ${Math.round(pdfX)}, Y: ${Math.round(pdfY)}`; canvasWrapper.appendChild(coordDisplay); // Call callback with PDF coordinates currentPickingCallback(currentPage, pdfX, pdfY); // End picking mode setTimeout(() => { cancelDestinationPicking(); }, 500); }); if (cancelPickingBtn) { cancelPickingBtn.addEventListener('click', () => { cancelDestinationPicking(); }); } }); function showConfirmModal(message) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; const modal = document.createElement('div'); modal.className = 'modal-content'; modal.innerHTML = `

Confirm Action

${message}

`; overlay.appendChild(modal); modalContainer.appendChild(overlay); modal.querySelector('#modal-cancel').addEventListener('click', () => { modalContainer.removeChild(overlay); resolve(false); }); modal.querySelector('#modal-confirm').addEventListener('click', () => { modalContainer.removeChild(overlay); resolve(true); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) { modalContainer.removeChild(overlay); resolve(false); } }); }); } function showAlertModal(title, message) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; const modal = document.createElement('div'); modal.className = 'modal-content'; modal.innerHTML = `

${title}

${message}

`; overlay.appendChild(modal); modalContainer.appendChild(overlay); modal.querySelector('#modal-ok').addEventListener('click', () => { modalContainer.removeChild(overlay); resolve(true); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) { modalContainer.removeChild(overlay); resolve(true); } }); }); } const fileInput = document.getElementById('file-input'); const csvInput = document.getElementById('csv-input'); const jsonInput = document.getElementById('json-input'); const autoExtractCheckbox = document.getElementById('auto-extract-checkbox'); const appEl = document.getElementById('app'); const uploaderEl = document.getElementById('uploader'); const canvas = document.getElementById('pdf-canvas'); const ctx = canvas.getContext('2d'); const pageIndicator = document.getElementById('page-indicator'); const prevPageBtn = document.getElementById('prev-page'); const nextPageBtn = document.getElementById('next-page'); const gotoPageInput = document.getElementById('goto-page'); const gotoBtn = document.getElementById('goto-btn'); const zoomInBtn = document.getElementById('zoom-in-btn'); const zoomOutBtn = document.getElementById('zoom-out-btn'); const zoomFitBtn = document.getElementById('zoom-fit-btn'); const zoomIndicator = document.getElementById('zoom-indicator'); const addTopLevelBtn = document.getElementById('add-top-level-btn'); const titleInput = document.getElementById('bookmark-title'); const treeList = document.getElementById('bookmark-tree-list'); const noBookmarksEl = document.getElementById('no-bookmarks'); const downloadBtn = document.getElementById('download-btn'); const undoBtn = document.getElementById('undo-btn'); const redoBtn = document.getElementById('redo-btn'); const resetBtn = document.getElementById('reset-btn'); const deleteAllBtn = document.getElementById('delete-all-btn'); const searchInput = document.getElementById('search-bookmarks'); const importDropdownBtn = document.getElementById('import-dropdown-btn'); const exportDropdownBtn = document.getElementById('export-dropdown-btn'); const importDropdown = document.getElementById('import-dropdown'); const exportDropdown = document.getElementById('export-dropdown'); const importCsvBtn = document.getElementById('import-csv-btn'); const exportCsvBtn = document.getElementById('export-csv-btn'); const importJsonBtn = document.getElementById('import-json-btn'); const exportJsonBtn = document.getElementById('export-json-btn'); const csvImportHidden = document.getElementById('csv-import-hidden'); const jsonImportHidden = document.getElementById('json-import-hidden'); const extractExistingBtn = document.getElementById('extract-existing-btn'); const currentPageDisplay = document.getElementById('current-page-display'); const filenameDisplay = document.getElementById('filename-display'); const batchModeCheckbox = document.getElementById('batch-mode-checkbox'); const batchOperations = document.getElementById('batch-operations'); const selectedCountDisplay = document.getElementById('selected-count'); const batchColorSelect = document.getElementById('batch-color-select'); const batchStyleSelect = document.getElementById('batch-style-select'); const batchDeleteBtn = document.getElementById('batch-delete-btn'); const selectAllBtn = document.getElementById('select-all-btn'); const deselectAllBtn = document.getElementById('deselect-all-btn'); const expandAllBtn = document.getElementById('expand-all-btn'); const collapseAllBtn = document.getElementById('collapse-all-btn'); const showViewerBtn = document.getElementById('show-viewer-btn'); const showBookmarksBtn = document.getElementById('show-bookmarks-btn'); const viewerSection = document.getElementById('viewer-section'); const bookmarksSection = document.getElementById('bookmarks-section'); // Handle responsive view switching function handleResize() { if (window.innerWidth >= 1024) { viewerSection.classList.remove('hidden'); bookmarksSection.classList.remove('hidden'); showViewerBtn.classList.remove('bg-blue-50', 'text-blue-600'); showBookmarksBtn.classList.remove('bg-blue-50', 'text-blue-600'); } } window.addEventListener('resize', handleResize); showViewerBtn.addEventListener('click', () => { viewerSection.classList.remove('hidden'); bookmarksSection.classList.add('hidden'); showViewerBtn.classList.add('bg-blue-50', 'text-blue-600'); showBookmarksBtn.classList.remove('bg-blue-50', 'text-blue-600'); }); showBookmarksBtn.addEventListener('click', () => { viewerSection.classList.add('hidden'); bookmarksSection.classList.remove('hidden'); showBookmarksBtn.classList.add('bg-blue-50', 'text-blue-600'); showViewerBtn.classList.remove('bg-blue-50', 'text-blue-600'); }); // Dropdown toggles importDropdownBtn.addEventListener('click', (e) => { e.stopPropagation(); importDropdown.classList.toggle('hidden'); exportDropdown.classList.add('hidden'); }); exportDropdownBtn.addEventListener('click', (e) => { e.stopPropagation(); exportDropdown.classList.toggle('hidden'); importDropdown.classList.add('hidden'); }); document.addEventListener('click', () => { importDropdown.classList.add('hidden'); exportDropdown.classList.add('hidden'); }); let pdfLibDoc = null; let pdfJsDoc = null; let currentPage = 1; let originalFileName = ''; let bookmarkTree = []; let history = []; let historyIndex = -1; let searchQuery = ''; let csvBookmarks = null; let jsonBookmarks = null; let batchMode = false; let selectedBookmarks = new Set(); let collapsedNodes = new Set(); const colorClasses = { red: 'bg-red-100 border-red-300', blue: 'bg-blue-100 border-blue-300', green: 'bg-green-100 border-green-300', yellow: 'bg-yellow-100 border-yellow-300', purple: 'bg-purple-100 border-purple-300', }; function saveState() { history = history.slice(0, historyIndex + 1); history.push(JSON.parse(JSON.stringify(bookmarkTree))); historyIndex++; updateUndoRedoButtons(); } function undo() { if (historyIndex > 0) { historyIndex--; bookmarkTree = JSON.parse(JSON.stringify(history[historyIndex])); renderBookmarkTree(); updateUndoRedoButtons(); } } function redo() { if (historyIndex < history.length - 1) { historyIndex++; bookmarkTree = JSON.parse(JSON.stringify(history[historyIndex])); renderBookmarkTree(); updateUndoRedoButtons(); } } function updateUndoRedoButtons() { undoBtn.disabled = historyIndex <= 0; redoBtn.disabled = historyIndex >= history.length - 1; } undoBtn.addEventListener('click', undo); redoBtn.addEventListener('click', redo); // Reset button - goes back to uploader resetBtn.addEventListener('click', async () => { const confirmed = await showConfirmModal( 'Reset and go back to file uploader? All unsaved changes will be lost.' ); if (confirmed) { resetToUploader(); } }); // Delete all bookmarks button deleteAllBtn.addEventListener('click', async () => { if (bookmarkTree.length === 0) { await showAlertModal('Info', 'No bookmarks to delete.'); return; } const confirmed = await showConfirmModal( `Delete all ${bookmarkTree.length} bookmark(s)?` ); if (confirmed) { bookmarkTree = []; selectedBookmarks.clear(); updateSelectedCount(); saveState(); renderBookmarkTree(); } }); function resetToUploader() { pdfLibDoc = null; pdfJsDoc = null; currentPage = 1; originalFileName = ''; bookmarkTree = []; history = []; historyIndex = -1; searchQuery = ''; csvBookmarks = null; jsonBookmarks = null; batchMode = false; selectedBookmarks.clear(); collapsedNodes.clear(); fileInput.value = ''; csvInput.value = ''; jsonInput.value = ''; appEl.classList.add('hidden'); uploaderEl.classList.remove('hidden'); // Reset mobile view viewerSection.classList.remove('hidden'); bookmarksSection.classList.add('hidden'); showViewerBtn.classList.add('bg-blue-50', 'text-blue-600'); showBookmarksBtn.classList.remove('bg-blue-50', 'text-blue-600'); } document.addEventListener('keydown', (e) => { if (e.ctrlKey || e.metaKey) { if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); } else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') { e.preventDefault(); redo(); } } }); batchModeCheckbox.addEventListener('change', (e) => { batchMode = e.target.checked; if (!batchMode) { selectedBookmarks.clear(); updateSelectedCount(); } batchOperations.classList.toggle( 'hidden', !batchMode || selectedBookmarks.size === 0 ); renderBookmarkTree(); }); function updateSelectedCount() { selectedCountDisplay.textContent = selectedBookmarks.size; if (batchMode) { batchOperations.classList.toggle('hidden', selectedBookmarks.size === 0); } } selectAllBtn.addEventListener('click', () => { const getAllIds = (nodes) => { let ids = []; nodes.forEach((node) => { ids.push(node.id); if (node.children.length > 0) { ids = ids.concat(getAllIds(node.children)); } }); return ids; }; getAllIds(bookmarkTree).forEach((id) => selectedBookmarks.add(id)); updateSelectedCount(); renderBookmarkTree(); }); deselectAllBtn.addEventListener('click', () => { selectedBookmarks.clear(); updateSelectedCount(); renderBookmarkTree(); }); batchColorSelect.addEventListener('change', (e) => { if (e.target.value && selectedBookmarks.size > 0) { const color = e.target.value === 'null' ? null : e.target.value; applyToSelected((node) => (node.color = color)); e.target.value = ''; } }); batchStyleSelect.addEventListener('change', (e) => { if (e.target.value && selectedBookmarks.size > 0) { const style = e.target.value === 'null' ? null : e.target.value; applyToSelected((node) => (node.style = style)); e.target.value = ''; } }); batchDeleteBtn.addEventListener('click', async () => { if (selectedBookmarks.size === 0) return; const confirmed = await showConfirmModal( `Delete ${selectedBookmarks.size} bookmark(s)?` ); if (!confirmed) return; const remove = (nodes) => { return nodes.filter((node) => { if (selectedBookmarks.has(node.id)) return false; node.children = remove(node.children); return true; }); }; bookmarkTree = remove(bookmarkTree); selectedBookmarks.clear(); updateSelectedCount(); saveState(); renderBookmarkTree(); }); function applyToSelected(fn) { const update = (nodes) => { return nodes.map((node) => { if (selectedBookmarks.has(node.id)) { fn(node); } node.children = update(node.children); return node; }); }; bookmarkTree = update(bookmarkTree); saveState(); renderBookmarkTree(); } expandAllBtn.addEventListener('click', () => { collapsedNodes.clear(); renderBookmarkTree(); }); collapseAllBtn.addEventListener('click', () => { const collapseAll = (nodes) => { nodes.forEach((node) => { if (node.children.length > 0) { collapsedNodes.add(node.id); collapseAll(node.children); } }); }; collapseAll(bookmarkTree); renderBookmarkTree(); }); fileInput.addEventListener('change', loadPDF); async function loadPDF(e) { const file = e ? e.target.files[0] : fileInput.files[0]; if (!file) return; originalFileName = file.name.replace('.pdf', ''); filenameDisplay.textContent = originalFileName; const arrayBuffer = await file.arrayBuffer(); currentPage = 1; bookmarkTree = []; history = []; historyIndex = -1; selectedBookmarks.clear(); collapsedNodes.clear(); pdfLibDoc = await PDFDocument.load(arrayBuffer, { ignoreEncryption: true }); const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(arrayBuffer), }); pdfJsDoc = await loadingTask.promise; gotoPageInput.max = pdfJsDoc.numPages; appEl.classList.remove('hidden'); uploaderEl.classList.add('hidden'); if (autoExtractCheckbox.checked) { const extracted = await extractExistingBookmarks(pdfLibDoc); if (extracted.length > 0) { bookmarkTree = extracted; } } if (csvBookmarks) { bookmarkTree = csvBookmarks; csvBookmarks = null; } else if (jsonBookmarks) { bookmarkTree = jsonBookmarks; jsonBookmarks = null; } saveState(); renderBookmarkTree(); renderPage(currentPage); createIcons({ icons }); } csvInput.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; const text = await file.text(); csvBookmarks = parseCSV(text); await showAlertModal( 'CSV Loaded', `Loaded ${csvBookmarks.length} bookmarks from CSV. Now upload your PDF.` ); }); jsonInput.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; const text = await file.text(); try { jsonBookmarks = JSON.parse(text); await showAlertModal( 'JSON Loaded', 'Loaded bookmarks from JSON. Now upload your PDF.' ); } catch (err) { await showAlertModal('Error', 'Invalid JSON format'); } }); async function renderPage(num, zoom = null, destX = null, destY = null) { if (!pdfJsDoc) return; const page = await pdfJsDoc.getPage(num); let zoomScale = currentZoom; if (zoom !== null && zoom !== '' && zoom !== '0') { zoomScale = parseFloat(zoom) / 100; } const dpr = window.devicePixelRatio || 1; let viewport = page.getViewport({ scale: zoomScale }); currentViewport = viewport; canvas.height = viewport.height * dpr; canvas.width = viewport.width * dpr; // Set CSS size to maintain aspect ratio (this is what the browser displays) canvas.style.width = viewport.width + 'px'; canvas.style.height = viewport.height + 'px'; // Scale the canvas context to match device pixel ratio ctx.scale(dpr, dpr); await page.render({ canvasContext: ctx, viewport: viewport }).promise; // Draw destination marker if coordinates are provided if (destX !== null && destY !== null) { const canvasX = destX; const canvasY = viewport.height - destY; // Flip Y axis (PDF bottom-left, canvas top-left) // Draw marker on canvas with animation effect ctx.save(); ctx.strokeStyle = '#3b82f6'; ctx.fillStyle = '#3b82f6'; ctx.lineWidth = 3; ctx.shadowBlur = 10; ctx.shadowColor = 'rgba(59, 130, 246, 0.5)'; ctx.beginPath(); ctx.arc(canvasX, canvasY, 12, 0, 2 * Math.PI); ctx.fill(); ctx.shadowBlur = 0; // Draw crosshair ctx.beginPath(); ctx.moveTo(canvasX - 15, canvasY); ctx.lineTo(canvasX + 15, canvasY); ctx.moveTo(canvasX, canvasY - 15); ctx.lineTo(canvasX, canvasY + 15); ctx.stroke(); // Draw inner circle ctx.beginPath(); ctx.arc(canvasX, canvasY, 6, 0, 2 * Math.PI); ctx.fill(); ctx.stroke(); // Draw coordinate text background const text = `X: ${Math.round(destX)}, Y: ${Math.round(destY)}`; ctx.font = 'bold 12px monospace'; const textMetrics = ctx.measureText(text); const textWidth = textMetrics.width; const textHeight = 18; ctx.fillStyle = 'rgba(59, 130, 246, 0.95)'; ctx.fillRect(canvasX + 18, canvasY - 25, textWidth + 10, textHeight); ctx.fillStyle = 'white'; ctx.fillText(text, canvasX + 23, canvasY - 10); ctx.restore(); } pageIndicator.textContent = `Page ${num} / ${pdfJsDoc.numPages}`; gotoPageInput.value = num; currentPage = num; currentPageDisplay.textContent = num; } async function renderPageWithDestination(pageNum, x, y, zoom) { await renderPage(pageNum, zoom, x, y); } prevPageBtn.addEventListener('click', () => { if (currentPage > 1) renderPage(currentPage - 1); }); nextPageBtn.addEventListener('click', () => { if (currentPage < pdfJsDoc.numPages) renderPage(currentPage + 1); }); gotoBtn.addEventListener('click', () => { const page = parseInt(gotoPageInput.value); if (page >= 1 && page <= pdfJsDoc.numPages) { renderPage(page); } }); gotoPageInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') gotoBtn.click(); }); // Zoom controls function updateZoomIndicator() { if (zoomIndicator) { zoomIndicator.textContent = `${Math.round(currentZoom * 100)}%`; } } zoomInBtn.addEventListener('click', () => { currentZoom = Math.min(currentZoom + 0.05, 2.0); // Max 200%, increment by 5% updateZoomIndicator(); renderPage(currentPage); }); zoomOutBtn.addEventListener('click', () => { currentZoom = Math.max(currentZoom - 0.05, 0.25); // Min 25%, decrement by 5% updateZoomIndicator(); renderPage(currentPage); }); zoomFitBtn.addEventListener('click', async () => { if (!pdfJsDoc) return; currentZoom = 1.0; updateZoomIndicator(); renderPage(currentPage); }); // Initialize zoom indicator updateZoomIndicator(); searchInput.addEventListener('input', (e) => { searchQuery = e.target.value.toLowerCase(); renderBookmarkTree(); }); function removeNodeById(nodes, id) { for (let i = 0; i < nodes.length; i++) { if (nodes[i].id === id) { nodes.splice(i, 1); return true; } if (removeNodeById(nodes[i].children, id)) return true; } return false; } function flattenBookmarks(nodes, level = 0) { let result = []; for (const node of nodes) { result.push({ ...node, level }); if (node.children.length > 0) { result = result.concat(flattenBookmarks(node.children, level + 1)); } } return result; } function matchesSearch(node, query) { if (!query) return true; if (node.title.toLowerCase().includes(query)) return true; return node.children.some((child) => matchesSearch(child, query)); } function makeSortable(element, parentNode = null, isTopLevel = false) { new Sortable(element, { group: isTopLevel ? 'top-level-only' : 'nested-level-' + (parentNode ? parentNode.id : 'none'), animation: 150, handle: '[data-drag-handle]', ghostClass: 'sortable-ghost', dragClass: 'sortable-drag', forceFallback: true, fallbackTolerance: 3, onEnd: function (evt) { try { if (evt.oldIndex === evt.newIndex) { renderBookmarkTree(); return; } const treeCopy = JSON.parse(JSON.stringify(bookmarkTree)); if (isTopLevel) { const movedItem = treeCopy.splice(evt.oldIndex, 1)[0]; treeCopy.splice(evt.newIndex, 0, movedItem); bookmarkTree = treeCopy; } else if (parentNode) { const parent = findNodeInTree(treeCopy, parentNode.id); if (parent && parent.children) { const movedChild = parent.children.splice(evt.oldIndex, 1)[0]; parent.children.splice(evt.newIndex, 0, movedChild); bookmarkTree = treeCopy; } else { renderBookmarkTree(); return; } } saveState(); renderBookmarkTree(); } catch (err) { console.error('Error in drag and drop:', err); if (historyIndex > 0) { bookmarkTree = JSON.parse(JSON.stringify(history[historyIndex])); } renderBookmarkTree(); } }, }); } function findNodeInTree(nodes, id) { if (!nodes || !Array.isArray(nodes)) return null; for (const node of nodes) { if (node.id === id) { return node; } if (node.children && node.children.length > 0) { const found = findNodeInTree(node.children, id); if (found) return found; } } return null; } function getStyleClasses(style) { if (style === 'bold') return 'font-bold'; if (style === 'italic') return 'italic'; if (style === 'bold-italic') return 'font-bold italic'; return ''; } function getTextColor(color) { if (!color) return ''; // Custom hex colors will use inline styles instead if (color.startsWith('#')) { return ''; } const colorMap = { red: 'text-red-600', blue: 'text-blue-600', green: 'text-green-600', yellow: 'text-yellow-600', purple: 'text-purple-600', }; return colorMap[color] || ''; } function renderBookmarkTree() { treeList.innerHTML = ''; const filtered = searchQuery ? bookmarkTree.filter((n) => matchesSearch(n, searchQuery)) : bookmarkTree; if (filtered.length === 0) { noBookmarksEl.classList.remove('hidden'); } else { noBookmarksEl.classList.add('hidden'); for (const node of filtered) { treeList.appendChild(createNodeElement(node)); } makeSortable(treeList, null, true); } createIcons({ icons }); updateSelectedCount(); } function createNodeElement(node, level = 0) { if (!node || !node.id) { console.error('Invalid node:', node); return document.createElement('li'); } const li = document.createElement('li'); li.dataset.bookmarkId = node.id; li.className = 'group'; const hasChildren = node.children && Array.isArray(node.children) && node.children.length > 0; const isCollapsed = collapsedNodes.has(node.id); const isSelected = selectedBookmarks.has(node.id); const isMatch = !searchQuery || node.title.toLowerCase().includes(searchQuery); const highlight = isMatch && searchQuery ? 'bg-yellow-100' : ''; const colorClass = node.color ? colorClasses[node.color] || '' : ''; const styleClass = getStyleClasses(node.style); const textColorClass = getTextColor(node.color); const div = document.createElement('div'); div.className = `flex items-center gap-2 p-2 rounded border border-gray-200 ${colorClass} ${highlight} ${isSelected ? 'ring-2 ring-blue-500' : ''} hover:bg-gray-50`; if (batchMode) { const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = isSelected; checkbox.className = 'w-4 h-4 flex-shrink-0'; checkbox.addEventListener('click', (e) => { e.stopPropagation(); if (selectedBookmarks.has(node.id)) { selectedBookmarks.delete(node.id); } else { selectedBookmarks.add(node.id); } updateSelectedCount(); checkbox.checked = selectedBookmarks.has(node.id); batchOperations.classList.toggle( 'hidden', !batchMode || selectedBookmarks.size === 0 ); }); div.appendChild(checkbox); } const dragHandle = document.createElement('div'); dragHandle.dataset.dragHandle = 'true'; dragHandle.className = 'cursor-move flex-shrink-0'; dragHandle.innerHTML = ''; div.appendChild(dragHandle); if (hasChildren) { const toggleBtn = document.createElement('button'); toggleBtn.className = 'p-0 flex-shrink-0'; toggleBtn.innerHTML = isCollapsed ? '' : ''; toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); if (collapsedNodes.has(node.id)) { collapsedNodes.delete(node.id); } else { collapsedNodes.add(node.id); } renderBookmarkTree(); }); div.appendChild(toggleBtn); } else { const spacer = document.createElement('div'); spacer.className = 'w-4 flex-shrink-0'; div.appendChild(spacer); } const titleDiv = document.createElement('div'); titleDiv.className = 'flex-1 min-w-0 cursor-pointer'; const customColorStyle = node.color && node.color.startsWith('#') ? `style="color: ${node.color}"` : ''; const hasDestination = node.destX !== null || node.destY !== null || node.zoom !== null; const destinationIcon = hasDestination ? '' : ''; titleDiv.innerHTML = ` ${escapeHTML(node.title)}${destinationIcon} Page ${node.page} `; titleDiv.addEventListener('click', async () => { // Check if bookmark has a custom destination if (node.destX !== null || node.destY !== null || node.zoom !== null) { // Render page with destination highlighted and zoom applied await renderPageWithDestination(node.page, node.destX, node.destY, node.zoom); // Highlight the destination briefly (2 seconds) setTimeout(() => { // Re-render without highlight but keep the zoom if it was set if (node.zoom !== null && node.zoom !== '' && node.zoom !== '0') { // Keep the bookmark's zoom for a moment, then restore current zoom setTimeout(() => { renderPage(node.page); }, 1000); } else { renderPage(node.page); } }, 2000); } else { renderPage(node.page); } if (window.innerWidth < 1024) { showViewerBtn.click(); } }); div.appendChild(titleDiv); const actionsDiv = document.createElement('div'); actionsDiv.className = 'flex gap-1 flex-shrink-0'; const addChildBtn = document.createElement('button'); addChildBtn.className = 'p-1 hover:bg-gray-200 rounded'; addChildBtn.title = 'Add child'; addChildBtn.innerHTML = ''; addChildBtn.addEventListener('click', async (e) => { e.stopPropagation(); const result = await showInputModal('Add Child Bookmark', [ { type: 'text', name: 'title', label: 'Title', placeholder: 'Enter bookmark title', }, ]); if (result && result.title) { node.children.push({ id: Date.now() + Math.random(), title: result.title, page: currentPage, children: [], color: null, style: null, destX: null, destY: null, zoom: null, }); collapsedNodes.delete(node.id); saveState(); renderBookmarkTree(); } }); actionsDiv.appendChild(addChildBtn); const editBtn = document.createElement('button'); editBtn.className = 'p-1 hover:bg-gray-200 rounded'; editBtn.title = 'Edit'; editBtn.innerHTML = ''; editBtn.addEventListener('click', async (e) => { e.stopPropagation(); const result = await showInputModal( 'Edit Bookmark', [ { type: 'text', name: 'title', label: 'Title', placeholder: 'Enter bookmark title', }, { type: 'select', name: 'color', label: 'Color', options: [ { value: '', label: 'None' }, { value: 'red', label: 'Red' }, { value: 'blue', label: 'Blue' }, { value: 'green', label: 'Green' }, { value: 'yellow', label: 'Yellow' }, { value: 'purple', label: 'Purple' }, { value: 'custom', label: 'Custom...' }, ], }, { type: 'select', name: 'style', label: 'Style', options: [ { value: '', label: 'Normal' }, { value: 'bold', label: 'Bold' }, { value: 'italic', label: 'Italic' }, { value: 'bold-italic', label: 'Bold & Italic' }, ], }, { type: 'destination', label: 'Destination', page: node.page, maxPages: pdfJsDoc ? pdfJsDoc.numPages : 1, }, { type: 'preview', label: 'Preview' }, ], { title: node.title, color: node.color || '', style: node.style || '', destPage: node.page, destX: node.destX, destY: node.destY, zoom: node.zoom, } ); if (result) { node.title = result.title; node.color = result.color || null; node.style = result.style || null; // Update destination if (result.destPage !== null && result.destPage !== undefined) { node.page = result.destPage; node.destX = result.destX; node.destY = result.destY; node.zoom = result.zoom; } saveState(); renderBookmarkTree(); } }); actionsDiv.appendChild(editBtn); const deleteBtn = document.createElement('button'); deleteBtn.className = 'p-1 hover:bg-gray-200 rounded text-red-600'; deleteBtn.title = 'Delete'; deleteBtn.innerHTML = ''; deleteBtn.addEventListener('click', async (e) => { e.stopPropagation(); const confirmed = await showConfirmModal(`Delete "${node.title}"?`); if (confirmed) { removeNodeById(bookmarkTree, node.id); saveState(); renderBookmarkTree(); } }); actionsDiv.appendChild(deleteBtn); div.appendChild(actionsDiv); li.appendChild(div); if (hasChildren && !isCollapsed) { const childContainer = document.createElement('ul'); childContainer.className = 'child-container space-y-2'; const nodeCopy = JSON.parse(JSON.stringify(node)); for (const child of node.children) { if (child && child.id) { childContainer.appendChild(createNodeElement(child, level + 1)); } } li.appendChild(childContainer); makeSortable(childContainer, nodeCopy, false); } return li; } addTopLevelBtn.addEventListener('click', async () => { const title = titleInput.value.trim(); if (!title) { await showAlertModal('Error', 'Please enter a title.'); return; } bookmarkTree.push({ id: Date.now(), title: title, page: currentPage, children: [], color: null, style: null, destX: null, destY: null, zoom: null, }); saveState(); renderBookmarkTree(); titleInput.value = ''; }); titleInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') addTopLevelBtn.click(); }); function escapeHTML(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } importCsvBtn.addEventListener('click', () => { csvImportHidden.click(); importDropdown.classList.add('hidden'); }); csvImportHidden.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; const text = await file.text(); const imported = parseCSV(text); if (imported.length > 0) { bookmarkTree = imported; saveState(); renderBookmarkTree(); await showAlertModal('Success', `Imported ${imported.length} bookmarks!`); } csvImportHidden.value = ''; }); exportCsvBtn.addEventListener('click', () => { exportDropdown.classList.add('hidden'); if (bookmarkTree.length === 0) { showAlertModal('Error', 'No bookmarks to export!'); return; } const flat = flattenBookmarks(bookmarkTree); const csv = 'title,page,level\n' + flat .map((b) => `"${b.title.replace(/"/g, '""')}",${b.page},${b.level}`) .join('\n'); const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${originalFileName}-bookmarks.csv`; a.click(); URL.revokeObjectURL(url); }); function parseCSV(text) { const lines = text.trim().split('\n').slice(1); const bookmarks = []; const stack = [{ children: bookmarks, level: -1 }]; for (const line of lines) { const match = line.match(/^"(.+)",(\d+),(\d+)$/) || line.match(/^([^,]+),(\d+),(\d+)$/); if (!match) continue; const [, title, page, level] = match; const bookmark = { id: Date.now() + Math.random(), title: title.replace(/""/g, '"'), page: parseInt(page), children: [], color: null, style: null, destX: null, destY: null, zoom: null, }; const lvl = parseInt(level); while (stack[stack.length - 1].level >= lvl) stack.pop(); stack[stack.length - 1].children.push(bookmark); stack.push({ ...bookmark, level: lvl }); } return bookmarks; } importJsonBtn.addEventListener('click', () => { jsonImportHidden.click(); importDropdown.classList.add('hidden'); }); jsonImportHidden.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; const text = await file.text(); try { const imported = JSON.parse(text); bookmarkTree = imported; saveState(); renderBookmarkTree(); await showAlertModal('Success', 'Bookmarks imported from JSON!'); } catch (err) { await showAlertModal('Error', 'Invalid JSON format'); } jsonImportHidden.value = ''; }); exportJsonBtn.addEventListener('click', () => { exportDropdown.classList.add('hidden'); if (bookmarkTree.length === 0) { showAlertModal('Error', 'No bookmarks to export!'); return; } const json = JSON.stringify(bookmarkTree, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${originalFileName}-bookmarks.json`; a.click(); URL.revokeObjectURL(url); }); extractExistingBtn.addEventListener('click', async () => { if (!pdfLibDoc) return; const extracted = await extractExistingBookmarks(pdfLibDoc); if (extracted.length > 0) { const confirmed = await showConfirmModal( `Found ${extracted.length} existing bookmarks. Replace current bookmarks?` ); if (confirmed) { bookmarkTree = extracted; saveState(); renderBookmarkTree(); } } else { await showAlertModal('Info', 'No existing bookmarks found in this PDF.'); } }); async function extractExistingBookmarks(doc) { try { const outlines = doc.catalog.lookup(PDFName.of('Outlines')); if (!outlines) return []; const pages = doc.getPages(); // Helper to resolve references function resolveRef(obj) { if (!obj) return null; if (obj.lookup) return obj; if (obj.objectNumber !== undefined && doc.context) { return doc.context.lookup(obj); } return obj; } // Build named destinations map (support full name-tree with Kids and Catalog-level Dests) const namedDests = new Map(); try { function addNamePair(nameObj, destObj) { try { const key = nameObj.decodeText ? nameObj.decodeText() : String(nameObj); namedDests.set(key, resolveRef(destObj)); } catch (_) { // ignore malformed entry } } function traverseNamesNode(node) { if (!node) return; node = resolveRef(node); if (!node) return; const namesArray = node.lookup ? node.lookup(PDFName.of('Names')) : null; if (namesArray && namesArray.array) { for (let i = 0; i < namesArray.array.length; i += 2) { const n = namesArray.array[i]; const d = namesArray.array[i + 1]; addNamePair(n, d); } } const kidsArray = node.lookup ? node.lookup(PDFName.of('Kids')) : null; if (kidsArray && kidsArray.array) { for (const kid of kidsArray.array) traverseNamesNode(kid); } } // Names tree under Catalog/Names/Dests const names = doc.catalog.lookup(PDFName.of('Names')); if (names) { const destsTree = names.lookup(PDFName.of('Dests')); if (destsTree) traverseNamesNode(destsTree); } // Some PDFs store Dests directly under the Catalog's Dests as a dictionary const catalogDests = doc.catalog.lookup(PDFName.of('Dests')); if (catalogDests && catalogDests.dict) { const entries = catalogDests.dict.entries(); for (const [key, value] of entries) { // keys are PDFName; convert to string const keyStr = key.decodeText ? key.decodeText() : key.toString(); namedDests.set(keyStr, resolveRef(value)); } } } catch (e) { console.error('Error building named destinations:', e); } function findPageIndex(pageRef) { if (!pageRef) return 0; try { const resolved = resolveRef(pageRef); if (pageRef.numberValue !== undefined) { const numericIndex = pageRef.numberValue | 0; if (numericIndex >= 0 && numericIndex < pages.length) return numericIndex; } if (pageRef.objectNumber !== undefined) { const idxByObjNum = pages.findIndex( (p) => p.ref.objectNumber === pageRef.objectNumber ); if (idxByObjNum !== -1) return idxByObjNum; } if (pageRef.toString) { const target = pageRef.toString(); const idxByString = pages.findIndex((p) => p.ref.toString() === target); if (idxByString !== -1) return idxByString; } if (resolved && resolved.get) { for (let i = 0; i < pages.length; i++) { const pageDict = doc.context.lookup(pages[i].ref); if (pageDict === resolved) return i; } } } catch (e) { console.error('Error finding page:', e); } // Fallback: keep current behavior but log for diagnostics console.warn('Falling back to page 0 for destination'); return 0; } function getDestination(item) { if (!item) return null; // Try Dest entry first let dest = item.lookup(PDFName.of('Dest')); // If no Dest, try Action/D if (!dest) { const action = resolveRef(item.lookup(PDFName.of('A'))); if (action) { dest = action.lookup(PDFName.of('D')); } } // Handle named destinations if (dest && !dest.array) { try { const name = dest.decodeText ? dest.decodeText() : dest.toString(); if (namedDests.has(name)) { dest = namedDests.get(name); } else if (dest.lookup) { // Some named destinations resolve to a dictionary with 'D' entry const maybeDict = resolveRef(dest); const dictD = maybeDict && maybeDict.lookup ? maybeDict.lookup(PDFName.of('D')) : null; if (dictD) dest = resolveRef(dictD); } } catch (_) { // leave dest as-is if it can't be decoded } } return resolveRef(dest); } function traverse(item) { if (!item) return null; item = resolveRef(item); if (!item) return null; const title = item.lookup(PDFName.of('Title')); const dest = getDestination(item); const colorObj = item.lookup(PDFName.of('C')); const flagsObj = item.lookup(PDFName.of('F')); let pageIndex = 0; let destX = null; let destY = null; let zoom = null; if (dest && dest.array) { const pageRef = dest.array[0]; pageIndex = findPageIndex(pageRef); if (dest.array.length > 2) { const xObj = resolveRef(dest.array[2]); const yObj = resolveRef(dest.array[3]); const zoomObj = resolveRef(dest.array[4]); if (xObj && xObj.numberValue !== undefined) destX = xObj.numberValue; if (yObj && yObj.numberValue !== undefined) destY = yObj.numberValue; if (zoomObj && zoomObj.numberValue !== undefined) { zoom = String(Math.round(zoomObj.numberValue * 100)); } } } // Rest of the color and style processing remains the same let color = null; if (colorObj && colorObj.array) { const [r, g, b] = colorObj.array; if (r > 0.8 && g < 0.3 && b < 0.3) color = 'red'; else if (r < 0.3 && g < 0.3 && b > 0.8) color = 'blue'; else if (r < 0.3 && g > 0.8 && b < 0.3) color = 'green'; else if (r > 0.8 && g > 0.8 && b < 0.3) color = 'yellow'; else if (r > 0.5 && g < 0.5 && b > 0.5) color = 'purple'; } let style = null; if (flagsObj) { const flags = flagsObj.numberValue || 0; const isBold = (flags & 2) !== 0; const isItalic = (flags & 1) !== 0; if (isBold && isItalic) style = 'bold-italic'; else if (isBold) style = 'bold'; else if (isItalic) style = 'italic'; } const bookmark = { id: Date.now() + Math.random(), title: title ? title.decodeText() : 'Untitled', page: pageIndex + 1, children: [], color, style, destX, destY, zoom }; // Process children (make sure to resolve refs) let child = resolveRef(item.lookup(PDFName.of('First'))); while (child) { const childBookmark = traverse(child); if (childBookmark) bookmark.children.push(childBookmark); child = resolveRef(child.lookup(PDFName.of('Next'))); } if (pageIndex === 0 && bookmark.children.length > 0) { const firstChild = bookmark.children[0]; if (firstChild) { bookmark.page = firstChild.page; bookmark.destX = firstChild.destX; bookmark.destY = firstChild.destY; bookmark.zoom = firstChild.zoom; } } return bookmark; } const result = []; let first = resolveRef(outlines.lookup(PDFName.of('First'))); while (first) { const bookmark = traverse(first); if (bookmark) result.push(bookmark); first = resolveRef(first.lookup(PDFName.of('Next'))); } return result; } catch (err) { console.error('Error extracting bookmarks:', err); return []; } } downloadBtn.addEventListener('click', async () => { const pages = pdfLibDoc.getPages(); const outlinesDict = pdfLibDoc.context.obj({}); const outlinesRef = pdfLibDoc.context.register(outlinesDict); function createOutlineItems(nodes, parentRef) { const items = []; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const itemDict = pdfLibDoc.context.obj({}); const itemRef = pdfLibDoc.context.register(itemDict); itemDict.set(PDFName.of('Title'), PDFString.of(node.title)); itemDict.set(PDFName.of('Parent'), parentRef); // Always map bookmark page to zero-based index consistently const pageIndex = Math.max(0, Math.min(node.page - 1, pages.length - 1)); const pageRef = pages[pageIndex].ref; // Handle custom destination with zoom and position let destArray; if (node.destX !== null || node.destY !== null || node.zoom !== null) { const x = node.destX !== null ? PDFNumber.of(node.destX) : null; const y = node.destY !== null ? PDFNumber.of(node.destY) : null; let zoom = null; if (node.zoom !== null && node.zoom !== '' && node.zoom !== '0') { // Convert percentage to decimal (100% = 1.0) zoom = PDFNumber.of(parseFloat(node.zoom) / 100); } destArray = pdfLibDoc.context.obj([ pageRef, PDFName.of('XYZ'), x, y, zoom, ]); } else { destArray = pdfLibDoc.context.obj([ pageRef, PDFName.of('XYZ'), null, null, null, ]); } itemDict.set(PDFName.of('Dest'), destArray); // Add color to PDF if (node.color) { let rgb; if (node.color.startsWith('#')) { // Custom hex color - convert to RGB const hex = node.color.replace('#', ''); const r = parseInt(hex.substr(0, 2), 16) / 255; const g = parseInt(hex.substr(2, 2), 16) / 255; const b = parseInt(hex.substr(4, 2), 16) / 255; rgb = [r, g, b]; } else { // Predefined colors const colorMap = { red: [1.0, 0.0, 0.0], blue: [0.0, 0.0, 1.0], green: [0.0, 1.0, 0.0], yellow: [1.0, 1.0, 0.0], purple: [0.5, 0.0, 0.5], }; rgb = colorMap[node.color]; } if (rgb) { const colorArray = pdfLibDoc.context.obj(rgb); itemDict.set(PDFName.of('C'), colorArray); } } // Add style flags to PDF if (node.style) { let flags = 0; if (node.style === 'italic') flags = 1; else if (node.style === 'bold') flags = 2; else if (node.style === 'bold-italic') flags = 3; if (flags > 0) { itemDict.set(PDFName.of('F'), PDFNumber.of(flags)); } } if (node.children.length > 0) { const childItems = createOutlineItems(node.children, itemRef); if (childItems.length > 0) { itemDict.set(PDFName.of('First'), childItems[0].ref); itemDict.set( PDFName.of('Last'), childItems[childItems.length - 1].ref ); itemDict.set( PDFName.of('Count'), pdfLibDoc.context.obj(childItems.length) ); } } if (i > 0) { itemDict.set(PDFName.of('Prev'), items[i - 1].ref); items[i - 1].dict.set(PDFName.of('Next'), itemRef); } items.push({ ref: itemRef, dict: itemDict }); } return items; } try { const topLevelItems = createOutlineItems(bookmarkTree, outlinesRef); if (topLevelItems.length > 0) { outlinesDict.set(PDFName.of('Type'), PDFName.of('Outlines')); outlinesDict.set(PDFName.of('First'), topLevelItems[0].ref); outlinesDict.set( PDFName.of('Last'), topLevelItems[topLevelItems.length - 1].ref ); outlinesDict.set( PDFName.of('Count'), pdfLibDoc.context.obj(topLevelItems.length) ); } pdfLibDoc.catalog.set(PDFName.of('Outlines'), outlinesRef); const pdfBytes = await pdfLibDoc.save(); const blob = new Blob([pdfBytes], { type: 'application/pdf' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${originalFileName}-bookmarked.pdf`; a.click(); URL.revokeObjectURL(url); await showAlertModal('Success', 'PDF saved successfully!'); // Reset to uploader after successful save setTimeout(() => { resetToUploader(); }, 500); } catch (err) { console.error(err); await showAlertModal( 'Error', 'Error saving PDF. Check console for details.' ); } });