From 85d9b5b83d1d20f32d017c62bdd4ed2606169a33 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: Sat, 29 Nov 2025 13:53:26 +0100 Subject: [PATCH] feat(viewer): Add interactive link layer with (basic) internal/external navigation support (#5077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Added a new `LinkLayer` component to the PDF viewer that renders clickable overlays for PDF link annotations, enabling both internal page navigation and external URL links. - Created `LinkLayer.tsx` component that extracts link annotations using the EmbedPDF annotation API with fallback to direct PDF document access - Implemented scale-aware positioning to maintain accurate link hotspots at different zoom levels - Added support for internal navigation (GoTo actions) using smooth scrolling and external links (URI actions) opening in new tabs - Integrated accessibility features with proper ARIA labels and keyboard navigation - Modified `LocalEmbedPDF.tsx` to always register the annotation plugin (even when editing is disabled) to enable reading existing link annotations - Updated `ReactRoutingController.java` and test formatting for code style consistency **Key features:** - Multi-source annotation detection (annotation API → document API → page API fallback) - Navigation lock to prevent race conditions - React performance optimizations (useMemo, useCallback) - TypeScript type safety for PDF actions and destinations This does not address support for Attachment links. Sadly, that does not seem to be possible with EmbedPDF image image image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. Signed-off-by: Balázs Szücs --- .../common/util/RequestUriUtilsTest.java | 3 +- .../web/ReactRoutingController.java | 6 +- .../src/core/components/viewer/LinkLayer.tsx | 244 ++++++++++++++++++ .../core/components/viewer/LocalEmbedPDF.tsx | 109 ++++---- 4 files changed, 305 insertions(+), 57 deletions(-) create mode 100644 frontend/src/core/components/viewer/LinkLayer.tsx diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 0edb546cc..b7c121ab3 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -51,7 +51,8 @@ public class RequestUriUtilsTest { @Test void testIsFrontendRoute() { - assertTrue(RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route"); + assertTrue( + RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route"); assertTrue( RequestUriUtils.isFrontendRoute("", "/app/dashboard"), "React routes without extensions should be frontend routes"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 95acd6297..daa6233ce 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -6,12 +6,14 @@ import org.springframework.web.bind.annotation.GetMapping; @Controller public class ReactRoutingController { - @GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}") + @GetMapping( + "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}") public String forwardRootPaths() { return "forward:/index.html"; } - @GetMapping("/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}") + @GetMapping( + "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}") public String forwardNestedPaths() { return "forward:/index.html"; } diff --git a/frontend/src/core/components/viewer/LinkLayer.tsx b/frontend/src/core/components/viewer/LinkLayer.tsx new file mode 100644 index 000000000..4062d96d7 --- /dev/null +++ b/frontend/src/core/components/viewer/LinkLayer.tsx @@ -0,0 +1,244 @@ +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; + +enum PDFActionType { + GoTo = 0, + GoToR = 1, + GoToE = 2, + URI = 3, + // Add other types as needed +} + +interface PDFRect { + origin: { x: number; y: number }; + size: { width: number; height: number }; +} + +interface PDFDestination { + pageIndex: number; + view: [number, number]; +} + +interface PDFAction { + type: string | number; + destination?: PDFDestination; + uri?: string; +} + +interface LinkAnnotation { + id: string; + type: number; + rect: PDFRect; + target?: { + type: string; + action?: PDFAction; + destination?: PDFDestination; + uri?: string; + }; +} + +function isGoToAction(action: PDFAction): boolean { + return action.type === 'GoTo' || action.type === PDFActionType.GoTo; +} + +function isURIAction(action: PDFAction): boolean { + return action.type === 'URI' || action.type === PDFActionType.URI; +} + +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))); +} + +interface LinkLayerProps { + pageIndex: number; + scale: number; + document?: any; + pdfFile?: File | Blob; + onLinkClick?: (target: any) => void; +} + +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'; +}; + +export const LinkLayer: React.FC = ({ + pageIndex, + scale, + document: pdfDocument, + onLinkClick +}) => { + const { provides: annotation } = useAnnotationCapability(); + const { provides: scroll } = useScroll(); + const [links, setLinks] = useState([]); + const [isNavigating, setIsNavigating] = useState(false); + + const processedLinks = useMemo(() => { + return links.map(link => ({ + ...link, + scaledRect: { + left: link.rect.origin.x * scale, + top: link.rect.origin.y * scale, + width: link.rect.size.width * scale, + height: link.rect.size.height * scale, + } + })); + }, [links, scale]); + + useEffect(() => { + const fetchLinks = async () => { + if (!annotation) return; + + try { + // Use the annotation API's built-in filtering if available + const pageAnnotations = await annotation + .getPageAnnotations({ + pageIndex, + // Try to filter for link annotations (type 2) if the API supports it + ...(annotation.getPageAnnotations.length > 1 ? { types: [2] } : {}) + }) + .toPromise(); + + // Filter for link annotations (type 2 is LINK in PDF spec) as fallback + const linkAnnotations = pageAnnotations.filter( + (ann: any) => ann.type === 2 + ) as LinkAnnotation[]; + + if (linkAnnotations.length > 0) { + setLinks(linkAnnotations); + return; + } + } catch (error) { + console.error('[LinkLayer] Failed to fetch links from annotation API:', error); + } + + if (pdfDocument) { + try { + // Try different methods to get link annotations + let pdfLinks: any[] = []; + + if (pdfDocument.getPageAnnotations && typeof pdfDocument.getPageAnnotations === 'function') { + pdfLinks = await pdfDocument.getPageAnnotations(pageIndex); + } else if (pdfDocument.getAnnotations && typeof pdfDocument.getAnnotations === 'function') { + const allAnnotations = await pdfDocument.getAnnotations(); + pdfLinks = allAnnotations.filter((ann: any) => ann.pageIndex === pageIndex && ann.type === 2); + } else if (pdfDocument.pages && pdfDocument.pages[pageIndex]) { + const page = pdfDocument.pages[pageIndex]; + if (page.getAnnotations && typeof page.getAnnotations === 'function') { + pdfLinks = await page.getAnnotations(); + } + } + + const convertedLinks = pdfLinks.map((ann: any) => ({ + id: ann.id || `pdf-link-${pageIndex}-${Math.random()}`, + type: ann.type || 2, + rect: ann.rect || ann, + target: ann.target || ann.action + })) as LinkAnnotation[]; + + setLinks(convertedLinks); + } catch (error) { + console.warn('[LinkLayer] Failed to get annotations from PDF document:', error); + } + } else { + console.warn('[LinkLayer] No annotation API or PDF document available'); + } + }; + + fetchLinks(); + }, [annotation, pageIndex, pdfDocument]); + + const handleLinkClick = useCallback(async (link: LinkAnnotation) => { + if (isNavigating) return; // Prevent multiple simultaneous navigations + + try { + setIsNavigating(true); + + if (onLinkClick) { + onLinkClick(link.target); + return; + } + + if (isInternalLink(link)) { + const targetPage = link.target?.destination?.pageIndex ?? + link.target?.action?.destination?.pageIndex; + if (targetPage !== undefined && scroll) { + await scroll.scrollToPage({ + pageNumber: targetPage + 1, // PDF pages are 1-indexed + behavior: 'smooth', + }); + } + } else if (isExternalLink(link)) { + const uri = link.target?.uri ?? link.target?.action?.uri; + if (uri) { + window.open(uri, '_blank', 'noopener,noreferrer'); + } + } else { + throw new Error(`Unsupported link type: ${link.target?.type}`); + } + } catch (error) { + console.error('[LinkLayer] Navigation failed:', error); + } finally { + setIsNavigating(false); + } + }, [isNavigating, onLinkClick, scroll]); + + return ( +
+ {processedLinks.map((link) => { + const { id } = link; + const { left, top, width, height } = link.scaledRect; + + return ( +
+ ); +}; diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 2de2a2e58..adef8c4fb 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -43,6 +43,7 @@ import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge'; import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge'; import { isPdfFile } from '@app/utils/fileUtils'; import { useTranslation } from 'react-i18next'; +import { LinkLayer } from '@app/components/viewer/LinkLayer'; interface LocalEmbedPDFProps { file?: File | Blob; @@ -103,15 +104,17 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur createPluginRegistration(SelectionPluginPackage), // Register history plugin for undo/redo (recommended for annotations) - ...(enableAnnotations ? [createPluginRegistration(HistoryPluginPackage)] : []), + // Always register for reading existing annotations + createPluginRegistration(HistoryPluginPackage), // Register annotation plugin (depends on InteractionManager, Selection, History) - ...(enableAnnotations ? [createPluginRegistration(AnnotationPluginPackage, { + // Always register for reading existing annotations like links + createPluginRegistration(AnnotationPluginPackage, { annotationAuthor: 'Digital Signature', autoCommit: true, deactivateToolAfterCreate: false, selectAfterCreate: true, - })] : []), + }), // Register pan plugin (depends on Viewport, InteractionManager) createPluginRegistration(PanPluginPackage, { @@ -229,68 +232,62 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur { + onInitialized={async (registry) => { const annotationPlugin = registry.getPlugin('annotation'); if (!annotationPlugin || !annotationPlugin.provides) return; const annotationApi = annotationPlugin.provides(); if (!annotationApi) return; - // Add custom signature stamp tool for image signatures - annotationApi.addTool({ - id: 'signatureStamp', - name: 'Digital Signature', - interaction: { exclusive: false, cursor: 'copy' }, - matchScore: () => 0, - defaults: { - type: PdfAnnotationSubtype.STAMP, - // Image will be set dynamically when signature is created - }, - }); + if (enableAnnotations) { + annotationApi.addTool({ + id: 'signatureStamp', + name: 'Digital Signature', + interaction: { exclusive: false, cursor: 'copy' }, + matchScore: () => 0, + defaults: { + type: PdfAnnotationSubtype.STAMP, + }, + }); - // Add custom ink signature tool for drawn signatures - annotationApi.addTool({ - id: 'signatureInk', - name: 'Signature Draw', - interaction: { exclusive: true, cursor: 'crosshair' }, - matchScore: () => 0, - defaults: { - type: PdfAnnotationSubtype.INK, - color: '#000000', - opacity: 1.0, - borderWidth: 2, - }, - }); + annotationApi.addTool({ + id: 'signatureInk', + name: 'Signature Draw', + interaction: { exclusive: true, cursor: 'crosshair' }, + matchScore: () => 0, + defaults: { + type: PdfAnnotationSubtype.INK, + color: '#000000', + opacity: 1.0, + borderWidth: 2, + }, + }); - // Listen for annotation events to track annotations and notify parent - annotationApi.onAnnotationEvent((event: any) => { - if (event.type === 'create' && event.committed) { - // Add to annotations list - setAnnotations(prev => [...prev, { - id: event.annotation.id, - pageIndex: event.pageIndex, - rect: event.annotation.rect - }]); + annotationApi.onAnnotationEvent((event: any) => { + if (event.type === 'create' && event.committed) { + setAnnotations(prev => [...prev, { + id: event.annotation.id, + pageIndex: event.pageIndex, + rect: event.annotation.rect + }]); - // Notify parent if callback provided - if (onSignatureAdded) { - onSignatureAdded(event.annotation); + if (onSignatureAdded) { + onSignatureAdded(event.annotation); + } + } else if (event.type === 'delete' && event.committed) { + setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id)); + } else if (event.type === 'loaded') { + const loadedAnnotations = event.annotations || []; + setAnnotations(loadedAnnotations.map((ann: any) => ({ + id: ann.id, + pageIndex: ann.pageIndex || 0, + rect: ann.rect + }))); } - } else if (event.type === 'delete' && event.committed) { - // Remove from annotations list - setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id)); - } else if (event.type === 'loaded') { - // Handle initial load of annotations - const loadedAnnotations = event.annotations || []; - setAnnotations(loadedAnnotations.map((ann: any) => ({ - id: ann.id, - pageIndex: ann.pageIndex || 0, - rect: ann.rect - }))); - } - }); - } : undefined} + }); + } + }} > @@ -352,6 +349,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur {/* Selection layer for text interaction */} + + {/* Link layer for clickable PDF links */} + + {/* Annotation layer for signatures (only when enabled) */} {enableAnnotations && (