diff --git a/.changeset/weak-pugs-hear.md b/.changeset/weak-pugs-hear.md index cefa200d..6c3f21b1 100644 --- a/.changeset/weak-pugs-hear.md +++ b/.changeset/weak-pugs-hear.md @@ -9,11 +9,9 @@ Add optional **form widget rendering** to the render pipeline. ### What changed - **@embedpdf/models** - - `PdfRenderPageOptions` now supports `withForms?: boolean` to request drawing interactive form widgets. - **@embedpdf/engines** - - `PdfiumEngine.renderPage` and `renderPageRect` honor `withForms`. When enabled, the engine initializes the page form handle and calls `FPDF_FFLDraw` with the correct device transform. - New helper `computeFormDrawParams(matrix, rect, pageSize, rotation)` calculates start offsets and sizes for `FPDF_FFLDraw`. diff --git a/.husky/pre-commit b/.husky/pre-commit index 75d0447f..ca520d74 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,13 @@ #!/usr/bin/env sh -./node_modules/.bin/lint-staged \ No newline at end of file +# Cross-platform pre-commit for GitHub Desktop, macOS, Windows (Git Bash), Linux. + +# Make Node visible for GUI apps (harmless elsewhere) +export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + +# Load nvm if present (no-op if missing) +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + +# Prefer Node's built-in npx so we don't rely on pnpm/yarn on PATH +# --no-install ensures it uses the local devDependency +exec npx --no-install lint-staged \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js index d12be845..7f09c266 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -19,10 +19,10 @@ module.exports = { }, }, { - "files": "*.svelte", - "options": { - "parser": "svelte" - } - } + files: '*.svelte', + options: { + parser: 'svelte', + }, + }, ], }; diff --git a/examples/react-mui/package.json b/examples/react-mui/package.json index 6fb9532e..be9e9fe7 100644 --- a/examples/react-mui/package.json +++ b/examples/react-mui/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@embedpdf/core": "workspace:*", - "@embedpdf/plugin-loader": "workspace:*", + "@embedpdf/plugin-document-manager": "workspace:*", "@embedpdf/plugin-viewport": "workspace:*", "@embedpdf/plugin-scroll": "workspace:*", "@embedpdf/plugin-zoom": "workspace:*", @@ -25,8 +25,11 @@ "@embedpdf/plugin-spread": "workspace:*", "@embedpdf/plugin-fullscreen": "workspace:*", "@embedpdf/plugin-export": "workspace:*", + "@embedpdf/plugin-print": "workspace:*", "@embedpdf/plugin-thumbnail": "workspace:*", "@embedpdf/plugin-selection": "workspace:*", + "@embedpdf/plugin-capture": "workspace:*", + "@embedpdf/plugin-history": "workspace:*", "@embedpdf/plugin-annotation": "workspace:*", "@embedpdf/plugin-redaction": "workspace:*", "@embedpdf/utils": "workspace:*", diff --git a/examples/react-mui/src/application.tsx b/examples/react-mui/src/application.tsx index 48a3754f..c37a4535 100644 --- a/examples/react-mui/src/application.tsx +++ b/examples/react-mui/src/application.tsx @@ -4,7 +4,12 @@ import { usePdfiumEngine } from '@embedpdf/engines/react'; import { ConsoleLogger, PdfAnnotationSubtype, PdfStampAnnoObject } from '@embedpdf/models'; import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react'; import { Scroller, ScrollPluginPackage, ScrollStrategy } from '@embedpdf/plugin-scroll/react'; -import { LoaderPluginPackage } from '@embedpdf/plugin-loader/react'; +import { + DocumentManagerPluginPackage, + DocumentContent, + DocumentContext, + DocumentManagerPlugin, +} from '@embedpdf/plugin-document-manager/react'; import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'; import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'; import { MarqueeZoom, ZoomMode, ZoomPluginPackage } from '@embedpdf/plugin-zoom/react'; @@ -16,20 +21,22 @@ import { } from '@embedpdf/plugin-interaction-manager/react'; import { PanPluginPackage } from '@embedpdf/plugin-pan/react'; import { Rotate, RotatePluginPackage } from '@embedpdf/plugin-rotate/react'; -import { SpreadPluginPackage } from '@embedpdf/plugin-spread/react'; +import { SpreadMode, SpreadPluginPackage } from '@embedpdf/plugin-spread/react'; import { FullscreenPluginPackage } from '@embedpdf/plugin-fullscreen/react'; import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; +import { PrintPluginPackage } from '@embedpdf/plugin-print/react'; import { RedactionLayer, RedactionPluginPackage } from '@embedpdf/plugin-redaction/react'; import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; import { SelectionPluginPackage } from '@embedpdf/plugin-selection/react'; import { SelectionLayer } from '@embedpdf/plugin-selection/react'; +import { CapturePluginPackage, MarqueeCapture } from '@embedpdf/plugin-capture/react'; +import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; import { AnnotationLayer, AnnotationPlugin, AnnotationPluginPackage, AnnotationTool, } from '@embedpdf/plugin-annotation/react'; - import { CircularProgress, Box, Alert } from '@mui/material'; import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; import { useMemo, useRef } from 'react'; @@ -43,63 +50,6 @@ import { ViewSidebarReverseIcon } from './icons'; import { AnnotationSelectionMenu } from './components/annotation-selection-menu'; import { RedactionSelectionMenu } from './components/redaction-selection-menu'; -const plugins = [ - createPluginRegistration(LoaderPluginPackage, { - loadingOptions: { - type: 'url', - pdfFile: { - id: 'pdf', - url: 'https://snippet.embedpdf.com/ebook.pdf', - }, - }, - }), - createPluginRegistration(ViewportPluginPackage, { - viewportGap: 10, - }), - createPluginRegistration(ScrollPluginPackage, { - strategy: ScrollStrategy.Vertical, - }), - createPluginRegistration(RenderPluginPackage), - createPluginRegistration(TilingPluginPackage, { - tileSize: 768, - overlapPx: 2.5, - extraRings: 0, - }), - createPluginRegistration(ZoomPluginPackage, { - defaultZoomLevel: ZoomMode.FitPage, - }), - createPluginRegistration(SearchPluginPackage), - createPluginRegistration(InteractionManagerPluginPackage), - createPluginRegistration(PanPluginPackage), - createPluginRegistration(RotatePluginPackage), - createPluginRegistration(SpreadPluginPackage), - createPluginRegistration(FullscreenPluginPackage), - createPluginRegistration(ExportPluginPackage), - createPluginRegistration(ThumbnailPluginPackage, { - paddingY: 10, - }), - createPluginRegistration(SelectionPluginPackage), - createPluginRegistration(AnnotationPluginPackage), - createPluginRegistration(RedactionPluginPackage), -]; - -const drawerComponents: DrawerComponent[] = [ - { - id: 'search', - component: Search, - icon: SearchOutlinedIcon, - label: 'Search', - position: 'right', - }, - { - id: 'sidebar', - component: Sidebar, - icon: ViewSidebarReverseIcon, - label: 'Sidebar', - position: 'left', - }, -]; - const consoleLogger = new ConsoleLogger(); function App() { @@ -111,6 +61,47 @@ function App() { const { engine, isLoading, error } = usePdfiumEngine(isDev ? { logger: consoleLogger } : {}); const popperContainerRef = useRef(null); + const plugins = useMemo( + () => [ + createPluginRegistration(ViewportPluginPackage, { + viewportGap: 10, + }), + createPluginRegistration(ScrollPluginPackage, { + defaultStrategy: ScrollStrategy.Vertical, + }), + createPluginRegistration(DocumentManagerPluginPackage), + createPluginRegistration(InteractionManagerPluginPackage), + createPluginRegistration(ZoomPluginPackage, { + defaultZoomLevel: ZoomMode.FitPage, + }), + createPluginRegistration(PanPluginPackage), + createPluginRegistration(SpreadPluginPackage, { + defaultSpreadMode: SpreadMode.None, + }), + createPluginRegistration(RotatePluginPackage), + createPluginRegistration(RenderPluginPackage), + createPluginRegistration(TilingPluginPackage, { + tileSize: 768, + overlapPx: 2.5, + extraRings: 0, + }), + createPluginRegistration(ExportPluginPackage), + createPluginRegistration(PrintPluginPackage), + createPluginRegistration(SelectionPluginPackage), + createPluginRegistration(SearchPluginPackage), + createPluginRegistration(RedactionPluginPackage), + createPluginRegistration(CapturePluginPackage), + createPluginRegistration(HistoryPluginPackage), + createPluginRegistration(AnnotationPluginPackage), + createPluginRegistration(FullscreenPluginPackage), + createPluginRegistration(ThumbnailPluginPackage, { + width: 120, + paddingY: 10, + }), + ], + [], + ); + if (error) { return ( - Failed to initialize PDF viewer: + Failed to initialize PDF viewer: {error.message} ); } @@ -144,159 +135,238 @@ function App() { } return ( - - { - const annotation = registry.getPlugin('annotation')?.provides(); - annotation?.addTool>({ - id: 'stampApproved', - name: 'Stamp Approved', - interaction: { - exclusive: false, - cursor: 'crosshair', - }, - matchScore: () => 0, - defaults: { - type: PdfAnnotationSubtype.STAMP, - imageSrc: - 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Eo_circle_green_checkmark.svg/512px-Eo_circle_green_checkmark.svg.png', - imageSize: { width: 20, height: 20 }, - }, - }); - }} - > - {({ pluginsReady }) => ( - - + { + // Load default PDF URL on initialization + registry + ?.getPlugin(DocumentManagerPlugin.id) + ?.provides() + ?.openDocumentUrl({ url: 'https://snippet.embedpdf.com/ebook.pdf' }) + .toPromise(); - {/* Main content area with sidebars */} - - {/* Left Sidebar */} - + // Add custom annotation tool + const annotation = registry.getPlugin('annotation')?.provides(); + annotation?.addTool>({ + id: 'stampApproved', + name: 'Stamp Approved', + interaction: { + exclusive: false, + cursor: 'crosshair', + }, + matchScore: () => 0, + defaults: { + type: PdfAnnotationSubtype.STAMP, + imageSrc: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Eo_circle_green_checkmark.svg/512px-Eo_circle_green_checkmark.svg.png', + imageSize: { width: 20, height: 20 }, + }, + }); + }} + > + {({ pluginsReady }) => ( + <> + {pluginsReady ? ( + + {({ activeDocumentId }) => { + // Define drawer components with documentId from context + const drawerComponents: DrawerComponent[] = [ + { + id: 'search', + component: Search, + icon: SearchOutlinedIcon, + label: 'Search', + position: 'right', + props: { documentId: activeDocumentId }, + }, + { + id: 'sidebar', + component: Sidebar, + icon: ViewSidebarReverseIcon, + label: 'Sidebar', + position: 'left', + props: { documentId: activeDocumentId }, + }, + ]; - {/* Main Viewport */} - - - - {!pluginsReady && ( - - - - )} - {pluginsReady && ( - ( - - - - - - - - ( - <> - {selected ? ( - - ) : null} - - )} - /> - ( - <> - {selected ? ( - - ) : null} - - )} - /> - - - )} - /> - )} - - - - + return ( + + + {activeDocumentId && } - {/* Right Sidebar */} - + {/* Main content area with sidebars */} + + {/* Left Sidebar */} + + + {/* Main Viewport */} + + {activeDocumentId && ( + + {({ isLoading: docLoading, isLoaded }) => ( + <> + {docLoading && ( + + + + )} + {isLoaded && ( + + + ( + + + + + + + + + ( + <> + {selected ? ( + + ) : null} + + )} + /> + ( + <> + {selected ? ( + + ) : null} + + )} + /> + + + )} + /> + + + + )} + + )} + + )} + + + {/* Right Sidebar */} + + + + + ); + }} + + ) : ( + + - - )} - - + )} + + )} + ); } diff --git a/examples/react-mui/src/components/annotation-selection-menu.tsx b/examples/react-mui/src/components/annotation-selection-menu.tsx index 0cc8fb92..19cdcceb 100644 --- a/examples/react-mui/src/components/annotation-selection-menu.tsx +++ b/examples/react-mui/src/components/annotation-selection-menu.tsx @@ -8,17 +8,22 @@ import { useState } from 'react'; interface AnnotationSelectionMenuProps { menuWrapperProps: MenuWrapperProps; selected: TrackedAnnotation; + documentId: string; container?: HTMLElement | null; } export function AnnotationSelectionMenu({ selected, + documentId, container, menuWrapperProps, }: AnnotationSelectionMenuProps) { - const { provides: annotation } = useAnnotationCapability(); + const { provides: annotationCapability } = useAnnotationCapability(); const [anchorEl, setAnchorEl] = useState(null); + // Get document-scoped API + const annotation = annotationCapability?.forDocument(documentId); + const handleDelete = () => { if (!annotation) return; const { pageIndex, id } = selected.object; diff --git a/examples/react-mui/src/components/page-controls/index.tsx b/examples/react-mui/src/components/page-controls/index.tsx index e07ee457..d5289128 100644 --- a/examples/react-mui/src/components/page-controls/index.tsx +++ b/examples/react-mui/src/components/page-controls/index.tsx @@ -5,12 +5,18 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore'; -export const PageControls = () => { - const { provides: viewport } = useViewportCapability(); - const { - provides: scroll, - state: { currentPage, totalPages }, - } = useScroll(); +interface PageControlsProps { + documentId: string; +} + +export const PageControls = ({ documentId }: PageControlsProps) => { + const { provides: viewportCapability } = useViewportCapability(); + const { provides: scrollCapability } = useScroll(documentId); + + // Get document-scoped APIs + const viewport = viewportCapability?.forDocument(documentId); + const currentPage = scrollCapability?.getCurrentPage() ?? 1; + const totalPages = scrollCapability?.getTotalPages() ?? 0; const [isVisible, setIsVisible] = useState(false); const [isHovering, setIsHovering] = useState(false); const hideTimeoutRef = useRef(null); @@ -67,7 +73,7 @@ export const PageControls = () => { const page = parseInt(pageStr); if (!isNaN(page) && page >= 1 && page <= totalPages) { - scroll?.scrollToPage?.({ + scrollCapability?.scrollToPage?.({ pageNumber: page, }); } @@ -77,9 +83,7 @@ export const PageControls = () => { e.preventDefault(); e.currentTarget.blur(); if (currentPage > 1) { - scroll?.scrollToPage?.({ - pageNumber: currentPage - 1, - }); + scrollCapability?.scrollToPreviousPage?.(); } }; @@ -87,9 +91,7 @@ export const PageControls = () => { e.preventDefault(); e.currentTarget.blur(); if (currentPage < totalPages) { - scroll?.scrollToPage?.({ - pageNumber: currentPage + 1, - }); + scrollCapability?.scrollToNextPage?.(); } }; diff --git a/examples/react-mui/src/components/redaction-selection-menu.tsx b/examples/react-mui/src/components/redaction-selection-menu.tsx index 59453513..c2a260e6 100644 --- a/examples/react-mui/src/components/redaction-selection-menu.tsx +++ b/examples/react-mui/src/components/redaction-selection-menu.tsx @@ -8,17 +8,22 @@ import { RedactionItem, useRedactionCapability } from '@embedpdf/plugin-redactio interface RedactionSelectionMenuProps { menuWrapperProps: MenuWrapperProps; selected: RedactionItem; + documentId: string; container?: HTMLElement | null; } export function RedactionSelectionMenu({ selected, + documentId, container, menuWrapperProps, }: RedactionSelectionMenuProps) { - const { provides: redaction } = useRedactionCapability(); + const { provides: redactionCapability } = useRedactionCapability(); const [anchorEl, setAnchorEl] = useState(null); + // Get document-scoped API + const redaction = redactionCapability?.forDocument(documentId); + const handleDelete = () => { if (!redaction) return; const { page, id } = selected; diff --git a/examples/react-mui/src/components/search/index.tsx b/examples/react-mui/src/components/search/index.tsx index b214047e..36029c53 100644 --- a/examples/react-mui/src/components/search/index.tsx +++ b/examples/react-mui/src/components/search/index.tsx @@ -21,6 +21,10 @@ import { MatchFlag } from '@embedpdf/models'; import { useScrollCapability } from '@embedpdf/plugin-scroll/react'; import { SearchResult } from '@embedpdf/models'; +interface SearchProps { + documentId: string; +} + const HitLine = ({ hit, onClick, @@ -68,12 +72,15 @@ const HitLine = ({ ); }; -export const Search = () => { - const { state, provides } = useSearch(); - const { provides: scroll } = useScrollCapability(); +export const Search = ({ documentId }: SearchProps) => { + const { state, provides } = useSearch(documentId); + const { provides: scrollCapability } = useScrollCapability(); const inputRef = useRef(null); const [inputValue, setInputValue] = useState(state.query || ''); + // Get document-scoped API + const scroll = scrollCapability?.forDocument(documentId); + useEffect(() => { if (inputRef.current) { inputRef.current.focus(); diff --git a/examples/react-mui/src/components/sidebar/index.tsx b/examples/react-mui/src/components/sidebar/index.tsx index ea7dbccc..313e1507 100644 --- a/examples/react-mui/src/components/sidebar/index.tsx +++ b/examples/react-mui/src/components/sidebar/index.tsx @@ -3,11 +3,18 @@ import { Box, Typography } from '@mui/material'; import { useScroll } from '@embedpdf/plugin-scroll/react'; import { ThumbnailsPane, ThumbImg } from '@embedpdf/plugin-thumbnail/react'; -export const Sidebar: React.FC = () => { - const { state, provides } = useScroll(); +interface SidebarProps { + documentId: string; +} + +export const Sidebar: React.FC = ({ documentId }) => { + const { provides: scrollCapability } = useScroll(documentId); + + // Get document-scoped API + const currentPage = scrollCapability?.getCurrentPage() ?? 1; return ( - + {(m) => ( { padding: '8px', }} onClick={() => { - provides?.scrollToPage?.({ + scrollCapability?.scrollToPage?.({ pageNumber: m.pageIndex + 1, }); }} @@ -34,7 +41,7 @@ export const Sidebar: React.FC = () => { width: m.width, height: m.height, border: '2px solid', - borderColor: state.currentPage === m.pageIndex + 1 ? 'primary.main' : '#e0e0e0', + borderColor: currentPage === m.pageIndex + 1 ? 'primary.main' : '#e0e0e0', borderRadius: 1, overflow: 'hidden', '&:hover': { @@ -44,6 +51,7 @@ export const Sidebar: React.FC = () => { }} > { - const { provides: annotationProvider } = useAnnotationCapability(); +interface AnnotationToolbarProps { + documentId: string; +} + +export const AnnotationToolbar = ({ documentId }: AnnotationToolbarProps) => { + const { provides: annotationCapability } = useAnnotationCapability(); const [activeTool, setActiveTool] = useState(null); + // Get document-scoped API + const annotationProvider = annotationCapability?.forDocument(documentId); + useEffect(() => { if (!annotationProvider) return; diff --git a/examples/react-mui/src/components/toolbar/index.tsx b/examples/react-mui/src/components/toolbar/index.tsx index 5520f211..2f31aafd 100644 --- a/examples/react-mui/src/components/toolbar/index.tsx +++ b/examples/react-mui/src/components/toolbar/index.tsx @@ -1,5 +1,5 @@ import { usePan } from '@embedpdf/plugin-pan/react'; -import { useRotateCapability } from '@embedpdf/plugin-rotate/react'; +import { useRotate } from '@embedpdf/plugin-rotate/react'; import { useSpread } from '@embedpdf/plugin-spread/react'; import MenuIcon from '@mui/icons-material/Menu'; import BackHandOutlinedIcon from '@mui/icons-material/BackHandOutlined'; @@ -35,19 +35,26 @@ import { AnnotationToolbar } from './annotation-toolbar'; import { SpreadMode } from '@embedpdf/plugin-spread'; import { useFullscreen } from '@embedpdf/plugin-fullscreen/react'; import { useExportCapability } from '@embedpdf/plugin-export/react'; -import { useLoaderCapability } from '@embedpdf/plugin-loader/react'; +import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/react'; import { useIsMobile } from '../../hooks/use-is-mobile'; import { RedactToolbar } from './redact-toolbar'; -export const Toolbar = () => { - const { provides: panProvider, isPanning } = usePan(); - const { provides: rotateProvider } = useRotateCapability(); - const { spreadMode, provides: spreadProvider } = useSpread(); +interface ToolbarProps { + documentId: string; +} + +export const Toolbar = ({ documentId }: ToolbarProps) => { + const { provides: panProvider, isPanning } = usePan(documentId); + const { provides: rotateProvider } = useRotate(documentId); + const { spreadMode, provides: spreadProvider } = useSpread(documentId); const { provides: fullscreenProvider, state: fullscreenState } = useFullscreen(); - const { provides: exportProvider } = useExportCapability(); - const { provides: loaderProvider } = useLoaderCapability(); + const { provides: exportCapability } = useExportCapability(); + const { provides: documentManager } = useDocumentManagerCapability(); const isMobile = useIsMobile(); + // Get document-scoped API for export + const exportProvider = exportCapability?.forDocument(documentId); + // Menu state for page settings const [pageSettingsAnchorEl, setPageSettingsAnchorEl] = useState(null); const pageSettingsOpen = Boolean(pageSettingsAnchorEl); @@ -109,7 +116,7 @@ export const Toolbar = () => { }; const handleOpenFilePicker = () => { - loaderProvider?.openFileDialog(); + documentManager?.openFileDialog(); handleMenuClose(); }; @@ -243,7 +250,7 @@ export const Toolbar = () => { flexItem sx={{ backgroundColor: 'white', my: 1.2, opacity: 0.5 }} /> - + {!isMobile && ( <> { - {mode === 'annotate' && } - {mode === 'redact' && } + {mode === 'annotate' && documentId && } + {mode === 'redact' && documentId && } ); }; diff --git a/examples/react-mui/src/components/toolbar/redact-toolbar.tsx b/examples/react-mui/src/components/toolbar/redact-toolbar.tsx index 5d890fe3..bb7e854b 100644 --- a/examples/react-mui/src/components/toolbar/redact-toolbar.tsx +++ b/examples/react-mui/src/components/toolbar/redact-toolbar.tsx @@ -6,8 +6,12 @@ import ClearIcon from '@mui/icons-material/Clear'; import { ToggleIconButton } from '../toggle-icon-button'; import { RedactIcon, RedactAreaIcon } from '../../icons'; -export const RedactToolbar = () => { - const { provides, state } = useRedaction(); +interface RedactToolbarProps { + documentId: string; +} + +export const RedactToolbar = ({ documentId }: RedactToolbarProps) => { + const { provides, state } = useRedaction(documentId); const handleTextRedact = () => { provides?.toggleRedactSelection(); diff --git a/examples/react-mui/src/components/zoom-controls/index.tsx b/examples/react-mui/src/components/zoom-controls/index.tsx index 75cdc535..7c6409a2 100644 --- a/examples/react-mui/src/components/zoom-controls/index.tsx +++ b/examples/react-mui/src/components/zoom-controls/index.tsx @@ -25,6 +25,10 @@ import { useInteractionManager } from '@embedpdf/plugin-interaction-manager/reac import { ToggleIconButton } from '../toggle-icon-button'; import { useIsMobile } from '../../hooks/use-is-mobile'; +interface ZoomControlsProps { + documentId: string; +} + interface ZoomModeItem { value: ZoomLevel; label: string; @@ -53,9 +57,9 @@ const ZOOM_MODES: ZoomModeItem[] = [ { value: ZoomMode.FitWidth, label: 'Fit to Width', icon: WidthFullIcon }, ] as const; -export const ZoomControls = () => { - const { state, provides } = useZoom(); - const { state: interactionManagerState } = useInteractionManager(); +export const ZoomControls = ({ documentId }: ZoomControlsProps) => { + const { state, provides } = useZoom(documentId); + const { state: interactionManagerState } = useInteractionManager(documentId); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const isMobile = useIsMobile(); diff --git a/examples/react-mui/src/icons.tsx b/examples/react-mui/src/icons.tsx index e1009185..d882b105 100644 --- a/examples/react-mui/src/icons.tsx +++ b/examples/react-mui/src/icons.tsx @@ -8,13 +8,13 @@ export const PageSettingsIcon = (props: SvgIconProps) => ( ); -/** Same icon, rotated 180 deg so it “points” the other way. */ +/** Same icon, rotated 180 deg so it "points" the other way. */ export const ViewSidebarReverseIcon = forwardRef((props, ref) => ( (null); - const { engine, isLoading, error } = usePdfiumEngine(); +export default function App() { + const { route } = useHashRoute(); - if (error) { - return
Error: {error.message}
; + switch (route) { + case '/': + return ; + case '/about': + return ; + case '/viewer': + return ; + case '/viewer-simple': + return ; // Changed component name + default: + return ; } - - if (isLoading || !engine) { - return
Loading...
; - } - - return ( -
-
- - {({ pluginsReady }) => ( - - - {pluginsReady ? ( - ( - - - - - - - - )} - /> - ) : ( -
Loading plugins...
- )} -
-
- )} -
-
-
- ); } diff --git a/examples/react-tailwind/src/components/annotation-selection-menu.tsx b/examples/react-tailwind/src/components/annotation-selection-menu.tsx new file mode 100644 index 00000000..7f9135df --- /dev/null +++ b/examples/react-tailwind/src/components/annotation-selection-menu.tsx @@ -0,0 +1,60 @@ +import { Rect } from '@embedpdf/models'; +import { TrackedAnnotation } from '@embedpdf/plugin-annotation'; +import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; +import { MenuWrapperProps } from '@embedpdf/utils/react'; +import { useState, useRef } from 'react'; +import { TrashIcon } from './icons'; + +interface AnnotationSelectionMenuProps { + menuWrapperProps: MenuWrapperProps; + selected: TrackedAnnotation; + rect: Rect; + documentId: string; +} + +export function AnnotationSelectionMenu({ + selected, + documentId, + menuWrapperProps, + rect, +}: AnnotationSelectionMenuProps) { + const { provides: annotationCapability } = useAnnotationCapability(); + const [anchorEl, setAnchorEl] = useState(null); + const popperRef = useRef(null); + + // Get document-scoped annotation API + const annotation = annotationCapability?.forDocument(documentId); + + const handleDelete = () => { + if (!annotation) return; + const { pageIndex, id } = selected.object; + annotation.deleteAnnotation(pageIndex, id); + }; + + return ( + <> + + {anchorEl && ( +
+ +
+ )} + + ); +} diff --git a/examples/react-tailwind/src/components/annotation-toolbar.tsx b/examples/react-tailwind/src/components/annotation-toolbar.tsx new file mode 100644 index 00000000..a28db155 --- /dev/null +++ b/examples/react-tailwind/src/components/annotation-toolbar.tsx @@ -0,0 +1,250 @@ +import { AnnotationTool, useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; +import { useHistoryCapability } from '@embedpdf/plugin-history/react'; +import { useEffect, useState, useMemo } from 'react'; +import { ToolbarButton } from './ui'; +import { + HighlightIcon, + UnderlineIcon, + StrikethroughIcon, + SquigglyIcon, + PenIcon, + TextIcon, + CircleIcon, + SquareIcon, + PolygonIcon, + PolylineIcon, + LineIcon, + ArrowIcon, + UndoIcon, + RedoIcon, +} from './icons'; + +type AnnotationToolbarProps = { + documentId: string; +}; + +// Helper type for tool colors +type ToolColors = Record; + +// Helper function to extract tool colors +function extractToolColors(tools: AnnotationTool[]): ToolColors { + const colors: ToolColors = {}; + tools.forEach((tool) => { + const defaults = tool.defaults as any; + colors[tool.id] = { + primaryColor: defaults.strokeColor || defaults.color || defaults.fontColor, + secondaryColor: defaults.color, + }; + }); + return colors; +} + +export function AnnotationToolbar({ documentId }: AnnotationToolbarProps) { + const { provides: annotationCapability } = useAnnotationCapability(); + const { provides: historyCapability } = useHistoryCapability(); + const [activeTool, setActiveTool] = useState(null); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + + // Initialize tool colors synchronously to avoid flash + const [toolColors, setToolColors] = useState(() => + annotationCapability ? extractToolColors(annotationCapability.getTools()) : {}, + ); + + // Get scoped API for this document + const annotationProvides = useMemo( + () => (annotationCapability ? annotationCapability.forDocument(documentId) : null), + [annotationCapability, documentId], + ); + + // Get scoped history for this document + const historyProvides = useMemo( + () => (historyCapability ? historyCapability.forDocument(documentId) : null), + [historyCapability, documentId], + ); + + useEffect(() => { + if (!annotationProvides) return; + + // Initialize with current tool + setActiveTool(annotationProvides.getActiveTool()); + + // Subscribe to changes + return annotationProvides.onActiveToolChange((tool) => { + setActiveTool(tool); + }); + }, [annotationProvides]); + + // Subscribe to tool changes to get tool defaults (only fires when tools are updated) + useEffect(() => { + if (!annotationCapability) return; + + // Subscribe to tool changes (only when tool defaults are updated) + return annotationCapability.onToolsChange((event) => { + setToolColors(extractToolColors(event.tools)); + }); + }, [annotationCapability]); + + // Subscribe to history state changes for this document + useEffect(() => { + if (!historyProvides) return; + + // Initialize with current state + const state = historyProvides.getHistoryState(); + setCanUndo(state.global.canUndo); + setCanRedo(state.global.canRedo); + + // Subscribe to history changes + return historyProvides.onHistoryChange(() => { + const newState = historyProvides.getHistoryState(); + setCanUndo(newState.global.canUndo); + setCanRedo(newState.global.canRedo); + }); + }, [historyProvides]); + + if (!annotationProvides) return null; + + const toggleTool = (toolId: string) => { + const currentId = activeTool?.id ?? null; + annotationProvides.setActiveTool(currentId === toolId ? null : toolId); + }; + + const handleUndo = () => { + if (historyProvides) { + historyProvides.undo(); + } + }; + + const handleRedo = () => { + if (historyProvides) { + historyProvides.redo(); + } + }; + + return ( +
+ toggleTool('highlight')} + isActive={activeTool?.id === 'highlight'} + aria-label="Highlight text" + title="Highlight Text" + > + + + + toggleTool('underline')} + isActive={activeTool?.id === 'underline'} + aria-label="Underline text" + title="Underline" + > + + + + toggleTool('strikeout')} + isActive={activeTool?.id === 'strikeout'} + aria-label="Strikethrough text" + title="Strikethrough" + > + + + + toggleTool('squiggly')} + isActive={activeTool?.id === 'squiggly'} + aria-label="Squiggly underline" + title="Squiggly Underline" + > + + + + toggleTool('ink')} + isActive={activeTool?.id === 'ink'} + aria-label="Freehand annotation" + title="Draw Freehand" + > + + + + toggleTool('freeText')} + isActive={activeTool?.id === 'freeText'} + aria-label="Text annotation" + title="Add Text Annotation" + > + + + + toggleTool('circle')} + isActive={activeTool?.id === 'circle'} + aria-label="Circle annotation" + title="Draw Circle" + > + + + + toggleTool('square')} + isActive={activeTool?.id === 'square'} + aria-label="Square annotation" + title="Draw Rectangle" + > + + + + toggleTool('polygon')} + isActive={activeTool?.id === 'polygon'} + aria-label="Polygon annotation" + title="Draw Polygon" + > + + + + toggleTool('polyline')} + isActive={activeTool?.id === 'polyline'} + aria-label="Polyline annotation" + title="Draw Polyline" + > + + + + toggleTool('line')} + isActive={activeTool?.id === 'line'} + aria-label="Line annotation" + title="Draw Line" + > + + + + toggleTool('lineArrow')} + isActive={activeTool?.id === 'lineArrow'} + aria-label="Arrow annotation" + title="Draw Arrow" + > + + + + {/* Divider */} +
+ + {/* Undo/Redo buttons */} + + + + + + + +
+ ); +} diff --git a/examples/react-tailwind/src/components/capture-dialog.tsx b/examples/react-tailwind/src/components/capture-dialog.tsx new file mode 100644 index 00000000..ab7c0191 --- /dev/null +++ b/examples/react-tailwind/src/components/capture-dialog.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from 'react'; +import { useCapture } from '@embedpdf/plugin-capture/react'; +import { Dialog, DialogContent, DialogFooter, Button } from './ui'; + +interface CaptureData { + pageIndex: number; + rect: any; + blob: Blob; +} + +type CaptureDialogProps = { + documentId: string; +}; + +export function CaptureDialog({ documentId }: CaptureDialogProps) { + const { provides: capture } = useCapture(documentId); + const [open, setOpen] = useState(false); + const [captureData, setCaptureData] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [downloadUrl, setDownloadUrl] = useState(null); + const urlRef = useRef(null); + const downloadLinkRef = useRef(null); + + const handleClose = () => { + // Clean up object URLs + if (urlRef.current) { + URL.revokeObjectURL(urlRef.current); + urlRef.current = null; + } + if (downloadUrl) { + URL.revokeObjectURL(downloadUrl); + setDownloadUrl(null); + } + setOpen(false); + setCaptureData(null); + setPreviewUrl(null); + }; + + const handleDownload = () => { + if (!captureData || !downloadLinkRef.current) return; + + // Create download URL and trigger download + const url = URL.createObjectURL(captureData.blob); + setDownloadUrl(url); + + // Use the ref to trigger download + downloadLinkRef.current.href = url; + downloadLinkRef.current.download = `pdf-capture-page-${captureData.pageIndex + 1}.png`; + downloadLinkRef.current.click(); + + handleClose(); + }; + + useEffect(() => { + if (!capture) return; + + return capture.onCaptureArea(({ pageIndex, rect, blob }) => { + setCaptureData({ pageIndex, rect, blob }); + + // Create preview URL + const objectUrl = URL.createObjectURL(blob); + urlRef.current = objectUrl; + setPreviewUrl(objectUrl); + setOpen(true); + }); + }, [capture]); + + const handleImageLoad = () => { + // Clean up the object URL after image loads + if (urlRef.current) { + URL.revokeObjectURL(urlRef.current); + urlRef.current = null; + } + }; + + return ( + <> + + +
+ {previewUrl && ( + Captured PDF area + )} +
+
+ + + + +
+ + {/* Hidden download link */} + + + ); +} diff --git a/examples/react-tailwind/src/components/document-menu.tsx b/examples/react-tailwind/src/components/document-menu.tsx new file mode 100644 index 00000000..cb6e9da9 --- /dev/null +++ b/examples/react-tailwind/src/components/document-menu.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { useExport } from '@embedpdf/plugin-export/react'; +import { useCapture } from '@embedpdf/plugin-capture/react'; +import { useFullscreen } from '@embedpdf/plugin-fullscreen/react'; +import { + MenuIcon, + PrintIcon, + DownloadIcon, + ScreenshotIcon, + FullscreenIcon, + FullscreenExitIcon, +} from './icons'; +import { PrintDialog } from './print-dialog'; +import { CaptureDialog } from './capture-dialog'; +import { ToolbarButton, DropdownMenu, DropdownItem } from './ui'; + +type DocumentMenuProps = { + documentId: string; +}; + +export function DocumentMenu({ documentId }: DocumentMenuProps) { + const { provides: exportProvider } = useExport(documentId); + const { provides: captureProvider, state: captureState } = useCapture(documentId); + const { provides: fullscreenProvider, state: fullscreenState } = useFullscreen(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isPrintDialogOpen, setIsPrintDialogOpen] = useState(false); + + if (!exportProvider) return null; + + const handleDownload = () => { + exportProvider.download(); + setIsMenuOpen(false); + }; + + const handlePrint = () => { + setIsMenuOpen(false); + setIsPrintDialogOpen(true); + }; + + const handleScreenshot = () => { + if (captureProvider) { + captureProvider.toggleMarqueeCapture(); + } + setIsMenuOpen(false); + }; + + const handleFullscreen = () => { + fullscreenProvider?.toggleFullscreen(`#${documentId}`); + setIsMenuOpen(false); + }; + + return ( + <> +
+ setIsMenuOpen(!isMenuOpen)} + isActive={isMenuOpen} + aria-label="Document Menu" + title="Document Menu" + > + + + + setIsMenuOpen(false)} className="w-48"> + } + > + Capture Area + + } + > + Print + + } + > + Download + + + ) : ( + + ) + } + > + {fullscreenState.isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'} + + +
+ + {/* Print Dialog */} + setIsPrintDialogOpen(false)} + /> + + {/* Capture Dialog */} + + + ); +} diff --git a/examples/react-tailwind/src/components/document-password-prompt.tsx b/examples/react-tailwind/src/components/document-password-prompt.tsx new file mode 100644 index 00000000..6ede58aa --- /dev/null +++ b/examples/react-tailwind/src/components/document-password-prompt.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/react'; +import { PdfErrorCode } from '@embedpdf/models'; +import { AlertIcon } from './icons'; +import { DocumentState } from '@embedpdf/core'; + +interface DocumentPasswordPromptProps { + documentState: DocumentState; +} + +export function DocumentPasswordPrompt({ documentState }: DocumentPasswordPromptProps) { + const { provides } = useDocumentManagerCapability(); + const [password, setPassword] = useState(''); + const [isRetrying, setIsRetrying] = useState(false); + + if (!documentState) return null; + + const { name, errorCode, passwordProvided } = documentState; + + // Clean logic using state + error code! + const isPasswordError = errorCode === PdfErrorCode.Password; + const isPasswordRequired = isPasswordError && !passwordProvided; + const isPasswordIncorrect = isPasswordError && passwordProvided; + + if (!isPasswordError) { + return ( +
+
+ +

Error loading document

+

+ {documentState.error || 'An unknown error occurred'} +

+ {errorCode &&

Error Code: {errorCode}

} + +
+
+ ); + } + + const handleRetry = async () => { + if (!provides || !password.trim()) return; + setIsRetrying(true); + + const task = provides.retryDocument(documentState.id, { password }); + task.wait( + () => { + setPassword(''); + setIsRetrying(false); + }, + (error) => { + console.error('Retry failed:', error); + setIsRetrying(false); + }, + ); + }; + + return ( +
+
+
+
+

Password Required

+ {name &&

{name}

} +
+ +
+ + {/* Different message based on state */} +

+ {isPasswordRequired && + 'This document is password protected. Please enter the password to open it.'} + {isPasswordIncorrect && 'The password you entered was incorrect. Please try again.'} +

+ +
+ + setPassword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isRetrying && password.trim() && handleRetry()} + disabled={isRetrying} + placeholder="Enter document password" + className="mt-1 block w-full rounded-md border px-3 py-2" + autoFocus + /> +
+ + {/* Show error feedback for incorrect password */} + {isPasswordIncorrect && ( +
+

Incorrect password. Please check and try again.

+
+ )} + +
+ + +
+
+
+ ); +} diff --git a/examples/react-tailwind/src/components/empty-state.tsx b/examples/react-tailwind/src/components/empty-state.tsx new file mode 100644 index 00000000..0f2e696c --- /dev/null +++ b/examples/react-tailwind/src/components/empty-state.tsx @@ -0,0 +1,65 @@ +import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/react'; + +interface EmptyStateProps { + onDocumentOpened?: (documentId: string) => void; +} + +export function EmptyState({ onDocumentOpened }: EmptyStateProps) { + const { provides } = useDocumentManagerCapability(); + + const handleOpenFile = () => { + const openTask = provides?.openFileDialog(); + openTask?.wait( + (result) => { + onDocumentOpened?.(result.documentId); + }, + (error) => { + console.error('Open file failed:', error); + }, + ); + }; + + return ( +
+
+
+
+ + + + +
+
+

No Documents Open

+

+ Get started by opening a PDF document. You can view multiple documents at once using tabs. +

+ +
Supported format: PDF
+
+
+ ); +} diff --git a/examples/react-tailwind/src/components/icons/index.tsx b/examples/react-tailwind/src/components/icons/index.tsx new file mode 100644 index 00000000..33ce0497 --- /dev/null +++ b/examples/react-tailwind/src/components/icons/index.tsx @@ -0,0 +1,1101 @@ +type IconProps = { + className?: string; + title?: string; + style?: React.CSSProperties; +}; + +export function DocumentIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function CloseIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function PlusIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function HandIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + ); +} + +export function SearchMinusIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function SearchPlusIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + ); +} + +export function ChevronDownIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function FitPageIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + ); +} + +export function FitWidthIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + ); +} + +export function MarqueeIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + + + + + ); +} + +export function RotateRightIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +export function RotateLeftIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +export function SinglePageIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function BookOpenIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function SettingsIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + + + + + ); +} + +export function PrintIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function DownloadIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function ScreenshotIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + + + + + ); +} + +export function FullscreenIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + ); +} + +export function FullscreenExitIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + ); +} + +export function MenuIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function MenuDotsIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function AlertIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function RefreshIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function CheckIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function SearchIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function ThumbnailsIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function ChevronLeftIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function ChevronRightIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function TextIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + ); +} + +export function PenIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + ); +} + +export function CircleIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function SquareIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function ArrowIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function HighlightIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + ); +} + +export function LineIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +export function PolygonIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +export function SquigglyIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + ); +} + +export function StrikethroughIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function UnderlineIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + ); +} + +export function ZigzagIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function PolylineIcon({ className, title, style }: IconProps) { + return ; +} + +export function ItalicIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + ); +} + +export function SquaresIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function TrashIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + ); +} + +export function UndoIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function RedoIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function RedactTextIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function RedactAreaIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/examples/react-tailwind/src/components/loading-spinner.tsx b/examples/react-tailwind/src/components/loading-spinner.tsx new file mode 100644 index 00000000..e1f42157 --- /dev/null +++ b/examples/react-tailwind/src/components/loading-spinner.tsx @@ -0,0 +1,34 @@ +type LoadingSpinnerProps = { + size?: 'sm' | 'md' | 'lg'; + message?: string; + className?: string; +}; + +const sizeClasses = { + sm: 'h-4 w-4', + md: 'h-5 w-5', + lg: 'h-8 w-8', +}; + +export function LoadingSpinner({ size = 'md', message, className = '' }: LoadingSpinnerProps) { + return ( +
+ + + + + {message && {message}} +
+ ); +} diff --git a/examples/react-tailwind/src/components/navigation-bar.tsx b/examples/react-tailwind/src/components/navigation-bar.tsx new file mode 100644 index 00000000..789e3b89 --- /dev/null +++ b/examples/react-tailwind/src/components/navigation-bar.tsx @@ -0,0 +1,17 @@ +export function NavigationBar() { + return ( +
+
+ + ← Home + + | + PDF Viewer +
+
EmbedPDF React Example
+
+ ); +} diff --git a/examples/react-tailwind/src/components/page-controls.tsx b/examples/react-tailwind/src/components/page-controls.tsx new file mode 100644 index 00000000..c937af6b --- /dev/null +++ b/examples/react-tailwind/src/components/page-controls.tsx @@ -0,0 +1,140 @@ +import { useViewportCapability } from '@embedpdf/plugin-viewport/react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; +import { useEffect, useRef, useState, useCallback } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from './icons'; + +type PageControlsProps = { + documentId: string; +}; + +export function PageControls({ documentId }: PageControlsProps) { + const { provides: viewport } = useViewportCapability(); + const { + provides: scroll, + state: { currentPage, totalPages }, + } = useScroll(documentId); + const [isVisible, setIsVisible] = useState(false); + const [isHovering, setIsHovering] = useState(false); + const hideTimeoutRef = useRef(null); + const [inputValue, setInputValue] = useState(currentPage.toString()); + + useEffect(() => { + setInputValue(currentPage.toString()); + }, [currentPage]); + + const startHideTimer = useCallback(() => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + hideTimeoutRef.current = setTimeout(() => { + if (!isHovering) { + setIsVisible(false); + } + }, 4000); + }, [isHovering]); + + useEffect(() => { + if (!viewport) return; + + return viewport.onScrollActivity((activity) => { + if (activity.documentId === documentId) { + setIsVisible(true); + startHideTimer(); + } + }); + }, [viewport, startHideTimer]); + + useEffect(() => { + return () => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + }; + }, []); + + const handleMouseEnter = () => { + setIsHovering(true); + setIsVisible(true); + }; + + const handleMouseLeave = () => { + setIsHovering(false); + startHideTimer(); + }; + + const handlePageChange = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const pageStr = formData.get('page') as string; + const page = parseInt(pageStr); + + if (!isNaN(page) && page >= 1 && page <= totalPages) { + scroll?.scrollToPage?.({ + pageNumber: page, + }); + } + }; + + const handlePreviousPage = (e: React.MouseEvent) => { + e.preventDefault(); + e.currentTarget.blur(); + if (currentPage > 1) { + scroll?.scrollToPreviousPage(); + } + }; + + const handleNextPage = (e: React.MouseEvent) => { + e.preventDefault(); + e.currentTarget.blur(); + if (currentPage < totalPages) { + scroll?.scrollToNextPage(); + } + }; + + return ( +
+
+ {/* Previous Button */} + + + {/* Page Input */} +
+ { + const value = e.target.value.replace(/[^0-9]/g, ''); + setInputValue(value); + }} + className="h-7 w-10 rounded border border-gray-300 bg-white px-1 text-center text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + / {totalPages} +
+ + {/* Next Button */} + +
+
+ ); +} diff --git a/examples/react-tailwind/src/components/page-settings-menu.tsx b/examples/react-tailwind/src/components/page-settings-menu.tsx new file mode 100644 index 00000000..67227a99 --- /dev/null +++ b/examples/react-tailwind/src/components/page-settings-menu.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { useRotate } from '@embedpdf/plugin-rotate/react'; +import { useSpread } from '@embedpdf/plugin-spread/react'; +import { SpreadMode } from '@embedpdf/plugin-spread'; +import { + SettingsIcon, + RotateRightIcon, + RotateLeftIcon, + SinglePageIcon, + BookOpenIcon, +} from './icons'; +import { ToolbarButton, DropdownMenu, DropdownSection, DropdownItem, DropdownDivider } from './ui'; + +type PageSettingsMenuProps = { + documentId: string; +}; + +export function PageSettingsMenu({ documentId }: PageSettingsMenuProps) { + const { provides: rotate } = useRotate(documentId); + const { spreadMode, provides: spread } = useSpread(documentId); + const [isOpen, setIsOpen] = useState(false); + + if (!rotate || !spread) return null; + + return ( +
+ setIsOpen(!isOpen)} + isActive={isOpen} + aria-label="Page Settings" + title="Page Settings" + > + + + + setIsOpen(false)} className="w-56"> + + { + rotate.rotateForward(); + setIsOpen(false); + }} + icon={} + > + Rotate Clockwise + + { + rotate.rotateBackward(); + setIsOpen(false); + }} + icon={} + > + Rotate Counter-clockwise + + + + + + + { + spread.setSpreadMode(SpreadMode.None); + setIsOpen(false); + }} + icon={} + isActive={spreadMode === SpreadMode.None} + > + Single Page + + { + spread.setSpreadMode(SpreadMode.Odd); + setIsOpen(false); + }} + icon={} + isActive={spreadMode === SpreadMode.Odd} + > + Odd Pages + + { + spread.setSpreadMode(SpreadMode.Even); + setIsOpen(false); + }} + icon={} + isActive={spreadMode === SpreadMode.Even} + > + Even Pages + + + +
+ ); +} diff --git a/examples/react-tailwind/src/components/pan-toggle.tsx b/examples/react-tailwind/src/components/pan-toggle.tsx new file mode 100644 index 00000000..14c63a91 --- /dev/null +++ b/examples/react-tailwind/src/components/pan-toggle.tsx @@ -0,0 +1,24 @@ +import { usePan } from '@embedpdf/plugin-pan/react'; +import { HandIcon } from './icons'; +import { ToolbarButton } from './ui'; + +type PanToggleButtonProps = { + documentId: string; +}; + +export function PanToggleButton({ documentId }: PanToggleButtonProps) { + const { provides: pan, isPanning } = usePan(documentId); + + if (!pan) return null; + + return ( + + + + ); +} diff --git a/examples/react-tailwind/src/components/print-dialog.tsx b/examples/react-tailwind/src/components/print-dialog.tsx new file mode 100644 index 00000000..75592d11 --- /dev/null +++ b/examples/react-tailwind/src/components/print-dialog.tsx @@ -0,0 +1,163 @@ +import { useState, useEffect } from 'react'; +import { usePrint } from '@embedpdf/plugin-print/react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; +import type { PdfPrintOptions } from '@embedpdf/models'; +import { Dialog, DialogContent, DialogFooter, Button } from './ui'; + +type PageSelection = 'all' | 'current' | 'custom'; + +type PrintDialogProps = { + documentId: string; + isOpen: boolean; + onClose: () => void; +}; + +export function PrintDialog({ documentId, isOpen, onClose }: PrintDialogProps) { + const { provides: print } = usePrint(documentId); + const { state: scrollState } = useScroll(documentId); + + const [selection, setSelection] = useState('all'); + const [customPages, setCustomPages] = useState(''); + const [includeAnnotations, setIncludeAnnotations] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + // Reset form when dialog opens/closes + useEffect(() => { + if (!isOpen) { + setSelection('all'); + setCustomPages(''); + setIncludeAnnotations(true); + setIsLoading(false); + } + }, [isOpen]); + + if (!isOpen) return null; + + const canSubmit = !isLoading && (selection !== 'custom' || customPages.trim().length > 0); + + const handlePrint = async () => { + if (!print || !canSubmit) return; + + setIsLoading(true); + + let pageRange: string | undefined; + + if (selection === 'current') { + pageRange = String(scrollState.currentPage); + } else if (selection === 'custom') { + pageRange = customPages.trim() || undefined; + } + + const options: PdfPrintOptions = { + includeAnnotations, + pageRange, + }; + + try { + const task = print.print(options); + + if (task) { + task.wait( + () => { + onClose(); + }, + (error) => { + console.error('Print failed:', error); + setIsLoading(false); + }, + ); + } + } catch (err) { + console.error('Print failed:', err); + setIsLoading(false); + } + }; + + return ( + + + {/* Pages to print */} +
+ +
+ + + + + +
+ + {/* Custom page range input */} +
+ setCustomPages(e.target.value)} + placeholder="e.g., 1-3, 5, 8-10" + disabled={selection !== 'custom'} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-500" + /> + {customPages.trim() && scrollState.totalPages > 0 && ( +

+ Total pages in document: {scrollState.totalPages} +

+ )} +
+
+ + {/* Include annotations */} +
+ +
+
+ + + + +
+ ); +} diff --git a/examples/react-tailwind/src/components/redaction-toolbar.tsx b/examples/react-tailwind/src/components/redaction-toolbar.tsx new file mode 100644 index 00000000..5f0e8862 --- /dev/null +++ b/examples/react-tailwind/src/components/redaction-toolbar.tsx @@ -0,0 +1,82 @@ +import { RedactionMode, useRedaction } from '@embedpdf/plugin-redaction/react'; +import { ToolbarButton } from './ui'; +import { CheckIcon, CloseIcon, RedactTextIcon, RedactAreaIcon } from './icons'; + +type RedactionToolbarProps = { + documentId: string; +}; + +export function RedactionToolbar({ documentId }: RedactionToolbarProps) { + const { provides, state } = useRedaction(documentId); + + if (!provides) return null; + + const handleTextRedact = () => { + provides.toggleRedactSelection(); + }; + + const handleAreaRedact = () => { + provides.toggleMarqueeRedact(); + }; + + const handleCommitPending = () => { + provides.commitAllPending(); + }; + + const handleClearPending = () => { + provides.clearPending(); + }; + + return ( +
+ {/* Redaction Mode Toggles */} + + + + + + + + + {/* Divider */} +
+ + {/* Action Buttons */} + + + + + {state.pendingCount > 0 && ( + + {state.pendingCount} pending redaction{state.pendingCount !== 1 ? 's' : ''} + + )} +
+ ); +} diff --git a/examples/react-tailwind/src/components/search-sidebar.tsx b/examples/react-tailwind/src/components/search-sidebar.tsx new file mode 100644 index 00000000..afe2c09e --- /dev/null +++ b/examples/react-tailwind/src/components/search-sidebar.tsx @@ -0,0 +1,251 @@ +import { useSearch } from '@embedpdf/plugin-search/react'; +import { useScrollCapability } from '@embedpdf/plugin-scroll/react'; +import { useState, useRef, useEffect } from 'react'; +import { MatchFlag } from '@embedpdf/models'; +import { SearchResult } from '@embedpdf/models'; +import { SearchIcon, CloseIcon, ChevronRightIcon, ChevronLeftIcon } from './icons'; + +const HitLine = ({ + hit, + onClick, + active, +}: { + hit: SearchResult; + onClick: () => void; + active: boolean; +}) => { + const ref = useRef(null); + + useEffect(() => { + if (active && ref.current) { + ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }, [active]); + + return ( + + ); +}; + +type SearchSidebarProps = { + documentId: string; + onClose: () => void; +}; + +export function SearchSidebar({ documentId, onClose }: SearchSidebarProps) { + const { state, provides } = useSearch(documentId); + const { provides: scroll } = useScrollCapability(); + const inputRef = useRef(null); + const [inputValue, setInputValue] = useState(''); + + // Sync inputValue with persisted state.query when state loads + useEffect(() => { + setInputValue(state.query || ''); + }, [state.query, documentId]); // Include documentId to reset on tab change + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [provides]); + + useEffect(() => { + if (state.activeResultIndex !== undefined && state.activeResultIndex >= 0 && !state.loading) { + scrollToItem(state.activeResultIndex); + } + }, [state.activeResultIndex, state.loading, state.query, state.flags]); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setInputValue(value); + + // Trigger search immediately on user input + if (value === '') { + provides?.stopSearch(); + } else { + provides?.searchAllPages(value); + } + }; + + const handleFlagChange = (flag: MatchFlag, checked: boolean) => { + if (checked) { + provides?.setFlags([...state.flags, flag]); + } else { + provides?.setFlags(state.flags.filter((f) => f !== flag)); + } + }; + + const clearInput = () => { + setInputValue(''); + provides?.stopSearch(); + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + const scrollToItem = (index: number) => { + const item = state.results[index]; + if (!item) return; + + const minCoordinates = item.rects.reduce( + (min, rect) => ({ + x: Math.min(min.x, rect.origin.x), + y: Math.min(min.y, rect.origin.y), + }), + { x: Infinity, y: Infinity }, + ); + + scroll?.forDocument(documentId).scrollToPage({ + pageNumber: item.pageIndex + 1, + pageCoordinates: minCoordinates, + center: true, + }); + }; + + const groupByPage = (results: typeof state.results) => { + return results.reduce>( + (map, r, i) => { + (map[r.pageIndex] ??= []).push({ hit: r, index: i }); + return map; + }, + {}, + ); + }; + + if (!provides) return null; + + const grouped = groupByPage(state.results); + + return ( +
+ {/* Header */} +
+

Search

+ +
+ + {/* Search Input */} +
+
+
+ +
+ + {inputValue && ( + + )} +
+ + {/* Options */} +
+ + +
+ + {/* Results count and navigation */} + {state.active && !state.loading && state.total > 0 && ( +
+ {state.total} results found + {state.total > 1 && ( +
+ + +
+ )} +
+ )} +
+ + {/* Results */} +
+ {state.loading ? ( +
+
+
+ ) : ( +
+ {Object.entries(grouped).map(([page, hits]) => ( +
+
+ Page {Number(page) + 1} +
+
+ {hits.map(({ hit, index }) => ( + provides.goToResult(index)} + /> + ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/examples/react-tailwind/src/components/split-view-layout.tsx b/examples/react-tailwind/src/components/split-view-layout.tsx new file mode 100644 index 00000000..2ad2f8f3 --- /dev/null +++ b/examples/react-tailwind/src/components/split-view-layout.tsx @@ -0,0 +1,64 @@ +import { + useAllViews, + useViewManagerCapability, + ViewContextRenderProps, +} from '@embedpdf/plugin-view-manager/react'; +import { ViewContext } from '@embedpdf/plugin-view-manager/react'; +import { ReactNode, useEffect } from 'react'; + +interface SplitViewLayoutProps { + renderView: (context: ViewContextRenderProps) => ReactNode; +} + +export function SplitViewLayout({ renderView }: SplitViewLayoutProps) { + const allViews = useAllViews(); + const { provides: viewManager } = useViewManagerCapability(); + + // Auto-remove empty views (except if it's the only view) + useEffect(() => { + if (!viewManager) return; + + const emptyViews = allViews.filter((v) => v.documentIds.length === 0); + + if (emptyViews.length > 0 && allViews.length > 1) { + emptyViews.forEach((emptyView) => { + if (allViews.length > 1) { + viewManager.removeView(emptyView.id); + } + }); + } + }, [allViews, viewManager]); + + const getLayoutClass = () => { + switch (allViews.length) { + case 1: + return 'grid-cols-1'; + case 2: + return 'grid-cols-2'; + case 3: + case 4: + return 'grid-cols-2 grid-rows-2'; + default: + return 'grid-cols-3'; + } + }; + + return ( +
+ {allViews.map((view) => ( + + {(context) => ( +
+ {renderView(context)} +
+ )} +
+ ))} +
+ ); +} diff --git a/examples/react-tailwind/src/components/tab-bar-2.tsx b/examples/react-tailwind/src/components/tab-bar-2.tsx new file mode 100644 index 00000000..3736af6a --- /dev/null +++ b/examples/react-tailwind/src/components/tab-bar-2.tsx @@ -0,0 +1,81 @@ +import { DocumentState } from '@embedpdf/core'; +import { CloseIcon, DocumentIcon, PlusIcon } from './icons'; + +type TabBarProps = { + documentStates: DocumentState[]; + activeDocumentId: string | null; + onSelect: (id: string) => void; + onClose: (id: string) => void; + onOpenFile: () => void; +}; + +export function TabBar({ + documentStates, + activeDocumentId, + onSelect, + onClose, + onOpenFile, +}: TabBarProps) { + return ( +
+ {/* Document Tabs */} +
+ {documentStates.map((document) => ( +
onSelect(document.id)} + role="tab" + tabIndex={0} + aria-selected={activeDocumentId === document.id} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(document.id); + } + }} + className={`group relative flex min-w-[120px] max-w-[240px] cursor-pointer items-center gap-2 rounded-t-md px-3 py-2.5 text-sm font-medium transition-all ${ + activeDocumentId === document.id + ? 'bg-white text-gray-900 shadow-[0_2px_4px_-1px_rgba(0,0,0,0.06)]' + : 'bg-gray-200/60 text-gray-600 hover:bg-gray-200 hover:text-gray-800' + } `} + > + {/* Document Icon */} + + + {/* Document Name */} + + {document.name ?? `Document ${document.id.slice(0, 8)}`} + + + {/* Close Button */} + +
+ ))} + + {/* Add Tab (Open File) - placed directly after tabs like Chrome */} + +
+
+ ); +} diff --git a/examples/react-tailwind/src/components/tab-bar.tsx b/examples/react-tailwind/src/components/tab-bar.tsx new file mode 100644 index 00000000..847f3ef8 --- /dev/null +++ b/examples/react-tailwind/src/components/tab-bar.tsx @@ -0,0 +1,103 @@ +import { DocumentState } from '@embedpdf/core'; +import { useState, MouseEvent } from 'react'; +import { TabContextMenu } from './tab-context-menu'; +import { View } from '@embedpdf/plugin-view-manager/react'; +import { useOpenDocuments } from '@embedpdf/plugin-document-manager/react'; +import { CloseIcon, DocumentIcon, PlusIcon } from './icons'; + +interface TabBarProps { + currentView: View | undefined; + onSelect: (documentId: string) => void; + onClose: (documentId: string) => void; + onOpenFile: () => void; +} + +export function TabBar({ currentView, onSelect, onClose, onOpenFile }: TabBarProps) { + const documentStates = useOpenDocuments(currentView?.documentIds ?? []); + const [contextMenu, setContextMenu] = useState<{ + documentState: DocumentState; + position: { x: number; y: number }; + } | null>(null); + + const handleContextMenu = (e: MouseEvent, documentState: DocumentState) => { + e.preventDefault(); + setContextMenu({ + documentState, + position: { x: e.clientX, y: e.clientY }, + }); + }; + + return ( + <> +
+ {/* Document Tabs */} +
+ {documentStates.map((doc) => ( +
onSelect(doc.id)} + onContextMenu={(e) => handleContextMenu(e, doc)} + role="tab" + tabIndex={0} + aria-selected={doc.id === currentView?.activeDocumentId} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(doc.id); + } + }} + className={`group relative flex min-w-[120px] max-w-[240px] cursor-pointer items-center gap-2 rounded-t-md px-3 py-2.5 text-sm font-medium transition-all ${ + doc.id === currentView?.activeDocumentId + ? 'bg-white text-gray-900 shadow-[0_2px_4px_-1px_rgba(0,0,0,0.06)]' + : 'bg-gray-200/60 text-gray-600 hover:bg-gray-200 hover:text-gray-800' + } `} + > + {/* Document Icon */} + + + {/* Document Name */} + {doc.name || 'Untitled'} + + {/* Close Button */} + +
+ ))} + + {/* Add Tab (Open File) - placed directly after tabs like Chrome */} + +
+
+ + {/* Context Menu */} + {contextMenu && currentView && ( + setContextMenu(null)} + /> + )} + + ); +} diff --git a/examples/react-tailwind/src/components/tab-context-menu.tsx b/examples/react-tailwind/src/components/tab-context-menu.tsx new file mode 100644 index 00000000..880839a0 --- /dev/null +++ b/examples/react-tailwind/src/components/tab-context-menu.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef } from 'react'; +import { DocumentState } from '@embedpdf/core'; +import { useViewManagerCapability, useAllViews } from '@embedpdf/plugin-view-manager/react'; + +interface TabContextMenuProps { + documentState: DocumentState; + currentViewId: string; + position: { x: number; y: number }; + onClose: () => void; +} + +export function TabContextMenu({ + documentState, + currentViewId, + position, + onClose, +}: TabContextMenuProps) { + const menuRef = useRef(null); + const { provides: viewManager } = useViewManagerCapability(); + const allViews = useAllViews(); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [onClose]); + + const handleOpenInNewView = () => { + if (!viewManager) return; + + const newViewId = viewManager.createView(); + viewManager.addDocumentToView(newViewId, documentState.id); + viewManager.removeDocumentFromView(currentViewId, documentState.id); + viewManager.setFocusedView(newViewId); + onClose(); + }; + + const handleMoveToView = (targetViewId: string) => { + if (!viewManager) return; + viewManager.moveDocumentBetweenViews(currentViewId, targetViewId, documentState.id); + viewManager.setFocusedView(targetViewId); + viewManager.setViewActiveDocument(targetViewId, documentState.id); + onClose(); + }; + + const otherViews = allViews.filter((v) => v.id !== currentViewId); + + return ( +
+
+ + + {otherViews.length > 0 && ( + <> +
+
Move to View
+ {otherViews.map((view, index) => ( + + ))} + + )} +
+
+ ); +} diff --git a/examples/react-tailwind/src/components/thumbnails-sidebar.tsx b/examples/react-tailwind/src/components/thumbnails-sidebar.tsx new file mode 100644 index 00000000..3c28f2a8 --- /dev/null +++ b/examples/react-tailwind/src/components/thumbnails-sidebar.tsx @@ -0,0 +1,90 @@ +import { ThumbnailsPane, ThumbImg } from '@embedpdf/plugin-thumbnail/react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; +import { CloseIcon } from './icons'; + +type ThumbnailsSidebarProps = { + documentId: string; + onClose: () => void; +}; + +export function ThumbnailsSidebar({ documentId, onClose }: ThumbnailsSidebarProps) { + const { state, provides } = useScroll(documentId); + + return ( +
+ {/* Header */} +
+

Thumbnails

+ +
+ + {/* Thumbnails */} +
+ + {(m) => ( +
{ + provides?.scrollToPage?.({ + pageNumber: m.pageIndex + 1, + }); + }} + > +
+ +
+
+ {m.pageIndex + 1} +
+
+ )} +
+
+
+ ); +} diff --git a/examples/react-tailwind/src/components/ui/button.tsx b/examples/react-tailwind/src/components/ui/button.tsx new file mode 100644 index 00000000..117911de --- /dev/null +++ b/examples/react-tailwind/src/components/ui/button.tsx @@ -0,0 +1,61 @@ +import { ButtonHTMLAttributes, ReactNode } from 'react'; + +type ButtonVariant = 'default' | 'primary' | 'secondary' | 'ghost'; + +type ButtonProps = ButtonHTMLAttributes & { + children: ReactNode; + variant?: ButtonVariant; + active?: boolean; + tooltip?: string; +}; + +const variantStyles: Record = { + default: 'hover:bg-gray-100 hover:ring hover:ring-[#1a466b]', + primary: + 'bg-blue-600 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50', + secondary: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50', + ghost: 'text-gray-700 hover:bg-gray-100 disabled:opacity-50', +}; + +export function Button({ + children, + onClick, + variant = 'default', + active = false, + disabled = false, + className = '', + tooltip, + ...props +}: ButtonProps) { + // For default variant with active state (toolbar buttons) + if (variant === 'default') { + return ( + + ); + } + + // For other variants (dialog buttons) + return ( + + ); +} diff --git a/examples/react-tailwind/src/components/ui/dialog-content.tsx b/examples/react-tailwind/src/components/ui/dialog-content.tsx new file mode 100644 index 00000000..c662899f --- /dev/null +++ b/examples/react-tailwind/src/components/ui/dialog-content.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from 'react'; + +type DialogContentProps = { + children: ReactNode; + className?: string; +}; + +export function DialogContent({ children, className = '' }: DialogContentProps) { + return
{children}
; +} diff --git a/examples/react-tailwind/src/components/ui/dialog-footer.tsx b/examples/react-tailwind/src/components/ui/dialog-footer.tsx new file mode 100644 index 00000000..13d9582a --- /dev/null +++ b/examples/react-tailwind/src/components/ui/dialog-footer.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from 'react'; + +type DialogFooterProps = { + children: ReactNode; + className?: string; +}; + +export function DialogFooter({ children, className = '' }: DialogFooterProps) { + return ( +
+ {children} +
+ ); +} diff --git a/examples/react-tailwind/src/components/ui/dialog.tsx b/examples/react-tailwind/src/components/ui/dialog.tsx new file mode 100644 index 00000000..8af2668f --- /dev/null +++ b/examples/react-tailwind/src/components/ui/dialog.tsx @@ -0,0 +1,102 @@ +import { ReactNode, useEffect, useRef } from 'react'; +import { CloseIcon } from '../icons'; + +export interface DialogProps { + /** Controlled visibility — `true` shows, `false` hides */ + open: boolean; + /** Dialog title */ + title?: string; + /** Dialog content */ + children: ReactNode; + /** Callback when dialog should close */ + onClose?: () => void; + /** Optional className for the dialog content */ + className?: string; + /** Whether to show close button */ + showCloseButton?: boolean; + /** Maximum width of the dialog */ + maxWidth?: string; +} + +export function Dialog({ + open, + title, + children, + onClose, + className, + showCloseButton = true, + maxWidth = '32rem', +}: DialogProps) { + const overlayRef = useRef(null); + + // Handle escape key + useEffect(() => { + if (!open) return; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose?.(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [open, onClose]); + + // Handle backdrop click + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === overlayRef.current) { + onClose?.(); + } + }; + + // Prevent body scroll when dialog is open + useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + + return () => { + document.body.style.overflow = ''; + }; + }, [open]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} + {(title || showCloseButton) && ( +
+ {title &&

{title}

} + {showCloseButton && ( + + )} +
+ )} + + {/* Content */} +
+ {children} +
+
+
+ ); +} diff --git a/examples/react-tailwind/src/components/ui/dropdown-menu.tsx b/examples/react-tailwind/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..36f58629 --- /dev/null +++ b/examples/react-tailwind/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,69 @@ +import { ReactNode } from 'react'; + +type DropdownMenuProps = { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + className?: string; +}; + +export function DropdownMenu({ isOpen, onClose, children, className = '' }: DropdownMenuProps) { + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Menu */} +
+ {children} +
+ + ); +} + +type DropdownItemProps = { + onClick: () => void; + icon?: ReactNode; + children: ReactNode; + isActive?: boolean; +}; + +export function DropdownItem({ onClick, icon, children, isActive = false }: DropdownItemProps) { + return ( + + ); +} + +type DropdownSectionProps = { + title?: string; + children: ReactNode; +}; + +export function DropdownSection({ title, children }: DropdownSectionProps) { + return ( + <> + {title && ( +
+ {title} +
+ )} + {children} + + ); +} + +export function DropdownDivider() { + return
; +} diff --git a/examples/react-tailwind/src/components/ui/index.ts b/examples/react-tailwind/src/components/ui/index.ts new file mode 100644 index 00000000..f2d8aa93 --- /dev/null +++ b/examples/react-tailwind/src/components/ui/index.ts @@ -0,0 +1,7 @@ +export * from './toolbar-button'; +export * from './dropdown-menu'; +export * from './dialog'; +export * from './dialog-content'; +export * from './dialog-footer'; +export * from './button'; +export * from './toolbar-divider'; diff --git a/examples/react-tailwind/src/components/ui/toolbar-button.tsx b/examples/react-tailwind/src/components/ui/toolbar-button.tsx new file mode 100644 index 00000000..f2bc0e3d --- /dev/null +++ b/examples/react-tailwind/src/components/ui/toolbar-button.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from 'react'; + +type ToolbarButtonProps = { + onClick: () => void; + isActive?: boolean; + disabled?: boolean; + children: ReactNode; + 'aria-label': string; + title?: string; + className?: string; +}; + +export function ToolbarButton({ + onClick, + isActive = false, + disabled = false, + children, + 'aria-label': ariaLabel, + title, + className = '', +}: ToolbarButtonProps) { + return ( + + ); +} diff --git a/examples/react-tailwind/src/components/ui/toolbar-divider.tsx b/examples/react-tailwind/src/components/ui/toolbar-divider.tsx new file mode 100644 index 00000000..b4e4b02e --- /dev/null +++ b/examples/react-tailwind/src/components/ui/toolbar-divider.tsx @@ -0,0 +1,7 @@ +type ToolbarDividerProps = { + className?: string; +}; + +export function ToolbarDivider({ className = '' }: ToolbarDividerProps) { + return
; +} diff --git a/examples/react-tailwind/src/components/viewer-toolbar.tsx b/examples/react-tailwind/src/components/viewer-toolbar.tsx new file mode 100644 index 00000000..993dabdc --- /dev/null +++ b/examples/react-tailwind/src/components/viewer-toolbar.tsx @@ -0,0 +1,107 @@ +import { ZoomToolbar } from './zoom-toolbar'; +import { PanToggleButton } from './pan-toggle'; +import { PageSettingsMenu } from './page-settings-menu'; +import { DocumentMenu } from './document-menu'; +import { SearchIcon, ThumbnailsIcon } from './icons'; +import { ToolbarButton, ToolbarDivider } from './ui'; +import { RedactionToolbar } from './redaction-toolbar'; +import { AnnotationToolbar } from './annotation-toolbar'; + +export type ViewMode = 'view' | 'annotate' | 'redact'; + +type ViewerToolbarProps = { + documentId: string; + onToggleSearch: () => void; + onToggleThumbnails: () => void; + isSearchOpen: boolean; + isThumbnailsOpen: boolean; + mode: ViewMode; + onModeChange: (mode: ViewMode) => void; +}; + +export function ViewerToolbar({ + documentId, + onToggleSearch, + onToggleThumbnails, + isSearchOpen, + isThumbnailsOpen, + mode, + onModeChange, +}: ViewerToolbarProps) { + return ( + <> + {/* Main Toolbar */} +
+ {/* Left side - Document menu and Thumbnails toggle */} + + + + + + + + {/* Center - Zoom toolbar */} + + + + + + + {/* Mode Tabs */} +
+
+ + + +
+
+ + {/* Right side - Search toggle */} + + + +
+ + {/* Redaction Toolbar */} + {mode === 'redact' && } + {mode === 'annotate' && } + + ); +} diff --git a/examples/react-tailwind/src/components/zoom-toolbar.tsx b/examples/react-tailwind/src/components/zoom-toolbar.tsx new file mode 100644 index 00000000..f88f0bab --- /dev/null +++ b/examples/react-tailwind/src/components/zoom-toolbar.tsx @@ -0,0 +1,162 @@ +import { useZoom } from '@embedpdf/plugin-zoom/react'; +import { ZoomMode } from '@embedpdf/plugin-zoom'; +import { useState } from 'react'; +import { + ChevronDownIcon, + FitPageIcon, + FitWidthIcon, + SearchMinusIcon, + SearchPlusIcon, + MarqueeIcon, +} from './icons'; +import { DropdownMenu, DropdownItem, DropdownDivider } from './ui'; + +interface ZoomToolbarProps { + documentId: string; +} + +interface ZoomPreset { + value: number; + label: string; +} + +interface ZoomModeItem { + value: ZoomMode; + label: string; +} + +const ZOOM_PRESETS: ZoomPreset[] = [ + { value: 0.5, label: '50%' }, + { value: 1, label: '100%' }, + { value: 1.5, label: '150%' }, + { value: 2, label: '200%' }, + { value: 4, label: '400%' }, + { value: 8, label: '800%' }, +]; + +const ZOOM_MODES: ZoomModeItem[] = [ + { value: ZoomMode.FitPage, label: 'Fit to Page' }, + { value: ZoomMode.FitWidth, label: 'Fit to Width' }, +]; + +export function ZoomToolbar({ documentId }: ZoomToolbarProps) { + const { state, provides } = useZoom(documentId); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + if (!provides) return null; + + const zoomPercentage = Math.round(state.currentZoomLevel * 100); + + const handleZoomIn = () => { + provides.zoomIn(); + setIsMenuOpen(false); + }; + + const handleZoomOut = () => { + provides.zoomOut(); + setIsMenuOpen(false); + }; + + const handleSelectZoom = (value: number | ZoomMode) => { + provides.requestZoom(value); + setIsMenuOpen(false); + }; + + const handleToggleMarquee = () => { + provides.toggleMarqueeZoom(); + setIsMenuOpen(false); + }; + + return ( +
+
+ {/* Zoom Out Button */} + + + {/* Zoom Percentage Display */} + + + {/* Zoom In Button */} + +
+ + setIsMenuOpen(false)} className="w-48"> + } + > + Zoom In + + } + > + Zoom Out + + + + + {/* Zoom Presets */} + {ZOOM_PRESETS.map(({ value, label }) => ( + handleSelectZoom(value)} + isActive={Math.abs(state.currentZoomLevel - value) < 0.01} + > + {label} + + ))} + + + + {/* Zoom Modes */} + {ZOOM_MODES.map(({ value, label }) => ( + handleSelectZoom(value)} + icon={ + value === ZoomMode.FitPage ? ( + + ) : ( + + ) + } + isActive={state.zoomLevel === value} + > + {label} + + ))} + + + + } + isActive={state.isMarqueeZoomActive} + > + Marquee Zoom + + +
+ ); +} diff --git a/examples/react-tailwind/src/pages/about.tsx b/examples/react-tailwind/src/pages/about.tsx new file mode 100644 index 00000000..4c30cf7e --- /dev/null +++ b/examples/react-tailwind/src/pages/about.tsx @@ -0,0 +1,53 @@ +export function AboutPage() { + return ( +
+
+

About This Example

+ +
+

+ This example demonstrates how to build a fully-featured PDF viewer using EmbedPDF with + React 18 and Tailwind CSS. It showcases the power and flexibility of EmbedPDF's plugin + system. +

+ +
+

Key Technologies:

+
    +
  • EmbedPDF - High-performance PDF rendering
  • +
  • React 18 - Modern component architecture
  • +
  • Tailwind CSS - Utility-first styling
  • +
  • PDFium Engine - Google's PDF rendering engine
  • +
  • TypeScript - Type-safe development
  • +
+
+ +
+

Use Cases:

+

+ This example serves as a starting point for building document management systems, + annotation tools, online document readers, and any application that needs robust PDF + viewing capabilities. +

+
+
+ + +
+
+ ); +} diff --git a/examples/react-tailwind/src/pages/home.tsx b/examples/react-tailwind/src/pages/home.tsx new file mode 100644 index 00000000..f0b37095 --- /dev/null +++ b/examples/react-tailwind/src/pages/home.tsx @@ -0,0 +1,58 @@ +export function HomePage() { + return ( +
+
+

EmbedPDF React + Tailwind

+ +
+

+ Welcome to the EmbedPDF example for React and Tailwind CSS. This demonstrates how to + integrate a powerful PDF viewer into your React applications with a beautiful, modern + UI. +

+ +
+

Features:

+
    +
  • High-performance PDF rendering with PDFium
  • +
  • Multiple document support with tabs
  • +
  • Zoom, pan, and page navigation
  • +
  • Responsive design with Tailwind CSS
  • +
  • Modern React 18 architecture
  • +
+
+
+ + + +
+

+ Getting Started: Choose a viewer to open and load your PDF documents. + The ViewManager version supports split views, while the simple version uses tabs. +

+
+
+
+ ); +} diff --git a/examples/react-tailwind/src/pages/viewer-simple.tsx b/examples/react-tailwind/src/pages/viewer-simple.tsx new file mode 100644 index 00000000..3c12cdb2 --- /dev/null +++ b/examples/react-tailwind/src/pages/viewer-simple.tsx @@ -0,0 +1,309 @@ +import { useMemo, useRef, useState } from 'react'; +import { EmbedPDF } from '@embedpdf/core/react'; +import { usePdfiumEngine } from '@embedpdf/engines/react'; +import { createPluginRegistration } from '@embedpdf/core'; +import { ViewportPluginPackage, Viewport } from '@embedpdf/plugin-viewport/react'; +import { ScrollPluginPackage, ScrollStrategy, Scroller } from '@embedpdf/plugin-scroll/react'; +import { + DocumentManagerPluginPackage, + DocumentContent, + DocumentContext, + DocumentManagerPlugin, +} from '@embedpdf/plugin-document-manager/react'; +import { + InteractionManagerPluginPackage, + GlobalPointerProvider, + PagePointerProvider, +} from '@embedpdf/plugin-interaction-manager/react'; +import { ZoomMode, ZoomPluginPackage, MarqueeZoom } from '@embedpdf/plugin-zoom/react'; +import { PanPluginPackage } from '@embedpdf/plugin-pan/react'; +import { SpreadMode, SpreadPluginPackage } from '@embedpdf/plugin-spread/react'; +import { Rotate, RotatePluginPackage } from '@embedpdf/plugin-rotate/react'; +import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'; +import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'; +import { RedactionLayer, RedactionPluginPackage } from '@embedpdf/plugin-redaction/react'; +import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; +import { PrintPluginPackage } from '@embedpdf/plugin-print/react'; +import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react'; +import { SearchLayer, SearchPluginPackage } from '@embedpdf/plugin-search/react'; +import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; +import { CapturePluginPackage, MarqueeCapture } from '@embedpdf/plugin-capture/react'; +import { FullscreenPluginPackage } from '@embedpdf/plugin-fullscreen/react'; +import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; +import { AnnotationPluginPackage, AnnotationLayer } from '@embedpdf/plugin-annotation/react'; +import { TabBar } from '../components/tab-bar-2'; +import { ViewerToolbar, ViewMode } from '../components/viewer-toolbar'; +import { LoadingSpinner } from '../components/loading-spinner'; +import { DocumentPasswordPrompt } from '../components/document-password-prompt'; +import { SearchSidebar } from '../components/search-sidebar'; +import { ThumbnailsSidebar } from '../components/thumbnails-sidebar'; +import { PageControls } from '../components/page-controls'; +import { ConsoleLogger } from '@embedpdf/models'; +import { NavigationBar } from '../components/navigation-bar'; +import { EmptyState } from '../components/empty-state'; + +const logger = new ConsoleLogger(); + +// Type for tracking sidebar state per document +type SidebarState = { + search: boolean; + thumbnails: boolean; +}; + +export function ViewerSimplePage() { + const containerRef = useRef(null); + const { engine, isLoading, error } = usePdfiumEngine({ + logger, + }); + + // Track sidebar state per document + const [sidebarStates, setSidebarStates] = useState>({}); + + // Track toolbar mode per document + const [toolbarModes, setToolbarModes] = useState>({}); + + const plugins = useMemo( + () => [ + createPluginRegistration(ViewportPluginPackage, { + viewportGap: 10, + }), + createPluginRegistration(ScrollPluginPackage, { + defaultStrategy: ScrollStrategy.Vertical, + }), + createPluginRegistration(DocumentManagerPluginPackage), + createPluginRegistration(InteractionManagerPluginPackage), + createPluginRegistration(ZoomPluginPackage, { + defaultZoomLevel: ZoomMode.FitPage, + }), + createPluginRegistration(PanPluginPackage), + createPluginRegistration(SpreadPluginPackage, { + defaultSpreadMode: SpreadMode.None, + }), + createPluginRegistration(RotatePluginPackage), + createPluginRegistration(ExportPluginPackage), + createPluginRegistration(PrintPluginPackage), + createPluginRegistration(RenderPluginPackage), + createPluginRegistration(TilingPluginPackage, { + tileSize: 768, + overlapPx: 2.5, + extraRings: 0, + }), + createPluginRegistration(SelectionPluginPackage), + createPluginRegistration(SearchPluginPackage), + createPluginRegistration(RedactionPluginPackage), + createPluginRegistration(CapturePluginPackage), + createPluginRegistration(HistoryPluginPackage), + createPluginRegistration(AnnotationPluginPackage), + createPluginRegistration(FullscreenPluginPackage), + createPluginRegistration(ThumbnailPluginPackage, { + width: 120, + paddingY: 10, + }), + ], + [], // Empty dependency array since these never change + ); + + const toggleSidebar = (documentId: string, sidebar: keyof SidebarState) => { + setSidebarStates((prev) => ({ + ...prev, + [documentId]: { + ...(prev[documentId] || { search: false, thumbnails: false }), + [sidebar]: !prev[documentId]?.[sidebar], + }, + })); + }; + + const getSidebarState = (documentId: string): SidebarState => { + return sidebarStates[documentId] || { search: false, thumbnails: false }; + }; + + const getToolbarMode = (documentId: string): ViewMode => { + return toolbarModes[documentId] || 'view'; + }; + + const setToolbarMode = (documentId: string, mode: ViewMode) => { + setToolbarModes((prev) => ({ + ...prev, + [documentId]: mode, + })); + }; + + if (error) { + return
Error: {error.message}
; + } + + if (isLoading || !engine) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ { + registry + ?.getPlugin(DocumentManagerPlugin.id) + ?.provides() + ?.openDocumentUrl({ url: 'https://snippet.embedpdf.com/ebook.pdf' }) + .toPromise(); + }} + > + {({ pluginsReady, registry }) => ( + <> + {pluginsReady ? ( + + {({ documentStates, activeDocumentId, actions }) => ( +
+ + registry + ?.getPlugin(DocumentManagerPlugin.id) + ?.provides() + ?.openFileDialog() + } + /> + + {activeDocumentId && ( + toggleSidebar(activeDocumentId, 'search')} + onToggleThumbnails={() => toggleSidebar(activeDocumentId, 'thumbnails')} + isSearchOpen={getSidebarState(activeDocumentId).search} + isThumbnailsOpen={getSidebarState(activeDocumentId).thumbnails} + mode={getToolbarMode(activeDocumentId)} + onModeChange={(mode) => setToolbarMode(activeDocumentId, mode)} + /> + )} + + {!activeDocumentId && } + + {/* Document Content Area */} + {activeDocumentId && ( +
+ {/* Thumbnails Sidebar - Left */} + {getSidebarState(activeDocumentId).thumbnails && ( + toggleSidebar(activeDocumentId, 'thumbnails')} + /> + )} + + {/* Main Viewer */} +
+ + {({ documentState, isLoading, isError, isLoaded }) => ( + <> + {isLoading && ( +
+ +
+ )} + {isError && ( + + )} + {isLoaded && ( +
+ + + ( + + + + + + + + + + + + + )} + /> + {/* Page Controls */} + + + +
+ )} + + )} +
+
+ + {/* Search Sidebar - Right */} + {getSidebarState(activeDocumentId).search && ( + toggleSidebar(activeDocumentId, 'search')} + /> + )} +
+ )} +
+ )} +
+ ) : ( +
+ +
+ )} + + )} +
+
+
+ ); +} diff --git a/examples/react-tailwind/src/pages/viewer.tsx b/examples/react-tailwind/src/pages/viewer.tsx new file mode 100644 index 00000000..beea5b90 --- /dev/null +++ b/examples/react-tailwind/src/pages/viewer.tsx @@ -0,0 +1,371 @@ +import { useMemo, useRef, useState } from 'react'; +import { EmbedPDF } from '@embedpdf/core/react'; +import { usePdfiumEngine } from '@embedpdf/engines/react'; +import { createPluginRegistration } from '@embedpdf/core'; +import { ViewportPluginPackage, Viewport } from '@embedpdf/plugin-viewport/react'; +import { ScrollPluginPackage, ScrollStrategy, Scroller } from '@embedpdf/plugin-scroll/react'; +import { + DocumentManagerPluginPackage, + DocumentContent, + DocumentManagerPlugin, +} from '@embedpdf/plugin-document-manager/react'; +import { + InteractionManagerPluginPackage, + GlobalPointerProvider, + PagePointerProvider, +} from '@embedpdf/plugin-interaction-manager/react'; +import { ZoomMode, ZoomPluginPackage, MarqueeZoom } from '@embedpdf/plugin-zoom/react'; +import { PanPluginPackage } from '@embedpdf/plugin-pan/react'; +import { SpreadMode, SpreadPluginPackage } from '@embedpdf/plugin-spread/react'; +import { Rotate, RotatePluginPackage } from '@embedpdf/plugin-rotate/react'; +import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'; +import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'; +import { ViewManagerPlugin, ViewManagerPluginPackage } from '@embedpdf/plugin-view-manager/react'; +import { RedactionLayer, RedactionPluginPackage } from '@embedpdf/plugin-redaction/react'; +import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; +import { PrintPluginPackage } from '@embedpdf/plugin-print/react'; +import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react'; +import { SearchLayer, SearchPluginPackage } from '@embedpdf/plugin-search/react'; +import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; +import { CapturePluginPackage, MarqueeCapture } from '@embedpdf/plugin-capture/react'; +import { FullscreenPluginPackage } from '@embedpdf/plugin-fullscreen/react'; +import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; +import { AnnotationPluginPackage, AnnotationLayer } from '@embedpdf/plugin-annotation/react'; +import { TabBar } from '../components/tab-bar'; +import { ViewerToolbar, ViewMode } from '../components/viewer-toolbar'; +import { LoadingSpinner } from '../components/loading-spinner'; +import { DocumentPasswordPrompt } from '../components/document-password-prompt'; +import { SearchSidebar } from '../components/search-sidebar'; +import { ThumbnailsSidebar } from '../components/thumbnails-sidebar'; +import { PageControls } from '../components/page-controls'; +import { ConsoleLogger } from '@embedpdf/models'; +import { SplitViewLayout } from '../components/split-view-layout'; +import { AnnotationSelectionMenu } from '../components/annotation-selection-menu'; +import { NavigationBar } from '../components/navigation-bar'; +import { EmptyState } from '../components/empty-state'; + +const logger = new ConsoleLogger(); + +// Type for tracking sidebar state per document +type SidebarState = { + search: boolean; + thumbnails: boolean; +}; + +export function ViewerPage() { + const containerRef = useRef(null); + const { engine, isLoading, error } = usePdfiumEngine({ + logger, + }); + + // Track sidebar state per document + const [sidebarStates, setSidebarStates] = useState>({}); + + // Track toolbar mode per document + const [toolbarModes, setToolbarModes] = useState>({}); + + const plugins = useMemo( + () => [ + createPluginRegistration(ViewportPluginPackage, { + viewportGap: 10, + }), + createPluginRegistration(ScrollPluginPackage, { + defaultStrategy: ScrollStrategy.Vertical, + }), + createPluginRegistration(DocumentManagerPluginPackage), + createPluginRegistration(InteractionManagerPluginPackage), + createPluginRegistration(ZoomPluginPackage, { + defaultZoomLevel: ZoomMode.FitPage, + }), + createPluginRegistration(PanPluginPackage), + createPluginRegistration(SpreadPluginPackage, { + defaultSpreadMode: SpreadMode.None, + }), + createPluginRegistration(RotatePluginPackage), + createPluginRegistration(ExportPluginPackage), + createPluginRegistration(PrintPluginPackage), + createPluginRegistration(RenderPluginPackage), + createPluginRegistration(TilingPluginPackage, { + tileSize: 768, + overlapPx: 2.5, + extraRings: 0, + }), + createPluginRegistration(SelectionPluginPackage), + createPluginRegistration(SearchPluginPackage), + createPluginRegistration(RedactionPluginPackage), + createPluginRegistration(CapturePluginPackage), + createPluginRegistration(HistoryPluginPackage), + createPluginRegistration(AnnotationPluginPackage), + createPluginRegistration(FullscreenPluginPackage, { + targetElement: '#document-content', + }), + createPluginRegistration(ThumbnailPluginPackage, { + width: 120, + paddingY: 10, + }), + createPluginRegistration(ViewManagerPluginPackage, { + defaultViewCount: 1, + }), + ], + [], // Empty dependency array since these never change + ); + + const toggleSidebar = (documentId: string, sidebar: keyof SidebarState) => { + setSidebarStates((prev) => ({ + ...prev, + [documentId]: { + ...(prev[documentId] || { search: false, thumbnails: false }), + [sidebar]: !prev[documentId]?.[sidebar], + }, + })); + }; + + const getSidebarState = (documentId: string): SidebarState => { + return sidebarStates[documentId] || { search: false, thumbnails: false }; + }; + + const getToolbarMode = (documentId: string): ViewMode => { + return toolbarModes[documentId] || 'view'; + }; + + const setToolbarMode = (documentId: string, mode: ViewMode) => { + setToolbarModes((prev) => ({ + ...prev, + [documentId]: mode, + })); + }; + + if (error) { + return
Error: {error.message}
; + } + + if (isLoading || !engine) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ { + // Load default PDF URL on initialization + const document = await registry + ?.getPlugin(DocumentManagerPlugin.id) + ?.provides() + ?.openDocumentUrl({ url: 'https://snippet.embedpdf.com/ebook.pdf' }) + .toPromise(); + + if (!document) return; + + const viewManager = registry + ?.getPlugin(ViewManagerPlugin.id) + ?.provides(); + if (!viewManager) return; + + const views = viewManager.getAllViews(); + if (views.length > 0 && views[0]) { + const firstViewId = views[0].id; + viewManager.addDocumentToView(firstViewId, document.documentId); + viewManager.setViewActiveDocument(firstViewId, document.documentId); + } + }} + > + {({ pluginsReady, registry }) => ( + <> + {pluginsReady ? ( + ( +
+ setActiveDocument(documentId)} + onClose={(docId) => + registry + ?.getPlugin(DocumentManagerPlugin.id) + ?.provides() + ?.closeDocument(docId) + } + onOpenFile={() => { + const openTask = registry + ?.getPlugin(DocumentManagerPlugin.id) + ?.provides() + ?.openFileDialog(); + openTask?.wait( + (result) => { + addDocument(result.documentId); + setActiveDocument(result.documentId); + }, + (error) => { + console.error('Open file failed:', error); + }, + ); + }} + /> + + {documentId && ( + toggleSidebar(documentId, 'search')} + onToggleThumbnails={() => toggleSidebar(documentId, 'thumbnails')} + isSearchOpen={getSidebarState(documentId).search} + isThumbnailsOpen={getSidebarState(documentId).thumbnails} + mode={getToolbarMode(documentId)} + onModeChange={(mode) => setToolbarMode(documentId, mode)} + /> + )} + + {/* Empty State - No Documents */} + {!documentId && ( + { + addDocument(documentId); + setActiveDocument(documentId); + }} + /> + )} + + {/* Document Content Area */} + {documentId && ( +
+ {/* Thumbnails Sidebar - Left */} + {getSidebarState(documentId).thumbnails && ( + toggleSidebar(documentId, 'thumbnails')} + /> + )} + + {/* Main Viewer */} +
+ + {({ documentState, isLoading, isError, isLoaded }) => ( + <> + {isLoading && ( +
+ +
+ )} + {isError && ( + + )} + {isLoaded && ( +
+ + + ( + + + + {/**/} + + + + + + ( + <> + {selected ? ( + + ) : null} + + )} + /> + + + )} + /> + {/* Page Controls */} + + + +
+ )} + + )} +
+
+ + {/* Search Sidebar - Right */} + {getSidebarState(documentId).search && ( + toggleSidebar(documentId, 'search')} + /> + )} +
+ )} +
+ )} + /> + ) : ( +
+ +
+ )} + + )} +
+
+
+ ); +} diff --git a/examples/react-tailwind/src/router.tsx b/examples/react-tailwind/src/router.tsx new file mode 100644 index 00000000..ee0b5ad4 --- /dev/null +++ b/examples/react-tailwind/src/router.tsx @@ -0,0 +1,22 @@ +import { useState, useEffect } from 'react'; + +export function useHashRoute() { + const [route, setRoute] = useState(() => { + return window.location.hash.slice(1) || '/'; + }); + + useEffect(() => { + const handleHashChange = () => { + setRoute(window.location.hash.slice(1) || '/'); + }; + + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + + const navigate = (path: string) => { + window.location.hash = path; + }; + + return { route, navigate }; +} diff --git a/examples/svelte-tailwind/.gitignore b/examples/svelte-tailwind/.gitignore new file mode 100644 index 00000000..ab5b1daf --- /dev/null +++ b/examples/svelte-tailwind/.gitignore @@ -0,0 +1,4 @@ +.vercel +dist +node_modules +.svelte-kit \ No newline at end of file diff --git a/examples/svelte-tailwind/README.md b/examples/svelte-tailwind/README.md index 824ca03f..b0cd2c02 100644 --- a/examples/svelte-tailwind/README.md +++ b/examples/svelte-tailwind/README.md @@ -20,4 +20,4 @@ This example shows how to build a PDF viewer with **EmbedPDF**, **Svelte 5** and ## License -Example code is released under the MIT license, the same as EmbedPDF itself. \ No newline at end of file +Example code is released under the MIT license, the same as EmbedPDF itself. diff --git a/examples/svelte-tailwind/package.json b/examples/svelte-tailwind/package.json index 478e5969..50e08d15 100644 --- a/examples/svelte-tailwind/package.json +++ b/examples/svelte-tailwind/package.json @@ -29,26 +29,31 @@ }, "devDependencies": { "@embedpdf/core": "workspace:*", - "@embedpdf/engines": "workspace:*", - "@embedpdf/models": "workspace:*", - "@embedpdf/pdfium": "workspace:*", - "@embedpdf/plugin-capture": "workspace:*", - "@embedpdf/plugin-export": "workspace:*", - "@embedpdf/plugin-fullscreen": "workspace:*", + "@embedpdf/plugin-document-manager": "workspace:*", + "@embedpdf/plugin-viewport": "workspace:*", + "@embedpdf/plugin-scroll": "workspace:*", + "@embedpdf/plugin-zoom": "workspace:*", + "@embedpdf/plugin-render": "workspace:*", + "@embedpdf/plugin-tiling": "workspace:*", + "@embedpdf/plugin-search": "workspace:*", "@embedpdf/plugin-interaction-manager": "workspace:*", - "@embedpdf/plugin-loader": "workspace:*", "@embedpdf/plugin-pan": "workspace:*", - "@embedpdf/plugin-print": "workspace:*", - "@embedpdf/plugin-render": "workspace:*", "@embedpdf/plugin-rotate": "workspace:*", - "@embedpdf/plugin-scroll": "workspace:*", - "@embedpdf/plugin-search": "workspace:*", - "@embedpdf/plugin-selection": "workspace:*", "@embedpdf/plugin-spread": "workspace:*", + "@embedpdf/plugin-fullscreen": "workspace:*", + "@embedpdf/plugin-export": "workspace:*", "@embedpdf/plugin-thumbnail": "workspace:*", - "@embedpdf/plugin-tiling": "workspace:*", - "@embedpdf/plugin-viewport": "workspace:*", - "@embedpdf/plugin-zoom": "workspace:*", + "@embedpdf/plugin-selection": "workspace:*", + "@embedpdf/plugin-print": "workspace:*", + "@embedpdf/plugin-redaction": "workspace:*", + "@embedpdf/plugin-capture": "workspace:*", + "@embedpdf/plugin-history": "workspace:*", + "@embedpdf/plugin-annotation": "workspace:*", + "@embedpdf/plugin-view-manager": "workspace:*", + "@embedpdf/utils": "workspace:*", + "@embedpdf/models": "workspace:*", + "@embedpdf/pdfium": "workspace:*", + "@embedpdf/engines": "workspace:*", "@sveltejs/adapter-auto": "^6.1.0", "@sveltejs/adapter-vercel": "^6.0.0", "@sveltejs/kit": "^2.43.2", diff --git a/examples/svelte-tailwind/src/app.d.ts b/examples/svelte-tailwind/src/app.d.ts index da08e6da..520c4217 100644 --- a/examples/svelte-tailwind/src/app.d.ts +++ b/examples/svelte-tailwind/src/app.d.ts @@ -1,13 +1,13 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { - namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} - } + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } } export {}; diff --git a/examples/svelte-tailwind/src/examples/capture-example-content.svelte b/examples/svelte-tailwind/src/examples/capture-example-content.svelte index 3cf97d29..9129be31 100644 --- a/examples/svelte-tailwind/src/examples/capture-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/capture-example-content.svelte @@ -10,7 +10,9 @@ type CaptureAreaEvent, } from '@embedpdf/plugin-capture/svelte'; - const capture = useCapture(); + let { documentId }: { documentId: string } = $props(); + + const capture = useCapture(() => documentId); let captureResult = $state(null); let imageUrl = $state(null); @@ -41,16 +43,10 @@ }; -{#snippet RenderPageSnippet(page: RenderPageProps)} - - - +{#snippet renderPage(page: RenderPageProps)} + + + {/snippet} @@ -61,17 +57,20 @@
- +
diff --git a/examples/svelte-tailwind/src/examples/capture-example.svelte b/examples/svelte-tailwind/src/examples/capture-example.svelte index 061a2bbc..bda1b922 100644 --- a/examples/svelte-tailwind/src/examples/capture-example.svelte +++ b/examples/svelte-tailwind/src/examples/capture-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/examples/export-example-content.svelte b/examples/svelte-tailwind/src/examples/export-example-content.svelte index a84d91fc..951057d9 100644 --- a/examples/svelte-tailwind/src/examples/export-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/export-example-content.svelte @@ -4,12 +4,15 @@ import { RenderLayer } from '@embedpdf/plugin-render/svelte'; import { useExportCapability } from '@embedpdf/plugin-export/svelte'; - const exportApi = useExportCapability(); + let { documentId }: { documentId: string } = $props(); + + const exportCapability = useExportCapability(); + const exportApi = $derived(exportCapability.provides?.forDocument(documentId)); -{#snippet RenderPageSnippet(page: RenderPageProps)} +{#snippet renderPage(page: RenderPageProps)}
- +
{/snippet} @@ -19,8 +22,8 @@ class="mb-4 mt-4 flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm" >
- +
diff --git a/examples/svelte-tailwind/src/examples/export-example.svelte b/examples/svelte-tailwind/src/examples/export-example.svelte index 0d6885bd..a8e3a0ed 100644 --- a/examples/svelte-tailwind/src/examples/export-example.svelte +++ b/examples/svelte-tailwind/src/examples/export-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/examples/getting-started.svelte b/examples/svelte-tailwind/src/examples/getting-started.svelte index 37dc4a06..a9d9271e 100644 --- a/examples/svelte-tailwind/src/examples/getting-started.svelte +++ b/examples/svelte-tailwind/src/examples/getting-started.svelte @@ -1,7 +1,7 @@ -{#snippet RenderPageSnippet(page: RenderPageProps)} -
- -
-{/snippet} + // 3. Load the document when the plugins are initialized + const onInitialized = async (registry: PluginRegistry) => { + registry + .getPlugin(DocumentManagerPlugin.id) + ?.provides() + ?.openDocumentUrl({ url: 'https://snippet.embedpdf.com/ebook.pdf' }); + }; +
{#if pdfEngine.isLoading || !pdfEngine.engine} @@ -45,10 +44,32 @@ Loading PDF Engine...
{:else} - - - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + {#snippet renderPage(page: RenderPageProps)} +
+ +
+ {/snippet} + + + + {/if} + {/snippet} +
+ {/if} + {/snippet} +
{/if}
diff --git a/examples/svelte-tailwind/src/examples/pan-example-content.svelte b/examples/svelte-tailwind/src/examples/pan-example-content.svelte index 7caab964..4b740c40 100644 --- a/examples/svelte-tailwind/src/examples/pan-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/pan-example-content.svelte @@ -5,12 +5,14 @@ import { GlobalPointerProvider } from '@embedpdf/plugin-interaction-manager/svelte'; import { usePan } from '@embedpdf/plugin-pan/svelte'; - const pan = usePan(); + let { documentId }: { documentId: string } = $props(); + + const pan = usePan(() => documentId); -{#snippet RenderPageSnippet(page: RenderPageProps)} +{#snippet renderPage(page: RenderPageProps)}
- +
{/snippet} @@ -53,9 +55,9 @@
{/if}
- - - + + +
diff --git a/examples/svelte-tailwind/src/examples/pan-example.svelte b/examples/svelte-tailwind/src/examples/pan-example.svelte index de3d7691..7f6c704a 100644 --- a/examples/svelte-tailwind/src/examples/pan-example.svelte +++ b/examples/svelte-tailwind/src/examples/pan-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/examples/print-example-content.svelte b/examples/svelte-tailwind/src/examples/print-example-content.svelte index b03f088d..8971a410 100644 --- a/examples/svelte-tailwind/src/examples/print-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/print-example-content.svelte @@ -4,13 +4,16 @@ import { RenderLayer } from '@embedpdf/plugin-render/svelte'; import { usePrintCapability } from '@embedpdf/plugin-print/svelte'; - const print = usePrintCapability(); + let { documentId }: { documentId: string } = $props(); + + const printCapability = usePrintCapability(); + const print = $derived(printCapability.provides?.forDocument(documentId)); let isPrinting = $state(false); const handlePrint = () => { - if (!print.provides || isPrinting) return; + if (!print || isPrinting) return; isPrinting = true; - const printTask = print.provides.print(); + const printTask = print.print(); printTask.wait( () => { isPrinting = false; @@ -22,9 +25,9 @@ }; -{#snippet RenderPageSnippet(page: RenderPageProps)} +{#snippet renderPage(page: RenderPageProps)}
- +
{/snippet} @@ -35,7 +38,7 @@ >
- +
diff --git a/examples/svelte-tailwind/src/examples/print-example.svelte b/examples/svelte-tailwind/src/examples/print-example.svelte index 7abbc706..8b17223d 100644 --- a/examples/svelte-tailwind/src/examples/print-example.svelte +++ b/examples/svelte-tailwind/src/examples/print-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/examples/render-example-content.svelte b/examples/svelte-tailwind/src/examples/render-example-content.svelte index 7d15c241..c9a9aed3 100644 --- a/examples/svelte-tailwind/src/examples/render-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/render-example-content.svelte @@ -3,14 +3,17 @@ import { Scroller, type RenderPageProps } from '@embedpdf/plugin-scroll/svelte'; import { RenderLayer, useRenderCapability } from '@embedpdf/plugin-render/svelte'; - const render = useRenderCapability(); + let { documentId }: { documentId: string } = $props(); + + const renderCapability = useRenderCapability(); + const render = $derived(renderCapability.provides?.forDocument(documentId)); let isExporting = $state(false); const exportPageAsPng = () => { - if (!render.provides || isExporting) return; + if (!render || isExporting) return; isExporting = true; - const renderTask = render.provides.renderPage({ + const renderTask = render.renderPage({ pageIndex: 0, options: { scaleFactor: 2.0, withAnnotations: true, imageType: 'image/png' }, }); @@ -34,9 +37,9 @@ }; -{#snippet RenderPageSnippet(page: RenderPageProps)} +{#snippet renderPage(page: RenderPageProps)}
- +
{/snippet} @@ -47,15 +50,15 @@ >
- - + +
diff --git a/examples/svelte-tailwind/src/examples/render-example.svelte b/examples/svelte-tailwind/src/examples/render-example.svelte index aca4807f..f36f71b2 100644 --- a/examples/svelte-tailwind/src/examples/render-example.svelte +++ b/examples/svelte-tailwind/src/examples/render-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/examples/rotate-example-content.svelte b/examples/svelte-tailwind/src/examples/rotate-example-content.svelte index 8fb4ab07..d9fcbec5 100644 --- a/examples/svelte-tailwind/src/examples/rotate-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/rotate-example-content.svelte @@ -5,19 +5,15 @@ import { PagePointerProvider } from '@embedpdf/plugin-interaction-manager/svelte'; import { useRotate, Rotate } from '@embedpdf/plugin-rotate/svelte'; - const rotate = useRotate(); + let { documentId }: { documentId: string } = $props(); + + const rotate = useRotate(() => documentId); -{#snippet RenderPageSnippet(page: RenderPageProps)} - - - +{#snippet renderPage(page: RenderPageProps)} + + + {/snippet} @@ -75,6 +71,7 @@ {/if}
- +
diff --git a/examples/svelte-tailwind/src/examples/rotate-example.svelte b/examples/svelte-tailwind/src/examples/rotate-example.svelte index 64cadea8..cbd4de3b 100644 --- a/examples/svelte-tailwind/src/examples/rotate-example.svelte +++ b/examples/svelte-tailwind/src/examples/rotate-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/examples/scroll-example-content.svelte b/examples/svelte-tailwind/src/examples/scroll-example-content.svelte index ea319d60..57771929 100644 --- a/examples/svelte-tailwind/src/examples/scroll-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/scroll-example-content.svelte @@ -3,7 +3,9 @@ import { Scroller, useScroll, type RenderPageProps } from '@embedpdf/plugin-scroll/svelte'; import { RenderLayer } from '@embedpdf/plugin-render/svelte'; - const scroll = useScroll(); + let { documentId }: { documentId: string } = $props(); + + const scroll = useScroll(() => documentId); let pageInput = $state(String(scroll.state.currentPage)); $effect(() => { @@ -19,9 +21,9 @@ }; -{#snippet RenderPageSnippet(page: RenderPageProps)} +{#snippet renderPage(page: RenderPageProps)}
- +
{/snippet} @@ -77,8 +79,8 @@
- - + +
diff --git a/examples/svelte-tailwind/src/examples/scroll-example.svelte b/examples/svelte-tailwind/src/examples/scroll-example.svelte index 691a03b1..3cdb83dd 100644 --- a/examples/svelte-tailwind/src/examples/scroll-example.svelte +++ b/examples/svelte-tailwind/src/examples/scroll-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/examples/selection-example-content.svelte b/examples/svelte-tailwind/src/examples/selection-example-content.svelte index e0d130ae..996178cc 100644 --- a/examples/svelte-tailwind/src/examples/selection-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/selection-example-content.svelte @@ -10,24 +10,25 @@ import { PagePointerProvider } from '@embedpdf/plugin-interaction-manager/svelte'; import { ignore } from '@embedpdf/models'; - const selection = useSelectionCapability(); + let { documentId }: { documentId: string } = $props(); + + const selectionCapability = useSelectionCapability(); + const selection = $derived(selectionCapability.provides?.forDocument(documentId)); let hasSelection = $state(false); let selectedText = $state(''); $effect(() => { - if (!selection.provides) return; + if (!selection) return; - const unsubscribe1 = selection.provides.onSelectionChange( - (selectionRange: SelectionRangeX | null) => { - hasSelection = !!selectionRange; - if (!selectionRange) { - selectedText = ''; - } - }, - ); + const unsubscribe1 = selection.onSelectionChange((selectionRange: SelectionRangeX | null) => { + hasSelection = !!selectionRange; + if (!selectionRange) { + selectedText = ''; + } + }); - const unsubscribe2 = selection.provides.onEndSelection(() => { - const textTask = selection.provides!.getSelectedText(); + const unsubscribe2 = selection.onEndSelection(() => { + const textTask = selection!.getSelectedText(); textTask.wait((textLines) => { selectedText = textLines.join('\n'); }, ignore); @@ -40,16 +41,10 @@ }); -{#snippet RenderPageSnippet(page: RenderPageProps)} - - - +{#snippet renderPage(page: RenderPageProps)} + + + {/snippet} @@ -58,7 +53,7 @@
- - + +
diff --git a/examples/svelte-tailwind/src/examples/selection-example.svelte b/examples/svelte-tailwind/src/examples/selection-example.svelte index bb3f8d96..2e44a904 100644 --- a/examples/svelte-tailwind/src/examples/selection-example.svelte +++ b/examples/svelte-tailwind/src/examples/selection-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/examples/spread-example-content.svelte b/examples/svelte-tailwind/src/examples/spread-example-content.svelte index 38fa6b1b..77e1b75a 100644 --- a/examples/svelte-tailwind/src/examples/spread-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/spread-example-content.svelte @@ -4,7 +4,9 @@ import { RenderLayer } from '@embedpdf/plugin-render/svelte'; import { useSpread, SpreadMode } from '@embedpdf/plugin-spread/svelte'; - const spread = useSpread(); + let { documentId }: { documentId: string } = $props(); + + const spread = useSpread(() => documentId); const modes = [ { label: 'Single Page', value: SpreadMode.None }, @@ -13,9 +15,9 @@ ]; -{#snippet RenderPageSnippet(page: RenderPageProps)} +{#snippet renderPage(page: RenderPageProps)}
- +
{/snippet} @@ -46,6 +48,7 @@ {/if}
- +
diff --git a/examples/svelte-tailwind/src/examples/spread-example.svelte b/examples/svelte-tailwind/src/examples/spread-example.svelte index c2827698..cd527fd7 100644 --- a/examples/svelte-tailwind/src/examples/spread-example.svelte +++ b/examples/svelte-tailwind/src/examples/spread-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/examples/thumbnail-example-content.svelte b/examples/svelte-tailwind/src/examples/thumbnail-example-content.svelte index 2bff194c..b10c520c 100644 --- a/examples/svelte-tailwind/src/examples/thumbnail-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/thumbnail-example-content.svelte @@ -4,12 +4,14 @@ import { RenderLayer } from '@embedpdf/plugin-render/svelte'; import { ThumbnailsPane, ThumbImg, type ThumbMeta } from '@embedpdf/plugin-thumbnail/svelte'; - const scroll = useScroll(); + let { documentId }: { documentId: string } = $props(); + + const scroll = useScroll(() => documentId); -{#snippet RenderPageSnippet(page: RenderPageProps)} +{#snippet renderPage(page: RenderPageProps)}
- +
{/snippet} @@ -23,7 +25,7 @@ border-right: 1px solid #dee2e6; " > - + {#snippet children(meta: ThumbMeta)}
- +
- +
diff --git a/examples/svelte-tailwind/src/examples/thumbnail-example.svelte b/examples/svelte-tailwind/src/examples/thumbnail-example.svelte index 0c76512e..770920b3 100644 --- a/examples/svelte-tailwind/src/examples/thumbnail-example.svelte +++ b/examples/svelte-tailwind/src/examples/thumbnail-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/examples/tiling-example-content.svelte b/examples/svelte-tailwind/src/examples/tiling-example-content.svelte index 5621e786..5338e51a 100644 --- a/examples/svelte-tailwind/src/examples/tiling-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/tiling-example-content.svelte @@ -5,13 +5,15 @@ import { useZoom } from '@embedpdf/plugin-zoom/svelte'; import { TilingLayer } from '@embedpdf/plugin-tiling/svelte'; - const zoom = useZoom(); + let { documentId }: { documentId: string } = $props(); + + const zoom = useZoom(() => documentId); -{#snippet RenderPageSnippet(page: RenderPageProps)} +{#snippet renderPage(page: RenderPageProps)}
- - + +
{/snippet} @@ -56,8 +58,8 @@ {/if}
- - + +
diff --git a/examples/svelte-tailwind/src/examples/tiling-example.svelte b/examples/svelte-tailwind/src/examples/tiling-example.svelte index fccb76c3..7f5bcff3 100644 --- a/examples/svelte-tailwind/src/examples/tiling-example.svelte +++ b/examples/svelte-tailwind/src/examples/tiling-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/examples/viewport-example-content.svelte b/examples/svelte-tailwind/src/examples/viewport-example-content.svelte index 5697ab69..e1a9d055 100644 --- a/examples/svelte-tailwind/src/examples/viewport-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/viewport-example-content.svelte @@ -7,17 +7,20 @@ import { Scroller, type RenderPageProps } from '@embedpdf/plugin-scroll/svelte'; import { RenderLayer } from '@embedpdf/plugin-render/svelte'; + let { documentId }: { documentId: string } = $props(); + const viewportCapability = useViewportCapability(); - const scrollActivity = useViewportScrollActivity(); + const viewport = $derived(viewportCapability.provides?.forDocument(documentId)); + const scrollActivity = useViewportScrollActivity(() => documentId); const scrollToTop = () => { - viewportCapability.provides?.scrollTo({ x: 0, y: 0, behavior: 'smooth' }); + viewport?.scrollTo({ x: 0, y: 0, behavior: 'smooth' }); }; const scrollToMiddle = () => { - if (!viewportCapability.provides) return; - const metrics = viewportCapability.provides?.getMetrics(); - viewportCapability.provides?.scrollTo({ + if (!viewport) return; + const metrics = viewport?.getMetrics(); + viewport?.scrollTo({ y: metrics.scrollHeight / 2, x: 0, behavior: 'smooth', @@ -26,15 +29,15 @@ }; const scrollToBottom = () => { - if (!viewportCapability.provides) return; - const metrics = viewportCapability.provides?.getMetrics(); - viewportCapability.provides?.scrollTo({ y: metrics.scrollHeight, x: 0, behavior: 'smooth' }); + if (!viewport) return; + const metrics = viewport?.getMetrics(); + viewport?.scrollTo({ y: metrics.scrollHeight, x: 0, behavior: 'smooth' }); }; -{#snippet RenderPageSnippet(page: RenderPageProps)} +{#snippet renderPage(page: RenderPageProps)}
- +
{/snippet} @@ -46,21 +49,21 @@
- - + +
diff --git a/examples/svelte-tailwind/src/examples/viewport-example.svelte b/examples/svelte-tailwind/src/examples/viewport-example.svelte index 19738497..11fc99c4 100644 --- a/examples/svelte-tailwind/src/examples/viewport-example.svelte +++ b/examples/svelte-tailwind/src/examples/viewport-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/examples/zoom-example-content.svelte b/examples/svelte-tailwind/src/examples/zoom-example-content.svelte index 05d20684..4bfed14a 100644 --- a/examples/svelte-tailwind/src/examples/zoom-example-content.svelte +++ b/examples/svelte-tailwind/src/examples/zoom-example-content.svelte @@ -6,20 +6,16 @@ import { PagePointerProvider } from '@embedpdf/plugin-interaction-manager/svelte'; import { TilingLayer } from '@embedpdf/plugin-tiling/svelte'; - const zoom = useZoom(); + let { documentId }: { documentId: string } = $props(); + + const zoom = useZoom(() => documentId); -{#snippet RenderPageSnippet(page: RenderPageProps)} - - - - +{#snippet renderPage(page: RenderPageProps)} + + + + {/snippet} @@ -114,6 +110,7 @@
- +
diff --git a/examples/svelte-tailwind/src/examples/zoom-example.svelte b/examples/svelte-tailwind/src/examples/zoom-example.svelte index d5252331..79eb7ccf 100644 --- a/examples/svelte-tailwind/src/examples/zoom-example.svelte +++ b/examples/svelte-tailwind/src/examples/zoom-example.svelte @@ -1,8 +1,13 @@ {#if pdfEngine.isLoading || !pdfEngine.engine}
Loading PDF Engine...
{:else} - - + + + {#snippet children(context)} + {#if context.activeDocumentId} + {@const documentId = context.activeDocumentId} + + {#snippet children(documentContent)} + {#if documentContent.isLoaded} + + {/if} + {/snippet} + + {/if} + {/snippet} + {/if} diff --git a/examples/svelte-tailwind/src/lib/assets/favicon.svg b/examples/svelte-tailwind/src/lib/assets/favicon.svg index cc5dc66a..86f17902 100644 --- a/examples/svelte-tailwind/src/lib/assets/favicon.svg +++ b/examples/svelte-tailwind/src/lib/assets/favicon.svg @@ -1 +1,15 @@ -svelte-logo \ No newline at end of file + + Group + + + + + + + + + + + \ No newline at end of file diff --git a/examples/svelte-tailwind/src/lib/components/CaptureDialog.svelte b/examples/svelte-tailwind/src/lib/components/CaptureDialog.svelte new file mode 100644 index 00000000..cf5430da --- /dev/null +++ b/examples/svelte-tailwind/src/lib/components/CaptureDialog.svelte @@ -0,0 +1,99 @@ + + + + +
+ {#if previewUrl} + Captured PDF area + {/if} +
+
+ + + + +
+ + + diff --git a/examples/svelte-tailwind/src/lib/components/DocumentMenu.svelte b/examples/svelte-tailwind/src/lib/components/DocumentMenu.svelte new file mode 100644 index 00000000..1484fba5 --- /dev/null +++ b/examples/svelte-tailwind/src/lib/components/DocumentMenu.svelte @@ -0,0 +1,108 @@ + + +{#if exportPlugin.provides} +
+ (isMenuOpen = !isMenuOpen)} + isActive={isMenuOpen} + aria-label="Document Menu" + title="Document Menu" + > + + + + (isMenuOpen = false)} className="w-48"> + + {#snippet icon()} + + {/snippet} + Capture Area + + + {#snippet icon()} + + {/snippet} + Print + + + {#snippet icon()} + + {/snippet} + Download + + + {#snippet icon()} + {#if fullscreenPlugin.state.isFullscreen} + + {:else} + + {/if} + {/snippet} + {fullscreenPlugin.state.isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'} + + +
+ + + (isPrintDialogOpen = false)} + /> + + + +{/if} diff --git a/examples/svelte-tailwind/src/lib/components/DocumentPasswordPrompt.svelte b/examples/svelte-tailwind/src/lib/components/DocumentPasswordPrompt.svelte new file mode 100644 index 00000000..c0ab7230 --- /dev/null +++ b/examples/svelte-tailwind/src/lib/components/DocumentPasswordPrompt.svelte @@ -0,0 +1,60 @@ + + +
+
+

Document Password Required

+

+ This document is password protected. Please enter the password to continue. +

+ +
+
+ + +
+ + {#if error} +
+ {error} +
+ {/if} + + +
+
+
diff --git a/examples/svelte-tailwind/src/lib/components/EmptyState.svelte b/examples/svelte-tailwind/src/lib/components/EmptyState.svelte new file mode 100644 index 00000000..238abffd --- /dev/null +++ b/examples/svelte-tailwind/src/lib/components/EmptyState.svelte @@ -0,0 +1,67 @@ + + +
+
+
+
+ + + + +
+
+

No Documents Open

+

+ Get started by opening a PDF document. You can view multiple documents at once using tabs and + split views. +

+ +
Supported format: PDF
+
+
diff --git a/examples/svelte-tailwind/src/lib/components/Icons.svelte b/examples/svelte-tailwind/src/lib/components/Icons.svelte new file mode 100644 index 00000000..02fa7153 --- /dev/null +++ b/examples/svelte-tailwind/src/lib/components/Icons.svelte @@ -0,0 +1,26 @@ + + + + + + {#if title} + {title} + {/if} + {@render children?.()} + + + diff --git a/examples/svelte-tailwind/src/lib/components/LoadingSpinner.svelte b/examples/svelte-tailwind/src/lib/components/LoadingSpinner.svelte new file mode 100644 index 00000000..dd00e2e6 --- /dev/null +++ b/examples/svelte-tailwind/src/lib/components/LoadingSpinner.svelte @@ -0,0 +1,29 @@ + + +
+ + + + + {#if message} + {message} + {/if} +
diff --git a/examples/svelte-tailwind/src/lib/components/NavigationBar.svelte b/examples/svelte-tailwind/src/lib/components/NavigationBar.svelte new file mode 100644 index 00000000..09eeb107 --- /dev/null +++ b/examples/svelte-tailwind/src/lib/components/NavigationBar.svelte @@ -0,0 +1,14 @@ + + +
+
+ + ← Home + + | + PDF Viewer +
+
EmbedPDF Svelte Example
+
diff --git a/examples/svelte-tailwind/src/lib/components/PageControls.svelte b/examples/svelte-tailwind/src/lib/components/PageControls.svelte index 1bc9ab33..37246970 100644 --- a/examples/svelte-tailwind/src/lib/components/PageControls.svelte +++ b/examples/svelte-tailwind/src/lib/components/PageControls.svelte @@ -2,8 +2,14 @@ import { useViewportCapability } from '@embedpdf/plugin-viewport/svelte'; import { useScroll } from '@embedpdf/plugin-scroll/svelte'; + interface PageControlsProps { + documentId: string; + } + + let { documentId }: PageControlsProps = $props(); + const viewport = useViewportCapability(); - const scroll = useScroll(); + const scroll = useScroll(() => documentId); let isVisible = $state(false); let isHovering = $state(false); diff --git a/examples/svelte-tailwind/src/lib/components/PageSettings.svelte b/examples/svelte-tailwind/src/lib/components/PageSettings.svelte index a7a771f0..41a1b06c 100644 --- a/examples/svelte-tailwind/src/lib/components/PageSettings.svelte +++ b/examples/svelte-tailwind/src/lib/components/PageSettings.svelte @@ -1,226 +1,107 @@ -
- - - {#if isOpen} -
+ (isOpen = !isOpen)} + isActive={isOpen} + aria-label="Page Settings" + title="Page Settings" > - -
-
Page Orientation
- - -
+ +
- -
+ (isOpen = false)} className="w-56"> + + + {#snippet icon()} + + {/snippet} + Rotate Clockwise + + + {#snippet icon()} + + {/snippet} + Rotate Counter-clockwise + + - + -
-
Page Layout
- - - -
-
- {/if} -
+ {#snippet icon()} + + {/snippet} + Even Pages + + + + +{/if} diff --git a/examples/svelte-tailwind/src/lib/components/PanToggle.svelte b/examples/svelte-tailwind/src/lib/components/PanToggle.svelte new file mode 100644 index 00000000..97b23cce --- /dev/null +++ b/examples/svelte-tailwind/src/lib/components/PanToggle.svelte @@ -0,0 +1,24 @@ + + +{#if pan.provides} + + + +{/if} diff --git a/examples/svelte-tailwind/src/lib/components/PrintDialog.svelte b/examples/svelte-tailwind/src/lib/components/PrintDialog.svelte index ae5ecebe..efd7bb4a 100644 --- a/examples/svelte-tailwind/src/lib/components/PrintDialog.svelte +++ b/examples/svelte-tailwind/src/lib/components/PrintDialog.svelte @@ -1,46 +1,50 @@ -{#if open} - -