From b1d44d566185de91efa431d6b66ce1ec322b9829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:16:13 +0100 Subject: [PATCH] feat(linklayer): improve link handling with pdf-lib integration and add link toolbar, add delete link functionality (#5715) --- .../src/core/components/viewer/LinkLayer.tsx | 574 ++++++++++-------- .../core/components/viewer/LocalEmbedPDF.tsx | 7 +- frontend/src/core/hooks/usePdfLibLinks.ts | 393 ++++++++++++ frontend/src/core/styles/theme.css | 192 ++++++ 4 files changed, 898 insertions(+), 268 deletions(-) create mode 100644 frontend/src/core/hooks/usePdfLibLinks.ts diff --git a/frontend/src/core/components/viewer/LinkLayer.tsx b/frontend/src/core/components/viewer/LinkLayer.tsx index d9a582b85..214dde257 100644 --- a/frontend/src/core/components/viewer/LinkLayer.tsx +++ b/frontend/src/core/components/viewer/LinkLayer.tsx @@ -1,316 +1,360 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useCallback, useState, useMemo, useRef } from 'react'; import { useDocumentState } from '@embedpdf/core/react'; -import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; import { useScroll } from '@embedpdf/plugin-scroll/react'; +import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; import { PdfAnnotationSubtype } from '@embedpdf/models'; +import { usePdfLibLinks, type PdfLibLink } from '@app/hooks/usePdfLibLinks'; -enum PDFActionType { - GoTo = 0, - GoToR = 1, - GoToE = 2, - URI = 3, - Launch = 4, - Named = 5, - JavaScript = 6, +// --------------------------------------------------------------------------- +// Inline SVG icons (thin-stroke, modern) +// --------------------------------------------------------------------------- + +const TrashIcon: React.FC<{ size?: number }> = ({ size = 13 }) => ( + + + +); + +const ExternalLinkIcon: React.FC<{ size?: number }> = ({ size = 12 }) => ( + + + +); + +const PageIcon: React.FC<{ size?: number }> = ({ size = 12 }) => ( + + + + +); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function truncateUrl(url: string, maxLen = 32): string { + try { + const u = new URL(url); + const display = u.hostname + (u.pathname !== '/' ? u.pathname : ''); + return display.length > maxLen ? display.slice(0, maxLen) + '\u2026' : display; + } catch { + return url.length > maxLen ? url.slice(0, maxLen) + '\u2026' : url; + } } -interface PDFRect { - origin: { x: number; y: number }; - size: { width: number; height: number }; +function getLinkLabel(link: PdfLibLink): string { + if (link.type === 'internal' && link.targetPage !== undefined) { + return `Page ${link.targetPage + 1}`; + } + if (link.type === 'external' && link.uri) { + return truncateUrl(link.uri); + } + return 'Open Link'; } -interface PDFDestination { - pageIndex: number; - view: [number, number]; + +interface LinkToolbarProps { + link: PdfLibLink; + scale: number; + flipped: boolean; + onNavigate: (link: PdfLibLink) => void; + onDelete: (link: PdfLibLink) => void; + onMouseEnter: () => void; + onMouseLeave: () => void; } -interface PDFAction { - type: string | number; - destination?: PDFDestination; - uri?: string; -} +const TOOLBAR_HEIGHT = 32; +const TOOLBAR_GAP = 8; -interface LinkAnnotation { - id: string; - type: number; - rect: PDFRect; - target?: { - type: string; - action?: PDFAction; - destination?: PDFDestination; - uri?: string; - }; -} +const LinkToolbar: React.FC = React.memo( + ({ link, scale, flipped, onNavigate, onDelete, onMouseEnter, onMouseLeave }) => { + const centerX = (link.rect.x + link.rect.width / 2) * scale; + const topY = flipped + ? (link.rect.y + link.rect.height) * scale + TOOLBAR_GAP + : link.rect.y * scale - TOOLBAR_HEIGHT - TOOLBAR_GAP; -function isGoToAction(action: PDFAction): boolean { - return action.type === 'GoTo' || action.type === PDFActionType.GoTo; -} + const isInternal = link.type === 'internal' && link.targetPage !== undefined; + const label = getLinkLabel(link); -function isURIAction(action: PDFAction): boolean { - return action.type === 'URI' || action.type === PDFActionType.URI; -} + return ( +
+ {/* Delete */} + -// Utility functions for link type detection - prefixed to indicate future use -function _isInternalLink(link: LinkAnnotation): boolean { - return Boolean(link.target?.type === 'destination' || - (link.target?.type === 'action' && link.target.action && isGoToAction(link.target.action))); -} + -function _isExternalLink(link: LinkAnnotation): boolean { - return Boolean(link.target?.type === 'uri' || - (link.target?.type === 'action' && link.target.action && isURIAction(link.target.action))); -} + {/* Navigate / Open */} + +
+ ); + }, +); + +LinkToolbar.displayName = 'LinkToolbar'; interface LinkLayerProps { documentId: string; pageIndex: number; - pageWidth: number; - pageHeight: number; + _pageWidth: number; + _pageHeight: number; + /** Blob/object URL of the current PDF (needed by pdf-lib). */ + pdfUrl: string | null; } export const LinkLayer: React.FC = ({ documentId, pageIndex, - pageWidth, - pageHeight, + _pageWidth, + _pageHeight, + pdfUrl, }) => { - const { provides: annotation } = useAnnotationCapability(); const { provides: scroll } = useScroll(documentId); + const { provides: annotation } = useAnnotationCapability(); const documentState = useDocumentState(documentId); - const [links, setLinks] = useState([]); - const [isNavigating, setIsNavigating] = useState(false); - // Get original PDF page dimensions from document state - const pdfPage = documentState?.document?.pages?.[pageIndex]; - const pdfPageWidth = pdfPage?.size?.width ?? 0; - const pdfPageHeight = pdfPage?.size?.height ?? 0; + // State + const [hoveredLinkId, setHoveredLinkId] = useState(null); + const [deletedLinkIds, setDeletedLinkIds] = useState>(new Set()); + const leaveTimerRef = useRef | null>(null); - // Process links with proper coordinate transformation - const processedLinks = useMemo(() => { - if (!pageWidth || !pageHeight || !pdfPageWidth || !pdfPageHeight) return []; - - // Use the document scale like EmbedPDF's AnnotationLayer does - const scale = documentState?.scale ?? 1; - + // pdf-lib extraction + const { links } = usePdfLibLinks(pdfUrl, pageIndex); - - return links.map(link => { - const { origin, size } = link.rect; - - // Use document scale like EmbedPDF does - const scaledLeft = origin.x * scale; - const scaledTop = origin.y * scale; - const scaledWidth = size.width * scale; - const scaledHeight = size.height * scale; - - return { - ...link, - scaledRect: { - left: scaledLeft, - top: scaledTop, - width: scaledWidth, - height: scaledHeight, - }, - }; - }); - }, [links, pageWidth, pageHeight, pdfPageWidth, pdfPageHeight, documentState?.scale]); + // EmbedPDF scale factor + const scale = documentState?.scale ?? 1; + + // Filter visible, non-deleted links + const visibleLinks = useMemo( + () => + links.filter( + (l) => l.rect.width > 0 && l.rect.height > 0 && !deletedLinkIds.has(l.id), + ), + [links, deletedLinkIds], + ); + + + const clearLeaveTimer = useCallback(() => { + if (leaveTimerRef.current) { + clearTimeout(leaveTimerRef.current); + leaveTimerRef.current = null; + } + }, []); + + const startLeaveTimer = useCallback(() => { + clearLeaveTimer(); + leaveTimerRef.current = setTimeout(() => { + setHoveredLinkId(null); + }, 120); + }, [clearLeaveTimer]); + + const handleLinkMouseEnter = useCallback( + (linkId: string) => { + clearLeaveTimer(); + setHoveredLinkId(linkId); + }, + [clearLeaveTimer], + ); + + const handleLinkMouseLeave = useCallback(() => { + startLeaveTimer(); + }, [startLeaveTimer]); + + const handleToolbarMouseEnter = useCallback(() => { + clearLeaveTimer(); + }, [clearLeaveTimer]); + + const handleToolbarMouseLeave = useCallback(() => { + startLeaveTimer(); + }, [startLeaveTimer]); + + const handleNavigate = useCallback( + (link: PdfLibLink) => { + if (link.type === 'internal' && link.targetPage !== undefined && scroll) { + scroll.scrollToPage({ + pageNumber: link.targetPage + 1, + behavior: 'smooth', + }); + } else if (link.uri) { + try { + const url = new URL(link.uri, window.location.href); + if (['http:', 'https:', 'mailto:'].includes(url.protocol)) { + window.open(link.uri, '_blank', 'noopener,noreferrer'); + } else { + console.warn('[LinkLayer] Blocked unsafe URL protocol:', url.protocol); + } + } catch { + window.open(link.uri, '_blank', 'noopener,noreferrer'); + } + } + setHoveredLinkId(null); + }, + [scroll], + ); + + const handleDelete = useCallback( + async (link: PdfLibLink) => { + setDeletedLinkIds((prev) => new Set(prev).add(link.id)); + setHoveredLinkId(null); - useEffect(() => { - const fetchLinks = async () => { if (!annotation) return; try { - const pageAnnotationsResult = annotation.getPageAnnotations({ - pageIndex, - }); + const result = annotation.getPageAnnotations({ pageIndex }); - const resolveAnnotations = async (result: unknown): Promise => { - if (result && typeof (result as any).toPromise === 'function') { - return (result as any).toPromise(); - } - if (result && typeof (result as any).then === 'function') { - return result as Promise; - } - if (Array.isArray(result)) { - return result; - } - return []; - }; - - const pageAnnotations = await resolveAnnotations(pageAnnotationsResult); - - const linkAnnotations = pageAnnotations.filter( - (ann: any) => ann.type === 2 || ann.type === PdfAnnotationSubtype.LINK - ) as LinkAnnotation[]; - - if (linkAnnotations.length > 0) { - setLinks(linkAnnotations); + let pageAnnotations: any[] = []; + if (result && typeof (result as any).toPromise === 'function') { + pageAnnotations = await (result as any).toPromise(); + } else if (result && typeof (result as any).then === 'function') { + pageAnnotations = await (result as unknown as Promise); + } else if (Array.isArray(result)) { + pageAnnotations = result; } - } catch (error) { - console.error('[LinkLayer] Failed to fetch links from annotation API:', { - error, - pageIndex, + + const match = pageAnnotations.find((ann: any) => { + if ( + ann.type !== 2 && + ann.type !== PdfAnnotationSubtype.LINK + ) + return false; + if (!ann.rect) return false; + + // EmbedPDF rects: { origin: { x, y }, size: { width, height } } + const r = ann.rect; + const tol = 2; // tolerance in PDF points + return ( + Math.abs((r.origin?.x ?? r.x ?? 0) - link.rect.x) <= tol && + Math.abs((r.origin?.y ?? r.y ?? 0) - link.rect.y) <= tol && + Math.abs((r.size?.width ?? r.width ?? 0) - link.rect.width) <= tol && + Math.abs((r.size?.height ?? r.height ?? 0) - link.rect.height) <= tol + ); }); - } - }; - fetchLinks(); - }, [annotation, pageIndex]); - - const handleLinkClick = useCallback(async (link: LinkAnnotation) => { - if (isNavigating) return; - - try { - setIsNavigating(true); - - // Try to extract destination from various possible locations in the link object - const linkData = link as any; - - // Check for destination in various locations - let targetPage: number | undefined; - let uri: string | undefined; - - // Try target.destination first - if (link.target?.destination?.pageIndex !== undefined) { - targetPage = link.target.destination.pageIndex; - } - // Try target.action.destination - else if (link.target?.action?.destination?.pageIndex !== undefined) { - targetPage = link.target.action.destination.pageIndex; - } - // Try direct dest property (PDF.js style) - else if (linkData.dest?.pageIndex !== undefined) { - targetPage = linkData.dest.pageIndex; - } - // Try destination at root level - else if (linkData.destination?.pageIndex !== undefined) { - targetPage = linkData.destination.pageIndex; - } - // Try action at root level - else if (linkData.action?.destination?.pageIndex !== undefined) { - targetPage = linkData.action.destination.pageIndex; - } - - // Check for URI in various locations - if (link.target?.uri) { - uri = link.target.uri; - } else if (link.target?.action?.uri) { - uri = link.target.action.uri; - } else if (linkData.uri) { - uri = linkData.uri; - } else if (linkData.url) { - uri = linkData.url; - } else if (linkData.action?.uri) { - uri = linkData.action.uri; - } - - if (targetPage !== undefined && scroll) { - scroll.scrollToPage({ - pageNumber: targetPage + 1, - behavior: 'smooth', - }); - } else if (uri) { - try { - const url = new URL(uri, window.location.href); - if (['http:', 'https:', 'mailto:'].includes(url.protocol)) { - window.open(uri, '_blank', 'noopener,noreferrer'); - } else { - console.warn('[LinkLayer] Blocked potentially unsafe URL protocol:', url.protocol); + if (match?.id) { + // Use EmbedPDF's native deletion (integrates with history / export) + if (typeof (annotation as any).deleteAnnotation === 'function') { + (annotation as any).deleteAnnotation(pageIndex, match.id); + } else if (typeof (annotation as any).purgeAnnotation === 'function') { + (annotation as any).purgeAnnotation(pageIndex, match.id); } - } catch { - window.open(uri, '_blank', 'noopener,noreferrer'); } - } else { - console.warn('[LinkLayer] Could not extract destination or URI from link:', link); + } catch (e) { + console.warn('[LinkLayer] Could not delete annotation via EmbedPDF:', e); } - } catch (error) { - console.error('[LinkLayer] Navigation failed:', error); - } finally { - setIsNavigating(false); - } - }, [isNavigating, scroll]); + }, + [annotation, pageIndex], + ); + + if (visibleLinks.length === 0) return null; return ( -
- {processedLinks.map((link) => { - const { id } = link; - const { left, top, width, height } = link.scaledRect; + {visibleLinks.map((link) => { + const isHovered = hoveredLinkId === link.id; + const left = link.rect.x * scale; + const top = link.rect.y * scale; + const width = link.rect.width * scale; + const height = link.rect.height * scale; + + // Flip toolbar below if link is near the top of the page + const flipped = link.rect.y * scale < TOOLBAR_HEIGHT + TOOLBAR_GAP + 4; return ( - { - e.preventDefault(); - e.stopPropagation(); - handleLinkClick(link); - }} - onMouseDown={(e) => e.stopPropagation()} - className={`absolute block cursor-pointer ${isNavigating ? 'cursor-not-allowed' : ''}`} - style={{ - left: `${left}px`, - top: `${top}px`, - width: `${width}px`, - height: `${height}px`, - minWidth: '8px', - minHeight: '8px', - pointerEvents: 'auto', - backgroundColor: 'transparent', - zIndex: 11, - border: '2px solid transparent', - borderRadius: '2px', - transition: 'background-color 0.15s ease-in-out, border-color 0.15s ease-in-out', - }} - onMouseEnter={(e) => { - e.currentTarget.style.backgroundColor = 'rgba(59, 130, 246, 0.15)'; - e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = 'transparent'; - e.currentTarget.style.borderColor = 'transparent'; - }} - title={getLinkTitle(link)} - aria-label={getLinkAriaLabel(link)} - > - {/* Invisible clickable area */} - + + {/* Hit-area overlay */} + { + e.preventDefault(); + e.stopPropagation(); + handleNavigate(link); + }} + onMouseDown={(e) => e.stopPropagation()} + onMouseEnter={() => handleLinkMouseEnter(link.id)} + onMouseLeave={handleLinkMouseLeave} + className={`pdf-link-overlay${isHovered ? ' pdf-link-overlay--active' : ''}`} + style={{ + left: `${left}px`, + top: `${top}px`, + width: `${width}px`, + height: `${height}px`, + minWidth: '6px', + minHeight: '6px', + }} + role="link" + tabIndex={0} + aria-label={getLinkLabel(link)} + /> + + {/* Floating toolbar */} + {isHovered && ( + + )} + ); })}
); }; - -const getLinkTitle = (link: LinkAnnotation): string => { - if (link.target?.type === 'destination') { - return `Go to page ${(link.target.destination?.pageIndex ?? 0) + 1}`; - } - if (link.target?.type === 'action' && link.target.action?.type === 'GoTo') { - return `Go to page ${(link.target.action.destination?.pageIndex ?? 0) + 1}`; - } - if (link.target?.type === 'action' && (link.target.action?.type === 'URI' || link.target.action?.type === 3)) { - return `Open link: ${link.target.action.uri}`; - } - if (link.target?.uri) { - return `Open link: ${link.target.uri}`; - } - return 'Link'; -}; - -const getLinkAriaLabel = (link: LinkAnnotation): string => { - if (link.target?.type === 'destination') { - return `Navigate to page ${(link.target.destination?.pageIndex ?? 0) + 1}`; - } - if (link.target?.type === 'action' && link.target.action?.type === 'GoTo') { - return `Navigate to page ${(link.target.action.destination?.pageIndex ?? 0) + 1}`; - } - if (link.target?.type === 'action' && (link.target.action?.type === 'URI' || link.target.action?.type === 3)) { - return 'Open external link'; - } - return 'Open external link'; -}; diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 267a9475e..c8607495c 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -731,12 +731,13 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableReda /> )} - {/* LinkLayer on top to handle link navigation - must be last for click priority */} + {/* LinkLayer – uses pdf-lib for link extraction, rendered last for click priority */} diff --git a/frontend/src/core/hooks/usePdfLibLinks.ts b/frontend/src/core/hooks/usePdfLibLinks.ts new file mode 100644 index 000000000..008bc12a9 --- /dev/null +++ b/frontend/src/core/hooks/usePdfLibLinks.ts @@ -0,0 +1,393 @@ +import { useState, useEffect, useRef } from 'react'; +import { + PDFDocument, + PDFDict, + PDFName, + PDFArray, + PDFString, + PDFHexString, + PDFNumber, + PDFRef, + PDFPage, + PDFContext, +} from 'pdf-lib'; + + +export type LinkType = 'internal' | 'external' | 'unknown'; + +export interface PdfLibLink { + id: string; + /** Index of this annotation in the page's /Annots array (used for deletion matching). */ + annotIndex: number; + /** Rectangle in PDF-page coordinate space (top-left origin, unscaled). */ + rect: { x: number; y: number; width: number; height: number }; + type: LinkType; + /** 0-based target page index (internal links). */ + targetPage?: number; + /** URI for external links. */ + uri?: string; +} + +export interface PdfLibLinksResult { + links: PdfLibLink[]; + /** Original PDF page width (unscaled, in PDF points). */ + pdfPageWidth: number; + /** Original PDF page height (unscaled, in PDF points). */ + pdfPageHeight: number; + loading: boolean; +} + +interface CachedDoc { + doc: PDFDocument; + /** Number of active consumers (hook instances) holding this entry. */ + refCount: number; + /** Per-page extracted links (lazy, filled on first request). */ + pageLinks: Map; +} + +const docCache = new Map>(); + +async function acquireDocument(url: string): Promise { + if (!docCache.has(url)) { + const promise = (async (): Promise => { + const response = await fetch(url); + const buffer = await response.arrayBuffer(); + const doc = await PDFDocument.load(new Uint8Array(buffer), { + ignoreEncryption: true, + throwOnInvalidObject: false, + }); + return { doc, refCount: 0, pageLinks: new Map() }; + })(); + docCache.set(url, promise); + } + const cached = await docCache.get(url)!; + cached.refCount++; + return cached; +} + +function releaseDocument(url: string): void { + const entry = docCache.get(url); + if (!entry) return; + entry.then((cached) => { + cached.refCount--; + if (cached.refCount <= 0) { + docCache.delete(url); + } + }); +} + +function num(ctx: PDFContext, value: unknown): number { + if (value instanceof PDFRef) value = ctx.lookup(value); + if (value instanceof PDFNumber) return value.asNumber(); + if (typeof value === 'number') return value; + return 0; +} + +function str(ctx: PDFContext, value: unknown): string | undefined { + if (value instanceof PDFRef) value = ctx.lookup(value); + if (value instanceof PDFString) return value.decodeText(); + if (value instanceof PDFHexString) return value.decodeText(); + if (typeof value === 'string') return value; + return undefined; +} + +function resolvePageIndex(doc: PDFDocument, pageRef: PDFRef): number | undefined { + const pages = doc.getPages(); + for (let i = 0; i < pages.length; i++) { + const ref = pages[i].ref; + if ( + ref === pageRef || + (ref.objectNumber === pageRef.objectNumber && + ref.generationNumber === pageRef.generationNumber) + ) { + return i; + } + } + return undefined; +} + +function resolveDestArray( + doc: PDFDocument, + ctx: PDFContext, + destArr: PDFArray, +): number | undefined { + if (destArr.size() < 1) return undefined; + const first = destArr.get(0); + if (first instanceof PDFRef) { + return resolvePageIndex(doc, first); + } + const n = num(ctx, first); + if (typeof n === 'number' && n >= 0) return n; + return undefined; +} + +function resolveNamedDest( + doc: PDFDocument, + ctx: PDFContext, + name: string, +): number | undefined { + try { + const catalog = doc.catalog; + + const namesRaw = catalog.get(PDFName.of('Names')); + const namesDict = namesRaw instanceof PDFRef ? ctx.lookup(namesRaw) : namesRaw; + if (namesDict instanceof PDFDict) { + const destsRaw = namesDict.get(PDFName.of('Dests')); + const destsTree = destsRaw instanceof PDFRef ? ctx.lookup(destsRaw) : destsRaw; + if (destsTree instanceof PDFDict) { + const result = searchNameTree(doc, ctx, destsTree, name); + if (result !== undefined) return result; + } + } + + const destsRaw = catalog.get(PDFName.of('Dests')); + const destsDict = destsRaw instanceof PDFRef ? ctx.lookup(destsRaw) : destsRaw; + if (destsDict instanceof PDFDict) { + const dest = destsDict.get(PDFName.of(name)); + const destResolved = dest instanceof PDFRef ? ctx.lookup(dest) : dest; + if (destResolved instanceof PDFArray) { + return resolveDestArray(doc, ctx, destResolved); + } + } + } catch { + // Swallow – named dest resolution is best-effort + } + return undefined; +} + +function searchNameTree( + doc: PDFDocument, + ctx: PDFContext, + node: PDFDict, + name: string, +): number | undefined { + const namesArr = node.get(PDFName.of('Names')); + const resolved = namesArr instanceof PDFRef ? ctx.lookup(namesArr) : namesArr; + if (resolved instanceof PDFArray) { + for (let i = 0; i < resolved.size(); i += 2) { + const key = str(ctx, resolved.get(i)); + if (key === name) { + const val = resolved.get(i + 1); + const valResolved = val instanceof PDFRef ? ctx.lookup(val) : val; + if (valResolved instanceof PDFArray) { + return resolveDestArray(doc, ctx, valResolved); + } + if (valResolved instanceof PDFDict) { + const d = valResolved.get(PDFName.of('D')); + const dResolved = d instanceof PDFRef ? ctx.lookup(d) : d; + if (dResolved instanceof PDFArray) { + return resolveDestArray(doc, ctx, dResolved); + } + } + } + } + } + + const kidsArr = node.get(PDFName.of('Kids')); + const kidsResolved = kidsArr instanceof PDFRef ? ctx.lookup(kidsArr) : kidsArr; + if (kidsResolved instanceof PDFArray) { + for (let i = 0; i < kidsResolved.size(); i++) { + const kidRef = kidsResolved.get(i); + const kid = kidRef instanceof PDFRef ? ctx.lookup(kidRef) : kidRef; + if (kid instanceof PDFDict) { + const limits = kid.get(PDFName.of('Limits')); + const limitsResolved = limits instanceof PDFRef ? ctx.lookup(limits) : limits; + if (limitsResolved instanceof PDFArray && limitsResolved.size() >= 2) { + const lo = str(ctx, limitsResolved.get(0)) ?? ''; + const hi = str(ctx, limitsResolved.get(1)) ?? ''; + if (name < lo || name > hi) continue; + } + const result = searchNameTree(doc, ctx, kid, name); + if (result !== undefined) return result; + } + } + } + + return undefined; +} + +function extractLinksFromPage( + doc: PDFDocument, + page: PDFPage, + pageIndex: number, +): PdfLibLink[] { + const links: PdfLibLink[] = []; + const ctx = doc.context; + const { height: pageHeight } = page.getSize(); + + const annotsRaw = page.node.get(PDFName.of('Annots')); + if (!annotsRaw) return links; + + const annots = annotsRaw instanceof PDFRef ? ctx.lookup(annotsRaw) : annotsRaw; + if (!(annots instanceof PDFArray)) return links; + + for (let i = 0; i < annots.size(); i++) { + try { + const annotRaw = annots.get(i); + const annot = annotRaw instanceof PDFRef ? ctx.lookup(annotRaw) : annotRaw; + if (!(annot instanceof PDFDict)) continue; + + const subtype = annot.get(PDFName.of('Subtype')); + if (subtype?.toString() !== '/Link') continue; + + const rectRaw = annot.get(PDFName.of('Rect')); + const rect = rectRaw instanceof PDFRef ? ctx.lookup(rectRaw) : rectRaw; + if (!(rect instanceof PDFArray) || rect.size() < 4) continue; + + const x1 = num(ctx, rect.get(0)); + const y1 = num(ctx, rect.get(1)); + const x2 = num(ctx, rect.get(2)); + const y2 = num(ctx, rect.get(3)); + + const left = Math.min(x1, x2); + const bottom = Math.min(y1, y2); + const width = Math.abs(x2 - x1); + const height = Math.abs(y2 - y1); + + const top = pageHeight - bottom - height; + + let linkType: LinkType = 'unknown'; + let targetPage: number | undefined; + let uri: string | undefined; + + const actionRaw = annot.get(PDFName.of('A')); + const action = actionRaw instanceof PDFRef ? ctx.lookup(actionRaw) : actionRaw; + + if (action instanceof PDFDict) { + const actionType = action.get(PDFName.of('S'))?.toString(); + + if (actionType === '/URI') { + linkType = 'external'; + uri = str(ctx, action.get(PDFName.of('URI'))); + } else if (actionType === '/GoTo') { + linkType = 'internal'; + const dest = action.get(PDFName.of('D')); + const destResolved = dest instanceof PDFRef ? ctx.lookup(dest) : dest; + if (destResolved instanceof PDFArray) { + targetPage = resolveDestArray(doc, ctx, destResolved); + } else { + const destName = str(ctx, destResolved); + if (destName) { + targetPage = resolveNamedDest(doc, ctx, destName); + } + } + } else if (actionType === '/GoToR') { + linkType = 'external'; + uri = str(ctx, action.get(PDFName.of('F'))); + } else if (actionType === '/Launch') { + linkType = 'external'; + uri = str(ctx, action.get(PDFName.of('F'))); + } + } + + if (linkType === 'unknown') { + const destRaw = annot.get(PDFName.of('Dest')); + const dest = destRaw instanceof PDFRef ? ctx.lookup(destRaw) : destRaw; + + if (dest instanceof PDFArray) { + linkType = 'internal'; + targetPage = resolveDestArray(doc, ctx, dest); + } else { + const destName = str(ctx, dest); + if (destName) { + linkType = 'internal'; + targetPage = resolveNamedDest(doc, ctx, destName); + } + } + } + + links.push({ + id: `pdflib-link-${pageIndex}-${i}`, + annotIndex: i, + rect: { x: left, y: top, width, height }, + type: linkType, + targetPage, + uri, + }); + } catch (e) { + console.warn('[usePdfLibLinks] Failed to parse annotation:', e); + } + } + + return links; +} + +export function usePdfLibLinks( + pdfUrl: string | null, + pageIndex: number, +): PdfLibLinksResult { + const [result, setResult] = useState({ + links: [], + pdfPageWidth: 0, + pdfPageHeight: 0, + loading: false, + }); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + if (!pdfUrl) { + setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false }); + return; + } + + let cancelled = false; + setResult((prev) => ({ ...prev, loading: true })); + + const url = pdfUrl; // capture for cleanup closure + + (async () => { + try { + const cached = await acquireDocument(url); + if (cancelled || !mountedRef.current) { + releaseDocument(url); + return; + } + + let pageData = cached.pageLinks.get(pageIndex); + if (!pageData) { + const pageCount = cached.doc.getPageCount(); + if (pageIndex < 0 || pageIndex >= pageCount) { + setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false }); + releaseDocument(url); + return; + } + + const page = cached.doc.getPage(pageIndex); + const { width, height } = page.getSize(); + const links = extractLinksFromPage(cached.doc, page, pageIndex); + pageData = { links, width, height }; + cached.pageLinks.set(pageIndex, pageData); + } + + if (!cancelled && mountedRef.current) { + setResult({ + links: pageData.links, + pdfPageWidth: pageData.width, + pdfPageHeight: pageData.height, + loading: false, + }); + } + + releaseDocument(url); + } catch (error) { + console.error('[usePdfLibLinks] Failed to extract links:', error); + if (!cancelled && mountedRef.current) { + setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false }); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [pdfUrl, pageIndex]); + + return result; +} diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css index db4cb9e86..ab1fd712a 100644 --- a/frontend/src/core/styles/theme.css +++ b/frontend/src/core/styles/theme.css @@ -675,6 +675,198 @@ * { transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; } +/* ── PDF Link Overlay (viewer) ── */ +:root { + --link-hover-bg: rgba(10, 139, 255, 0.04); + --link-hover-border: rgba(10, 139, 255, 0.15); + --link-hover-shadow: + 0 0 0 0.5px rgba(10, 139, 255, 0.05), + 0 1px 3px rgba(10, 139, 255, 0.04); + --link-focus-ring: rgba(10, 139, 255, 0.25); + --link-toolbar-bg: rgba(15, 23, 42, 0.88); + --link-toolbar-border: rgba(255, 255, 255, 0.1); + --link-toolbar-shadow: + 0 12px 40px rgba(0, 0, 0, 0.2), + 0 4px 12px rgba(0, 0, 0, 0.16), + 0 0 0 0.5px rgba(255, 255, 255, 0.05); +} + +[data-mantine-color-scheme="dark"] { + --link-hover-bg: rgba(10, 139, 255, 0.06); + --link-hover-border: rgba(59, 170, 255, 0.2); + --link-hover-shadow: + 0 0 0 0.5px rgba(59, 170, 255, 0.1), + 0 1px 3px rgba(10, 139, 255, 0.08); + --link-focus-ring: rgba(59, 170, 255, 0.3); + --link-toolbar-bg: rgba(15, 23, 42, 0.92); + --link-toolbar-border: rgba(255, 255, 255, 0.08); + --link-toolbar-shadow: + 0 12px 40px rgba(0, 0, 0, 0.35), + 0 4px 12px rgba(0, 0, 0, 0.25), + 0 0 0 0.5px rgba(255, 255, 255, 0.04); +} + +/* ── Link hit-area ── */ +.pdf-link-overlay { + position: absolute; + display: block; + pointer-events: auto; + text-decoration: none; + outline: none; + cursor: pointer; + z-index: 11; + background: transparent; + border: 1px solid transparent; + border-radius: 2px; + box-shadow: none; + transition: + background 0.15s ease, + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.pdf-link-overlay:hover, +.pdf-link-overlay--active { + background: var(--link-hover-bg); + border-bottom: 2px solid var(--color-primary-500); + box-shadow: var(--link-hover-shadow); +} + +.pdf-link-overlay:focus-visible { + background: var(--link-hover-bg); + border-color: var(--link-hover-border); + box-shadow: + var(--link-hover-shadow), + 0 0 0 2.5px var(--link-focus-ring); +} + +/* ── Floating toolbar ── */ +@keyframes pdf-link-toolbar-enter { + from { + opacity: 0; + transform: translateX(-50%) translateY(3px) scale(0.95); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + +@keyframes pdf-link-toolbar-enter-below { + from { + opacity: 0; + transform: translateX(-50%) translateY(-3px) scale(0.95); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + +.pdf-link-toolbar { + position: absolute; + z-index: 50; + display: flex; + align-items: center; + height: 32px; + padding: 0 4px; + pointer-events: auto; + white-space: nowrap; + user-select: none; + + background: var(--link-toolbar-bg); + backdrop-filter: blur(20px) saturate(190%); + -webkit-backdrop-filter: blur(20px) saturate(190%); + border: 1px solid var(--link-toolbar-border); + border-radius: 9999px; + box-shadow: var(--link-toolbar-shadow); + + transform: translateX(-50%); + animation: pdf-link-toolbar-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1) both; + transform-origin: center bottom; +} + +.pdf-link-toolbar--below { + animation-name: pdf-link-toolbar-enter-below; + transform-origin: center top; +} + +/* ── Toolbar buttons ── */ +.pdf-link-toolbar-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 24px; + padding: 0 10px; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.9); + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 11.5px; + font-weight: 500; + letter-spacing: 0.01em; + cursor: pointer; + transition: all 0.12s ease; + white-space: nowrap; + line-height: 1; + border-radius: 9999px; +} + +.pdf-link-toolbar-label { + transition: color 0.12s ease; +} + +.pdf-link-toolbar-btn:hover .pdf-link-toolbar-label { + color: #60a5fa; /* Modern blue accent */ + text-decoration: underline; + text-underline-offset: 3px; +} + +.pdf-link-toolbar-btn:hover { + background: rgba(255, 255, 255, 0.08); +} + +.pdf-link-toolbar-btn--delete:hover { + background: rgba(239, 68, 68, 0.15); + color: #fca5a5; +} + +.pdf-link-toolbar-sep { + width: 1px; + height: 12px; + background: rgba(255, 255, 255, 0.12); + margin: 0 4px; +} + +.pdf-link-toolbar-btn:last-child { + border-radius: 0 9px 9px 0; +} + +.pdf-link-toolbar-btn:hover { + color: #fff; +} + +.pdf-link-toolbar-btn--delete { + padding: 0 10px; +} + +.pdf-link-toolbar-btn--delete:hover { + background: rgba(239, 68, 68, 0.20); + color: #f87171; +} + +.pdf-link-toolbar-btn--go:hover { + background: rgba(255, 255, 255, 0.08); +} + +.pdf-link-toolbar-sep { + width: 1px; + height: 16px; + background: rgba(255, 255, 255, 0.10); + flex-shrink: 0; +} + :root { --shadow-color: rgba(15, 23, 42, 0.55); }