Files
bentopdf/src/js/logic/bookmark-pdf.ts

2309 lines
69 KiB
TypeScript

import { PDFDocument, PDFName, PDFNumber, PDFHexString, PDFRef } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { PDFDocumentProxy, PageViewport } from 'pdfjs-dist';
import Sortable from 'sortablejs';
import { createIcons, icons } from 'lucide';
import '../../css/bookmark.css';
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
import {
truncateFilename,
getPDFDocument,
formatBytes,
downloadFile,
escapeHtml,
hexToRgb,
} from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import {
BookmarkNode,
BookmarkTree,
BookmarkColor,
BookmarkStyle,
ModalField,
ModalResult,
ModalDefaultValues,
DestinationCallback,
FlattenedBookmark,
OutlineItem,
PDFOutlineItem,
COLOR_CLASSES,
TEXT_COLOR_CLASSES,
HEX_COLOR_MAP,
PDF_COLOR_MAP,
} from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
const modalContainer = document.getElementById(
'modal-container'
) as HTMLElement | null;
let isPickingDestination = false;
let currentPickingCallback: DestinationCallback | null = null;
let destinationMarker: HTMLDivElement | null = null;
let savedModalOverlay: HTMLDivElement | null = null;
let savedModal: HTMLDivElement | null = null;
let currentViewport: PageViewport | null = null;
let currentZoom = 1.0;
const fileInput = document.getElementById(
'file-input'
) as HTMLInputElement | null;
const csvInput = document.getElementById(
'csv-input'
) as HTMLInputElement | null;
const jsonInput = document.getElementById(
'json-input'
) as HTMLInputElement | null;
const autoExtractCheckbox = document.getElementById(
'auto-extract-checkbox'
) as HTMLInputElement | null;
const appEl = document.getElementById('app') as HTMLElement | null;
const uploaderEl = document.getElementById('uploader') as HTMLElement | null;
const loaderModal = document.getElementById(
'loader-modal'
) as HTMLElement | null;
const fileDisplayArea = document.getElementById(
'file-display-area'
) as HTMLElement | null;
const backToToolsBtn = document.getElementById(
'back-to-tools'
) as HTMLButtonElement | null;
const closeBtn = document.getElementById(
'back-btn'
) as HTMLButtonElement | null;
const canvas = document.getElementById(
'pdf-canvas'
) as HTMLCanvasElement | null;
const ctx = canvas?.getContext('2d') ?? null;
const pageIndicator = document.getElementById(
'page-indicator'
) as HTMLElement | null;
const prevPageBtn = document.getElementById(
'prev-page'
) as HTMLButtonElement | null;
const nextPageBtn = document.getElementById(
'next-page'
) as HTMLButtonElement | null;
const gotoPageInput = document.getElementById(
'goto-page'
) as HTMLInputElement | null;
const gotoBtn = document.getElementById('goto-btn') as HTMLButtonElement | null;
const zoomInBtn = document.getElementById(
'zoom-in-btn'
) as HTMLButtonElement | null;
const zoomOutBtn = document.getElementById(
'zoom-out-btn'
) as HTMLButtonElement | null;
const zoomFitBtn = document.getElementById(
'zoom-fit-btn'
) as HTMLButtonElement | null;
const zoomIndicator = document.getElementById(
'zoom-indicator'
) as HTMLElement | null;
const addTopLevelBtn = document.getElementById(
'add-top-level-btn'
) as HTMLButtonElement | null;
const titleInput = document.getElementById(
'bookmark-title'
) as HTMLInputElement | null;
const treeList = document.getElementById(
'bookmark-tree-list'
) as HTMLElement | null;
const noBookmarksEl = document.getElementById(
'no-bookmarks'
) as HTMLElement | null;
const downloadBtn = document.getElementById(
'download-btn'
) as HTMLButtonElement | null;
const undoBtn = document.getElementById('undo-btn') as HTMLButtonElement | null;
const redoBtn = document.getElementById('redo-btn') as HTMLButtonElement | null;
const resetBtn = document.getElementById(
'reset-btn'
) as HTMLButtonElement | null;
const deleteAllBtn = document.getElementById(
'delete-all-btn'
) as HTMLButtonElement | null;
const searchInput = document.getElementById(
'search-bookmarks'
) as HTMLInputElement | null;
const importDropdownBtn = document.getElementById(
'import-dropdown-btn'
) as HTMLButtonElement | null;
const exportDropdownBtn = document.getElementById(
'export-dropdown-btn'
) as HTMLButtonElement | null;
const importDropdown = document.getElementById(
'import-dropdown'
) as HTMLElement | null;
const exportDropdown = document.getElementById(
'export-dropdown'
) as HTMLElement | null;
const importCsvBtn = document.getElementById(
'import-csv-btn'
) as HTMLButtonElement | null;
const exportCsvBtn = document.getElementById(
'export-csv-btn'
) as HTMLButtonElement | null;
const importJsonBtn = document.getElementById(
'import-json-btn'
) as HTMLButtonElement | null;
const exportJsonBtn = document.getElementById(
'export-json-btn'
) as HTMLButtonElement | null;
const csvImportHidden = document.getElementById(
'csv-import-hidden'
) as HTMLInputElement | null;
const jsonImportHidden = document.getElementById(
'json-import-hidden'
) as HTMLInputElement | null;
const extractExistingBtn = document.getElementById(
'extract-existing-btn'
) as HTMLButtonElement | null;
const currentPageDisplay = document.getElementById(
'current-page-display'
) as HTMLElement | null;
const filenameDisplay = document.getElementById(
'filename-display'
) as HTMLElement | null;
const batchModeCheckbox = document.getElementById(
'batch-mode-checkbox'
) as HTMLInputElement | null;
const batchOperations = document.getElementById(
'batch-operations'
) as HTMLElement | null;
const selectedCountDisplay = document.getElementById(
'selected-count'
) as HTMLElement | null;
const batchColorSelect = document.getElementById(
'batch-color-select'
) as HTMLSelectElement | null;
const batchStyleSelect = document.getElementById(
'batch-style-select'
) as HTMLSelectElement | null;
const batchDeleteBtn = document.getElementById(
'batch-delete-btn'
) as HTMLButtonElement | null;
const selectAllBtn = document.getElementById(
'select-all-btn'
) as HTMLButtonElement | null;
const deselectAllBtn = document.getElementById(
'deselect-all-btn'
) as HTMLButtonElement | null;
const expandAllBtn = document.getElementById(
'expand-all-btn'
) as HTMLButtonElement | null;
const collapseAllBtn = document.getElementById(
'collapse-all-btn'
) as HTMLButtonElement | null;
const showViewerBtn = document.getElementById(
'show-viewer-btn'
) as HTMLButtonElement | null;
const showBookmarksBtn = document.getElementById(
'show-bookmarks-btn'
) as HTMLButtonElement | null;
const viewerSection = document.getElementById(
'viewer-section'
) as HTMLElement | null;
const bookmarksSection = document.getElementById(
'bookmarks-section'
) as HTMLElement | null;
function showInputModal(
title: string,
fields: ModalField[] = [],
defaultValues: ModalDefaultValues = {}
): Promise<ModalResult | null> {
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 `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
<input type="text" id="modal-${field.name}" value="${escapeHTML(String(defaultValues[field.name] || ''))}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900"
placeholder="${field.placeholder || ''}" />
</div>
`;
} else if (field.type === 'select') {
return `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
<select id="modal-${field.name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900">
${field.options
.map(
(opt) => `
<option value="${opt.value}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
${opt.label}
</option>
`
)
.join('')}
</select>
${field.name === 'color' ? '<input type="color" id="modal-color-picker" class="hidden mt-2" value="#000000" />' : ''}
</div>
`;
} else if (field.type === 'destination') {
const hasDestination =
defaultValues.destX !== null && defaultValues.destX !== undefined;
return `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200 space-y-2">
<div class="flex items-center gap-2">
<label class="flex items-center gap-1 text-xs">
<input type="checkbox" id="modal-use-destination" class="w-4 h-4" ${hasDestination ? 'checked' : ''}>
<span class="text-gray-700">Set custom destination</span>
</label>
</div>
<div id="destination-controls" class="${hasDestination ? '' : 'hidden'} space-y-2">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-gray-600">Page</label>
<input type="number" id="modal-dest-page" min="1" max="${field.maxPages || 1}" value="${defaultValues.destPage || field.page || 1}"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" step="1" />
</div>
<div>
<label class="text-xs text-gray-600">Zoom(%)</label>
<select id="modal-dest-zoom" class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900">
<option value="">Inherit</option>
<option value="0">Fit Page</option>
<option value="50">50%</option>
<option value="75">75%</option>
<option value="100">100%</option>
<option value="125">125%</option>
<option value="150">150%</option>
<option value="200">200%</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-gray-600">X Position</label>
<input type="number" id="modal-dest-x" value="0" step="10"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
</div>
<div>
<label class="text-xs text-gray-600">Y Position</label>
<input type="number" id="modal-dest-y" value="0" step="10"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
</div>
</div>
<button id="modal-pick-destination" class="w-full px-3 py-2 btn-gradient text-white rounded text-xs !flex items-center justify-center gap-1">
<i data-lucide="crosshair" class="w-3 h-3"></i> Click on PDF to Pick Location
</button>
<p class="text-xs text-gray-500 italic">Click the button above, then click on the PDF where you want the bookmark to jump to</p>
</div>
</div>
</div>
`;
} else if (field.type === 'preview') {
return `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
<div id="modal-preview" class="style-preview bg-gray-50">
<span id="preview-text" style="font-size: 16px;">Preview Text</span>
</div>
</div>
`;
}
return '';
})
.join('');
modal.innerHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
<div class="mb-6">
${fieldsHTML}
</div>
<div class="flex gap-2 justify-end">
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">Cancel</button>
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button>
</div>
</div>
`;
overlay.appendChild(modal);
modalContainer?.appendChild(overlay);
function updatePreview(): void {
const previewText = modal.querySelector(
'#preview-text'
) as HTMLSpanElement | null;
if (previewText) {
const titleInputEl = modal.querySelector(
'#modal-title'
) as HTMLInputElement | null;
const colorSelectEl = modal.querySelector(
'#modal-color'
) as HTMLSelectElement | null;
const styleSelectEl = modal.querySelector(
'#modal-style'
) as HTMLSelectElement | null;
const colorPickerEl = modal.querySelector(
'#modal-color-picker'
) as HTMLInputElement | null;
const titleVal = titleInputEl ? titleInputEl.value : 'Preview Text';
const color = colorSelectEl ? colorSelectEl.value : '';
const style = styleSelectEl ? styleSelectEl.value : '';
previewText.textContent = titleVal || 'Preview Text';
if (color === 'custom' && colorPickerEl) {
previewText.style.color = colorPickerEl.value;
} else {
previewText.style.color = HEX_COLOR_MAP[color] || '#000';
}
previewText.style.fontWeight =
style === 'bold' || style === 'bold-italic' ? 'bold' : 'normal';
previewText.style.fontStyle =
style === 'italic' || style === 'bold-italic' ? 'italic' : 'normal';
}
}
const modalTitleInput = modal.querySelector(
'#modal-title'
) as HTMLInputElement | null;
const modalColorSelect = modal.querySelector(
'#modal-color'
) as HTMLSelectElement | null;
const modalStyleSelect = modal.querySelector(
'#modal-style'
) as HTMLSelectElement | null;
if (modalTitleInput)
modalTitleInput.addEventListener('input', updatePreview);
if (modalColorSelect) {
modalColorSelect.addEventListener('change', (e: Event) => {
const target = e.target as HTMLSelectElement;
const colorPickerEl = modal.querySelector(
'#modal-color-picker'
) as HTMLInputElement | null;
if (target.value === 'custom' && colorPickerEl) {
colorPickerEl.classList.remove('hidden');
setTimeout(() => colorPickerEl.click(), 100);
} else if (colorPickerEl) {
colorPickerEl.classList.add('hidden');
}
updatePreview();
});
}
const modalColorPicker = modal.querySelector(
'#modal-color-picker'
) as HTMLInputElement | null;
if (modalColorPicker) {
modalColorPicker.addEventListener('input', updatePreview);
}
if (modalStyleSelect)
modalStyleSelect.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'
) as HTMLButtonElement | null;
if (useDestCheckbox && destControls) {
useDestCheckbox.addEventListener('change', (e: Event) => {
const target = e.target as HTMLInputElement;
destControls.classList.toggle('hidden', !target.checked);
});
if (defaultValues.destX !== null && defaultValues.destX !== undefined) {
const destPageInputEl = modal.querySelector(
'#modal-dest-page'
) as HTMLInputElement | null;
const destXInputEl = modal.querySelector(
'#modal-dest-x'
) as HTMLInputElement | null;
const destYInputEl = modal.querySelector(
'#modal-dest-y'
) as HTMLInputElement | null;
const destZoomSelectEl = modal.querySelector(
'#modal-dest-zoom'
) as HTMLSelectElement | null;
if (destPageInputEl && defaultValues.destPage !== undefined) {
destPageInputEl.value = String(defaultValues.destPage);
}
if (destXInputEl && defaultValues.destX !== null) {
destXInputEl.value = String(Math.round(defaultValues.destX));
}
if (destYInputEl && defaultValues.destY !== null) {
destYInputEl.value = String(Math.round(defaultValues.destY));
}
if (destZoomSelectEl && defaultValues.zoom !== null) {
destZoomSelectEl.value = defaultValues.zoom || '';
}
}
}
if (pickDestBtn) {
pickDestBtn.addEventListener('click', () => {
savedModalOverlay = overlay;
savedModal = modal;
overlay.style.display = 'none';
startDestinationPicking((page: number, pdfX: number, pdfY: number) => {
const destPageInputEl = modal.querySelector(
'#modal-dest-page'
) as HTMLInputElement | null;
const destXInputEl = modal.querySelector(
'#modal-dest-x'
) as HTMLInputElement | null;
const destYInputEl = modal.querySelector(
'#modal-dest-y'
) as HTMLInputElement | null;
if (destPageInputEl) destPageInputEl.value = String(page);
if (destXInputEl) destXInputEl.value = String(Math.round(pdfX));
if (destYInputEl) destYInputEl.value = String(Math.round(pdfY));
overlay.style.display = '';
setTimeout(() => {
updateDestinationPreview();
}, 100);
});
});
}
const destPageInputEl = modal.querySelector(
'#modal-dest-page'
) as HTMLInputElement | null;
if (destPageInputEl) {
destPageInputEl.addEventListener('input', (e: Event) => {
const target = e.target as HTMLInputElement;
const value = parseInt(target.value);
const maxPages = parseInt(target.max) || 1;
if (isNaN(value) || value < 1) {
target.value = '1';
} else if (value > maxPages) {
target.value = String(maxPages);
} else {
target.value = String(Math.floor(value));
}
updateDestinationPreview();
});
destPageInputEl.addEventListener('blur', (e: Event) => {
const target = e.target as HTMLInputElement;
const value = parseInt(target.value);
const maxPages = parseInt(target.max) || 1;
if (isNaN(value) || value < 1) {
target.value = '1';
} else if (value > maxPages) {
target.value = String(maxPages);
} else {
target.value = String(Math.floor(value));
}
updateDestinationPreview();
});
}
function updateDestinationPreview(): void {
if (!pdfJsDoc) return;
const destPageEl = modal.querySelector(
'#modal-dest-page'
) as HTMLInputElement | null;
const destXEl = modal.querySelector(
'#modal-dest-x'
) as HTMLInputElement | null;
const destYEl = modal.querySelector(
'#modal-dest-y'
) as HTMLInputElement | null;
const destZoomEl = modal.querySelector(
'#modal-dest-zoom'
) as HTMLSelectElement | null;
const pageNum = destPageEl ? parseInt(destPageEl.value) : currentPage;
const x = destXEl ? parseFloat(destXEl.value) : null;
const y = destYEl ? parseFloat(destYEl.value) : null;
const zoom = destZoomEl ? destZoomEl.value : null;
if (pageNum >= 1 && pageNum <= pdfJsDoc.numPages) {
// Render the page with zoom if specified
renderPageWithDestination(pageNum, x, y, zoom);
}
}
const destXInputListener = modal.querySelector(
'#modal-dest-x'
) as HTMLInputElement | null;
const destYInputListener = modal.querySelector(
'#modal-dest-y'
) as HTMLInputElement | null;
const destZoomSelectListener = modal.querySelector(
'#modal-dest-zoom'
) as HTMLSelectElement | null;
if (destXInputListener) {
destXInputListener.addEventListener('input', updateDestinationPreview);
}
if (destYInputListener) {
destYInputListener.addEventListener('input', updateDestinationPreview);
}
if (destZoomSelectListener) {
destZoomSelectListener.addEventListener(
'change',
updateDestinationPreview
);
}
updatePreview();
modal.querySelector('#modal-cancel')?.addEventListener('click', () => {
cancelDestinationPicking();
modalContainer?.removeChild(overlay);
resolve(null);
});
modal.querySelector('#modal-confirm')?.addEventListener('click', () => {
const result: ModalResult = {};
fields.forEach((field) => {
if (field.type !== 'preview' && field.type !== 'destination') {
const input = modal.querySelector(`#modal-${field.name}`) as
| HTMLInputElement
| HTMLSelectElement
| null;
if (input) {
result[field.name] = input.value;
}
}
});
const colorSelectEl = modal.querySelector(
'#modal-color'
) as HTMLSelectElement | null;
const colorPickerEl = modal.querySelector(
'#modal-color-picker'
) as HTMLInputElement | null;
if (colorSelectEl && colorSelectEl.value === 'custom' && colorPickerEl) {
result.color = colorPickerEl.value;
}
const useDestCheckboxEl = modal.querySelector(
'#modal-use-destination'
) as HTMLInputElement | null;
if (useDestCheckboxEl && useDestCheckboxEl.checked) {
const destPageEl = modal.querySelector(
'#modal-dest-page'
) as HTMLInputElement | null;
const destXEl = modal.querySelector(
'#modal-dest-x'
) as HTMLInputElement | null;
const destYEl = modal.querySelector(
'#modal-dest-y'
) as HTMLInputElement | null;
const destZoomEl = modal.querySelector(
'#modal-dest-zoom'
) as HTMLSelectElement | null;
result.destPage = destPageEl ? parseInt(destPageEl.value) : null;
result.destX = destXEl ? parseFloat(destXEl.value) : null;
result.destY = destYEl ? parseFloat(destYEl.value) : null;
result.zoom = destZoomEl && destZoomEl.value ? destZoomEl.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: MouseEvent) => {
if (e.target === overlay) {
cancelDestinationPicking();
modalContainer?.removeChild(overlay);
resolve(null);
}
});
setTimeout(() => {
const firstInput = modal.querySelector(
'input, select'
) as HTMLElement | null;
if (firstInput) firstInput.focus();
}, 0);
createIcons({ icons });
});
}
function startDestinationPicking(callback: DestinationCallback): void {
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');
if (window.innerWidth < 1024) {
(
document.getElementById('show-viewer-btn') as HTMLButtonElement | null
)?.click();
}
createIcons({ icons });
}
function cancelDestinationPicking(): void {
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');
if (destinationMarker) {
destinationMarker.remove();
destinationMarker = null;
}
const coordDisplay = document.getElementById('destination-coord-display');
if (coordDisplay) {
coordDisplay.remove();
}
if (savedModalOverlay) {
savedModalOverlay.style.display = '';
savedModalOverlay = null;
savedModal = null;
}
}
document.addEventListener('DOMContentLoaded', () => {
initializeGlobalShortcuts();
const canvasEl = document.getElementById(
'pdf-canvas'
) as HTMLCanvasElement | null;
const canvasWrapperEl = document.getElementById(
'pdf-canvas-wrapper'
) as HTMLElement | null;
const cancelPickingBtn = document.getElementById(
'cancel-picking-btn'
) as HTMLButtonElement | null;
let coordTooltip: HTMLDivElement | null = null;
canvasWrapperEl?.addEventListener('mousemove', (e: MouseEvent) => {
if (!isPickingDestination || !canvasEl) return;
const rect = canvasEl.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (!coordTooltip) {
coordTooltip = document.createElement('div');
coordTooltip.className = 'coordinate-tooltip';
canvasWrapperEl.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';
});
canvasWrapperEl?.addEventListener('mouseleave', () => {
if (coordTooltip) {
coordTooltip.remove();
coordTooltip = null;
}
});
canvasEl?.addEventListener('click', async (e: MouseEvent) => {
if (
!isPickingDestination ||
!currentPickingCallback ||
!pdfJsDoc ||
!canvasEl ||
!canvasWrapperEl
)
return;
const rect = canvasEl.getBoundingClientRect();
const canvasX = e.clientX - rect.left;
const canvasY = e.clientY - rect.top;
let viewport = currentViewport;
if (!viewport) {
const page = await pdfJsDoc.getPage(currentPage);
viewport = page.getViewport({ scale: currentZoom });
}
// Convert canvas pixel coordinates to PDF coordinates (PDF uses bottom-left origin)
const scaleX = viewport.width / rect.width;
const scaleY = viewport.height / rect.height;
const pdfX = canvasX * scaleX;
const pdfY = viewport.height - canvasY * scaleY;
if (destinationMarker) {
destinationMarker.remove();
}
const oldCoordDisplay = document.getElementById(
'destination-coord-display'
);
if (oldCoordDisplay) {
oldCoordDisplay.remove();
}
destinationMarker = document.createElement('div');
destinationMarker.className = 'destination-marker';
destinationMarker.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2">
<circle cx="12" cy="12" r="10" fill="#3b82f6" fill-opacity="0.2" />
<path d="M12 2 L12 22 M2 12 L22 12" />
<circle cx="12" cy="12" r="2" fill="#3b82f6" />
</svg>
`;
const canvasRect = canvasEl.getBoundingClientRect();
const wrapperRect = canvasWrapperEl.getBoundingClientRect();
destinationMarker.style.position = 'absolute';
destinationMarker.style.left =
canvasX + canvasRect.left - wrapperRect.left + 'px';
destinationMarker.style.top =
canvasY + canvasRect.top - wrapperRect.top + 'px';
canvasWrapperEl.appendChild(destinationMarker);
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)} `;
canvasWrapperEl.appendChild(coordDisplay);
currentPickingCallback(currentPage, pdfX, pdfY);
setTimeout(() => {
cancelDestinationPicking();
}, 500);
});
if (cancelPickingBtn) {
cancelPickingBtn.addEventListener('click', () => {
cancelDestinationPicking();
});
}
});
function showConfirmModal(message: string): Promise<boolean> {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
const modal = document.createElement('div');
modal.className = 'modal-content';
modal.innerHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">Confirm Action</h3>
<p class="text-gray-600 mb-6">${message}</p>
<div class="flex gap-2 justify-end">
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">Cancel</button>
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button>
</div>
</div>
`;
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: MouseEvent) => {
if (e.target === overlay) {
modalContainer?.removeChild(overlay);
resolve(false);
}
});
});
}
function showAlertModal(title: string, message: string): Promise<boolean> {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
const modal = document.createElement('div');
modal.className = 'modal-content';
modal.innerHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
<p class="text-gray-600 mb-6">${message}</p>
<div class="flex justify-end">
<button id="modal-ok" class="px-4 py-2 rounded btn-gradient text-white">OK</button>
</div>
</div>
`;
overlay.appendChild(modal);
modalContainer?.appendChild(overlay);
modal.querySelector('#modal-ok')?.addEventListener('click', () => {
modalContainer?.removeChild(overlay);
resolve(true);
});
overlay.addEventListener('click', (e: MouseEvent) => {
if (e.target === overlay) {
modalContainer?.removeChild(overlay);
resolve(true);
}
});
});
}
function handleResize(): void {
if (window.innerWidth >= 1024) {
viewerSection?.classList.remove('hidden');
bookmarksSection?.classList.remove('hidden');
showViewerBtn?.classList.remove('bg-indigo-600', 'text-white');
showViewerBtn?.classList.add('text-gray-300');
showBookmarksBtn?.classList.remove('bg-indigo-600', 'text-white');
showBookmarksBtn?.classList.add('text-gray-300');
}
}
window.addEventListener('resize', handleResize);
showViewerBtn?.addEventListener('click', () => {
viewerSection?.classList.remove('hidden');
bookmarksSection?.classList.add('hidden');
showViewerBtn?.classList.add('bg-indigo-600', 'text-white');
showViewerBtn?.classList.remove('text-gray-300');
showBookmarksBtn?.classList.remove('bg-indigo-600', 'text-white');
showBookmarksBtn?.classList.add('text-gray-300');
});
showBookmarksBtn?.addEventListener('click', () => {
viewerSection?.classList.add('hidden');
bookmarksSection?.classList.remove('hidden');
showBookmarksBtn?.classList.add('bg-indigo-600', 'text-white');
showBookmarksBtn?.classList.remove('text-gray-300');
showViewerBtn?.classList.remove('bg-indigo-600', 'text-white');
showViewerBtn?.classList.add('text-gray-300');
});
importDropdownBtn?.addEventListener('click', (e: MouseEvent) => {
e.stopPropagation();
importDropdown?.classList.toggle('hidden');
exportDropdown?.classList.add('hidden');
});
exportDropdownBtn?.addEventListener('click', (e: MouseEvent) => {
e.stopPropagation();
exportDropdown?.classList.toggle('hidden');
importDropdown?.classList.add('hidden');
});
document.addEventListener('click', () => {
importDropdown?.classList.add('hidden');
exportDropdown?.classList.add('hidden');
});
let pdfLibDoc: PDFDocument | null = null;
let pdfJsDoc: PDFDocumentProxy | null = null;
let currentPage = 1;
let originalFileName = '';
let bookmarkTree: BookmarkTree = [];
let history: BookmarkTree[] = [];
let historyIndex = -1;
let searchQuery = '';
let csvBookmarks: BookmarkTree | null = null;
let jsonBookmarks: BookmarkTree | null = null;
let batchMode = false;
const selectedBookmarks = new Set<number>();
const collapsedNodes = new Set<number>();
function saveState(): void {
history = history.slice(0, historyIndex + 1);
history.push(JSON.parse(JSON.stringify(bookmarkTree)));
historyIndex++;
updateUndoRedoButtons();
}
function undo(): void {
if (historyIndex > 0) {
historyIndex--;
bookmarkTree = JSON.parse(JSON.stringify(history[historyIndex]));
renderBookmarkTree();
updateUndoRedoButtons();
}
}
function redo(): void {
if (historyIndex < history.length - 1) {
historyIndex++;
bookmarkTree = JSON.parse(JSON.stringify(history[historyIndex]));
renderBookmarkTree();
updateUndoRedoButtons();
}
}
function updateUndoRedoButtons(): void {
if (undoBtn) undoBtn.disabled = historyIndex <= 0;
if (redoBtn) redoBtn.disabled = historyIndex >= history.length - 1;
}
undoBtn?.addEventListener('click', undo);
redoBtn?.addEventListener('click', redo);
resetBtn?.addEventListener('click', async () => {
const confirmed = await showConfirmModal(
'Reset and go back to file uploader? All unsaved changes will be lost.'
);
if (confirmed) {
resetToUploader();
}
});
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(): void {
pdfLibDoc = null;
pdfJsDoc = null;
currentPage = 1;
originalFileName = '';
bookmarkTree = [];
history = [];
historyIndex = -1;
searchQuery = '';
csvBookmarks = null;
jsonBookmarks = null;
batchMode = false;
selectedBookmarks.clear();
collapsedNodes.clear();
if (fileInput) fileInput.value = '';
if (csvInput) csvInput.value = '';
if (jsonInput) jsonInput.value = '';
appEl?.classList.add('hidden');
uploaderEl?.classList.remove('hidden');
viewerSection?.classList.remove('hidden');
bookmarksSection?.classList.add('hidden');
showViewerBtn?.classList.add('bg-indigo-600', 'text-white');
showViewerBtn?.classList.remove('text-gray-300');
showBookmarksBtn?.classList.remove('bg-indigo-600', 'text-white');
showBookmarksBtn?.classList.add('text-gray-300');
}
document.addEventListener('keydown', (e: KeyboardEvent) => {
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();
}
} else if (e.key === 'PageUp') {
e.preventDefault();
prevPageBtn?.click();
} else if (e.key === 'PageDown') {
e.preventDefault();
nextPageBtn?.click();
}
});
batchModeCheckbox?.addEventListener('change', (e: Event) => {
const target = e.target as HTMLInputElement;
batchMode = target.checked;
if (!batchMode) {
selectedBookmarks.clear();
updateSelectedCount();
}
batchOperations?.classList.toggle(
'hidden',
!batchMode || selectedBookmarks.size === 0
);
renderBookmarkTree();
});
function updateSelectedCount(): void {
if (selectedCountDisplay)
selectedCountDisplay.textContent = String(selectedBookmarks.size);
if (batchMode) {
batchOperations?.classList.toggle('hidden', selectedBookmarks.size === 0);
}
}
selectAllBtn?.addEventListener('click', () => {
const getAllIds = (nodes: BookmarkNode[]): number[] => {
let ids: number[] = [];
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: Event) => {
const target = e.target as HTMLSelectElement;
if (target.value && selectedBookmarks.size > 0) {
const color = target.value === 'null' ? null : target.value;
applyToSelected((node) => (node.color = color));
target.value = '';
}
});
batchStyleSelect?.addEventListener('change', (e: Event) => {
const target = e.target as HTMLSelectElement;
if (target.value && selectedBookmarks.size > 0) {
const style =
target.value === 'null' ? null : (target.value as BookmarkStyle);
applyToSelected((node) => (node.style = style));
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: BookmarkNode[]): BookmarkNode[] => {
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: (node: BookmarkNode) => void): void {
const update = (nodes: BookmarkNode[]): BookmarkNode[] => {
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: BookmarkNode[]): void => {
nodes.forEach((node) => {
if (node.children.length > 0) {
collapsedNodes.add(node.id);
collapseAll(node.children);
}
});
};
collapseAll(bookmarkTree);
renderBookmarkTree();
});
function renderFileDisplay(file: File): void {
if (!fileDisplayArea) return;
fileDisplayArea.innerHTML = '';
fileDisplayArea.classList.remove('hidden');
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const nameSpan = document.createElement('span');
nameSpan.className = 'truncate font-medium text-gray-200';
nameSpan.textContent = file.name;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400';
sizeSpan.textContent = formatBytes(file.size);
fileDiv.append(nameSpan, sizeSpan);
fileDisplayArea.appendChild(fileDiv);
}
fileInput?.addEventListener('change', loadPDF);
async function loadPDF(e?: Event): Promise<void> {
const file = e
? (e.target as HTMLInputElement).files?.[0]
: fileInput?.files?.[0];
if (!file) return;
// Show loader
loaderModal?.classList.remove('hidden');
originalFileName = file.name.replace('.pdf', '');
if (filenameDisplay)
filenameDisplay.textContent = truncateFilename(file.name);
renderFileDisplay(file);
loaderModal?.classList.add('hidden');
const result = await loadPdfWithPasswordPrompt(file);
if (!result) {
loaderModal?.classList.add('hidden');
return;
}
loaderModal?.classList.remove('hidden');
currentPage = 1;
bookmarkTree = [];
history = [];
historyIndex = -1;
selectedBookmarks.clear();
collapsedNodes.clear();
pdfLibDoc = await PDFDocument.load(result.bytes, { ignoreEncryption: true });
pdfJsDoc = result.pdf;
if (gotoPageInput) gotoPageInput.max = String(pdfJsDoc.numPages);
appEl?.classList.remove('hidden');
uploaderEl?.classList.add('hidden');
if (autoExtractCheckbox?.checked) {
const extracted = await extractExistingBookmarks();
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 });
// Hide loader
loaderModal?.classList.add('hidden');
}
csvInput?.addEventListener('change', async (e: Event) => {
const target = e.target as HTMLInputElement;
const file = 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: Event) => {
const target = e.target as HTMLInputElement;
const file = 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: number,
zoom: string | null = null,
destX: number | null = null,
destY: number | null = null
): Promise<void> {
if (!pdfJsDoc || !canvas || !ctx) 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;
const viewport = page.getViewport({ scale: zoomScale });
currentViewport = viewport;
canvas.height = viewport.height * dpr;
canvas.width = viewport.width * dpr;
canvas.style.width = viewport.width + 'px';
canvas.style.height = viewport.height + 'px';
ctx.scale(dpr, dpr);
await page.render({ canvasContext: ctx, viewport: viewport, canvas: canvas })
.promise;
if (destX !== null && destY !== null) {
const canvasX = destX;
const canvasY = viewport.height - destY;
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;
ctx.beginPath();
ctx.moveTo(canvasX - 15, canvasY);
ctx.lineTo(canvasX + 15, canvasY);
ctx.moveTo(canvasX, canvasY - 15);
ctx.lineTo(canvasX, canvasY + 15);
ctx.stroke();
ctx.beginPath();
ctx.arc(canvasX, canvasY, 6, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
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}`;
if (gotoPageInput) gotoPageInput.value = String(num);
currentPage = num;
if (currentPageDisplay) currentPageDisplay.textContent = String(num);
}
async function renderPageWithDestination(
pageNum: number,
x: number | null,
y: number | null,
zoom: string | null
): Promise<void> {
await renderPage(pageNum, zoom, x, y);
}
prevPageBtn?.addEventListener('click', () => {
if (currentPage > 1) renderPage(currentPage - 1);
});
nextPageBtn?.addEventListener('click', () => {
if (pdfJsDoc && currentPage < pdfJsDoc.numPages) renderPage(currentPage + 1);
});
gotoBtn?.addEventListener('click', () => {
if (!pdfJsDoc || !gotoPageInput) return;
const page = parseInt(gotoPageInput.value);
if (page >= 1 && page <= pdfJsDoc.numPages) {
renderPage(page);
}
});
gotoPageInput?.addEventListener('keypress', (e: KeyboardEvent) => {
if (e.key === 'Enter') gotoBtn?.click();
});
function updateZoomIndicator(): void {
if (zoomIndicator) {
zoomIndicator.textContent = `${Math.round(currentZoom * 100)}%`;
}
}
zoomInBtn?.addEventListener('click', () => {
currentZoom = Math.min(currentZoom + 0.05, 2.0);
updateZoomIndicator();
renderPage(currentPage);
});
zoomOutBtn?.addEventListener('click', () => {
currentZoom = Math.max(currentZoom - 0.05, 0.25);
updateZoomIndicator();
renderPage(currentPage);
});
zoomFitBtn?.addEventListener('click', async () => {
if (!pdfJsDoc) return;
currentZoom = 1.0;
updateZoomIndicator();
renderPage(currentPage);
});
updateZoomIndicator();
searchInput?.addEventListener('input', (e: Event) => {
const target = e.target as HTMLInputElement;
searchQuery = target.value.toLowerCase();
renderBookmarkTree();
});
function removeNodeById(nodes: BookmarkNode[], id: number): boolean {
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: BookmarkNode[],
level = 0
): FlattenedBookmark[] {
let result: FlattenedBookmark[] = [];
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: BookmarkNode, query: string): boolean {
if (!query) return true;
if (node.title.toLowerCase().includes(query)) return true;
return node.children.some((child) => matchesSearch(child, query));
}
function makeSortable(
element: HTMLElement,
parentNode: BookmarkNode | null = null,
isTopLevel = false
): void {
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: BookmarkTree = JSON.parse(JSON.stringify(bookmarkTree));
if (
isTopLevel &&
evt.oldIndex !== undefined &&
evt.newIndex !== undefined
) {
const movedItem = treeCopy.splice(evt.oldIndex, 1)[0];
treeCopy.splice(evt.newIndex, 0, movedItem);
bookmarkTree = treeCopy;
} else if (
parentNode &&
evt.oldIndex !== undefined &&
evt.newIndex !== undefined
) {
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: BookmarkNode[],
id: number
): BookmarkNode | null {
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: BookmarkStyle): string {
if (style === 'bold') return 'font-bold';
if (style === 'italic') return 'italic';
if (style === 'bold-italic') return 'font-bold italic';
return '';
}
function getTextColor(color: BookmarkColor | string): string {
if (!color) return 'text-gray-700';
if (typeof color === 'string' && color.startsWith('#')) {
return '';
}
return TEXT_COLOR_CLASSES[color] || 'text-gray-700';
}
function renderBookmarkTree(): void {
if (!treeList) return;
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: BookmarkNode, level = 0): HTMLLIElement {
if (!node || !node.id) {
console.error('Invalid node:', node);
return document.createElement('li');
}
const li = document.createElement('li');
li.dataset.bookmarkId = String(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 && typeof node.color === 'string'
? COLOR_CLASSES[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: MouseEvent) => {
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 =
'<i data-lucide="grip-vertical" class="w-4 h-4 text-gray-400"></i>';
div.appendChild(dragHandle);
if (hasChildren) {
const toggleBtn = document.createElement('button');
toggleBtn.className = 'p-0 flex-shrink-0';
toggleBtn.innerHTML = isCollapsed
? '<i data-lucide="chevron-right" class="w-4 h-4"></i>'
: '<i data-lucide="chevron-down" class="w-4 h-4"></i>';
toggleBtn.addEventListener('click', (e: MouseEvent) => {
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 && typeof node.color === 'string' && node.color.startsWith('#')
? `style="color: ${node.color}"`
: '';
const hasDestination =
node.destX !== null || node.destY !== null || node.zoom !== null;
const destinationIcon = hasDestination
? '<i data-lucide="crosshair" class="w-3 h-3 inline-block ml-1 text-blue-500"></i>'
: '';
titleDiv.innerHTML = `
<span class="text-sm block ${styleClass} ${textColorClass}" ${customColorStyle}>${escapeHTML(node.title)}${destinationIcon}</span>
<span class="text-xs text-gray-500">Page ${node.page}</span>
`;
titleDiv.addEventListener('click', async () => {
if (node.destX !== null || node.destY !== null || node.zoom !== null) {
await renderPageWithDestination(
node.page,
node.destX,
node.destY,
node.zoom
);
setTimeout(() => {
if (node.zoom !== null && node.zoom !== '' && node.zoom !== '0') {
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 text-gray-700';
addChildBtn.title = 'Add child';
addChildBtn.innerHTML = '<i data-lucide="plus" class="w-4 h-4"></i>';
addChildBtn.addEventListener('click', async (e: MouseEvent) => {
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: cleanTitle(String(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 text-gray-700';
editBtn.title = 'Edit';
editBtn.innerHTML = '<i data-lucide="edit-2" class="w-4 h-4"></i>';
editBtn.addEventListener('click', async (e: MouseEvent) => {
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',
name: '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 = cleanTitle(String(result.title || ''));
node.color = result.color || null;
node.style = (result.style as BookmarkStyle) || null;
if (result.destPage !== null && result.destPage !== undefined) {
node.page = result.destPage;
node.destX = result.destX ?? null;
node.destY = result.destY ?? null;
node.zoom = result.zoom ?? null;
}
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 = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
deleteBtn.addEventListener('click', async (e: MouseEvent) => {
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: BookmarkNode = 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();
if (titleInput) titleInput.value = '';
});
titleInput?.addEventListener('keypress', (e: KeyboardEvent) => {
if (e.key === 'Enter') addTopLevelBtn?.click();
});
function escapeHTML(str: string): string {
return escapeHtml(str);
}
importCsvBtn?.addEventListener('click', () => {
csvImportHidden?.click();
importDropdown?.classList.add('hidden');
});
csvImportHidden?.addEventListener('change', async (e: Event) => {
const target = e.target as HTMLInputElement;
const file = 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!`);
}
if (csvImportHidden) 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' });
downloadFile(blob, `${originalFileName}-bookmarks.csv`);
});
function parseCSV(text: string): BookmarkTree {
const lines = text.trim().split('\n').slice(1);
const bookmarks: BookmarkTree = [];
const stack: Array<{ children: BookmarkNode[]; level: number }> = [
{ 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: BookmarkNode = {
id: Date.now() + Math.random(),
title: cleanTitle(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({ children: bookmark.children, level: lvl });
}
return bookmarks;
}
importJsonBtn?.addEventListener('click', () => {
jsonImportHidden?.click();
importDropdown?.classList.add('hidden');
});
jsonImportHidden?.addEventListener('change', async (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
const text = await file.text();
try {
const imported = JSON.parse(text) as BookmarkTree;
function cleanImportedTree(nodes: BookmarkNode[]): void {
if (!nodes) return;
for (const node of nodes) {
if (node.title) node.title = cleanTitle(node.title);
if (node.children) cleanImportedTree(node.children);
}
}
cleanImportedTree(imported);
bookmarkTree = imported;
saveState();
renderBookmarkTree();
await showAlertModal('Success', 'Bookmarks imported from JSON!');
} catch (err) {
await showAlertModal('Error', 'Invalid JSON format');
}
if (jsonImportHidden) 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' });
downloadFile(blob, `${originalFileName}-bookmarks.json`);
});
extractExistingBtn?.addEventListener('click', async () => {
if (!pdfLibDoc) return;
const extracted = await extractExistingBookmarks();
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.');
}
});
// function cleanTitle(title) {
// // @TODO@ALAM: visit this for encoding issues later
// if (typeof title === 'string') {
// if (title.includes('€') && !title.includes(' ')) {
// return title.replace(/€/g, ' ');
// }
// return title.replace(/[\x00-\x1F\x7F-\x9F]/g, '').trim();
// }
// return title;
// }
function cleanTitle(title: string): string {
if (typeof title === 'string') {
return title.replace(/[\x00-\x1F\x7F-\x9F]/g, '').trim();
}
return title;
}
async function extractExistingBookmarks(): Promise<BookmarkTree> {
try {
if (!pdfJsDoc) return [];
const outline = await pdfJsDoc.getOutline();
if (!outline) return [];
async function processOutlineItem(
item: PDFOutlineItem
): Promise<BookmarkNode> {
let pageIndex = 0;
let destX: number | null = null;
let destY: number | null = null;
let zoom: string | null = null;
try {
let dest = item.dest;
if (typeof dest === 'string' && pdfJsDoc) {
dest = await pdfJsDoc.getDestination(dest);
}
if (Array.isArray(dest) && pdfJsDoc) {
const destRef = dest[0] as { num: number; gen: number };
pageIndex = await pdfJsDoc.getPageIndex(destRef);
if (dest.length > 2) {
const x = dest[2];
const y = dest[3];
const z = dest[4];
if (typeof x === 'number') destX = x;
if (typeof y === 'number') destY = y;
if (typeof z === 'number') zoom = String(Math.round(z * 100));
}
}
} catch (e) {
console.warn('Error resolving destination:', e);
}
let color: BookmarkColor = null;
if (item.color) {
const [r, g, b] = item.color;
const rN = r / 255;
const gN = g / 255;
const bN = b / 255;
if (rN > 0.8 && gN < 0.3 && bN < 0.3) color = 'red';
else if (rN < 0.3 && gN < 0.3 && bN > 0.8) color = 'blue';
else if (rN < 0.3 && gN > 0.8 && bN < 0.3) color = 'green';
else if (rN > 0.8 && gN > 0.8 && bN < 0.3) color = 'yellow';
else if (rN > 0.5 && gN < 0.5 && bN > 0.5) color = 'purple';
}
let style: BookmarkStyle = null;
if (item.bold && item.italic) style = 'bold-italic';
else if (item.bold) style = 'bold';
else if (item.italic) style = 'italic';
const bookmark: BookmarkNode = {
id: Date.now() + Math.random(),
title: cleanTitle(item.title),
page: pageIndex + 1,
children: [],
color,
style,
destX,
destY,
zoom,
};
if (item.items && item.items.length > 0) {
for (const childItem of item.items) {
const childBookmark = await processOutlineItem(childItem);
bookmark.children.push(childBookmark);
}
}
return bookmark;
}
const result: BookmarkTree = [];
for (const item of outline) {
const bookmark = await processOutlineItem(item as PDFOutlineItem);
result.push(bookmark);
}
return result;
} catch (err) {
console.error('Error extracting bookmarks:', err);
return [];
}
}
if (backToToolsBtn) {
backToToolsBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
if (closeBtn) {
closeBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
downloadBtn?.addEventListener('click', async () => {
if (!pdfLibDoc) return;
const pages = pdfLibDoc.getPages();
const outlinesDict = pdfLibDoc.context.obj({});
const outlinesRef = pdfLibDoc.context.register(outlinesDict);
function createOutlineItems(
nodes: BookmarkNode[],
parentRef: PDFRef
): OutlineItem[] {
const items: OutlineItem[] = [];
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const itemDict = pdfLibDoc!.context.obj({}) as unknown as ReturnType<
typeof pdfLibDoc.context.obj
> & { set: (key: PDFName, value: unknown) => void };
const itemRef = pdfLibDoc!.context.register(
itemDict as unknown as Parameters<typeof pdfLibDoc.context.register>[0]
);
itemDict.set(PDFName.of('Title'), PDFHexString.fromText(node.title));
itemDict.set(PDFName.of('Parent'), parentRef);
const pageIndex = Math.max(0, Math.min(node.page - 1, pages.length - 1));
const pageRef = pages[pageIndex].ref;
let destArray: unknown;
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') {
zoom = PDFNumber.of(parseFloat(node.zoom) / 100);
}
destArray = pdfLibDoc!.context.obj([
pageRef,
PDFName.of('XYZ'),
x,
y,
zoom,
] as (PDFRef | PDFName | PDFNumber | null)[]);
} else {
destArray = pdfLibDoc!.context.obj([
pageRef,
PDFName.of('XYZ'),
null,
null,
null,
] as (PDFRef | PDFName | null)[]);
}
itemDict.set(PDFName.of('Dest'), destArray);
if (node.color) {
let rgb: number[] | undefined;
const colorStr = node.color as string;
if (colorStr.startsWith('#')) {
const { r, g, b } = hexToRgb(colorStr);
rgb = [r, g, b];
} else if (PDF_COLOR_MAP[colorStr]) {
rgb = PDF_COLOR_MAP[colorStr];
}
if (rgb) {
const colorArray = pdfLibDoc!.context.obj(rgb);
itemDict.set(PDFName.of('C'), colorArray);
}
}
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([new Uint8Array(pdfBytes)], {
type: 'application/pdf',
});
downloadFile(blob, `${originalFileName}-bookmarked.pdf`);
await showAlertModal('Success', 'PDF saved successfully!');
setTimeout(() => {
resetToUploader();
}, 500);
} catch (err) {
console.error(err);
await showAlertModal(
'Error',
'Error saving PDF. Check console for details.'
);
}
});