Feature/v2/viewer tabs (#4646)

# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] 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)
- [ ] 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)

### 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)

- [ ] 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.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <james@stirlingpdf.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Reece Browne
2025-10-13 15:03:07 +01:00
committed by GitHub
parent 3cebcc70af
commit af57ae02dd
15 changed files with 435 additions and 249 deletions

View File

@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Center, Text, ActionIcon } from '@mantine/core';
import { useMantineTheme, useMantineColorScheme } from '@mantine/core';
import CloseIcon from '@mui/icons-material/Close';
import { useFileState, useFileActions } from "../../contexts/FileContext";
@@ -20,6 +19,8 @@ export interface EmbedPdfViewerProps {
setSidebarsVisible: (v: boolean) => void;
onClose?: () => void;
previewFile?: File | null;
activeFileIndex?: number;
setActiveFileIndex?: (index: number) => void;
}
const EmbedPdfViewerContent = ({
@@ -27,9 +28,9 @@ const EmbedPdfViewerContent = ({
setSidebarsVisible: _setSidebarsVisible,
onClose,
previewFile,
activeFileIndex: externalActiveFileIndex,
setActiveFileIndex: externalSetActiveFileIndex,
}: EmbedPdfViewerProps) => {
const theme = useMantineTheme();
const { colorScheme: _colorScheme } = useMantineColorScheme();
const viewerRef = React.useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
@@ -52,10 +53,11 @@ const EmbedPdfViewerContent = ({
const { signatureApiRef, historyApiRef } = useSignature();
// Get current file from FileContext
const { selectors } = useFileState();
const { selectors, state } = useFileState();
const { actions } = useFileActions();
const activeFiles = selectors.getFiles();
const activeFileIds = activeFiles.map(f => f.fileId);
const selectedFileIds = state.ui.selectedFileIds;
// Navigation guard for unsaved changes
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
@@ -67,15 +69,40 @@ const EmbedPdfViewerContent = ({
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
// Track which file tab is active
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
const activeFileIndex = externalActiveFileIndex ?? internalActiveFileIndex;
const setActiveFileIndex = externalSetActiveFileIndex ?? setInternalActiveFileIndex;
const hasInitializedFromSelection = useRef(false);
// When viewer opens with a selected file, switch to that file
useEffect(() => {
if (!hasInitializedFromSelection.current && selectedFileIds.length > 0 && activeFiles.length > 0) {
const selectedFileId = selectedFileIds[0];
const index = activeFiles.findIndex(f => f.fileId === selectedFileId);
if (index !== -1 && index !== activeFileIndex) {
setActiveFileIndex(index);
}
hasInitializedFromSelection.current = true;
}
}, [selectedFileIds, activeFiles, activeFileIndex]);
// Reset active tab if it's out of bounds
useEffect(() => {
if (activeFileIndex >= activeFiles.length && activeFiles.length > 0) {
setActiveFileIndex(0);
}
}, [activeFiles.length, activeFileIndex]);
// Determine which file to display
const currentFile = React.useMemo(() => {
if (previewFile) {
return previewFile;
} else if (activeFiles.length > 0) {
return activeFiles[0]; // Use first file for simplicity
return activeFiles[activeFileIndex] || activeFiles[0];
}
return null;
}, [previewFile, activeFiles]);
}, [previewFile, activeFiles, activeFileIndex]);
// Get file with URL for rendering
const fileWithUrl = useFileWithUrl(currentFile);
@@ -244,15 +271,6 @@ const EmbedPdfViewerContent = ({
</Center>
) : (
<>
{/* Tabs for multiple files */}
{activeFiles.length > 1 && !previewFile && (
<Box p="md" style={{ borderBottom: `1px solid ${theme.colors.gray[3]}` }}>
<Text size="sm" c="dimmed">
Multiple files loaded - showing first file for now
</Text>
</Box>
)}
{/* EmbedPDF Viewer */}
<Box style={{
position: 'relative',
@@ -317,6 +335,7 @@ const EmbedPdfViewerContent = ({
<ThumbnailSidebar
visible={isThumbnailSidebarVisible}
onToggle={toggleThumbnailSidebar}
activeFileIndex={activeFileIndex}
/>
{/* Navigation Warning Modal */}

View File

@@ -18,7 +18,6 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
import { Rotation } from '@embedpdf/models';
// Import annotation plugins
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
@@ -67,6 +66,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
const plugins = useMemo(() => {
if (!pdfUrl) return [];
// Calculate 3.5rem in pixels dynamically based on root font size
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const viewportGap = rootFontSize * 3.5;
return [
createPluginRegistration(LoaderPluginPackage, {
loadingOptions: {
@@ -78,7 +81,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
},
}),
createPluginRegistration(ViewportPluginPackage, {
viewportGap: 10,
viewportGap,
}),
createPluginRegistration(ScrollPluginPackage, {
strategy: ScrollStrategy.Vertical,
@@ -134,9 +137,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
createPluginRegistration(ThumbnailPluginPackage),
// Register rotate plugin
createPluginRegistration(RotatePluginPackage, {
defaultRotation: Rotation.Degree0, // Start with no rotation
}),
createPluginRegistration(RotatePluginPackage),
// Register export plugin for downloading PDFs
createPluginRegistration(ExportPluginPackage, {
@@ -288,48 +289,50 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
}}
>
<Scroller
renderPage={({ width, height, pageIndex, scale, rotation }: { width: number; height: number; pageIndex: number; scale: number; rotation?: number }) => (
<Rotate pageSize={{ width, height }}>
<PagePointerProvider {...{ pageWidth: width, pageHeight: height, pageIndex, scale, rotation: rotation || 0 }}>
<div
style={{
width,
height,
position: 'relative',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)'
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
>
{/* High-resolution tile layer */}
<TilingLayer pageIndex={pageIndex} scale={scale} />
renderPage={({ document, width, height, pageIndex, scale, rotation }) => {
return (
<Rotate key={document?.id} pageSize={{ width, height }}>
<PagePointerProvider pageIndex={pageIndex} pageWidth={width} pageHeight={height} scale={scale} rotation={rotation}>
<div
style={{
width,
height,
position: 'relative',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)'
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
>
{/* High-resolution tile layer */}
<TilingLayer pageIndex={pageIndex} scale={scale} />
{/* Search highlight layer */}
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
{/* Search highlight layer */}
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
{/* Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{/* Annotation layer for signatures (only when enabled) */}
{enableAnnotations && (
<AnnotationLayer
pageIndex={pageIndex}
scale={scale}
pageWidth={width}
pageHeight={height}
rotation={rotation || 0}
selectionOutlineColor="#007ACC"
/>
)}
</div>
</PagePointerProvider>
</Rotate>
)}
{/* Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{/* Annotation layer for signatures (only when enabled) */}
{enableAnnotations && (
<AnnotationLayer
pageIndex={pageIndex}
scale={scale}
pageWidth={width}
pageHeight={height}
rotation={rotation}
selectionOutlineColor="#007ACC"
/>
)}
</div>
</PagePointerProvider>
</Rotate>
);
}}
/>
</Viewport>
</GlobalPointerProvider>

View File

@@ -64,6 +64,10 @@ export function LocalEmbedPDFWithAnnotations({
const plugins = useMemo(() => {
if (!pdfUrl) return [];
// Calculate 3.5rem in pixels dynamically based on root font size
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const viewportGap = rootFontSize * 3.5;
return [
createPluginRegistration(LoaderPluginPackage, {
loadingOptions: {
@@ -75,7 +79,7 @@ export function LocalEmbedPDFWithAnnotations({
},
}),
createPluginRegistration(ViewportPluginPackage, {
viewportGap: 10,
viewportGap,
}),
createPluginRegistration(ScrollPluginPackage, {
strategy: ScrollStrategy.Vertical,

View File

@@ -5,15 +5,27 @@ import { useViewer } from '../../contexts/ViewerContext';
interface ThumbnailSidebarProps {
visible: boolean;
onToggle: () => void;
activeFileIndex?: number;
}
export function ThumbnailSidebar({ visible, onToggle: _onToggle }: ThumbnailSidebarProps) {
export function ThumbnailSidebar({ visible, onToggle: _onToggle, activeFileIndex }: ThumbnailSidebarProps) {
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({});
const scrollState = getScrollState();
const thumbnailAPI = getThumbnailAPI();
// Clear thumbnails when active file changes
useEffect(() => {
// Revoke old blob URLs to prevent memory leaks
Object.values(thumbnails).forEach((thumbUrl) => {
if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) {
URL.revokeObjectURL(thumbUrl);
}
});
setThumbnails({});
}, [activeFileIndex]);
// Clear thumbnails when sidebar closes and revoke blob URLs to prevent memory leaks
useEffect(() => {
if (!visible) {

View File

@@ -5,6 +5,8 @@ export interface ViewerProps {
setSidebarsVisible: (v: boolean) => void;
onClose?: () => void;
previewFile?: File | null;
activeFileIndex?: number;
setActiveFileIndex?: (index: number) => void;
}
const Viewer = (props: ViewerProps) => {