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", "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,6 +13,7 @@ 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";
@ -43,9 +44,11 @@ export default function App() {
<FilesModalProvider> <FilesModalProvider>
<ToolWorkflowProvider> <ToolWorkflowProvider>
<SidebarProvider> <SidebarProvider>
<RightRailProvider> <ViewerProvider>
<HomePage /> <RightRailProvider>
</RightRailProvider> <HomePage />
</RightRailProvider>
</ViewerProvider>
</SidebarProvider> </SidebarProvider>
</ToolWorkflowProvider> </ToolWorkflowProvider>
</FilesModalProvider> </FilesModalProvider>

View File

@ -14,11 +14,15 @@ 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]);
@ -32,23 +36,13 @@ export default function RightRail() {
// Navigation view // Navigation view
const { workbench: currentView } = useNavigationState(); const { workbench: currentView } = useNavigationState();
// Sync rotation state with EmbedPDF API // Update rotation display when switching to viewer mode
useEffect(() => { useEffect(() => {
if (currentView === 'viewer' && window.embedPdfRotate) { if (currentView === 'viewer' && viewerContext) {
const updateRotation = () => { const rotationState = viewerContext.getRotationState();
const rotation = window.embedPdfRotate?.getRotation() || 0; setCurrentRotation((rotationState?.rotation ?? 0) * 90);
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]); }, [currentView, viewerContext]);
// File state and selection // File state and selection
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
@ -269,7 +263,7 @@ export default function RightRail() {
radius="md" radius="md"
className="right-rail-icon" className="right-rail-icon"
onClick={() => { onClick={() => {
window.embedPdfPan?.togglePan(); viewerContext?.panActions.togglePan();
setIsPanning(!isPanning); setIsPanning(!isPanning);
}} }}
disabled={currentView !== 'viewer'} disabled={currentView !== 'viewer'}
@ -285,7 +279,7 @@ export default function RightRail() {
radius="md" radius="md"
className="right-rail-icon" className="right-rail-icon"
onClick={() => { onClick={() => {
window.embedPdfRotate?.rotateBackward(); viewerContext?.rotationActions.rotateBackward();
}} }}
disabled={currentView !== 'viewer'} disabled={currentView !== 'viewer'}
> >
@ -300,7 +294,7 @@ export default function RightRail() {
radius="md" radius="md"
className="right-rail-icon" className="right-rail-icon"
onClick={() => { onClick={() => {
window.embedPdfRotate?.rotateForward(); viewerContext?.rotationActions.rotateForward();
}} }}
disabled={currentView !== 'viewer'} disabled={currentView !== 'viewer'}
> >
@ -314,7 +308,9 @@ export default function RightRail() {
variant="subtle" variant="subtle"
radius="md" radius="md"
className="right-rail-icon" className="right-rail-icon"
onClick={() => window.toggleThumbnailSidebar?.()} onClick={() => {
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,5 +1,6 @@
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;
@ -32,6 +33,7 @@ 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
@ -41,27 +43,13 @@ export function CustomSearchLayer({
} }
const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => { const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => {
// Expose search results globally for SearchInterface // Auto-scroll to active search result
if (state?.results) { if (state?.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
(window as any).currentSearchResults = state.results; const activeResult = state.results[state.activeResultIndex];
(window as any).currentActiveIndex = (state.activeResultIndex || 0) + 1; // Convert to 1-based index if (activeResult) {
const pageNumber = activeResult.pageIndex + 1; // Convert to 1-based page number
// Auto-scroll to active result if we have one scrollActions.scrollToPage(pageNumber);
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 { ViewerProvider, useViewer } from "../../contexts/ViewerContext"; import { 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,7 +28,12 @@ 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 } = 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 // Get current file from FileContext
const { selectors } = useFileState(); const { selectors } = useFileState();
@ -70,19 +75,13 @@ const EmbedPdfViewerContent = ({
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
// Convert smooth scrolling gestures into discrete notches if (event.deltaY < 0) {
accumulator += event.deltaY; // Scroll up - zoom in
const threshold = 10; 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 // Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
if (event.ctrlKey || event.metaKey) { if (event.ctrlKey || event.metaKey) {
const zoomAPI = window.embedPdfZoom; if (event.key === '=' || event.key === '+') {
if (zoomAPI) { // Ctrl+= or Ctrl++ for zoom in
if (event.key === '=' || event.key === '+') { event.preventDefault();
// Ctrl+= or Ctrl++ for zoom in zoomActions.zoomIn();
event.preventDefault(); } else if (event.key === '-' || event.key === '_') {
zoomAPI.zoomIn(); // Ctrl+- for zoom out
} else if (event.key === '-' || event.key === '_') { event.preventDefault();
// Ctrl+- for zoom out zoomActions.zoomOut();
event.preventDefault();
zoomAPI.zoomOut();
}
} }
} }
}; };
@ -124,14 +120,6 @@ 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
@ -180,12 +168,13 @@ 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>
</> </>
@ -208,17 +197,17 @@ const EmbedPdfViewerContent = ({
> >
<div style={{ pointerEvents: "auto" }}> <div style={{ pointerEvents: "auto" }}>
<PdfViewerToolbar <PdfViewerToolbar
currentPage={1} currentPage={scrollState.currentPage}
totalPages={1} totalPages={scrollState.totalPages}
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={false} dualPage={spreadState.isDualPage}
onDualPageToggle={() => { onDualPageToggle={() => {
window.embedPdfSpread?.toggleSpreadMode(); spreadActions.toggleSpreadMode();
}} }}
currentZoom={100} currentZoom={zoomState.zoomPercent}
/> />
</div> </div>
</div> </div>
@ -229,18 +218,13 @@ 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 ( return <EmbedPdfViewerContent {...props} />;
<ViewerProvider>
<EmbedPdfViewerContent {...props} />
</ViewerProvider>
);
}; };
export default EmbedPdfViewer; export default EmbedPdfViewer;

View File

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

View File

@ -1,47 +1,45 @@
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 bridges pan controls to global window * Component that runs inside EmbedPDF context and updates pan state in ViewerContext
*/ */
export function PanAPIBridge() { export function PanAPIBridge() {
const { provides: pan, isPanning } = usePan(); 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(() => { useEffect(() => {
if (pan) { if (pan) {
// Export pan controls to global window for right rail access // Update local state
(window as any).embedPdfPan = { const newState = {
enablePan: () => { isPanning
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,6 +1,7 @@
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';
@ -19,104 +20,70 @@ interface PdfViewerToolbarProps {
dualPage?: boolean; dualPage?: boolean;
onDualPageToggle?: () => void; onDualPageToggle?: () => void;
// Zoom controls (will connect to window.embedPdfZoom) // Zoom controls (connected via ViewerContext)
currentZoom?: number; currentZoom?: number;
} }
export function PdfViewerToolbar({ export function PdfViewerToolbar({
currentPage = 1, currentPage = 1,
totalPages = 1, totalPages: _totalPages = 1,
onPageChange, onPageChange,
dualPage = false, dualPage = false,
onDualPageToggle, onDualPageToggle,
currentZoom = 100, currentZoom: _currentZoom = 100,
}: PdfViewerToolbarProps) { }: PdfViewerToolbarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [pageInput, setPageInput] = useState(currentPage); const { getScrollState, getZoomState, scrollActions, zoomActions, registerImmediateZoomUpdate, registerImmediateScrollUpdate } = useViewer();
const [dynamicZoom, setDynamicZoom] = useState(currentZoom);
const [dynamicPage, setDynamicPage] = useState(currentPage); const scrollState = getScrollState();
const [dynamicTotalPages, setDynamicTotalPages] = useState(totalPages); 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(() => { useEffect(() => {
const updateState = () => { registerImmediateScrollUpdate((currentPage, totalPages) => {
// Update zoom setPageInput(currentPage);
if (window.embedPdfZoom) { });
const zoomPercent = window.embedPdfZoom.zoomPercent || currentZoom; setPageInput(scrollState.currentPage);
setDynamicZoom(zoomPercent); }, [registerImmediateScrollUpdate]);
}
// 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);
}
};
// Update state immediately // Register for immediate zoom updates and sync with actual zoom state
updateState(); useEffect(() => {
registerImmediateZoomUpdate(setDisplayZoomPercent);
// Set up periodic updates to keep state in sync setDisplayZoomPercent(zoomState.zoomPercent || 140);
const interval = setInterval(updateState, 200); }, [zoomState.zoomPercent, registerImmediateZoomUpdate]);
return () => clearInterval(interval);
}, [currentZoom, currentPage, totalPages]);
const handleZoomOut = () => { const handleZoomOut = () => {
if (window.embedPdfZoom) { zoomActions.zoomOut();
window.embedPdfZoom.zoomOut();
}
}; };
const handleZoomIn = () => { const handleZoomIn = () => {
if (window.embedPdfZoom) { zoomActions.zoomIn();
window.embedPdfZoom.zoomIn();
}
}; };
const handlePageNavigation = (page: number) => { const handlePageNavigation = (page: number) => {
if (window.embedPdfScroll) { scrollActions.scrollToPage(page);
window.embedPdfScroll.scrollToPage(page); if (onPageChange) {
} else if (onPageChange) {
onPageChange(page); onPageChange(page);
} }
setPageInput(page); setPageInput(page);
}; };
const handleFirstPage = () => { const handleFirstPage = () => {
if (window.embedPdfScroll) { scrollActions.scrollToFirstPage();
window.embedPdfScroll.scrollToFirstPage();
} else {
handlePageNavigation(1);
}
}; };
const handlePreviousPage = () => { const handlePreviousPage = () => {
if (window.embedPdfScroll) { scrollActions.scrollToPreviousPage();
window.embedPdfScroll.scrollToPreviousPage();
} else {
handlePageNavigation(Math.max(1, dynamicPage - 1));
}
}; };
const handleNextPage = () => { const handleNextPage = () => {
if (window.embedPdfScroll) { scrollActions.scrollToNextPage();
window.embedPdfScroll.scrollToNextPage();
} else {
handlePageNavigation(Math.min(dynamicTotalPages, dynamicPage + 1));
}
}; };
const handleLastPage = () => { const handleLastPage = () => {
if (window.embedPdfScroll) { scrollActions.scrollToLastPage();
window.embedPdfScroll.scrollToLastPage();
} else {
handlePageNavigation(dynamicTotalPages);
}
}; };
return ( return (
@ -146,7 +113,7 @@ export function PdfViewerToolbar({
px={8} px={8}
radius="xl" radius="xl"
onClick={handleFirstPage} onClick={handleFirstPage}
disabled={dynamicPage === 1} disabled={scrollState.currentPage === 1}
style={{ minWidth: '2.5rem' }} style={{ minWidth: '2.5rem' }}
title={t("viewer.firstPage", "First Page")} title={t("viewer.firstPage", "First Page")}
> >
@ -161,7 +128,7 @@ export function PdfViewerToolbar({
px={8} px={8}
radius="xl" radius="xl"
onClick={handlePreviousPage} onClick={handlePreviousPage}
disabled={dynamicPage === 1} disabled={scrollState.currentPage === 1}
style={{ minWidth: '2.5rem' }} style={{ minWidth: '2.5rem' }}
title={t("viewer.previousPage", "Previous Page")} title={t("viewer.previousPage", "Previous Page")}
> >
@ -174,12 +141,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 <= dynamicTotalPages) { if (!isNaN(page) && page >= 1 && page <= scrollState.totalPages) {
handlePageNavigation(page); handlePageNavigation(page);
} }
}} }}
min={1} min={1}
max={dynamicTotalPages} max={scrollState.totalPages}
hideControls hideControls
styles={{ styles={{
input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16 }, input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16 },
@ -187,7 +154,7 @@ export function PdfViewerToolbar({
/> />
<span style={{ fontWeight: 500, fontSize: 16 }}> <span style={{ fontWeight: 500, fontSize: 16 }}>
/ {dynamicTotalPages} / {scrollState.totalPages}
</span> </span>
{/* Next Page Button */} {/* Next Page Button */}
@ -198,7 +165,7 @@ export function PdfViewerToolbar({
px={8} px={8}
radius="xl" radius="xl"
onClick={handleNextPage} onClick={handleNextPage}
disabled={dynamicPage === dynamicTotalPages} disabled={scrollState.currentPage === scrollState.totalPages}
style={{ minWidth: '2.5rem' }} style={{ minWidth: '2.5rem' }}
title={t("viewer.nextPage", "Next Page")} title={t("viewer.nextPage", "Next Page")}
> >
@ -213,7 +180,7 @@ export function PdfViewerToolbar({
px={8} px={8}
radius="xl" radius="xl"
onClick={handleLastPage} onClick={handleLastPage}
disabled={dynamicPage === dynamicTotalPages} disabled={scrollState.currentPage === scrollState.totalPages}
style={{ minWidth: '2.5rem' }} style={{ minWidth: '2.5rem' }}
title={t("viewer.lastPage", "Last Page")} title={t("viewer.lastPage", "Last Page")}
> >
@ -247,7 +214,7 @@ export function PdfViewerToolbar({
</Button> </Button>
<span style={{ minWidth: '2.5rem', textAlign: "center" }}> <span style={{ minWidth: '2.5rem', textAlign: "center" }}>
{dynamicZoom}% {displayZoomPercent}%
</span> </span>
<Button <Button
variant="subtle" 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 { 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() { 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) {
// Export rotate controls to global window for right rail access // Update local state
window.embedPdfRotate = { const newState = {
rotateForward: () => rotate.rotateForward(), rotation
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,25 +1,41 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useScroll } from '@embedpdf/plugin-scroll/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() { 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) {
// Export scroll controls to global window for toolbar access const newState = {
(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,52 +1,63 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useSearch } from '@embedpdf/plugin-search/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() { 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(() => { useEffect(() => {
if (search && state) { if (!search) return;
// Export search controls to global window for toolbar access const unsubscribe = search.onSearchResultStateChange?.((state: any) => {
(window as any).embedPdfSearch = { const newState = {
search: async (query: string) => { results: state?.results || null,
try { activeIndex: (state?.activeResultIndex || 0) + 1 // Convert to 1-based index
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, state]); }, [search, localState]);
return null; return null;
} }

View File

@ -2,6 +2,7 @@ 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;
@ -10,6 +11,11 @@ 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<{
@ -24,26 +30,24 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
if (!visible) return; if (!visible) return;
const checkSearchState = () => { const checkSearchState = () => {
const searchAPI = (window as any).embedPdfSearch; // Use ViewerContext state instead of window APIs
if (searchAPI) { if (searchResults && searchResults.length > 0) {
const state = searchAPI.state; const activeIndex = searchActiveIndex || 1;
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;
setResultInfo({ setResultInfo({
currentIndex: activeIndex, currentIndex: activeIndex,
totalResults: searchResults ? searchResults.length : 0, totalResults: searchResults.length,
query: state.query query: searchQuery // Use local search query
}); });
} else if (state && !state.active) { } else if (searchQuery && searchResults?.length === 0) {
setResultInfo(null); // Show "no results" state
} setResultInfo({
currentIndex: 0,
setIsSearching(state ? state.loading : false); totalResults: 0,
query: searchQuery
});
} else {
setResultInfo(null);
} }
}; };
@ -52,7 +56,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
const interval = setInterval(checkSearchState, 200); const interval = setInterval(checkSearchState, 200);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [visible]); }, [visible, searchResults, searchActiveIndex, searchQuery]);
const handleSearch = async (query: string) => { const handleSearch = async (query: string) => {
if (!query.trim()) { if (!query.trim()) {
@ -61,11 +65,10 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
return; return;
} }
const searchAPI = (window as any).embedPdfSearch; if (query.trim() && searchActions) {
if (searchAPI) {
setIsSearching(true); setIsSearching(true);
try { try {
await searchAPI.search(query.trim()); await searchActions.search(query.trim());
} catch (error) { } catch (error) {
console.error('Search failed:', error); console.error('Search failed:', error);
} finally { } finally {
@ -83,45 +86,26 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
}; };
const handleNext = () => { const handleNext = () => {
const searchAPI = (window as any).embedPdfSearch; searchActions?.next();
if (searchAPI) {
searchAPI.nextResult();
}
}; };
const handlePrevious = () => { const handlePrevious = () => {
const searchAPI = (window as any).embedPdfSearch; searchActions?.previous();
if (searchAPI) {
searchAPI.previousResult();
}
}; };
const handleClearSearch = () => { const handleClearSearch = () => {
const searchAPI = (window as any).embedPdfSearch; searchActions?.clear();
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);
}; };
// Sync search query with API state on mount // No longer need to sync with external API on mount - removed
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) => {
const searchAPI = (window as any).embedPdfSearch; // Use context actions instead of window API - functionality simplified for now
if (searchAPI && resultInfo && index >= 1 && index <= resultInfo.totalResults) { if (resultInfo && index >= 1 && index <= resultInfo.totalResults) {
// Convert 1-based user input to 0-based API index // Note: goToResult functionality would need to be implemented in SearchAPIBridge
searchAPI.goToResult(index - 1); console.log('Jump to result:', index);
} }
}; };
@ -138,7 +122,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
} }
}; };
const handleClose = () => { const _handleClose = () => {
handleClearSearch(); handleClearSearch();
onClose(); onClose();
}; };

View File

@ -1,31 +1,53 @@
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 exports selection controls globally * Component that runs inside EmbedPDF context and updates selection state in ViewerContext
*/ */
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) {
// Export selection controls to global window // Update local state
(window as any).embedPdfSelection = { const newState = {
copyToClipboard: () => selection.copyToClipboard(), hasSelection
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);
// Update global state const updatedState = { hasSelection: hasText };
if ((window as any).embedPdfSelection) { setLocalState(updatedState);
(window as any).embedPdfSelection.hasSelection = hasText; // 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 // 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 { 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() { 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) {
// Export spread controls to global window for toolbar access // Update local state
(window as any).embedPdfSpread = { const newState = {
setSpreadMode: (mode: SpreadMode) => { spreadMode,
console.log('EmbedPDF: Setting spread mode to:', mode); isDualPage: spreadMode !== SpreadMode.None
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);
console.log('EmbedPDF spread controls exported to window.embedPdfSpread', { // Register this bridge with ViewerContext
currentSpreadMode: spreadMode, registerBridge('spread', {
isDualPage: spreadMode !== SpreadMode.None, state: newState,
spreadAPI: spread, api: {
availableMethods: Object.keys(spread) 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]); }, [spread, spreadMode]);

View File

@ -1,24 +1,21 @@
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';
/** /**
* 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() { 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) {
console.log('📄 Exporting thumbnail controls to window:', { registerBridge('thumbnail', {
availableMethods: Object.keys(thumbnail), state: null, // No state - just provides API
renderThumb: typeof thumbnail.renderThumb api: thumbnail
}); });
// Export thumbnail controls to global window for debugging
(window as any).embedPdfThumbnail = {
thumbnailAPI: thumbnail,
availableMethods: Object.keys(thumbnail),
};
} }
}, [thumbnail]); }, [thumbnail]);

View File

@ -1,48 +1,38 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Box, ScrollArea, ActionIcon, Tooltip } from '@mantine/core'; import { Box, ScrollArea } from '@mantine/core';
import { LocalIcon } from '../shared/LocalIcon'; import { useViewer } from '../../contexts/ViewerContext';
import '../../types/embedPdf'; import '../../types/embedPdf';
interface ThumbnailSidebarProps { interface ThumbnailSidebarProps {
visible: boolean; visible: boolean;
onToggle: () => void; onToggle: () => void;
colorScheme: 'light' | 'dark' | 'auto'; colorScheme?: 'light' | 'dark' | 'auto'; // Optional since we use CSS variables
} }
export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSidebarProps) { export function ThumbnailSidebar({ visible, onToggle: _onToggle }: ThumbnailSidebarProps) {
const [selectedPage, setSelectedPage] = useState<number>(1); const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
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 || totalPages === 0) return; if (!visible || scrollState.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, totalPages: scrollState.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', 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 if (thumbnails[pageIndex]) continue; // Skip if already generated
try { try {
@ -89,17 +79,11 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
} }
}); });
}; };
}, [visible, totalPages]); }, [visible, scrollState.totalPages, thumbnailAPI]);
const handlePageClick = (pageIndex: number) => { const handlePageClick = (pageIndex: number) => {
const pageNumber = pageIndex + 1; // Convert to 1-based const pageNumber = pageIndex + 1; // Convert to 1-based
setSelectedPage(pageNumber); scrollActions.scrollToPage(pageNumber);
// Use scroll API to navigate to page
const scrollAPI = window.embedPdfScroll;
if (scrollAPI && scrollAPI.scrollToPage) {
scrollAPI.scrollToPage(pageNumber);
}
}; };
return ( return (
@ -113,8 +97,8 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
top: 0, top: 0,
bottom: 0, bottom: 0,
width: '15rem', width: '15rem',
backgroundColor: actualColorScheme === 'dark' ? '#1a1b1e' : '#f8f9fa', backgroundColor: 'var(--bg-surface)',
borderLeft: `1px solid ${actualColorScheme === 'dark' ? '#373A40' : '#e9ecef'}`, borderLeft: '1px solid var(--border-subtle)',
zIndex: 998, zIndex: 998,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -129,7 +113,7 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
flexDirection: 'column', flexDirection: 'column',
gap: '12px' gap: '12px'
}}> }}>
{Array.from({ length: totalPages }, (_, pageIndex) => ( {Array.from({ length: scrollState.totalPages }, (_, pageIndex) => (
<Box <Box
key={pageIndex} key={pageIndex}
onClick={() => handlePageClick(pageIndex)} onClick={() => handlePageClick(pageIndex)}
@ -137,11 +121,11 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
cursor: 'pointer', cursor: 'pointer',
borderRadius: '8px', borderRadius: '8px',
padding: '8px', padding: '8px',
backgroundColor: selectedPage === pageIndex + 1 backgroundColor: scrollState.currentPage === pageIndex + 1
? (actualColorScheme === 'dark' ? '#364FC7' : '#e7f5ff') ? 'var(--color-primary-100)'
: 'transparent', : 'transparent',
border: selectedPage === pageIndex + 1 border: scrollState.currentPage === pageIndex + 1
? '2px solid #1c7ed6' ? '2px solid var(--color-primary-500)'
: '2px solid transparent', : '2px solid transparent',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
display: 'flex', display: 'flex',
@ -150,12 +134,12 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
gap: '8px' gap: '8px'
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (selectedPage !== pageIndex + 1) { if (scrollState.currentPage !== pageIndex + 1) {
e.currentTarget.style.backgroundColor = actualColorScheme === 'dark' ? '#25262b' : '#f1f3f5'; e.currentTarget.style.backgroundColor = 'var(--hover-bg)';
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (selectedPage !== pageIndex + 1) { if (scrollState.currentPage !== pageIndex + 1) {
e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.backgroundColor = 'transparent';
} }
}} }}
@ -170,20 +154,20 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
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 ${actualColorScheme === 'dark' ? '#373A40' : '#e9ecef'}` border: '1px solid var(--border-subtle)'
}} }}
/> />
) : thumbnails[pageIndex] === 'error' ? ( ) : thumbnails[pageIndex] === 'error' ? (
<div style={{ <div style={{
width: '11.5rem', width: '11.5rem',
height: '15rem', height: '15rem',
backgroundColor: actualColorScheme === 'dark' ? '#2d1b1b' : '#ffebee', backgroundColor: 'var(--color-red-50)',
border: `1px solid ${actualColorScheme === 'dark' ? '#5d3737' : '#ffcdd2'}`, border: '1px solid var(--color-red-200)',
borderRadius: '4px', borderRadius: '4px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
color: '#d32f2f', color: 'var(--color-red-500)',
fontSize: '12px' fontSize: '12px'
}}> }}>
Failed Failed
@ -192,13 +176,13 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
<div style={{ <div style={{
width: '11.5rem', width: '11.5rem',
height: '15rem', height: '15rem',
backgroundColor: actualColorScheme === 'dark' ? '#25262b' : '#f8f9fa', backgroundColor: 'var(--bg-muted)',
border: `1px solid ${actualColorScheme === 'dark' ? '#373A40' : '#e9ecef'}`, border: '1px solid var(--border-subtle)',
borderRadius: '4px', borderRadius: '4px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
color: actualColorScheme === 'dark' ? '#adb5bd' : '#6c757d', color: 'var(--text-muted)',
fontSize: '12px' fontSize: '12px'
}}> }}>
Loading... Loading...
@ -209,9 +193,9 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
<div style={{ <div style={{
fontSize: '12px', fontSize: '12px',
fontWeight: 500, fontWeight: 500,
color: selectedPage === pageIndex + 1 color: scrollState.currentPage === pageIndex + 1
? (actualColorScheme === 'dark' ? '#ffffff' : '#1c7ed6') ? 'var(--color-primary-500)'
: (actualColorScheme === 'dark' ? '#adb5bd' : '#6c757d') : 'var(--text-muted)'
}}> }}>
Page {pageIndex + 1} Page {pageIndex + 1}
</div> </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 { 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() { 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) {
@ -20,18 +28,23 @@ export function ZoomAPIBridge() {
}, [zoom]); }, [zoom]);
useEffect(() => { useEffect(() => {
if (zoom) { if (zoom && zoomState) {
// Update local state
// Export zoom controls to global window for right rail access const currentZoomLevel = zoomState.currentZoomLevel || 1.4;
(window as any).embedPdfZoom = { const newState = {
zoomIn: () => zoom.zoomIn(), currentZoom: currentZoomLevel,
zoomOut: () => zoom.zoomOut(), zoomPercent: Math.round(currentZoomLevel * 100),
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,33 +1,437 @@
import React, { createContext, useContext, useState, ReactNode } from 'react'; import React, { createContext, useContext, useState, ReactNode, useRef } from 'react';
interface ViewerContextType { // State interfaces - represent the shape of data from each bridge
// Thumbnail sidebar state interface ScrollState {
isThumbnailSidebarVisible: boolean; currentPage: number;
toggleThumbnailSidebar: () => void; totalPages: number;
setThumbnailSidebarVisible: (visible: boolean) => void;
} }
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 { 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);
}; };
const setThumbnailSidebarVisible = (visible: boolean) => { // State getters - read from bridge refs
setIsThumbnailSidebarVisible(visible); 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 = { 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 (