feat(viewer): Add interactive link layer with (basic) internal/external navigation support (#5077)

# 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


<img width="773" height="957" alt="image"
src="https://github.com/user-attachments/assets/8a04d15a-79b5-46b6-af8b-3d27246581a7"
/>
<img width="773" height="957" alt="image"
src="https://github.com/user-attachments/assets/eeb39ca7-a114-4bd5-a4eb-9e8a27331297"
/>
<img width="773" height="957" alt="image"
src="https://github.com/user-attachments/assets/1ee6bba3-d233-4a11-bf1a-1b56696265e1"
/>


<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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 <bszucs1209@gmail.com>
This commit is contained in:
Balázs Szücs 2025-11-29 13:53:26 +01:00 committed by GitHub
parent 058a81d554
commit 85d9b5b83d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 305 additions and 57 deletions

View File

@ -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");

View File

@ -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";
}

View File

@ -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<LinkLayerProps> = ({
pageIndex,
scale,
document: pdfDocument,
onLinkClick
}) => {
const { provides: annotation } = useAnnotationCapability();
const { provides: scroll } = useScroll();
const [links, setLinks] = useState<LinkAnnotation[]>([]);
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 (
<div className="absolute inset-0 pointer-events-none">
{processedLinks.map((link) => {
const { id } = link;
const { left, top, width, height } = link.scaledRect;
return (
<button
key={id}
onClick={() => handleLinkClick(link)}
disabled={isNavigating}
className={`absolute opacity-0 hover:opacity-20 bg-blue-500 transition-opacity cursor-pointer pointer-events-auto border border-blue-400 hover:border-blue-600 ${isNavigating ? 'cursor-not-allowed opacity-50' : ''}`}
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
minWidth: '8px',
minHeight: '8px',
}}
title={getLinkTitle(link)}
aria-label={getLinkAriaLabel(link)}
/>
);
})}
</div>
);
};

View File

@ -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
<EmbedPDF
engine={engine}
plugins={plugins}
onInitialized={enableAnnotations ? async (registry) => {
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}
});
}
}}
>
<ZoomAPIBridge />
<ScrollAPIBridge />
@ -352,6 +349,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
{/* Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{/* Link layer for clickable PDF links */}
<LinkLayer pageIndex={pageIndex} scale={scale} document={document} pdfFile={file} />
{/* Annotation layer for signatures (only when enabled) */}
{enableAnnotations && (
<AnnotationLayer