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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user