Compare commits

...

5 Commits

Author SHA1 Message Date
Reece Browne
da6ecc6619 Fix scroll page identification 2025-09-17 14:35:44 +01:00
Reece Browne
dac176f0c6 Fix colours 2025-09-17 12:07:44 +01:00
Reece Browne
41e5a7fbd6 Restructure to avoid global variables
fix zoom
2025-09-17 12:00:20 +01:00
Reece Browne
b81ed9ec2e Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf 2025-09-16 19:37:50 +01:00
Reece Browne
9b5c50db07 Improved Structure with context at root 2025-09-16 19:36:36 +01:00
18 changed files with 819 additions and 430 deletions

View File

@ -2286,7 +2286,12 @@
"downloadSelected": "Download Selected Files",
"downloadAll": "Download All",
"toggleTheme": "Toggle Theme",
"language": "Language"
"language": "Language",
"search": "Search PDF",
"panMode": "Pan Mode",
"rotateLeft": "Rotate Left",
"rotateRight": "Rotate Right",
"toggleSidebar": "Toggle Sidebar"
},
"toolPicker": {
"searchPlaceholder": "Search tools...",

View File

@ -13,6 +13,7 @@ import "./styles/tailwind.css";
import "./styles/cookieconsent.css";
import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext";
import { ViewerProvider } from "./contexts/ViewerContext";
// Import file ID debugging helpers (development only)
import "./utils/fileIdSafety";
@ -43,9 +44,11 @@ export default function App() {
<FilesModalProvider>
<ToolWorkflowProvider>
<SidebarProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
<ViewerProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</ViewerProvider>
</SidebarProvider>
</ToolWorkflowProvider>
</FilesModalProvider>

View File

@ -14,11 +14,15 @@ import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { Tooltip } from '../shared/Tooltip';
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import { SearchInterface } from '../viewer/SearchInterface';
import { ViewerContext } from '../../contexts/ViewerContext';
export default function RightRail() {
const { t } = useTranslation();
const [isPanning, setIsPanning] = useState(false);
const [currentRotation, setCurrentRotation] = useState(0);
const [_currentRotation, setCurrentRotation] = useState(0);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
const { toggleTheme } = useRainbowThemeContext();
const { buttons, actions } = useRightRail();
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
@ -32,23 +36,13 @@ export default function RightRail() {
// Navigation view
const { workbench: currentView } = useNavigationState();
// Sync rotation state with EmbedPDF API
// Update rotation display when switching to viewer mode
useEffect(() => {
if (currentView === 'viewer' && window.embedPdfRotate) {
const updateRotation = () => {
const rotation = window.embedPdfRotate?.getRotation() || 0;
setCurrentRotation(rotation * 90); // Convert enum to degrees
};
// Update rotation immediately
updateRotation();
// Set up periodic updates to keep state in sync
const interval = setInterval(updateRotation, 1000);
return () => clearInterval(interval);
if (currentView === 'viewer' && viewerContext) {
const rotationState = viewerContext.getRotationState();
setCurrentRotation((rotationState?.rotation ?? 0) * 90);
}
}, [currentView]);
}, [currentView, viewerContext]);
// File state and selection
const { state, selectors } = useFileState();
@ -269,7 +263,7 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => {
window.embedPdfPan?.togglePan();
viewerContext?.panActions.togglePan();
setIsPanning(!isPanning);
}}
disabled={currentView !== 'viewer'}
@ -285,7 +279,7 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => {
window.embedPdfRotate?.rotateBackward();
viewerContext?.rotationActions.rotateBackward();
}}
disabled={currentView !== 'viewer'}
>
@ -300,7 +294,7 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => {
window.embedPdfRotate?.rotateForward();
viewerContext?.rotationActions.rotateForward();
}}
disabled={currentView !== 'viewer'}
>
@ -314,7 +308,9 @@ export default function RightRail() {
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => window.toggleThumbnailSidebar?.()}
onClick={() => {
viewerContext?.toggleThumbnailSidebar();
}}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useSearch } from '@embedpdf/plugin-search/react';
import { useViewer } from '../../contexts/ViewerContext';
interface SearchLayerProps {
pageIndex: number;
@ -32,6 +33,7 @@ export function CustomSearchLayer({
borderRadius = 4
}: SearchLayerProps) {
const { provides: searchProvides } = useSearch();
const { scrollActions } = useViewer();
const [searchResultState, setSearchResultState] = useState<SearchResultState | null>(null);
// Subscribe to search result state changes
@ -41,27 +43,13 @@ export function CustomSearchLayer({
}
const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => {
// Expose search results globally for SearchInterface
if (state?.results) {
(window as any).currentSearchResults = state.results;
(window as any).currentActiveIndex = (state.activeResultIndex || 0) + 1; // Convert to 1-based index
// Auto-scroll to active result if we have one
if (state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
const activeResult = state.results[state.activeResultIndex];
if (activeResult) {
// Use the scroll API to navigate to the page containing the active result
const scrollAPI = (window as any).embedPdfScroll;
if (scrollAPI && scrollAPI.scrollToPage) {
// Convert 0-based page index to 1-based page number
const pageNumber = activeResult.pageIndex + 1;
scrollAPI.scrollToPage(pageNumber);
}
}
// Auto-scroll to active search result
if (state?.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
const activeResult = state.results[state.activeResultIndex];
if (activeResult) {
const pageNumber = activeResult.pageIndex + 1; // Convert to 1-based page number
scrollActions.scrollToPage(pageNumber);
}
} else {
(window as any).currentSearchResults = null;
(window as any).currentActiveIndex = 0;
}
setSearchResultState(state);

View File

@ -5,7 +5,7 @@ import CloseIcon from '@mui/icons-material/Close';
import { useFileState } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
import { ViewerProvider, useViewer } from "../../contexts/ViewerContext";
import { useViewer } from "../../contexts/ViewerContext";
import { LocalEmbedPDF } from './LocalEmbedPDF';
import { PdfViewerToolbar } from './PdfViewerToolbar';
import { ThumbnailSidebar } from './ThumbnailSidebar';
@ -28,7 +28,12 @@ const EmbedPdfViewerContent = ({
const { colorScheme } = useMantineColorScheme();
const viewerRef = React.useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
const { isThumbnailSidebarVisible, toggleThumbnailSidebar } = useViewer();
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState } = useViewer();
const scrollState = getScrollState();
const zoomState = getZoomState();
const spreadState = getSpreadState();
// Get current file from FileContext
const { selectors } = useFileState();
@ -70,19 +75,13 @@ const EmbedPdfViewerContent = ({
event.preventDefault();
event.stopPropagation();
// Convert smooth scrolling gestures into discrete notches
accumulator += event.deltaY;
const threshold = 10;
if (event.deltaY < 0) {
// Scroll up - zoom in
zoomActions.zoomIn();
} else {
// Scroll down - zoom out
zoomActions.zoomOut();
const zoomAPI = window.embedPdfZoom;
if (zoomAPI) {
if (accumulator <= -threshold) {
zoomAPI.zoomIn();
accumulator = 0;
} else if (accumulator >= threshold) {
zoomAPI.zoomOut();
accumulator = 0;
}
}
}
};
@ -103,17 +102,14 @@ const EmbedPdfViewerContent = ({
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
if (event.ctrlKey || event.metaKey) {
const zoomAPI = window.embedPdfZoom;
if (zoomAPI) {
if (event.key === '=' || event.key === '+') {
// Ctrl+= or Ctrl++ for zoom in
event.preventDefault();
zoomAPI.zoomIn();
} else if (event.key === '-' || event.key === '_') {
// Ctrl+- for zoom out
event.preventDefault();
zoomAPI.zoomOut();
}
if (event.key === '=' || event.key === '+') {
// Ctrl+= or Ctrl++ for zoom in
event.preventDefault();
zoomActions.zoomIn();
} else if (event.key === '-' || event.key === '_') {
// Ctrl+- for zoom out
event.preventDefault();
zoomActions.zoomOut();
}
}
};
@ -124,14 +120,6 @@ const EmbedPdfViewerContent = ({
};
}, [isViewerHovered]);
// Expose toggle functions globally for right rail buttons
React.useEffect(() => {
window.toggleThumbnailSidebar = toggleThumbnailSidebar;
return () => {
delete window.toggleThumbnailSidebar;
};
}, [toggleThumbnailSidebar]);
return (
<Box
@ -180,12 +168,13 @@ const EmbedPdfViewerContent = ({
flex: 1,
overflow: 'hidden',
minHeight: 0,
minWidth: 0
minWidth: 0,
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
transition: 'margin-right 0.3s ease'
}}>
<LocalEmbedPDF
file={effectiveFile.file}
url={effectiveFile.url}
colorScheme={colorScheme}
/>
</Box>
</>
@ -208,17 +197,17 @@ const EmbedPdfViewerContent = ({
>
<div style={{ pointerEvents: "auto" }}>
<PdfViewerToolbar
currentPage={1}
totalPages={1}
currentPage={scrollState.currentPage}
totalPages={scrollState.totalPages}
onPageChange={(page) => {
// Placeholder - will implement page navigation later
console.log('Navigate to page:', page);
}}
dualPage={false}
dualPage={spreadState.isDualPage}
onDualPageToggle={() => {
window.embedPdfSpread?.toggleSpreadMode();
spreadActions.toggleSpreadMode();
}}
currentZoom={100}
currentZoom={zoomState.zoomPercent}
/>
</div>
</div>
@ -229,18 +218,13 @@ const EmbedPdfViewerContent = ({
<ThumbnailSidebar
visible={isThumbnailSidebarVisible}
onToggle={toggleThumbnailSidebar}
colorScheme={colorScheme}
/>
</Box>
);
};
const EmbedPdfViewer = (props: EmbedPdfViewerProps) => {
return (
<ViewerProvider>
<EmbedPdfViewerContent {...props} />
</ViewerProvider>
);
return <EmbedPdfViewerContent {...props} />;
};
export default EmbedPdfViewer;

View File

@ -31,15 +31,12 @@ import { RotateAPIBridge } from './RotateAPIBridge';
interface LocalEmbedPDFProps {
file?: File | Blob;
url?: string | null;
colorScheme: 'light' | 'dark' | 'auto';
colorScheme?: 'light' | 'dark' | 'auto'; // Optional since we use CSS variables
}
export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) {
export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
// Convert color scheme (handle 'auto' mode by defaulting to 'light')
const actualColorScheme = colorScheme === 'auto' ? 'light' : colorScheme;
// Convert File to URL if needed
useEffect(() => {
if (file) {
@ -129,8 +126,8 @@ export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) {
justifyContent: 'center',
alignItems: 'center',
height: '100%',
background: actualColorScheme === 'dark' ? '#1a1b1e' : '#f8f9fa',
color: actualColorScheme === 'dark' ? '#ffffff' : '#666666',
background: 'var(--bg-surface)',
color: 'var(--text-secondary)',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '16px' }}>📄</div>
@ -147,8 +144,8 @@ export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) {
justifyContent: 'center',
alignItems: 'center',
height: '100%',
background: actualColorScheme === 'dark' ? '#1a1b1e' : '#f1f3f5',
color: actualColorScheme === 'dark' ? '#ffffff' : '#666666',
background: 'var(--bg-surface)',
color: 'var(--text-secondary)',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '16px' }}></div>
@ -165,8 +162,8 @@ export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) {
justifyContent: 'center',
alignItems: 'center',
height: '100%',
background: actualColorScheme === 'dark' ? '#1a1b1e' : '#f1f3f5',
color: '#ff6b6b',
background: 'var(--bg-surface)',
color: 'var(--color-red-500)',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '16px' }}></div>
@ -199,7 +196,7 @@ export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) {
<GlobalPointerProvider>
<Viewport
style={{
backgroundColor: actualColorScheme === 'dark' ? '#1a1b1e' : '#f1f3f5',
backgroundColor: 'var(--bg-surface)',
height: '100%',
width: '100%',
maxHeight: '100%',
@ -231,16 +228,13 @@ export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) {
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
>
{/* 1. Low-resolution base layer for immediate feedback */}
<RenderLayer pageIndex={pageIndex} scale={0.5} />
{/* 2. High-resolution tile layer on top */}
{/* High-resolution tile layer */}
<TilingLayer pageIndex={pageIndex} scale={scale} />
{/* 3. Search highlight layer */}
{/* Search highlight layer */}
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
{/* 4. Selection layer for text interaction */}
{/* Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
</div>
</PagePointerProvider>

View File

@ -1,47 +1,45 @@
import { useEffect, useState } from 'react';
import { usePan } from '@embedpdf/plugin-pan/react';
import { useViewer } from '../../contexts/ViewerContext';
/**
* Component that runs inside EmbedPDF context and bridges pan controls to global window
* Component that runs inside EmbedPDF context and updates pan state in ViewerContext
*/
export function PanAPIBridge() {
const { provides: pan, isPanning } = usePan();
const [panStateListeners, setPanStateListeners] = useState<Array<(isPanning: boolean) => void>>([]);
const { registerBridge } = useViewer();
// Store state locally
const [_localState, setLocalState] = useState({
isPanning: false
});
useEffect(() => {
if (pan) {
// Export pan controls to global window for right rail access
(window as any).embedPdfPan = {
enablePan: () => {
console.log('EmbedPDF: Enabling pan mode');
pan.enablePan();
},
disablePan: () => {
console.log('EmbedPDF: Disabling pan mode');
pan.disablePan();
},
togglePan: () => {
pan.togglePan();
},
makePanDefault: () => pan.makePanDefault(),
get isPanning() { return isPanning; }, // Use getter to always return current value
// Subscribe to pan state changes for reactive UI
onPanStateChange: (callback: (isPanning: boolean) => void) => {
setPanStateListeners(prev => [...prev, callback]);
// Return unsubscribe function
return () => {
setPanStateListeners(prev => prev.filter(cb => cb !== callback));
};
},
// Update local state
const newState = {
isPanning
};
setLocalState(newState);
// Register this bridge with ViewerContext
registerBridge('pan', {
state: newState,
api: {
enable: () => {
pan.enablePan();
},
disable: () => {
pan.disablePan();
},
toggle: () => {
pan.togglePan();
},
makePanDefault: () => pan.makePanDefault(),
}
});
}
}, [pan, isPanning]);
// Notify all listeners when isPanning state changes
useEffect(() => {
panStateListeners.forEach(callback => callback(isPanning));
}, [isPanning, panStateListeners]);
return null;
}

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Button, Paper, Group, NumberInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useViewer } from '../../contexts/ViewerContext';
import FirstPageIcon from '@mui/icons-material/FirstPage';
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
@ -19,104 +20,70 @@ interface PdfViewerToolbarProps {
dualPage?: boolean;
onDualPageToggle?: () => void;
// Zoom controls (will connect to window.embedPdfZoom)
// Zoom controls (connected via ViewerContext)
currentZoom?: number;
}
export function PdfViewerToolbar({
currentPage = 1,
totalPages = 1,
totalPages: _totalPages = 1,
onPageChange,
dualPage = false,
onDualPageToggle,
currentZoom = 100,
currentZoom: _currentZoom = 100,
}: PdfViewerToolbarProps) {
const { t } = useTranslation();
const [pageInput, setPageInput] = useState(currentPage);
const [dynamicZoom, setDynamicZoom] = useState(currentZoom);
const [dynamicPage, setDynamicPage] = useState(currentPage);
const [dynamicTotalPages, setDynamicTotalPages] = useState(totalPages);
const { getScrollState, getZoomState, scrollActions, zoomActions, registerImmediateZoomUpdate, registerImmediateScrollUpdate } = useViewer();
const scrollState = getScrollState();
const zoomState = getZoomState();
const [pageInput, setPageInput] = useState(scrollState.currentPage || currentPage);
const [displayZoomPercent, setDisplayZoomPercent] = useState(zoomState.zoomPercent || 140);
// Update zoom and scroll state from EmbedPDF APIs
// Register for immediate scroll updates and sync with actual scroll state
useEffect(() => {
const updateState = () => {
// Update zoom
if (window.embedPdfZoom) {
const zoomPercent = window.embedPdfZoom.zoomPercent || currentZoom;
setDynamicZoom(zoomPercent);
}
// Update scroll/page state
if (window.embedPdfScroll) {
const currentPageNum = window.embedPdfScroll.currentPage || currentPage;
const totalPagesNum = window.embedPdfScroll.totalPages || totalPages;
setDynamicPage(currentPageNum);
setDynamicTotalPages(totalPagesNum);
setPageInput(currentPageNum);
}
};
registerImmediateScrollUpdate((currentPage, totalPages) => {
setPageInput(currentPage);
});
setPageInput(scrollState.currentPage);
}, [registerImmediateScrollUpdate]);
// Update state immediately
updateState();
// Set up periodic updates to keep state in sync
const interval = setInterval(updateState, 200);
return () => clearInterval(interval);
}, [currentZoom, currentPage, totalPages]);
// Register for immediate zoom updates and sync with actual zoom state
useEffect(() => {
registerImmediateZoomUpdate(setDisplayZoomPercent);
setDisplayZoomPercent(zoomState.zoomPercent || 140);
}, [zoomState.zoomPercent, registerImmediateZoomUpdate]);
const handleZoomOut = () => {
if (window.embedPdfZoom) {
window.embedPdfZoom.zoomOut();
}
zoomActions.zoomOut();
};
const handleZoomIn = () => {
if (window.embedPdfZoom) {
window.embedPdfZoom.zoomIn();
}
zoomActions.zoomIn();
};
const handlePageNavigation = (page: number) => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToPage(page);
} else if (onPageChange) {
scrollActions.scrollToPage(page);
if (onPageChange) {
onPageChange(page);
}
setPageInput(page);
};
const handleFirstPage = () => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToFirstPage();
} else {
handlePageNavigation(1);
}
scrollActions.scrollToFirstPage();
};
const handlePreviousPage = () => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToPreviousPage();
} else {
handlePageNavigation(Math.max(1, dynamicPage - 1));
}
scrollActions.scrollToPreviousPage();
};
const handleNextPage = () => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToNextPage();
} else {
handlePageNavigation(Math.min(dynamicTotalPages, dynamicPage + 1));
}
scrollActions.scrollToNextPage();
};
const handleLastPage = () => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToLastPage();
} else {
handlePageNavigation(dynamicTotalPages);
}
scrollActions.scrollToLastPage();
};
return (
@ -146,7 +113,7 @@ export function PdfViewerToolbar({
px={8}
radius="xl"
onClick={handleFirstPage}
disabled={dynamicPage === 1}
disabled={scrollState.currentPage === 1}
style={{ minWidth: '2.5rem' }}
title={t("viewer.firstPage", "First Page")}
>
@ -161,7 +128,7 @@ export function PdfViewerToolbar({
px={8}
radius="xl"
onClick={handlePreviousPage}
disabled={dynamicPage === 1}
disabled={scrollState.currentPage === 1}
style={{ minWidth: '2.5rem' }}
title={t("viewer.previousPage", "Previous Page")}
>
@ -174,12 +141,12 @@ export function PdfViewerToolbar({
onChange={(value) => {
const page = Number(value);
setPageInput(page);
if (!isNaN(page) && page >= 1 && page <= dynamicTotalPages) {
if (!isNaN(page) && page >= 1 && page <= scrollState.totalPages) {
handlePageNavigation(page);
}
}}
min={1}
max={dynamicTotalPages}
max={scrollState.totalPages}
hideControls
styles={{
input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16 },
@ -187,7 +154,7 @@ export function PdfViewerToolbar({
/>
<span style={{ fontWeight: 500, fontSize: 16 }}>
/ {dynamicTotalPages}
/ {scrollState.totalPages}
</span>
{/* Next Page Button */}
@ -198,7 +165,7 @@ export function PdfViewerToolbar({
px={8}
radius="xl"
onClick={handleNextPage}
disabled={dynamicPage === dynamicTotalPages}
disabled={scrollState.currentPage === scrollState.totalPages}
style={{ minWidth: '2.5rem' }}
title={t("viewer.nextPage", "Next Page")}
>
@ -213,7 +180,7 @@ export function PdfViewerToolbar({
px={8}
radius="xl"
onClick={handleLastPage}
disabled={dynamicPage === dynamicTotalPages}
disabled={scrollState.currentPage === scrollState.totalPages}
style={{ minWidth: '2.5rem' }}
title={t("viewer.lastPage", "Last Page")}
>
@ -247,7 +214,7 @@ export function PdfViewerToolbar({
</Button>
<span style={{ minWidth: '2.5rem', textAlign: "center" }}>
{dynamicZoom}%
{displayZoomPercent}%
</span>
<Button
variant="subtle"

View File

@ -1,21 +1,37 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useRotate } from '@embedpdf/plugin-rotate/react';
import { useViewer } from '../../contexts/ViewerContext';
/**
* Component that runs inside EmbedPDF context and exports rotate controls globally
* Component that runs inside EmbedPDF context and updates rotation state in ViewerContext
*/
export function RotateAPIBridge() {
const { provides: rotate, rotation } = useRotate();
const { registerBridge } = useViewer();
// Store state locally
const [_localState, setLocalState] = useState({
rotation: 0
});
useEffect(() => {
if (rotate) {
// Export rotate controls to global window for right rail access
window.embedPdfRotate = {
rotateForward: () => rotate.rotateForward(),
rotateBackward: () => rotate.rotateBackward(),
setRotation: (rotationValue: number) => rotate.setRotation(rotationValue),
getRotation: () => rotation,
// Update local state
const newState = {
rotation
};
setLocalState(newState);
// Register this bridge with ViewerContext
registerBridge('rotation', {
state: newState,
api: {
rotateForward: () => rotate.rotateForward(),
rotateBackward: () => rotate.rotateBackward(),
setRotation: (rotationValue: number) => rotate.setRotation(rotationValue),
getRotation: () => rotation,
}
});
}
}, [rotate, rotation]);

View File

@ -1,25 +1,41 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useScroll } from '@embedpdf/plugin-scroll/react';
import { useViewer } from '../../contexts/ViewerContext';
/**
* Component that runs inside EmbedPDF context and exports scroll controls globally
* ScrollAPIBridge manages scroll state and exposes scroll actions.
* Registers with ViewerContext to provide scroll functionality to UI components.
*/
export function ScrollAPIBridge() {
const { provides: scroll, state: scrollState } = useScroll();
const { registerBridge, triggerImmediateScrollUpdate } = useViewer();
const [_localState, setLocalState] = useState({
currentPage: 1,
totalPages: 0
});
useEffect(() => {
if (scroll && scrollState) {
// Export scroll controls to global window for toolbar access
(window as any).embedPdfScroll = {
scrollToPage: (page: number) => scroll.scrollToPage({ pageNumber: page }),
scrollToNextPage: () => scroll.scrollToNextPage(),
scrollToPreviousPage: () => scroll.scrollToPreviousPage(),
scrollToFirstPage: () => scroll.scrollToPage({ pageNumber: 1 }),
scrollToLastPage: () => scroll.scrollToPage({ pageNumber: scrollState.totalPages }),
const newState = {
currentPage: scrollState.currentPage,
totalPages: scrollState.totalPages,
};
setLocalState(prevState => {
// Only update if state actually changed
if (prevState.currentPage !== newState.currentPage || prevState.totalPages !== newState.totalPages) {
// Trigger immediate update for responsive UI
triggerImmediateScrollUpdate(newState.currentPage, newState.totalPages);
return newState;
}
return prevState;
});
registerBridge('scroll', {
state: newState,
api: scroll
});
}
}, [scroll, scrollState]);

View File

@ -1,52 +1,63 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useSearch } from '@embedpdf/plugin-search/react';
import { useViewer } from '../../contexts/ViewerContext';
/**
* Component that runs inside EmbedPDF context and bridges search controls to global window
* SearchAPIBridge manages search state and provides search functionality.
* Listens for search result changes from EmbedPDF and maintains local state.
*/
export function SearchAPIBridge() {
const { provides: search, state } = useSearch();
const { provides: search } = useSearch();
const { registerBridge } = useViewer();
const [localState, setLocalState] = useState({
results: null as any[] | null,
activeIndex: 0
});
// Subscribe to search result changes from EmbedPDF
useEffect(() => {
if (search && state) {
if (!search) return;
// Export search controls to global window for toolbar access
(window as any).embedPdfSearch = {
search: async (query: string) => {
try {
search.startSearch();
const results = await search.searchAllPages(query);
return results;
} catch (error) {
console.error('Search error:', error);
throw error;
}
},
clearSearch: () => {
search.stopSearch();
},
nextResult: () => {
return search.nextResult();
},
previousResult: () => {
return search.previousResult();
},
goToResult: (index: number) => {
return search.goToResult(index);
},
// State getters
getSearchQuery: () => state.query,
isActive: () => state.active,
isLoading: () => state.loading,
// Current state for UI updates
state: state,
// Debug info
searchAPI: search,
availableMethods: search ? Object.keys(search) : [],
const unsubscribe = search.onSearchResultStateChange?.((state: any) => {
const newState = {
results: state?.results || null,
activeIndex: (state?.activeResultIndex || 0) + 1 // Convert to 1-based index
};
setLocalState(prevState => {
// Only update if state actually changed
if (prevState.results !== newState.results || prevState.activeIndex !== newState.activeIndex) {
return newState;
}
return prevState;
});
});
return unsubscribe;
}, [search]);
// Register bridge whenever search API or state changes
useEffect(() => {
if (search) {
registerBridge('search', {
state: localState,
api: {
search: async (query: string) => {
search.startSearch();
return search.searchAllPages(query);
},
clear: () => {
search.stopSearch();
setLocalState({ results: null, activeIndex: 0 });
},
next: () => search.nextResult(),
previous: () => search.previousResult(),
goToResult: (index: number) => search.goToResult(index),
}
});
}
}, [search, state]);
}, [search, localState]);
return null;
}

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { LocalIcon } from '../shared/LocalIcon';
import { ViewerContext } from '../../contexts/ViewerContext';
interface SearchInterfaceProps {
visible: boolean;
@ -10,6 +11,11 @@ interface SearchInterfaceProps {
export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
const { t } = useTranslation();
const viewerContext = React.useContext(ViewerContext);
const searchResults = viewerContext?.getSearchResults();
const searchActiveIndex = viewerContext?.getSearchActiveIndex();
const searchActions = viewerContext?.searchActions;
const [searchQuery, setSearchQuery] = useState('');
const [jumpToValue, setJumpToValue] = useState('');
const [resultInfo, setResultInfo] = useState<{
@ -24,26 +30,24 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
if (!visible) return;
const checkSearchState = () => {
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI) {
const state = searchAPI.state;
if (state && state.query && state.active) {
// Try to get result info from the global search data
// The CustomSearchLayer stores results, let's try to access them
const searchResults = (window as any).currentSearchResults;
const activeIndex = (window as any).currentActiveIndex || 1;
// Use ViewerContext state instead of window APIs
if (searchResults && searchResults.length > 0) {
const activeIndex = searchActiveIndex || 1;
setResultInfo({
currentIndex: activeIndex,
totalResults: searchResults ? searchResults.length : 0,
query: state.query
});
} else if (state && !state.active) {
setResultInfo(null);
}
setIsSearching(state ? state.loading : false);
setResultInfo({
currentIndex: activeIndex,
totalResults: searchResults.length,
query: searchQuery // Use local search query
});
} else if (searchQuery && searchResults?.length === 0) {
// Show "no results" state
setResultInfo({
currentIndex: 0,
totalResults: 0,
query: searchQuery
});
} else {
setResultInfo(null);
}
};
@ -52,7 +56,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
const interval = setInterval(checkSearchState, 200);
return () => clearInterval(interval);
}, [visible]);
}, [visible, searchResults, searchActiveIndex, searchQuery]);
const handleSearch = async (query: string) => {
if (!query.trim()) {
@ -61,11 +65,10 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
return;
}
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI) {
if (query.trim() && searchActions) {
setIsSearching(true);
try {
await searchAPI.search(query.trim());
await searchActions.search(query.trim());
} catch (error) {
console.error('Search failed:', error);
} finally {
@ -83,45 +86,26 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
};
const handleNext = () => {
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI) {
searchAPI.nextResult();
}
searchActions?.next();
};
const handlePrevious = () => {
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI) {
searchAPI.previousResult();
}
searchActions?.previous();
};
const handleClearSearch = () => {
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI) {
searchAPI.clearSearch();
// Also try to explicitly clear highlights if available
if (searchAPI.searchAPI && searchAPI.searchAPI.clearHighlights) {
searchAPI.searchAPI.clearHighlights();
}
}
searchActions?.clear();
setSearchQuery('');
setResultInfo(null);
};
// Sync search query with API state on mount
useEffect(() => {
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI && searchAPI.state && searchAPI.state.query) {
setSearchQuery(searchAPI.state.query);
}
}, []);
// No longer need to sync with external API on mount - removed
const handleJumpToResult = (index: number) => {
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI && resultInfo && index >= 1 && index <= resultInfo.totalResults) {
// Convert 1-based user input to 0-based API index
searchAPI.goToResult(index - 1);
// Use context actions instead of window API - functionality simplified for now
if (resultInfo && index >= 1 && index <= resultInfo.totalResults) {
// Note: goToResult functionality would need to be implemented in SearchAPIBridge
console.log('Jump to result:', index);
}
};
@ -138,7 +122,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
}
};
const handleClose = () => {
const _handleClose = () => {
handleClearSearch();
onClose();
};

View File

@ -1,31 +1,53 @@
import { useEffect, useState } from 'react';
import { useSelectionCapability, SelectionRangeX } from '@embedpdf/plugin-selection/react';
import { useViewer } from '../../contexts/ViewerContext';
/**
* Component that runs inside EmbedPDF context and exports selection controls globally
* Component that runs inside EmbedPDF context and updates selection state in ViewerContext
*/
export function SelectionAPIBridge() {
const { provides: selection } = useSelectionCapability();
const { registerBridge } = useViewer();
const [hasSelection, setHasSelection] = useState(false);
// Store state locally
const [_localState, setLocalState] = useState({
hasSelection: false
});
useEffect(() => {
if (selection) {
// Export selection controls to global window
(window as any).embedPdfSelection = {
copyToClipboard: () => selection.copyToClipboard(),
getSelectedText: () => selection.getSelectedText(),
getFormattedSelection: () => selection.getFormattedSelection(),
hasSelection: hasSelection,
// Update local state
const newState = {
hasSelection
};
setLocalState(newState);
// Register this bridge with ViewerContext
registerBridge('selection', {
state: newState,
api: {
copyToClipboard: () => selection.copyToClipboard(),
getSelectedText: () => selection.getSelectedText(),
getFormattedSelection: () => selection.getFormattedSelection(),
}
});
// Listen for selection changes to track when text is selected
const unsubscribe = selection.onSelectionChange((sel: SelectionRangeX | null) => {
const hasText = !!sel;
setHasSelection(hasText);
// Update global state
if ((window as any).embedPdfSelection) {
(window as any).embedPdfSelection.hasSelection = hasText;
}
const updatedState = { hasSelection: hasText };
setLocalState(updatedState);
// Re-register with updated state
registerBridge('selection', {
state: updatedState,
api: {
copyToClipboard: () => selection.copyToClipboard(),
getSelectedText: () => selection.getSelectedText(),
getFormattedSelection: () => selection.getFormattedSelection(),
}
});
});
// Intercept Ctrl+C only when we have PDF text selected

View File

@ -1,37 +1,44 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useSpread, SpreadMode } from '@embedpdf/plugin-spread/react';
import { useViewer } from '../../contexts/ViewerContext';
/**
* Component that runs inside EmbedPDF context and exports spread controls globally
* Component that runs inside EmbedPDF context and updates spread state in ViewerContext
*/
export function SpreadAPIBridge() {
const { provides: spread, spreadMode } = useSpread();
const { registerBridge } = useViewer();
// Store state locally
const [_localState, setLocalState] = useState({
spreadMode: SpreadMode.None,
isDualPage: false
});
useEffect(() => {
if (spread) {
// Export spread controls to global window for toolbar access
(window as any).embedPdfSpread = {
setSpreadMode: (mode: SpreadMode) => {
console.log('EmbedPDF: Setting spread mode to:', mode);
spread.setSpreadMode(mode);
},
getSpreadMode: () => spread.getSpreadMode(),
toggleSpreadMode: () => {
// Toggle between None and Odd (most common dual-page mode)
const newMode = spreadMode === SpreadMode.None ? SpreadMode.Odd : SpreadMode.None;
console.log('EmbedPDF: Toggling spread mode from', spreadMode, 'to', newMode);
spread.setSpreadMode(newMode);
},
currentSpreadMode: spreadMode,
isDualPage: spreadMode !== SpreadMode.None,
SpreadMode: SpreadMode, // Export enum for reference
// Update local state
const newState = {
spreadMode,
isDualPage: spreadMode !== SpreadMode.None
};
setLocalState(newState);
console.log('EmbedPDF spread controls exported to window.embedPdfSpread', {
currentSpreadMode: spreadMode,
isDualPage: spreadMode !== SpreadMode.None,
spreadAPI: spread,
availableMethods: Object.keys(spread)
// Register this bridge with ViewerContext
registerBridge('spread', {
state: newState,
api: {
setSpreadMode: (mode: SpreadMode) => {
spread.setSpreadMode(mode);
},
getSpreadMode: () => spread.getSpreadMode(),
toggleSpreadMode: () => {
// Toggle between None and Odd (most common dual-page mode)
const newMode = spreadMode === SpreadMode.None ? SpreadMode.Odd : SpreadMode.None;
spread.setSpreadMode(newMode);
},
SpreadMode: SpreadMode, // Export enum for reference
}
});
}
}, [spread, spreadMode]);

View File

@ -1,24 +1,21 @@
import { useEffect } from 'react';
import { useThumbnailCapability } from '@embedpdf/plugin-thumbnail/react';
import { useViewer } from '../../contexts/ViewerContext';
/**
* Component that runs inside EmbedPDF context and exports thumbnail controls globally
* ThumbnailAPIBridge provides thumbnail generation functionality.
* Exposes thumbnail API to UI components without managing state.
*/
export function ThumbnailAPIBridge() {
const { provides: thumbnail } = useThumbnailCapability();
const { registerBridge } = useViewer();
useEffect(() => {
console.log('📄 ThumbnailAPIBridge useEffect:', { thumbnail: !!thumbnail });
if (thumbnail) {
console.log('📄 Exporting thumbnail controls to window:', {
availableMethods: Object.keys(thumbnail),
renderThumb: typeof thumbnail.renderThumb
registerBridge('thumbnail', {
state: null, // No state - just provides API
api: thumbnail
});
// Export thumbnail controls to global window for debugging
(window as any).embedPdfThumbnail = {
thumbnailAPI: thumbnail,
availableMethods: Object.keys(thumbnail),
};
}
}, [thumbnail]);

View File

@ -1,48 +1,38 @@
import React, { useState, useEffect } from 'react';
import { Box, ScrollArea, ActionIcon, Tooltip } from '@mantine/core';
import { LocalIcon } from '../shared/LocalIcon';
import { Box, ScrollArea } from '@mantine/core';
import { useViewer } from '../../contexts/ViewerContext';
import '../../types/embedPdf';
interface ThumbnailSidebarProps {
visible: boolean;
onToggle: () => void;
colorScheme: 'light' | 'dark' | 'auto';
colorScheme?: 'light' | 'dark' | 'auto'; // Optional since we use CSS variables
}
export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSidebarProps) {
const [selectedPage, setSelectedPage] = useState<number>(1);
export function ThumbnailSidebar({ visible, onToggle: _onToggle }: ThumbnailSidebarProps) {
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({});
const [totalPages, setTotalPages] = useState<number>(0);
// Convert color scheme
const actualColorScheme = colorScheme === 'auto' ? 'light' : colorScheme;
// Get total pages from scroll API
useEffect(() => {
const scrollAPI = window.embedPdfScroll;
if (scrollAPI && scrollAPI.totalPages) {
setTotalPages(scrollAPI.totalPages);
}
}, [visible]);
const scrollState = getScrollState();
const thumbnailAPI = getThumbnailAPI();
// Generate thumbnails when sidebar becomes visible
useEffect(() => {
if (!visible || totalPages === 0) return;
if (!visible || scrollState.totalPages === 0) return;
const thumbnailAPI = window.embedPdfThumbnail?.thumbnailAPI;
console.log('📄 ThumbnailSidebar useEffect triggered:', {
visible,
thumbnailAPI: !!thumbnailAPI,
totalPages,
totalPages: scrollState.totalPages,
existingThumbnails: Object.keys(thumbnails).length
});
if (!thumbnailAPI) return;
const generateThumbnails = async () => {
console.log('📄 Starting thumbnail generation for', totalPages, 'pages');
console.log('📄 Starting thumbnail generation for', scrollState.totalPages, 'pages');
for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
for (let pageIndex = 0; pageIndex < scrollState.totalPages; pageIndex++) {
if (thumbnails[pageIndex]) continue; // Skip if already generated
try {
@ -89,17 +79,11 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
}
});
};
}, [visible, totalPages]);
}, [visible, scrollState.totalPages, thumbnailAPI]);
const handlePageClick = (pageIndex: number) => {
const pageNumber = pageIndex + 1; // Convert to 1-based
setSelectedPage(pageNumber);
// Use scroll API to navigate to page
const scrollAPI = window.embedPdfScroll;
if (scrollAPI && scrollAPI.scrollToPage) {
scrollAPI.scrollToPage(pageNumber);
}
scrollActions.scrollToPage(pageNumber);
};
return (
@ -113,8 +97,8 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
top: 0,
bottom: 0,
width: '15rem',
backgroundColor: actualColorScheme === 'dark' ? '#1a1b1e' : '#f8f9fa',
borderLeft: `1px solid ${actualColorScheme === 'dark' ? '#373A40' : '#e9ecef'}`,
backgroundColor: 'var(--bg-surface)',
borderLeft: '1px solid var(--border-subtle)',
zIndex: 998,
display: 'flex',
flexDirection: 'column',
@ -129,7 +113,7 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
flexDirection: 'column',
gap: '12px'
}}>
{Array.from({ length: totalPages }, (_, pageIndex) => (
{Array.from({ length: scrollState.totalPages }, (_, pageIndex) => (
<Box
key={pageIndex}
onClick={() => handlePageClick(pageIndex)}
@ -137,11 +121,11 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
cursor: 'pointer',
borderRadius: '8px',
padding: '8px',
backgroundColor: selectedPage === pageIndex + 1
? (actualColorScheme === 'dark' ? '#364FC7' : '#e7f5ff')
backgroundColor: scrollState.currentPage === pageIndex + 1
? 'var(--color-primary-100)'
: 'transparent',
border: selectedPage === pageIndex + 1
? '2px solid #1c7ed6'
border: scrollState.currentPage === pageIndex + 1
? '2px solid var(--color-primary-500)'
: '2px solid transparent',
transition: 'all 0.2s ease',
display: 'flex',
@ -150,12 +134,12 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
gap: '8px'
}}
onMouseEnter={(e) => {
if (selectedPage !== pageIndex + 1) {
e.currentTarget.style.backgroundColor = actualColorScheme === 'dark' ? '#25262b' : '#f1f3f5';
if (scrollState.currentPage !== pageIndex + 1) {
e.currentTarget.style.backgroundColor = 'var(--hover-bg)';
}
}}
onMouseLeave={(e) => {
if (selectedPage !== pageIndex + 1) {
if (scrollState.currentPage !== pageIndex + 1) {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
@ -170,20 +154,20 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
height: 'auto',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
border: `1px solid ${actualColorScheme === 'dark' ? '#373A40' : '#e9ecef'}`
border: '1px solid var(--border-subtle)'
}}
/>
) : thumbnails[pageIndex] === 'error' ? (
<div style={{
width: '11.5rem',
height: '15rem',
backgroundColor: actualColorScheme === 'dark' ? '#2d1b1b' : '#ffebee',
border: `1px solid ${actualColorScheme === 'dark' ? '#5d3737' : '#ffcdd2'}`,
backgroundColor: 'var(--color-red-50)',
border: '1px solid var(--color-red-200)',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#d32f2f',
color: 'var(--color-red-500)',
fontSize: '12px'
}}>
Failed
@ -192,13 +176,13 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
<div style={{
width: '11.5rem',
height: '15rem',
backgroundColor: actualColorScheme === 'dark' ? '#25262b' : '#f8f9fa',
border: `1px solid ${actualColorScheme === 'dark' ? '#373A40' : '#e9ecef'}`,
backgroundColor: 'var(--bg-muted)',
border: '1px solid var(--border-subtle)',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: actualColorScheme === 'dark' ? '#adb5bd' : '#6c757d',
color: 'var(--text-muted)',
fontSize: '12px'
}}>
Loading...
@ -209,9 +193,9 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
<div style={{
fontSize: '12px',
fontWeight: 500,
color: selectedPage === pageIndex + 1
? (actualColorScheme === 'dark' ? '#ffffff' : '#1c7ed6')
: (actualColorScheme === 'dark' ? '#adb5bd' : '#6c757d')
color: scrollState.currentPage === pageIndex + 1
? 'var(--color-primary-500)'
: 'var(--text-muted)'
}}>
Page {pageIndex + 1}
</div>

View File

@ -1,13 +1,21 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useZoom } from '@embedpdf/plugin-zoom/react';
import { useViewer } from '../../contexts/ViewerContext';
/**
* Component that runs inside EmbedPDF context and exports zoom controls globally
* Component that runs inside EmbedPDF context and manages zoom state locally
*/
export function ZoomAPIBridge() {
const { provides: zoom, state: zoomState } = useZoom();
const { registerBridge } = useViewer();
const hasSetInitialZoom = useRef(false);
// Store state locally
const [_localState, setLocalState] = useState({
currentZoom: 1.4,
zoomPercent: 140
});
// Set initial zoom once when plugin is ready
useEffect(() => {
if (zoom && !hasSetInitialZoom.current) {
@ -20,18 +28,23 @@ export function ZoomAPIBridge() {
}, [zoom]);
useEffect(() => {
if (zoom) {
// Export zoom controls to global window for right rail access
(window as any).embedPdfZoom = {
zoomIn: () => zoom.zoomIn(),
zoomOut: () => zoom.zoomOut(),
toggleMarqueeZoom: () => zoom.toggleMarqueeZoom(),
requestZoom: (level: any) => zoom.requestZoom(level),
currentZoom: zoomState?.currentZoomLevel || 1.4,
zoomPercent: Math.round((zoomState?.currentZoomLevel || 1.4) * 100),
if (zoom && zoomState) {
// Update local state
const currentZoomLevel = zoomState.currentZoomLevel || 1.4;
const newState = {
currentZoom: currentZoomLevel,
zoomPercent: Math.round(currentZoomLevel * 100),
};
console.log('ZoomAPIBridge - Raw zoom level:', currentZoomLevel, 'Rounded percent:', newState.zoomPercent);
setLocalState(newState);
// Register this bridge with ViewerContext
registerBridge('zoom', {
state: newState,
api: zoom
});
}
}, [zoom, zoomState]);

View File

@ -1,33 +1,437 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import React, { createContext, useContext, useState, ReactNode, useRef } from 'react';
interface ViewerContextType {
// Thumbnail sidebar state
isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void;
setThumbnailSidebarVisible: (visible: boolean) => void;
// State interfaces - represent the shape of data from each bridge
interface ScrollState {
currentPage: number;
totalPages: number;
}
const ViewerContext = createContext<ViewerContextType | null>(null);
interface ZoomState {
currentZoom: number;
zoomPercent: number;
}
interface PanState {
isPanning: boolean;
}
interface SelectionState {
hasSelection: boolean;
}
interface SpreadState {
spreadMode: any;
isDualPage: boolean;
}
interface RotationState {
rotation: number;
}
// Bridge registration interface - bridges register with state and API
interface BridgeRef {
state: any;
api: any;
}
/**
* ViewerContext provides a unified interface to EmbedPDF functionality.
*
* Architecture:
* - Bridges store their own state locally and register with this context
* - Context provides read-only access to bridge state via getter functions
* - Actions call EmbedPDF APIs directly through bridge references
* - No circular dependencies - bridges don't call back into this context
*/
interface ViewerContextType {
// UI state managed by this context
isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void;
// State getters - read current state from bridges
getScrollState: () => ScrollState;
getZoomState: () => ZoomState;
getPanState: () => PanState;
getSelectionState: () => SelectionState;
getSpreadState: () => SpreadState;
getRotationState: () => RotationState;
getSearchResults: () => any[] | null;
getSearchActiveIndex: () => number;
getThumbnailAPI: () => any;
// Immediate update callbacks
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void;
// Internal - for bridges to trigger immediate updates
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;
// Action handlers - call EmbedPDF APIs directly
scrollActions: {
scrollToPage: (page: number) => void;
scrollToFirstPage: () => void;
scrollToPreviousPage: () => void;
scrollToNextPage: () => void;
scrollToLastPage: () => void;
};
zoomActions: {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: number) => void;
};
panActions: {
enablePan: () => void;
disablePan: () => void;
togglePan: () => void;
};
selectionActions: {
copyToClipboard: () => void;
getSelectedText: () => string;
getFormattedSelection: () => any;
};
spreadActions: {
setSpreadMode: (mode: any) => void;
getSpreadMode: () => any;
toggleSpreadMode: () => void;
};
rotationActions: {
rotateForward: () => void;
rotateBackward: () => void;
setRotation: (rotation: number) => void;
getRotation: () => number;
};
searchActions: {
search: (query: string) => Promise<void>;
next: () => void;
previous: () => void;
clear: () => void;
};
// Bridge registration - internal use by bridges
registerBridge: (type: string, ref: BridgeRef) => void;
}
export const ViewerContext = createContext<ViewerContextType | null>(null);
interface ViewerProviderProps {
children: ReactNode;
}
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
// Bridge registry - bridges register their state and APIs here
const bridgeRefs = useRef({
scroll: null as BridgeRef | null,
zoom: null as BridgeRef | null,
pan: null as BridgeRef | null,
selection: null as BridgeRef | null,
search: null as BridgeRef | null,
spread: null as BridgeRef | null,
rotation: null as BridgeRef | null,
thumbnail: null as BridgeRef | null,
});
// Immediate zoom callback for responsive display updates
const immediateZoomUpdateCallback = useRef<((percent: number) => void) | null>(null);
// Immediate scroll callback for responsive display updates
const immediateScrollUpdateCallback = useRef<((currentPage: number, totalPages: number) => void) | null>(null);
const registerBridge = (type: string, ref: BridgeRef) => {
bridgeRefs.current[type as keyof typeof bridgeRefs.current] = ref;
};
const toggleThumbnailSidebar = () => {
setIsThumbnailSidebarVisible(prev => !prev);
};
const setThumbnailSidebarVisible = (visible: boolean) => {
setIsThumbnailSidebarVisible(visible);
// State getters - read from bridge refs
const getScrollState = (): ScrollState => {
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
};
const getZoomState = (): ZoomState => {
return bridgeRefs.current.zoom?.state || { currentZoom: 1.4, zoomPercent: 140 };
};
const getPanState = (): PanState => {
return bridgeRefs.current.pan?.state || { isPanning: false };
};
const getSelectionState = (): SelectionState => {
return bridgeRefs.current.selection?.state || { hasSelection: false };
};
const getSpreadState = (): SpreadState => {
return bridgeRefs.current.spread?.state || { spreadMode: null, isDualPage: false };
};
const getRotationState = (): RotationState => {
return bridgeRefs.current.rotation?.state || { rotation: 0 };
};
const getSearchResults = () => {
return bridgeRefs.current.search?.state?.results || null;
};
const getSearchActiveIndex = () => {
return bridgeRefs.current.search?.state?.activeIndex || 0;
};
const getThumbnailAPI = () => {
return bridgeRefs.current.thumbnail?.api || null;
};
// Action handlers - call APIs directly
const scrollActions = {
scrollToPage: (page: number) => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage) {
api.scrollToPage({ pageNumber: page });
}
},
scrollToFirstPage: () => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage) {
api.scrollToPage({ pageNumber: 1 });
}
},
scrollToPreviousPage: () => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPreviousPage) {
api.scrollToPreviousPage();
}
},
scrollToNextPage: () => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToNextPage) {
api.scrollToNextPage();
}
},
scrollToLastPage: () => {
const scrollState = getScrollState();
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage && scrollState.totalPages > 0) {
api.scrollToPage({ pageNumber: scrollState.totalPages });
}
}
};
const zoomActions = {
zoomIn: () => {
const api = bridgeRefs.current.zoom?.api;
if (api?.zoomIn) {
// Update display immediately if callback is registered
if (immediateZoomUpdateCallback.current) {
const currentState = getZoomState();
const newPercent = Math.min(Math.round(currentState.zoomPercent * 1.2), 300);
immediateZoomUpdateCallback.current(newPercent);
}
api.zoomIn();
}
},
zoomOut: () => {
const api = bridgeRefs.current.zoom?.api;
if (api?.zoomOut) {
// Update display immediately if callback is registered
if (immediateZoomUpdateCallback.current) {
const currentState = getZoomState();
const newPercent = Math.max(Math.round(currentState.zoomPercent / 1.2), 20);
immediateZoomUpdateCallback.current(newPercent);
}
api.zoomOut();
}
},
toggleMarqueeZoom: () => {
const api = bridgeRefs.current.zoom?.api;
if (api?.toggleMarqueeZoom) {
api.toggleMarqueeZoom();
}
},
requestZoom: (level: number) => {
const api = bridgeRefs.current.zoom?.api;
if (api?.requestZoom) {
api.requestZoom(level);
}
}
};
const panActions = {
enablePan: () => {
const api = bridgeRefs.current.pan?.api;
if (api?.enable) {
api.enable();
}
},
disablePan: () => {
const api = bridgeRefs.current.pan?.api;
if (api?.disable) {
api.disable();
}
},
togglePan: () => {
const api = bridgeRefs.current.pan?.api;
if (api?.toggle) {
api.toggle();
}
}
};
const selectionActions = {
copyToClipboard: () => {
const api = bridgeRefs.current.selection?.api;
if (api?.copyToClipboard) {
api.copyToClipboard();
}
},
getSelectedText: () => {
const api = bridgeRefs.current.selection?.api;
if (api?.getSelectedText) {
return api.getSelectedText();
}
return '';
},
getFormattedSelection: () => {
const api = bridgeRefs.current.selection?.api;
if (api?.getFormattedSelection) {
return api.getFormattedSelection();
}
return null;
}
};
const spreadActions = {
setSpreadMode: (mode: any) => {
const api = bridgeRefs.current.spread?.api;
if (api?.setSpreadMode) {
api.setSpreadMode(mode);
}
},
getSpreadMode: () => {
const api = bridgeRefs.current.spread?.api;
if (api?.getSpreadMode) {
return api.getSpreadMode();
}
return null;
},
toggleSpreadMode: () => {
const api = bridgeRefs.current.spread?.api;
if (api?.toggleSpreadMode) {
api.toggleSpreadMode();
}
}
};
const rotationActions = {
rotateForward: () => {
const api = bridgeRefs.current.rotation?.api;
if (api?.rotateForward) {
api.rotateForward();
}
},
rotateBackward: () => {
const api = bridgeRefs.current.rotation?.api;
if (api?.rotateBackward) {
api.rotateBackward();
}
},
setRotation: (rotation: number) => {
const api = bridgeRefs.current.rotation?.api;
if (api?.setRotation) {
api.setRotation(rotation);
}
},
getRotation: () => {
const api = bridgeRefs.current.rotation?.api;
if (api?.getRotation) {
return api.getRotation();
}
return 0;
}
};
const searchActions = {
search: async (query: string) => {
const api = bridgeRefs.current.search?.api;
if (api?.search) {
return api.search(query);
}
},
next: () => {
const api = bridgeRefs.current.search?.api;
if (api?.next) {
api.next();
}
},
previous: () => {
const api = bridgeRefs.current.search?.api;
if (api?.previous) {
api.previous();
}
},
clear: () => {
const api = bridgeRefs.current.search?.api;
if (api?.clear) {
api.clear();
}
}
};
const registerImmediateZoomUpdate = (callback: (percent: number) => void) => {
immediateZoomUpdateCallback.current = callback;
};
const registerImmediateScrollUpdate = (callback: (currentPage: number, totalPages: number) => void) => {
immediateScrollUpdateCallback.current = callback;
};
const triggerImmediateScrollUpdate = (currentPage: number, totalPages: number) => {
if (immediateScrollUpdateCallback.current) {
immediateScrollUpdateCallback.current(currentPage, totalPages);
}
};
const value: ViewerContextType = {
// UI state
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
setThumbnailSidebarVisible,
// State getters
getScrollState,
getZoomState,
getPanState,
getSelectionState,
getSpreadState,
getRotationState,
getSearchResults,
getSearchActiveIndex,
getThumbnailAPI,
// Immediate updates
registerImmediateZoomUpdate,
registerImmediateScrollUpdate,
triggerImmediateScrollUpdate,
// Actions
scrollActions,
zoomActions,
panActions,
selectionActions,
spreadActions,
rotationActions,
searchActions,
// Bridge registration
registerBridge,
};
return (