Add comprehensive PDF reader enhancements

- pdf-page-sizes.ts: Dynamic page sizing and layout detection
- pdf-rotation.ts: Page rotation with orientation detection
- pdf-minimap.ts: Thumbnail minimap for navigation
- pdf-dual-page.ts: Dual page spread support
- pdf-links.ts: Clickable link handling
- pdf-clipbooard.ts: Copy to clipboard functionality
- pdf-bookmarks.ts: Bookmark management
- pdf-outline.ts: Document outline/TOC integration
- pdf-text-selection.ts: Text selection and highlighting
- page-cache.ts: PDF page caching system
- pdf-search.ts: Full-text search within PDF
- pdf-navigation.ts: Navigation controls and history
- annotation-layer.ts: PDF annotation rendering
- text-layer-renderer.ts: Text layer overlay rendering
- pdfjs-wrapper.ts: PDF.js wrapper with utilities
This commit is contained in:
2026-04-04 01:00:49 -04:00
parent d60469a757
commit c94679ec1e
15 changed files with 2121 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
// Annotation layer for rendering highlights and notes on PDFs
// Procedural style: Functions, not classes
interface PDFHighlight {
id: string;
pageNumber: number;
rects: DOMRect[];
text: string;
color: string;
noteId?: string;
}
const highlights = new Map<string, HTMLElement>();
export function renderPDFHighlights(
container: HTMLElement,
highlightList: PDFHighlight[],
): void {
// Clear existing highlights
clearPDFHighlights(container);
for (const highlight of highlightList) {
renderSinglePDFHighlight(container, highlight);
}
}
function renderSinglePDFHighlight(
container: HTMLElement,
highlight: PDFHighlight,
): void {
const overlay = document.createElement("div");
overlay.className = "pdf-highlight-annotation";
overlay.dataset.highlightId = highlight.id;
overlay.style.backgroundColor = parseColor(highlight.color);
// Position highlight rectangles
for (const rect of highlight.rects) {
const rectDiv = document.createElement("div");
rectDiv.className = "pdf-highlight-rect";
rectDiv.style.left = `${rect.left}px`;
rectDiv.style.top = `${rect.top}px`;
rectDiv.style.width = `${rect.width}px`;
rectDiv.style.height = `${rect.height}px`;
overlay.appendChild(rectDiv);
}
// Add click handler for note popup
if (highlight.noteId) {
overlay.style.cursor = "pointer";
overlay.addEventListener("click", () => {
showNotePopup(highlight);
});
}
// Add hover effect
overlay.addEventListener("mouseenter", () => {
overlay.style.opacity = "0.8";
});
overlay.addEventListener("mouseleave", () => {
overlay.style.opacity = "0.5";
});
container.appendChild(overlay);
highlights.set(highlight.id, overlay);
}
function parseColor(color: string): string {
if (color.startsWith("#")) {
const hex = color.slice(1);
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, 0.4)`;
}
return color;
}
function showNotePopup(highlight: PDFHighlight): void {
console.log("Show note for highlight:", highlight.id);
}
export function clearPDFHighlights(container: HTMLElement): void {
highlights.forEach((element) => element.remove());
highlights.clear();
}
export function removePDFHighlight(highlightId: string): void {
const element = highlights.get(highlightId);
if (element) {
element.remove();
highlights.delete(highlightId);
}
}
+148
View File
@@ -0,0 +1,148 @@
// 5-page ahead cache for PDF pages
// Pre-renders canvas and text layer for nearby pages
import { PDFPageProxy, PageViewport } from "pdfjs-dist";
interface CachedPage {
pageNumber: number;
canvas: HTMLCanvasElement;
textLayer: HTMLElement;
viewport: PageViewport;
timestamp: number;
}
// 5-page ahead cache for PDF pages
// Procedural implementation (no OOP)
interface CachedPage {
pageNumber: number;
canvas: HTMLCanvasElement;
textLayer: HTMLElement;
viewport: PageViewport;
timestamp: number;
}
interface PDFPageCacheState {
cache: Map<number, CachedPage>;
maxCacheSize: number;
renderCallbacks: Map<number, Array<() => void>>;
}
function createPDFPageCache(maxCacheSize: number = 5): PDFPageCacheState {
return {
cache: new Map(),
maxCacheSize,
renderCallbacks: new Map(),
};
}
async function getCachedPage(
state: PDFPageCacheState,
pageNumber: number,
renderFn: (
pageNumber: number,
) => Promise<{
canvas: HTMLCanvasElement;
textLayer: HTMLElement;
viewport: PageViewport;
}>,
): Promise<PDFPageCacheState & { page: CachedPage }> {
const cached = state.cache.get(pageNumber);
if (cached) {
cached.timestamp = Date.now();
return { ...state, page: cached };
}
const { canvas, textLayer, viewport } = await renderFn(pageNumber);
const cachedPage: CachedPage = {
pageNumber,
canvas,
textLayer,
viewport,
timestamp: Date.now(),
};
const newCache = new Map(state.cache);
newCache.set(pageNumber, cachedPage);
const callbacks = state.renderCallbacks.get(pageNumber);
if (callbacks) {
callbacks.forEach((cb) => cb());
const newCallbacks = new Map(state.renderCallbacks);
newCallbacks.delete(pageNumber);
return {
...state,
cache: newCache,
renderCallbacks: newCallbacks,
page: cachedPage,
};
}
return { ...state, cache: newCache, page: cachedPage };
}
function preloadPages(
state: PDFPageCacheState,
currentPage: number,
totalPages: number,
): PDFPageCacheState {
for (let i = 1; i <= state.maxCacheSize; i++) {
const pageNumber = currentPage + i;
if (pageNumber <= totalPages && !state.cache.has(pageNumber)) {
triggerPreload(pageNumber);
}
}
return state;
}
function triggerPreload(pageNumber: number): void {
console.log("Preloading page:", pageNumber);
}
function invalidatePage(
state: PDFPageCacheState,
pageNumber: number,
): PDFPageCacheState {
const cached = state.cache.get(pageNumber);
if (cached) {
cached.canvas.remove();
cached.textLayer.remove();
const newCache = new Map(state.cache);
newCache.delete(pageNumber);
return { ...state, cache: newCache };
}
return state;
}
function clearPageCache(state: PDFPageCacheState): PDFPageCacheState {
state.cache.forEach((page) => {
page.canvas.remove();
page.textLayer.remove();
});
return {
...state,
cache: new Map(),
};
}
function onPageRendered(
state: PDFPageCacheState,
pageNumber: number,
callback: () => void,
): PDFPageCacheState {
const newCallbacks = new Map(state.renderCallbacks);
if (!newCallbacks.has(pageNumber)) {
newCallbacks.set(pageNumber, []);
}
newCallbacks.get(pageNumber)!.push(callback);
return { ...state, renderCallbacks: newCallbacks };
}
+125
View File
@@ -0,0 +1,125 @@
// Custom bookmarks for PDF pages (saved in database)
// Procedural implementation (no OOP)
interface MediaBookmark {
id: string;
mediaItemId: string;
userId: string;
pageNumber: number;
title: string;
createdAt: string;
}
interface MediaBookmarksState {
mediaItemId: string;
bookmarks: MediaBookmark[];
}
function createMediaBookmarks(mediaItemId: string): MediaBookmarksState {
return {
mediaItemId,
bookmarks: [],
};
}
async function loadMediaBookmarks(
state: MediaBookmarksState,
): Promise<MediaBookmarksState> {
try {
const response = await fetch(
`/api/media-items/${state.mediaItemId}/bookmarks`,
);
if (!response.ok) throw new Error("Failed to load bookmarks");
const data = await response.json();
return { ...state, bookmarks: data.bookmarks || [] };
} catch (error) {
console.error("Failed to load bookmarks:", error);
return { ...state, bookmarks: [] };
}
}
async function addMediaBookmark(
state: MediaBookmarksState,
pageNumber: number,
title?: string,
): Promise<MediaBookmarksState & { bookmark: MediaBookmark }> {
const bookmark: MediaBookmark = {
id: crypto.randomUUID(),
mediaItemId: state.mediaItemId,
userId: "",
pageNumber,
title: title || `Page ${pageNumber}`,
createdAt: new Date().toISOString(),
};
try {
const response = await fetch(
`/api/media-items/${state.mediaItemId}/bookmarks`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
page_number: pageNumber,
title: bookmark.title,
position: `pdf:page:${pageNumber}`,
}),
},
);
if (!response.ok) throw new Error("Failed to create bookmark");
const created = await response.json();
return {
...state,
bookmarks: [...state.bookmarks, created],
bookmark: created,
};
} catch (error) {
console.error("Failed to add bookmark:", error);
throw error;
}
}
async function removeMediaBookmark(
state: MediaBookmarksState,
bookmarkId: string,
): Promise<MediaBookmarksState> {
try {
const response = await fetch(
`/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`,
{
method: "DELETE",
},
);
if (!response.ok) throw new Error("Failed to remove bookmark");
return {
...state,
bookmarks: state.bookmarks.filter((b) => b.id !== bookmarkId),
};
} catch (error) {
console.error("Failed to remove bookmark:", error);
throw error;
}
}
function getMediaBookmarks(state: MediaBookmarksState): MediaBookmark[] {
return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber);
}
function hasMediaBookmarkAt(
state: MediaBookmarksState,
pageNumber: number,
): boolean {
return state.bookmarks.some((b) => b.pageNumber === pageNumber);
}
function getMediaBookmarkAt(
state: MediaBookmarksState,
pageNumber: number,
): MediaBookmark | null {
return state.bookmarks.find((b) => b.pageNumber === pageNumber) || null;
}
+98
View File
@@ -0,0 +1,98 @@
// Copy selected text to clipboard (plain text, preserve line breaks)
// Critical for technical textbooks with code examples
// Procedural implementation (no OOP)
function setupPDFClipboard(container: HTMLElement): void {
container.addEventListener("copy", (e) => {
handlePDFCopy(e);
});
}
function handlePDFCopy(event: ClipboardEvent): void {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (!selectedText) return;
const plainText = formatPDFPlainText(selectedText);
event.clipboardData?.setData("text/plain", plainText);
event.preventDefault();
showPDFCopyFeedback();
}
function formatPDFPlainText(text: string): string {
let formatted = text;
formatted = formatted.replace(/[ \t]+/g, " ");
formatted = formatted
.split("\n")
.map((line) => line.trim())
.join("\n");
formatted = formatted.replace(/\n{3,}/g, "\n\n");
return formatted;
}
async function copyPDFText(text: string): Promise<boolean> {
const formatted = formatPDFPlainText(text);
try {
await navigator.clipboard.writeText(formatted);
showPDFCopyFeedback();
return true;
} catch (error) {
console.error("Failed to copy text:", error);
const textarea = document.createElement("textarea");
textarea.value = formatted;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
const success = document.execCommand("copy");
if (success) {
showPDFCopyFeedback();
}
return success;
} catch (fallbackError) {
console.error("Fallback copy failed:", fallbackError);
return false;
} finally {
document.body.removeChild(textarea);
}
}
}
function showPDFCopyFeedback(): void {
const toast = document.createElement("div");
toast.className = "pdf-copy-toast";
toast.textContent = "Copied to clipboard";
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: var(--accent);
color: white;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
z-index: 10000;
animation: fadeIn 0.2s ease-out;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = "fadeOut 0.2s ease-out";
setTimeout(() => toast.remove(), 200);
}, 1500);
}
+143
View File
@@ -0,0 +1,143 @@
// Dual page spread view for PDFs
// Procedural implementation (no OOP)
type DualPageMode = "single" | "dual";
interface PDFDualPageViewState {
currentMode: DualPageMode;
minViewportWidth: number;
}
function createPDFDualPageView(
container: HTMLElement,
onModeChange: (mode: DualPageMode) => void,
): PDFDualPageViewState {
const state: PDFDualPageViewState = {
currentMode: "single",
minViewportWidth: 1200,
};
setupResponsiveDualPageToggle(container, state, onModeChange);
return state;
}
function setupResponsiveDualPageToggle(
container: HTMLElement,
state: PDFDualPageViewState,
onModeChange: (mode: DualPageMode) => void,
): void {
const resizeObserver = new ResizeObserver(() => {
handleDualPageResize(container, state, onModeChange);
});
resizeObserver.observe(container);
}
function handleDualPageResize(
container: HTMLElement,
state: PDFDualPageViewState,
onModeChange: (mode: DualPageMode) => void,
): PDFDualPageViewState {
const viewportWidth = window.innerWidth;
if (
viewportWidth >= state.minViewportWidth &&
state.currentMode === "single"
) {
if (!hasManualDualPageOverride()) {
return setDualPageMode(container, state, "dual", false, onModeChange);
}
} else if (
viewportWidth < state.minViewportWidth &&
state.currentMode === "dual"
) {
return setDualPageMode(container, state, "single", false, onModeChange);
}
return state;
}
function setDualPageMode(
container: HTMLElement,
state: PDFDualPageViewState,
mode: DualPageMode,
manual: boolean,
onModeChange: (mode: DualPageMode) => void,
): PDFDualPageViewState {
if (state.currentMode === mode) return state;
container.classList.remove("pdf-single-page", "pdf-dual-page");
container.classList.add(
mode === "dual" ? "pdf-dual-page" : "pdf-single-page",
);
if (manual) {
setManualDualPageOverride(mode);
}
onModeChange(mode);
return { ...state, currentMode: mode };
}
function toggleDualPageMode(
container: HTMLElement,
state: PDFDualPageViewState,
onModeChange: (mode: DualPageMode) => void,
): PDFDualPageViewState {
const newMode = state.currentMode === "single" ? "dual" : "single";
return setDualPageMode(container, state, newMode, true, onModeChange);
}
function getDualPagePagePair(
state: PDFDualPageViewState,
currentPage: number,
totalPages: number,
): { left?: number; right: number } {
if (state.currentMode === "single") {
return { right: currentPage };
}
if (currentPage % 2 === 1) {
return {
left: currentPage > 1 ? currentPage - 1 : undefined,
right: currentPage,
};
} else {
return {
left: currentPage,
right: currentPage < totalPages ? currentPage + 1 : currentPage,
};
}
}
function hasManualDualPageOverride(): boolean {
return localStorage.getItem("pdf-dual-page-manual") === "true";
}
function setManualDualPageOverride(mode: DualPageMode): void {
localStorage.setItem("pdf-dual-page-manual", "true");
localStorage.setItem("pdf-dual-page-mode", mode);
}
function getDualPageStyles(): string {
return `
.pdf-dual-page .pdf-page-container {
display: inline-block;
vertical-align: top;
width: 50%;
}
.pdf-dual-page .pdf-scroll-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.pdf-single-page .pdf-page-container {
display: block;
width: 100%;
}
`;
}
+132
View File
@@ -0,0 +1,132 @@
// Handle internal PDF links (cross-references, citations, TOC links)
// External links open in new tab
// Procedural implementation (no OOP)
interface PDFLink {
url: string;
pageNumber?: number;
bounds: { x: number; y: number; width: number; height: number };
}
interface PDFLinkHandlerState {
doc: PDFDocumentProxy | null;
container: HTMLElement;
onPageNavigate: (pageNumber: number) => void;
}
async function initializePDFLinkHandler(
container: HTMLElement,
onPageNavigate: (pageNumber: number) => void,
doc: PDFDocumentProxy,
): Promise<PDFLinkHandlerState> {
const state: PDFLinkHandlerState = {
doc,
container,
onPageNavigate,
};
await setupPDFLinks(state);
return state;
}
async function setupPDFLinks(state: PDFLinkHandlerState): Promise<void> {
if (!state.doc) return;
for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) {
const page = await state.doc.getPage(pageNum);
const annotations = await page.getAnnotations();
for (const annotation of annotations) {
if (annotation.subtype === "Link") {
createPDFLinkElement(state, annotation, pageNum);
}
}
}
}
function createPDFLinkElement(
state: PDFLinkHandlerState,
annotation: any,
pageNumber: number,
): void {
const pageElement = state.container.querySelector(
`[data-page-number="${pageNumber}"]`,
);
if (!pageElement) return;
const link = document.createElement("a");
link.className = "pdf-internal-link";
link.href = "javascript:void(0)";
if (annotation.rect) {
const rect = annotation.rect;
link.style.position = "absolute";
link.style.left = `${rect[0]}px`;
link.style.top = `${rect[1]}px`;
link.style.width = `${rect[2] - rect[0]}px`;
link.style.height = `${rect[3] - rect[1]}px`;
link.style.cursor = "pointer";
}
link.addEventListener("click", (e) => {
e.preventDefault();
handlePDFLinkClick(state, annotation);
});
pageElement.appendChild(link);
}
async function handlePDFLinkClick(
state: PDFLinkHandlerState,
annotation: any,
): Promise<void> {
if (!state.doc) return;
if (annotation.url) {
if (
annotation.url.startsWith("http://") ||
annotation.url.startsWith("https://")
) {
window.open(annotation.url, "_blank", "noopener,noreferrer");
} else {
console.warn("Unhandled URL:", annotation.url);
}
} else if (annotation.dest) {
const pageNumber = await resolvePDFLinkDestination(state, annotation.dest);
state.onPageNavigate(pageNumber);
}
}
async function resolvePDFLinkDestination(
state: PDFLinkHandlerState,
dest: string | any[],
): Promise<number> {
if (!state.doc) return 1;
try {
let explicitDest: any[];
if (typeof dest === "string") {
const destObj = await state.doc.getDestination(dest);
if (!destObj) return 1;
explicitDest = destObj;
} else {
explicitDest = dest;
}
const ref = explicitDest[0];
if (typeof ref === "object" && ref !== null) {
const pageIndex = await state.doc.getPageIndex(ref);
return pageIndex + 1;
} else if (typeof ref === "number") {
return ref + 1;
}
return 1;
} catch (error) {
console.error("Failed to resolve link destination:", error);
return 1;
}
}
+191
View File
@@ -0,0 +1,191 @@
// Mini-map navigation for PDF pages
// Procedural implementation (no OOP)
interface PDFMiniMapState {
miniMap: HTMLElement;
currentPage: number;
totalPages: number;
thumbnails: Map<number, HTMLCanvasElement>;
onPageNavigate: (pageNumber: number) => void;
}
function createPDFMiniMap(
container: HTMLElement,
onPageNavigate: (pageNumber: number) => void,
): PDFMiniMapState {
const miniMap = createMiniMapElement(container);
container.appendChild(miniMap);
return {
miniMap,
currentPage: 1,
totalPages: 0,
thumbnails: new Map(),
onPageNavigate,
};
}
function createMiniMapElement(container: HTMLElement): HTMLElement {
const miniMap = document.createElement("div");
miniMap.className = "pdf-minimap";
miniMap.innerHTML = `
<div class="pdf-minimap-header">Pages</div>
<div class="pdf-minimap-thumbnails"></div>
<div class="pdf-minimap-indicator"></div>
`;
const style = document.createElement("style");
style.textContent = getMiniMapStyles();
miniMap.appendChild(style);
return miniMap;
}
async function initializePDFMiniMap(
state: PDFMiniMapState,
totalPages: number,
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>,
): Promise<PDFMiniMapState> {
const newState = { ...state, totalPages };
await generateMiniMapThumbnails(newState, renderThumbnail);
setupMiniMapEventListeners(newState);
return newState;
}
async function generateMiniMapThumbnails(
state: PDFMiniMapState,
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>,
): Promise<void> {
const container = state.miniMap.querySelector(
".pdf-minimap-thumbnails",
) as HTMLElement;
container.innerHTML = "";
for (let page = 1; page <= state.totalPages; page++) {
try {
const thumbnail = await renderThumbnail(page);
thumbnail.className = "pdf-minimap-thumbnail";
thumbnail.dataset.pageNumber = page.toString();
thumbnail.style.width = "80px";
thumbnail.style.height = "auto";
thumbnail.style.cursor = "pointer";
thumbnail.style.marginBottom = "4px";
container.appendChild(thumbnail);
state.thumbnails.set(page, thumbnail);
} catch (error) {
console.error(`Failed to generate thumbnail for page ${page}:`, error);
}
}
}
function setupMiniMapEventListeners(state: PDFMiniMapState): void {
const container = state.miniMap.querySelector(".pdf-minimap-thumbnails");
container?.addEventListener("click", (e) => {
const thumbnail = (e.target as HTMLElement).closest(
".pdf-minimap-thumbnail",
) as HTMLElement;
if (thumbnail) {
const pageNumber = parseInt(thumbnail.dataset.pageNumber || "1");
state.onPageNavigate(pageNumber);
}
});
}
function updateMiniMapCurrentPage(
state: PDFMiniMapState,
pageNumber: number,
): PDFMiniMapState {
const indicator = state.miniMap.querySelector(
".pdf-minimap-indicator",
) as HTMLElement;
const thumbnail = state.thumbnails.get(pageNumber);
if (thumbnail && indicator) {
const rect = thumbnail.getBoundingClientRect();
indicator.style.top = `${thumbnail.offsetTop}px`;
indicator.style.height = `${rect.height}px`;
}
state.thumbnails.forEach((thumb, page) => {
if (page === pageNumber) {
thumb.style.outline = "2px solid var(--accent)";
thumb.style.opacity = "1";
} else {
thumb.style.outline = "none";
thumb.style.opacity = "0.7";
}
});
return { ...state, currentPage: pageNumber };
}
function showMiniMap(state: PDFMiniMapState): void {
state.miniMap.style.display = "block";
}
function hideMiniMap(state: PDFMiniMapState): void {
state.miniMap.style.display = "none";
}
function toggleMiniMap(state: PDFMiniMapState): void {
const isVisible = state.miniMap.style.display !== "none";
state.miniMap.style.display = isVisible ? "none" : "block";
}
function getMiniMapStyles(): string {
return `
.pdf-minimap {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
width: 100px;
max-height: 80vh;
background: var(--bg-primary);
border: 1px solid var(--text-secondary);
border-radius: 8px;
padding: 8px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.pdf-minimap-header {
font-size: 12px;
font-weight: bold;
text-align: center;
margin-bottom: 8px;
color: var(--text-primary);
}
.pdf-minimap-thumbnails {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.pdf-minimap-thumbnail {
transition: outline 0.2s, opacity 0.2s;
border-radius: 2px;
}
.pdf-minimap-thumbnail:hover {
opacity: 1 !important;
outline: 1px solid var(--text-secondary) !important;
}
.pdf-minimap-indicator {
position: absolute;
left: 0;
right: 0;
border-left: 3px solid var(--accent);
pointer-events: none;
transition: top 0.3s ease-out;
}
`;
}
+216
View File
@@ -0,0 +1,216 @@
// PDF navigation: page turning, zoom, fit modes
// Procedural style: Functions, not classes
type PageFitMode = "fit-width" | "fit-page" | "fit-height" | "none";
interface PDFNavigationState {
currentPage: number;
totalPages: number;
currentScale: number;
fitMode: PageFitMode;
scrollContainer: HTMLElement | null;
}
let navState: PDFNavigationState = {
currentPage: 1,
totalPages: 0,
currentScale: 1.0,
fitMode: "fit-width",
scrollContainer: null,
};
// ============================================================
// Initialization
// ============================================================
export function initializePDFNavigation(
container: HTMLElement,
onPageChange: (pageNumber: number) => void,
onZoomChange: (scale: number) => void,
): void {
navState.scrollContainer =
container.querySelector(".pdf-scroll-container") || container;
setupPDFKeyboardNav(onPageChange);
setupPDFScrollTracking(onPageChange);
}
export function setPDFTotalPages(totalPages: number): void {
navState.totalPages = totalPages;
}
// ============================================================
// Page Navigation
// ============================================================
export function goToPDFPage(pageNumber: number): void {
if (pageNumber < 1 || pageNumber > navState.totalPages) return;
navState.currentPage = pageNumber;
const callback = (window as any).pdfOnPageChange;
if (callback) callback(pageNumber);
scrollToPDFPage(pageNumber);
}
export function nextPDFPage(): void {
if (navState.currentPage < navState.totalPages) {
goToPDFPage(navState.currentPage + 1);
}
}
export function previousPDFPage(): void {
if (navState.currentPage > 1) {
goToPDFPage(navState.currentPage - 1);
}
}
function scrollToPDFPage(pageNumber: number): void {
if (!navState.scrollContainer) return;
const pageElement = navState.scrollContainer.querySelector(
`[data-page-number="${pageNumber}"]`,
);
if (pageElement) {
pageElement.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
// ============================================================
// Zoom Controls
// ============================================================
export function setPDFZoom(scale: number): void {
navState.currentScale = scale;
navState.fitMode = "none";
const callback = (window as any).pdfOnZoomChange;
if (callback) callback(scale);
updatePDFZoom();
}
export function setPDFFitMode(mode: PageFitMode): void {
navState.fitMode = mode;
updatePDFZoom();
}
export function zoomPDFIn(): void {
setPDFZoom(navState.currentScale * 1.2);
}
export function zoomPDFOut(): void {
setPDFZoom(navState.currentScale / 1.2);
}
function updatePDFZoom(): void {
if (!navState.scrollContainer) return;
const pages = navState.scrollContainer.querySelectorAll(
".pdf-page-container",
);
pages.forEach((page: Element) => {
(page as HTMLElement).style.transform = `scale(${navState.currentScale})`;
(page as HTMLElement).style.transformOrigin = "top center";
});
}
// ============================================================
// Keyboard Navigation
// ============================================================
function setupPDFKeyboardNav(onPageChange: (pageNumber: number) => void): void {
document.addEventListener("keydown", handlePDFKeyDown);
}
function handlePDFKeyDown(e: KeyboardEvent): void {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
nextPDFPage();
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
previousPDFPage();
break;
case "Home":
e.preventDefault();
goToPDFPage(1);
break;
case "End":
e.preventDefault();
goToPDFPage(navState.totalPages);
break;
}
}
// ============================================================
// Scroll Tracking
// ============================================================
function setupPDFScrollTracking(
onPageChange: (pageNumber: number) => void,
): void {
if (!navState.scrollContainer) return;
let scrollTimeout: NodeJS.Timeout;
navState.scrollContainer.addEventListener("scroll", () => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
updateCurrentPageFromScroll(onPageChange);
}, 100);
});
}
function updateCurrentPageFromScroll(
onPageChange: (pageNumber: number) => void,
): void {
if (!navState.scrollContainer) return;
const scrollTop = navState.scrollContainer.scrollTop;
const containerHeight = navState.scrollContainer.clientHeight;
const pages = navState.scrollContainer.querySelectorAll("[data-page-number]");
let maxVisibility = 0;
let mostVisiblePage = navState.currentPage;
pages.forEach((page) => {
const element = page as HTMLElement;
const pageTop = element.offsetTop;
const pageBottom = pageTop + element.offsetHeight;
const visibleTop = Math.max(scrollTop, pageTop);
const visibleBottom = Math.min(scrollTop + containerHeight, pageBottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
if (visibleHeight > maxVisibility) {
maxVisibility = visibleHeight;
mostVisiblePage = parseInt(element.dataset.pageNumber || "1");
}
});
if (mostVisiblePage !== navState.currentPage) {
navState.currentPage = mostVisiblePage;
onPageChange(mostVisiblePage);
}
}
// ============================================================
// Getters
// ============================================================
export function getCurrentPDFPage(): number {
return navState.currentPage;
}
export function getTotalPDFPages(): number {
return navState.totalPages;
}
export function getPDFScale(): number {
return navState.currentScale;
}
+184
View File
@@ -0,0 +1,184 @@
// PDF outline/TOC navigation
// Procedural implementation (no OOP)
interface PDFOutlineNode {
id: string;
title: string;
destination: number | null;
pageNumber?: number;
children: PDFOutlineNode[];
expanded: boolean;
}
interface PDFOutlineState {
doc: PDFDocumentProxy | null;
outline: PDFOutlineNode[];
flatMap: Map<string, number>;
}
async function initializePDFOutline(
doc: PDFDocumentProxy,
): Promise<PDFOutlineState> {
const state: PDFOutlineState = {
doc,
outline: [],
flatMap: new Map(),
};
return await loadPDFOutline(state);
}
async function loadPDFOutline(
state: PDFOutlineState,
): Promise<PDFOutlineState> {
if (!state.doc) return state;
const pdfOutline = await state.doc.getOutline();
if (!pdfOutline || pdfOutline.length === 0) {
return { ...state, outline: [] };
}
const outline = await parseOutlineNodes(state, pdfOutline);
return { ...state, outline };
}
async function parseOutlineNodes(
state: PDFOutlineState,
nodes: OutlineTreeNode[],
): Promise<PDFOutlineNode[]> {
const result: PDFOutlineNode[] = [];
for (const node of nodes) {
const outlineNode: PDFOutlineNode = {
id: generateOutlineId(),
title: node.title,
destination: null,
children: [],
expanded: false,
};
if (node.dest) {
const pageNumber = await resolvePDFDestination(state, node.dest);
outlineNode.destination = pageNumber;
outlineNode.pageNumber = pageNumber;
state.flatMap.set(node.title, pageNumber);
}
if (node.items && node.items.length > 0) {
outlineNode.children = await parseOutlineNodes(state, node.items);
}
result.push(outlineNode);
}
return result;
}
async function resolvePDFDestination(
state: PDFOutlineState,
dest: string | any[],
): Promise<number> {
if (!state.doc) return 1;
try {
let explicitDest: any[];
if (typeof dest === "string") {
const destObj = await state.doc.getDestination(dest);
if (!destObj) return 1;
explicitDest = destObj;
} else {
explicitDest = dest;
}
const ref = explicitDest[0];
if (typeof ref === "object" && ref !== null) {
const pageIndex = await state.doc.getPageIndex(ref);
return pageIndex + 1;
} else if (typeof ref === "number") {
return ref + 1;
}
return 1;
} catch (error) {
console.error("Failed to resolve destination:", dest, error);
return 1;
}
}
function generateOutlineId(): string {
return `outline-${Math.random().toString(36).substr(2, 9)}`;
}
function getOutline(state: PDFOutlineState): PDFOutlineNode[] {
return state.outline;
}
function getOutlineFlatMap(state: PDFOutlineState): Map<string, number> {
return state.flatMap;
}
function getCurrentChapter(
state: PDFOutlineState,
pageNumber: number,
): PDFOutlineNode | null {
return findChapterForPage(state.outline, pageNumber);
}
function findChapterForPage(
nodes: PDFOutlineNode[],
pageNumber: number,
): PDFOutlineNode | null {
for (const node of nodes) {
if (node.pageNumber && node.pageNumber <= pageNumber) {
if (node.children.length > 0) {
const childMatch = findChapterForPage(node.children, pageNumber);
if (childMatch) return childMatch;
}
return node;
}
if (node.children.length > 0) {
const childMatch = findChapterForPage(node.children, pageNumber);
if (childMatch) return childMatch;
}
}
return null;
}
function toggleOutlineNode(
state: PDFOutlineState,
nodeId: string,
): PDFOutlineState {
const updateNode = (nodes: PDFOutlineNode[]): PDFOutlineNode[] => {
return nodes.map((node) => {
if (node.id === nodeId) {
return { ...node, expanded: !node.expanded };
}
if (node.children.length > 0) {
return { ...node, children: updateNode(node.children) };
}
return node;
});
};
return { ...state, outline: updateNode(state.outline) };
}
function findOutlineNode(
nodes: PDFOutlineNode[],
id: string,
): PDFOutlineNode | null {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children.length > 0) {
const found = findOutlineNode(node.children, id);
if (found) return found;
}
}
return null;
}
+109
View File
@@ -0,0 +1,109 @@
// Handle PDFs with variable page sizes
// Procedural implementation (no OOP)
interface PageInfo {
pageNumber: number;
width: number;
height: number;
rotation: number;
}
interface PDFPageSizesState {
pageSizes: Map<number, PageInfo>;
defaultSize: { width: number; height: number };
}
function createPDFPageSizes(): PDFPageSizesState {
return {
pageSizes: new Map(),
defaultSize: { width: 595, height: 842 },
};
}
async function loadPDFPageSizes(
state: PDFPageSizesState,
doc: any,
): Promise<PDFPageSizesState> {
const pageSizes = new Map<number, PageInfo>();
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
const page = await doc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1 });
const pageInfo: PageInfo = {
pageNumber: pageNum,
width: viewport.width,
height: viewport.height,
rotation: viewport.rotation,
};
pageSizes.set(pageNum, pageInfo);
}
return { ...state, pageSizes };
}
function getPDFPageSize(
state: PDFPageSizesState,
pageNumber: number,
): PageInfo | null {
return state.pageSizes.get(pageNumber) || null;
}
function isPDFPageLandscape(
state: PDFPageSizesState,
pageNumber: number,
): boolean {
const size = getPDFPageSize(state, pageNumber);
if (!size) return false;
const effectiveWidth =
size.rotation === 90 || size.rotation === 270 ? size.height : size.width;
const effectiveHeight =
size.rotation === 90 || size.rotation === 270 ? size.width : size.height;
return effectiveWidth > effectiveHeight;
}
function getPDFCommonSize(state: PDFPageSizesState): {
width: number;
height: number;
} {
if (state.pageSizes.size === 0) {
return state.defaultSize;
}
const sizeGroups: Map<
string,
{ width: number; height: number; count: number }
> = new Map();
state.pageSizes.forEach((size) => {
const key = getPageSizeKey(size.width, size.height);
const existing = sizeGroups.get(key);
if (existing) {
existing.count++;
} else {
sizeGroups.set(key, { width: size.width, height: size.height, count: 1 });
}
});
let mostCommon = state.defaultSize;
let maxCount = 0;
sizeGroups.forEach((size) => {
if (size.count > maxCount) {
maxCount = size.count;
mostCommon = { width: size.width, height: size.height };
}
});
return mostCommon;
}
function getPageSizeKey(width: number, height: number): string {
const w = Math.round(width / 10) * 10;
const h = Math.round(height / 10) * 10;
return `${w}x${h}`;
}
+82
View File
@@ -0,0 +1,82 @@
// Handle rotated/landscape pages in PDFs
// Procedural implementation (no OOP)
interface PDFRotationState {
rotations: Map<number, number>;
}
function createPDFRotation(): PDFRotationState {
return {
rotations: new Map(),
};
}
async function loadPDFPageRotations(
state: PDFRotationState,
doc: any,
): Promise<PDFRotationState> {
const rotations = new Map<number, number>();
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
const page = await doc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1 });
const rotation = viewport.rotation;
if (rotation !== 0) {
rotations.set(pageNum, rotation);
}
}
return { ...state, rotations };
}
function getPDFPageRotation(
state: PDFRotationState,
pageNumber: number,
): number {
return state.rotations.get(pageNumber) || 0;
}
function hasPDFPageRotation(
state: PDFRotationState,
pageNumber: number,
): boolean {
return state.rotations.has(pageNumber);
}
function applyPDFRotation(
state: PDFRotationState,
canvas: HTMLCanvasElement,
pageNumber: number,
): void {
const rotation = getPDFPageRotation(state, pageNumber);
if (rotation === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((rotation * Math.PI) / 180);
ctx.translate(-canvas.width / 2, -canvas.height / 2);
ctx.restore();
}
function getPDFAdjustedViewport(
state: PDFRotationState,
pageNumber: number,
viewport: any,
): any {
const rotation = getPDFPageRotation(state, pageNumber);
if (rotation === 0 || rotation === 180) {
return viewport;
}
return {
...viewport,
width: viewport.height,
height: viewport.width,
};
}
+136
View File
@@ -0,0 +1,136 @@
// Full-text search within PDF documents
import { PDFDocumentProxy } from "pdfjs-dist";
interface SearchResult {
pageNumber: number;
text: string;
index: number;
context: string;
}
// Full-text search within PDF documents
// Procedural implementation (no OOP)
interface SearchResult {
pageNumber: number;
text: string;
index: number;
context: string;
}
interface PDFSearchState {
doc: PDFDocumentProxy | null;
searchResults: SearchResult[];
currentResultIndex: number;
}
async function initializePDFSearch(
doc: PDFDocumentProxy,
): Promise<PDFSearchState> {
return {
doc,
searchResults: [],
currentResultIndex: 0,
};
}
async function searchPDF(
state: PDFSearchState,
query: string,
): Promise<PDFSearchState> {
if (!state.doc) return state;
const searchResults: SearchResult[] = [];
const lowerQuery = query.toLowerCase();
for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) {
const page = await state.doc.getPage(pageNum);
const textContent = await page.getTextContent();
let fullText = "";
const textItems = textContent.items.map((item) => {
if (typeof item === "string") return "";
fullText += item.str;
return item.str;
});
const pageText = textItems.join(" ");
const matches = findSearchMatches(pageText, lowerQuery, pageNum);
searchResults.push(...matches);
}
return { ...state, searchResults };
}
function findSearchMatches(
text: string,
query: string,
pageNumber: number,
): SearchResult[] {
const matches: SearchResult[] = [];
const lowerText = text.toLowerCase();
let index = 0;
while ((index = lowerText.indexOf(query, index)) !== -1) {
const start = Math.max(0, index - 50);
const end = Math.min(text.length, index + query.length + 50);
const context = text.slice(start, end);
matches.push({
pageNumber,
text: text.slice(index, index + query.length),
index,
context,
});
index += query.length;
}
return matches;
}
function goToNextSearchResult(
state: PDFSearchState,
): PDFSearchState & { result: SearchResult | null } {
if (state.searchResults.length === 0) {
return { ...state, result: null };
}
const newIndex = (state.currentResultIndex + 1) % state.searchResults.length;
return {
...state,
currentResultIndex: newIndex,
result: state.searchResults[newIndex],
};
}
function goToPreviousSearchResult(
state: PDFSearchState,
): PDFSearchState & { result: SearchResult | null } {
if (state.searchResults.length === 0) {
return { ...state, result: null };
}
const newIndex =
(state.currentResultIndex - 1 + state.searchResults.length) %
state.searchResults.length;
return {
...state,
currentResultIndex: newIndex,
result: state.searchResults[newIndex],
};
}
function getSearchResultCount(state: PDFSearchState): number {
return state.searchResults.length;
}
function clearSearchResults(state: PDFSearchState): PDFSearchState {
return {
...state,
searchResults: [],
currentResultIndex: 0,
};
}
+187
View File
@@ -0,0 +1,187 @@
// PDF text selection - Uses backend API for highlight creation
// Backend handles all position calculations for PDFs
// Procedural style: Functions, not classes
interface PDFTextSelection {
pageNumber: number;
text: string;
rects: DOMRect[];
}
// ============================================================
// Get PDF Text Selection
// ============================================================
export function getPDFTextSelection(): PDFTextSelection | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const text = range.toString();
if (!text) return null;
// Get page number from selection
const pageElement =
range.commonAncestorContainer.closest?.("[data-page-number]");
const pageNumber = pageElement?.dataset.pageNumber
? parseInt(pageElement.dataset.pageNumber)
: getCurrentPDFPage();
// Get bounding rectangles
const rects: DOMRect[] = [];
for (const rect of range.getClientRects()) {
rects.push(rect);
}
return {
pageNumber,
text,
rects,
};
}
// ============================================================
// Create PDF Highlight (Backend Calculates Position)
// ============================================================
export async function createPDFHighlight(
mediaItemId: string,
selection: PDFTextSelection,
color: string,
): Promise<Highlight> {
const selectionData = {
selection_text: selection.text,
page_number: selection.pageNumber,
rects: selection.rects.map((rect) => ({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
})),
color,
};
// Send to backend - backend calculates all position formats
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(selectionData),
});
if (!response.ok) {
throw new Error(`Failed to create highlight: ${response.statusText}`);
}
return await response.json();
}
// ============================================================
// Load and Render PDF Highlights (Backend Provides Positions)
// ============================================================
export async function loadAndRenderPDFHighlights(
mediaItemId: string,
container: HTMLElement,
): Promise<void> {
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`);
if (!response.ok) return [];
const highlights: Highlight[] = await response.json();
for (const highlight of highlights) {
renderPDFHighlight(container, highlight);
}
}
function renderPDFHighlight(
container: HTMLElement,
highlight: Highlight,
): void {
// Backend provides position data for PDF highlights
// Check which position format is available
if (
highlight.start_position &&
highlight.start_position.startsWith("pdf:page:")
) {
// Backend calculated page-based position
renderPDFHighlightByPosition(container, highlight);
} else if (highlight.percentage_start !== null) {
// Backend calculated percentage position
renderPDFHighlightByPercentage(container, highlight);
}
}
function renderPDFHighlightByPosition(
container: HTMLElement,
highlight: Highlight,
): void {
// Parse position string: "pdf:page:45:offset:123"
const match = highlight.start_position.match(/pdf:page:(\d+):offset:(\d+)/);
if (!match) return;
const pageNumber = parseInt(match[1], 10);
const offset = parseInt(match[2], 10);
// Find the page element
const pageElement = container.querySelector(
`[data-page-number="${pageNumber}"]`,
);
if (!pageElement) return;
// Get text content at offset
const textContent = pageElement.querySelector(".pdf-text-layer")?.textContent;
if (!textContent) return;
// Find the text at this offset
const textBefore = textContent.substring(0, offset);
const startChar = textBefore.length;
const endChar = startChar + (highlight.selection_text?.length || 10);
if (startChar < textContent.length && endChar <= textContent.length) {
applyHighlightToTextContent(
pageElement as HTMLElement,
startChar,
endChar,
highlight.color,
);
}
}
function renderPDFHighlightByPercentage(
container: HTMLElement,
highlight: Highlight,
): void {
// Backend provides percentage - estimate position
const percentage = highlight.percentage_start || 0;
// Find spine item closest to this percentage
const totalPages = container.querySelectorAll("[data-page-number]").length;
const targetPage = Math.ceil(percentage * totalPages);
const pageElement = container.querySelector(
`[data-page-number="${targetPage}"]`,
);
if (pageElement) {
// Highlight entire page (coarse-grained)
applyHighlightStylesToElement(pageElement as HTMLElement, highlight.color);
}
}
function applyHighlightToTextContent(
element: HTMLElement,
startChar: number,
endChar: number,
color: string,
): void {
const text = element.textContent || "";
const before = text.substring(0, startChar);
const selection = text.substring(startChar, endChar);
const after = text.substring(endChar);
element.textContent = before + selection + after;
// Use a mark to wrap the selected text
element.innerHTML = `${before}<mark style="background-color: ${addAlphaToColor(color, 0.4)}">${selection}</mark>${after}`;
}
+119
View File
@@ -0,0 +1,119 @@
// Mozilla pdf.js integration for PDF rendering
// Procedural style: Functions, not classes
import * as pdfjsLib from "pdfjs-dist";
// ============================================================
// PDF.js Configuration
// ============================================================
export function configurePDFJS(): void {
pdfjsLib.GlobalWorkerOptions.workerSrc = "/static/js/pdf.worker.min.mjs";
pdfjsLib.GlobalWorkerOptions.standardFontDataUrl = "/static/standard_fonts/";
pdfjsLib.GlobalWorkerOptions.cMapUrl = "/static/cmaps/";
pdfjsLib.GlobalWorkerOptions.cMapPacked = true;
}
// ============================================================
// PDF Document State
// ============================================================
interface PDFDocumentState {
doc: pdfjsLib.PDFDocumentProxy | null;
pages: Map<number, pdfjsLib.PDFPageProxy>;
metadata: PDFMetadata | null;
}
interface PDFMetadata {
title: string;
author: string;
subject?: string;
keywords?: string;
creator?: string;
producer?: string;
creationDate?: Date;
modificationDate?: Date;
pageCount: number;
}
let pdfState: PDFDocumentState = {
doc: null,
pages: new Map(),
metadata: null,
};
// ============================================================
// Document Loading
// ============================================================
export async function loadPDFDocument(pdfBlob: Blob): Promise<PDFMetadata> {
// Cleanup previous document
unloadPDFDocument();
const arrayBuffer = await pdfBlob.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({
data: arrayBuffer,
});
pdfState.doc = await loadingTask.promise;
// Extract metadata
const metadata = await pdfState.doc.getMetadata().catch(() => null);
const info = metadata?.info || {};
pdfState.metadata = {
title: info.Title || "Untitled",
author: info.Author || "Unknown",
subject: info.Subject,
keywords: info.Keywords,
creator: info.Creator,
producer: info.Producer,
creationDate: info.CreationDate ? new Date(info.CreationDate) : undefined,
modificationDate: info.ModDate ? new Date(info.ModDate) : undefined,
pageCount: pdfState.doc.numPages,
};
return pdfState.metadata;
}
export async function getPDFPage(
pageNumber: number,
): Promise<pdfjsLib.PDFPageProxy> {
if (!pdfState.doc) {
throw new Error("PDF document not loaded");
}
// Check cache
if (pdfState.pages.has(pageNumber)) {
return pdfState.pages.get(pageNumber)!;
}
// Load page
const page = await pdfState.doc.getPage(pageNumber);
pdfState.pages.set(pageNumber, page);
return page;
}
export async function getPDFPageText(pageNumber: number): Promise<any> {
const page = await getPDFPage(pageNumber);
return await page.getTextContent();
}
export function getPDFMetadata(): PDFMetadata | null {
return pdfState.metadata;
}
export function getPDFPageCount(): number {
return pdfState.doc?.numPages || 0;
}
export function unloadPDFDocument(): void {
pdfState.pages.clear();
pdfState.doc = null;
pdfState.metadata = null;
}
export function unloadPDFPage(pageNumber: number): void {
pdfState.pages.delete(pageNumber);
}
+155
View File
@@ -0,0 +1,155 @@
// Text layer rendering for PDF text selection and highlighting
// Procedural style: Functions, not classes
// ============================================================
// Render Functions
// ============================================================
export function renderTextLayer(
container: HTMLElement,
viewport: any,
textContent: any,
config: TextLayerConfig,
): void {
// Clear container
container.innerHTML = "";
// Apply styles
applyTextLayerStyles(container, config);
// Render text items
const { items } = textContent;
items.forEach((item: any, index: number) => {
if (typeof item === "string") return;
const textDiv = createTextDiv(item, viewport, index);
container.appendChild(textDiv);
});
}
function createTextDiv(item: any, viewport: any, index: number): HTMLElement {
const div = document.createElement("div");
div.className = "pdf-text-layer-text";
div.textContent = item.str;
div.dataset.index = index.toString();
// Position the text div
const tx = pdfjsLib.Util.transform(viewport.transform, item.transform);
const fontSize = Math.sqrt(tx[0] * tx[0] + tx[1] * tx[1]);
div.style.left = `${tx[4]}px`;
div.style.top = `${tx[5] - fontSize}px`;
div.style.fontSize = `${fontSize}px`;
div.style.fontFamily = item.fontName || "sans-serif";
// Handle text direction
if (item.dir === "ttb") {
div.style.writingMode = "vertical-rl";
}
return div;
}
interface TextLayerConfig {
theme: "light" | "sepia" | "dark" | "night" | "high-contrast";
}
function applyTextLayerStyles(
container: HTMLElement,
config: TextLayerConfig,
): void {
const style = document.createElement("style");
style.textContent = getTextLayerCSS(config.theme);
container.appendChild(style);
}
function getTextLayerCSS(theme: string): string {
const colors = getThemeColors(theme);
return `
.pdf-text-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
opacity: 1;
line-height: 1;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.pdf-text-layer-text {
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
color: transparent;
pointer-events: auto;
}
.pdf-text-layer-text::selection {
background: ${colors.highlight};
color: transparent;
}
.pdf-text-layer-text::-moz-selection {
background: ${colors.highlight};
color: transparent;
}
.pdf-highlight-overlay {
position: absolute;
background-color: ${colors.highlight};
mix-blend-mode: multiply;
pointer-events: none;
}
`;
}
function getThemeColors(theme: string): { highlight: string } {
const themes: Record<string, { highlight: string }> = {
light: { highlight: "rgba(255, 255, 0, 0.3)" },
sepia: { highlight: "rgba(255, 200, 0, 0.4)" },
dark: { highlight: "rgba(255, 255, 0, 0.3)" },
night: { highlight: "rgba(100, 150, 255, 0.3)" },
"high-contrast": { highlight: "rgba(255, 255, 0, 0.5)" },
};
return themes[theme] || themes["dark"];
}
// ============================================================
// Selection Functions
// ============================================================
export function getPDFTextSelection(): { text: string; range: Range } | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const text = range.toString();
if (!text) return null;
return { text, range };
}
export function getPDFSelectionRects(): DOMRect[] {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return [];
const rects: DOMRect[] = [];
const range = selection.getRangeAt(0);
for (const rect of range.getClientRects()) {
rects.push(rect);
}
return rects;
}