mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
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:
parent
058a81d554
commit
85d9b5b83d
@ -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");
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
244
frontend/src/core/components/viewer/LinkLayer.tsx
Normal file
244
frontend/src/core/components/viewer/LinkLayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user