Compare commits

..

No commits in common. "da6ecc6619e0e904d19180df9ff48eeecdb1611b" and "81c5d8ff46dcc5fc983109fb2348b6d6dfb129d2" have entirely different histories.

18 changed files with 429 additions and 818 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,45 +1,47 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { usePan } from '@embedpdf/plugin-pan/react'; import { usePan } from '@embedpdf/plugin-pan/react';
import { useViewer } from '../../contexts/ViewerContext';
/** /**
* Component that runs inside EmbedPDF context and updates pan state in ViewerContext * Component that runs inside EmbedPDF context and bridges pan controls to global window
*/ */
export function PanAPIBridge() { export function PanAPIBridge() {
const { provides: pan, isPanning } = usePan(); const { provides: pan, isPanning } = usePan();
const { registerBridge } = useViewer(); const [panStateListeners, setPanStateListeners] = useState<Array<(isPanning: boolean) => void>>([]);
// Store state locally
const [_localState, setLocalState] = useState({
isPanning: false
});
useEffect(() => { useEffect(() => {
if (pan) { if (pan) {
// Update local state // Export pan controls to global window for right rail access
const newState = { (window as any).embedPdfPan = {
isPanning 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));
};
},
}; };
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]); }, [pan, isPanning]);
// Notify all listeners when isPanning state changes
useEffect(() => {
panStateListeners.forEach(callback => callback(isPanning));
}, [isPanning, panStateListeners]);
return null; return null;
} }

View File

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

View File

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

View File

@ -1,41 +1,25 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useScroll } from '@embedpdf/plugin-scroll/react'; import { useScroll } from '@embedpdf/plugin-scroll/react';
import { useViewer } from '../../contexts/ViewerContext';
/** /**
* ScrollAPIBridge manages scroll state and exposes scroll actions. * Component that runs inside EmbedPDF context and exports scroll controls globally
* Registers with ViewerContext to provide scroll functionality to UI components.
*/ */
export function ScrollAPIBridge() { export function ScrollAPIBridge() {
const { provides: scroll, state: scrollState } = useScroll(); const { provides: scroll, state: scrollState } = useScroll();
const { registerBridge, triggerImmediateScrollUpdate } = useViewer();
const [_localState, setLocalState] = useState({
currentPage: 1,
totalPages: 0
});
useEffect(() => { useEffect(() => {
if (scroll && scrollState) { if (scroll && scrollState) {
const newState = { // 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 }),
currentPage: scrollState.currentPage, currentPage: scrollState.currentPage,
totalPages: scrollState.totalPages, 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]); }, [scroll, scrollState]);

View File

@ -1,63 +1,52 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useSearch } from '@embedpdf/plugin-search/react'; import { useSearch } from '@embedpdf/plugin-search/react';
import { useViewer } from '../../contexts/ViewerContext';
/** /**
* SearchAPIBridge manages search state and provides search functionality. * Component that runs inside EmbedPDF context and bridges search controls to global window
* Listens for search result changes from EmbedPDF and maintains local state.
*/ */
export function SearchAPIBridge() { export function SearchAPIBridge() {
const { provides: search } = useSearch(); const { provides: search, state } = useSearch();
const { registerBridge } = useViewer();
const [localState, setLocalState] = useState({
results: null as any[] | null,
activeIndex: 0
});
// Subscribe to search result changes from EmbedPDF
useEffect(() => { useEffect(() => {
if (!search) return; if (search && state) {
const unsubscribe = search.onSearchResultStateChange?.((state: any) => { // Export search controls to global window for toolbar access
const newState = { (window as any).embedPdfSearch = {
results: state?.results || null, search: async (query: string) => {
activeIndex: (state?.activeResultIndex || 0) + 1 // Convert to 1-based index 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) : [],
}; };
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, localState]); }, [search, state]);
return null; return null;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,437 +1,33 @@
import React, { createContext, useContext, useState, ReactNode, useRef } from 'react'; import React, { createContext, useContext, useState, ReactNode } from 'react';
// State interfaces - represent the shape of data from each bridge
interface ScrollState {
currentPage: number;
totalPages: number;
}
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 { interface ViewerContextType {
// UI state managed by this context // Thumbnail sidebar state
isThumbnailSidebarVisible: boolean; isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void; toggleThumbnailSidebar: () => void;
setThumbnailSidebarVisible: (visible: boolean) => 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); const ViewerContext = createContext<ViewerContextType | null>(null);
interface ViewerProviderProps { interface ViewerProviderProps {
children: ReactNode; children: ReactNode;
} }
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => { export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false); 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 = () => { const toggleThumbnailSidebar = () => {
setIsThumbnailSidebarVisible(prev => !prev); setIsThumbnailSidebarVisible(prev => !prev);
}; };
// State getters - read from bridge refs const setThumbnailSidebarVisible = (visible: boolean) => {
const getScrollState = (): ScrollState => { setIsThumbnailSidebarVisible(visible);
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 = { const value: ViewerContextType = {
// UI state
isThumbnailSidebarVisible, isThumbnailSidebarVisible,
toggleThumbnailSidebar, 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 ( return (