mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
feat(linklayer): improve link handling with pdf-lib integration and add link toolbar, add delete link functionality (#5715)
This commit is contained in:
parent
71c845bcd8
commit
b1d44d5661
@ -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 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3 4h8M5.5 4v-1a0.5 0 0 1 0.5-0.5h2a0.5 0 0 1 0.5 0.5v1M4.5 4l0.4 7a0.8 0 0 0 0.8 0.7h2.6a0.8 0 0 0 0.8-0.7l0.4-7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ExternalLinkIcon: React.FC<{ size?: number }> = ({ size = 12 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M8.5 3.5l-3.5 3.5m3.5-3.5v2.5m0-2.5h-2.5M3.5 3.5h-0.5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-0.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PageIcon: React.FC<{ size?: number }> = ({ size = 12 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3 2.5h3l2 2v4.5a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-6.5a1 1 0 0 1 1-1z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 2.5v2h2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<LinkToolbarProps> = 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 (
|
||||
<div
|
||||
className={`pdf-link-toolbar${flipped ? ' pdf-link-toolbar--below' : ''}`}
|
||||
style={{ left: `${centerX}px`, top: `${topY}px` }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
className="pdf-link-toolbar-btn pdf-link-toolbar-btn--delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(link);
|
||||
}}
|
||||
aria-label="Delete link"
|
||||
title="Delete link"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
|
||||
// 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)));
|
||||
}
|
||||
<span className="pdf-link-toolbar-sep" />
|
||||
|
||||
function _isExternalLink(link: LinkAnnotation): boolean {
|
||||
return Boolean(link.target?.type === 'uri' ||
|
||||
(link.target?.type === 'action' && link.target.action && isURIAction(link.target.action)));
|
||||
}
|
||||
{/* Navigate / Open */}
|
||||
<button
|
||||
type="button"
|
||||
className="pdf-link-toolbar-btn pdf-link-toolbar-btn--go"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigate(link);
|
||||
}}
|
||||
aria-label={isInternal ? `Go to page ${(link.targetPage ?? 0) + 1}` : 'Open link'}
|
||||
title={isInternal ? `Go to page ${(link.targetPage ?? 0) + 1}` : link.uri ?? 'Open link'}
|
||||
>
|
||||
{isInternal ? <PageIcon /> : <ExternalLinkIcon />}
|
||||
<span className="pdf-link-toolbar-label">{label}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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<LinkLayerProps> = ({
|
||||
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<LinkAnnotation[]>([]);
|
||||
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<string | null>(null);
|
||||
const [deletedLinkIds, setDeletedLinkIds] = useState<Set<string>>(new Set());
|
||||
const leaveTimerRef = useRef<ReturnType<typeof setTimeout> | 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<any[]> => {
|
||||
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<any[]>;
|
||||
}
|
||||
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<any[]>);
|
||||
} 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 (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10, // Above selection layer but below UI controls
|
||||
}}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ pointerEvents: 'none', zIndex: 10 }}
|
||||
>
|
||||
{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 (
|
||||
<a
|
||||
key={id}
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
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 */}
|
||||
</a>
|
||||
<React.Fragment key={link.id}>
|
||||
{/* Hit-area overlay */}
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
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 && (
|
||||
<LinkToolbar
|
||||
link={link}
|
||||
scale={scale}
|
||||
flipped={flipped}
|
||||
onNavigate={handleNavigate}
|
||||
onDelete={handleDelete}
|
||||
onMouseEnter={handleToolbarMouseEnter}
|
||||
onMouseLeave={handleToolbarMouseLeave}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
@ -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 */}
|
||||
<LinkLayer
|
||||
documentId={documentId}
|
||||
pageIndex={pageIndex}
|
||||
pageWidth={width}
|
||||
pageHeight={height}
|
||||
_pageWidth={width}
|
||||
_pageHeight={height}
|
||||
pdfUrl={pdfUrl}
|
||||
/>
|
||||
</div>
|
||||
</PagePointerProvider>
|
||||
|
||||
393
frontend/src/core/hooks/usePdfLibLinks.ts
Normal file
393
frontend/src/core/hooks/usePdfLibLinks.ts
Normal file
@ -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<number, { links: PdfLibLink[]; width: number; height: number }>;
|
||||
}
|
||||
|
||||
const docCache = new Map<string, Promise<CachedDoc>>();
|
||||
|
||||
async function acquireDocument(url: string): Promise<CachedDoc> {
|
||||
if (!docCache.has(url)) {
|
||||
const promise = (async (): Promise<CachedDoc> => {
|
||||
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<PdfLibLinksResult>({
|
||||
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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user