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