diff --git a/about.html b/about.html index 0f32bb9..03abade 100644 --- a/about.html +++ b/about.html @@ -4,10 +4,6 @@ About Bentopdf - Fast, Private, and Free PDF Tools - - - - @@ -343,9 +339,8 @@ - + + diff --git a/contact.html b/contact.html index d6b35cd..4fd24e1 100644 --- a/contact.html +++ b/contact.html @@ -4,8 +4,6 @@ Contact Us - BentoPDF - - @@ -208,9 +206,8 @@ - + + diff --git a/faq.html b/faq.html index cbd2173..f4e7c22 100644 --- a/faq.html +++ b/faq.html @@ -4,11 +4,6 @@ Frequently Asked Questions - BentoPDF - - - - - @@ -396,9 +391,8 @@ - + + diff --git a/index.html b/index.html index a1b371c..8ed0554 100644 --- a/index.html +++ b/index.html @@ -915,6 +915,7 @@ + diff --git a/privacy.html b/privacy.html index 6e78eb2..0053c5b 100644 --- a/privacy.html +++ b/privacy.html @@ -4,10 +4,6 @@ Privacy Policy - BentoPDF - - - - @@ -318,9 +314,8 @@ - + + diff --git a/src/js/config/pdf-tools.ts b/src/js/config/pdf-tools.ts index f43cfaa..8b054fa 100644 --- a/src/js/config/pdf-tools.ts +++ b/src/js/config/pdf-tools.ts @@ -40,6 +40,7 @@ export const singlePdfLoadTools = [ 'add-attachments', 'sanitize-pdf', 'remove-restrictions', + 'bookmark-pdf', ]; export const simpleTools = [ diff --git a/src/js/config/tools.ts b/src/js/config/tools.ts index 868d936..e70b9f9 100644 --- a/src/js/config/tools.ts +++ b/src/js/config/tools.ts @@ -76,7 +76,13 @@ export const categories = [ subtitle: 'Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs.', }, - // { id: 'crop', name: 'Crop PDF', icon: 'crop', subtitle: 'Trim the margins of every page in your PDF.' }, + { + // id: 'bookmark-pdf', + href: '/src/pages/bookmark.html', + name: 'Edit Bookmarks', + icon: 'bookmark', + subtitle: 'Add, edit, import, delete and extract PDF bookmarks.', + }, { id: 'add-page-numbers', name: 'Page Numbers', diff --git a/src/js/logic/bookmark-pdf.ts b/src/js/logic/bookmark-pdf.ts new file mode 100644 index 0000000..2c5c228 --- /dev/null +++ b/src/js/logic/bookmark-pdf.ts @@ -0,0 +1,2175 @@ +// @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.' + ); + } +}); diff --git a/src/js/main.ts b/src/js/main.ts index 8ac0a92..2026e2f 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -137,10 +137,19 @@ const init = () => { 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6'; category.tools.forEach((tool) => { - const toolCard = document.createElement('div'); - toolCard.className = - 'tool-card bg-gray-800 rounded-xl p-4 cursor-pointer flex flex-col items-center justify-center text-center'; - toolCard.dataset.toolId = tool.id; + let toolCard: HTMLDivElement | HTMLAnchorElement; + + if (tool.href) { + toolCard = document.createElement('a'); + toolCard.href = tool.href; + toolCard.className = + 'tool-card block bg-gray-800 rounded-xl p-4 cursor-pointer flex flex-col items-center justify-center text-center no-underline hover:shadow-lg transition duration-200'; + } else { + toolCard = document.createElement('div'); + toolCard.className = + 'tool-card bg-gray-800 rounded-xl p-4 cursor-pointer flex flex-col items-center justify-center text-center hover:shadow-lg transition duration-200'; + toolCard.dataset.toolId = tool.id; + } const icon = document.createElement('i'); icon.className = 'w-10 h-10 mb-3 text-indigo-400'; diff --git a/src/js/utils/helpers.ts b/src/js/utils/helpers.ts index 0998487..bf92f66 100644 --- a/src/js/utils/helpers.ts +++ b/src/js/utils/helpers.ts @@ -1,5 +1,6 @@ import createModule from '@neslinesli93/qpdf-wasm'; import { showLoader, hideLoader, showAlert } from '../ui'; +import { createIcons } from 'lucide'; const STANDARD_SIZES = { A4: { width: 595.28, height: 841.89 }, @@ -177,3 +178,12 @@ export async function initializeQpdf() { return qpdfInstance; } + +export function initializeIcons(): void { + createIcons({ + attrs: { + class: 'bento-icon', + 'stroke-width': '1.5', + }, + }); +} diff --git a/src/js/utils/lucide-init.ts b/src/js/utils/lucide-init.ts new file mode 100644 index 0000000..2959664 --- /dev/null +++ b/src/js/utils/lucide-init.ts @@ -0,0 +1,5 @@ +import { createIcons, icons } from 'lucide'; + +document.addEventListener('DOMContentLoaded', () => { + createIcons({ icons }); +}); diff --git a/src/pages/bookmark.html b/src/pages/bookmark.html new file mode 100644 index 0000000..aa01321 --- /dev/null +++ b/src/pages/bookmark.html @@ -0,0 +1,867 @@ + + + + + + Advanced PDF Bookmark Tool - BentoPDF + + + + + + +
+
+

+ Upload a PDF to begin editing bookmarks +

+ + +
+
+ +

+ Click to select a file or drag + and drop +

+

A single PDF file

+

+ Your files never leave your device. +

+
+ +
+ + +
+ +
+ + +
+

+ Or Import Bookmarks +

+ + + + + + +
+
+
+ + + + + + + + + + + + + + diff --git a/terms.html b/terms.html index 6ae74f8..b32ec1a 100644 --- a/terms.html +++ b/terms.html @@ -4,9 +4,6 @@ Terms and Conditions - Bentopdf - - - @@ -307,9 +304,8 @@ - + + diff --git a/vite.config.ts b/vite.config.ts index a13454e..6928dbe 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -36,6 +36,7 @@ export default defineConfig(({ mode }) => ({ faq: resolve(__dirname, 'faq.html'), privacy: resolve(__dirname, 'privacy.html'), terms: resolve(__dirname, 'terms.html'), + bookmark: resolve(__dirname, 'src/pages/bookmark.html'), }, }, },