diff --git a/README.md b/README.md index 99f7514..585629b 100644 --- a/README.md +++ b/README.md @@ -4,62 +4,82 @@ React wrapper for pdfjs with multiple file support. This library uses [pdf.js][1]. -> What does in development mean: -> -> 1. **MINOR** versions represent **breaking changes** -> 1. **PATCH** versions represent **fixes _and_ features** -> 1. There are **no deprecation warnings** between releases - ## Installation & Usage -`yarn add react-pdfjs-multi` +`npm i react-pdfjs-multi` -or +### Example Usage -`npm i react-pdfjs-multi` +#### Minimal example -Example Usage MultiViewer: +If you only need the defaults, you can render the component with just the `pdfs` array and the bundled CSS. -```javascript +```tsx import React from 'react'; import { PdfMultiViewer } from 'react-pdfjs-multi'; +import workerSrc from 'react-pdfjs-multi/dist/pdf.worker.min.mjs?url'; import 'react-pdfjs-multi/dist/react-pdfjs-multi.css'; -const MultiViewerExample = () => { - const pdfFiles = [ - 'pdfs/test-pdf-a.pdf', - { - title: - 'Trace-based Just-in-Time Type Specialization for DynamicLanguages', - source: 'pdfs/compressed.tracemonkey-pldi-09.pdf', - }, - 'pdfs/test-pdf-b.pdf', - 'pdfs/test-pdf-c.pdf', - ]; - - return ( - - ); +const MinimalMultiViewer = () => ( + +); + +export default MinimalMultiViewer; +``` + +#### MultiViewer customization + +This example picks a non-default document via `initialLoadIndex`, and the `icons` prop overrides the visual assets via CSS variables. + +```tsx +import React from 'react'; +import { PdfMultiViewer, type IconConfig } from 'react-pdfjs-multi'; +import workerSrc from 'react-pdfjs-multi/dist/pdf.worker.min.mjs?url'; +import 'react-pdfjs-multi/dist/react-pdfjs-multi.css'; + +const pdfs = ['/pdfs/sample-a.pdf', '/pdfs/sample-b.pdf']; + +const icons: IconConfig = { + zoomIn: '/icons/zoom-in.svg', + zoomOut: '/icons/zoom-out.svg', + rotateLeft: '/icons/rotate-left.svg', + rotateRight: '/icons/rotate-right.svg', + download: '/icons/download.svg', + toggleList: '/icons/toggle-list.svg', + selectArrow: '/icons/select-arrow.svg', + texture: 'none', }; +const MultiViewerExample = () => ( + +); + export default MultiViewerExample; ``` -Example Usage Renderer (Typescript) +#### PdfRenderer integration + +When building your own viewer, assign the worker once and listen to the `pdfChangeHook` if you need to cache zoom/rotation/scroll positions manually. -```typescript -import React, { FC, useState, useEffect } from 'react'; -import { PdfRenderer, PdfjsLib, PDFDocumentProxy } from 'react-pdfjs-multi'; +```tsx +import React, { FC, useEffect, useState } from 'react'; +import { + PdfRenderer, + PdfjsLib, + type PDFDocumentProxy, + type RendererDocumentPosition, +} from 'react-pdfjs-multi'; import workerSrc from 'react-pdfjs-multi/dist/pdf.worker.min.mjs?url'; PdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc; @@ -67,21 +87,25 @@ PdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc; const RendererExample: FC = () => { const [pdfDoc, setPdfDoc] = useState(); + const logPosition = (index: string, position: RendererDocumentPosition) => { + console.log('document position', index, position); + }; + useEffect(() => { - const getPdfDoc = async () => { + const load = async () => { const doc = await PdfjsLib.getDocument({ - url: 'pdfs/compressed.tracemonkey-pldi-09.pdf', + url: '/pdfs/sample-a.pdf', }).promise; setPdfDoc(doc); }; - getPdfDoc(); + load(); }, []); if (!pdfDoc) return null; - return ; + return ; }; export default RendererExample; @@ -91,14 +115,15 @@ export default RendererExample; A minimal workspace example lives at `apps/example`. +- `apps/example/src/examples/DefaultExample.tsx` +- `apps/example/src/examples/ContrastExample.tsx` + `ContrastExample.css` + ```bash pnpm install pnpm start pnpm dev:example ``` -Run `pnpm start` and `pnpm dev:example` in separate terminals during development. - ### Styling This library ships with optional default styles in @@ -119,6 +144,70 @@ Example override: } ``` +#### Theming recipe (example) + +Here is a compact “high-contrast” pattern similar to the example app. It uses +icon overrides, CSS variables, and a scoped theme class: + +```tsx +import React from 'react'; +import type { IconConfig } from 'react-pdfjs-multi'; +import { PdfMultiViewer } from 'react-pdfjs-multi'; + +const icons: IconConfig = { + zoomIn: '/icons/zoom-in.svg', + zoomOut: '/icons/zoom-out.svg', + rotateLeft: '/icons/rotate-left.svg', + rotateRight: '/icons/rotate-right.svg', + download: '/icons/download.svg', + toggleList: '/icons/toggle-list.svg', + selectArrow: '/icons/select-arrow.svg', +}; + +const viewerStyle = { + '--pdfjs-multi-bg': '#0a0f1c', + '--pdfjs-multi-texture': 'none', + '--pdfjs-multi-text': '#e2e8f0', + '--pdfjs-multi-surface': 'rgba(15, 23, 42, 0.9)', + '--pdfjs-multi-surface-active': 'rgba(30, 41, 59, 0.92)', + '--pdfjs-multi-muted-text': '#94a3b8', +} as const; + +const rendererStyle = { + '--pdfjs-multi-controls-bg': '#0f172a', + '--pdfjs-multi-controls-text': '#f8fafc', + '--pdfjs-multi-control-icon-size': '18px', + '--pdfjs-multi-select-text': '#f8fafc', +} as const; + +const HighContrastViewer = () => ( +
+ +
+); +``` + +```css +.theme-contrast .renderer-controls { + /* renderer-controls defines its own icon vars, so inherit in themed skins */ + --pdfjs-multi-control-icon-zoom-out: inherit; + --pdfjs-multi-control-icon-zoom-in: inherit; + --pdfjs-multi-control-icon-rotate-left: inherit; + --pdfjs-multi-control-icon-rotate-right: inherit; + --pdfjs-multi-control-icon-download: inherit; +} + +.theme-contrast .dropdown-toolbar-container { + --pdfjs-multi-select-icon: inherit; +} +``` + Available variables (non-exhaustive): | Scope | Variable | Default | Purpose | @@ -166,24 +255,35 @@ const icons: IconConfig = { }; ``` +If you want the same icon set applied to the renderer controls, pass +`rendererIcons` to `PdfMultiViewer` (or use `icons` directly on `PdfRenderer`). + ### PdfMultiViewer -The MultiViewer allows you to pass an array of source strings or an object definition and it lazy-loads PDFs by default, showing the renderer as soon as the active PDF is loaded. +The MultiViewer allows you to pass an array of source strings or objects (optionally with a custom `title`) and it lazy-loads PDFs by default, showing the renderer as soon as the active PDF is loaded. +The list is guarded by the toggle in the option bar; on widths between ~330px and ~667px it switches into an overlay so the renderer keeps the extra space, and selecting a document when the list is overlaid closes it automatically. The viewer also records zoom, rotation, and scroll positions per document via the embedded `pdfChangeHook`, so revisiting a PDF restores the position you left it in. Props: | Name | Required | Default | Type | Description | | --------------- | -------- | ------- | --------------- | -------------------------------------------------------------- | -| pdfs | true | | {array} | An array of strings or objects | +| pdfs | true | | {array} | Strings or `{ source, title? }` objects for each PDF | | autoZoom | | true | {boolean} | enables/disables autoZoom on component mount and window resize | +| className | | | {string} | Adds a class to the outer viewer container | | controls | | true | {boolean} | enables/disables controls to e.g. change renderer zoom | | icons | | | {IconConfig} | Override default icons and texture | +| rendererIcons | | | {IconConfig} | Override renderer control icons | +| rendererClassName | | | {string} | Adds a class to the renderer container | +| rendererStyle | | | {CSSProperties} | Inline styles for the renderer container | | i18nData | | {}\* | {I18nData} | An object of translated strings, default language is en | | startIndex | | 0 | {number} | first pdf to display using array index | | lazyLoad | | true | {boolean} | load only the active PDF until a list item is selected | | initialLoadIndex| | startIndex | {number|string} | which PDF to prefetch on mount when lazyLoad is enabled | +| style | | | {CSSProperties} | Inline styles for the outer viewer container | | workerSrc | | | {string} | pdf.js worker URL | +When `lazyLoad` is enabled (the default), you can use `initialLoadIndex` to prime a different PDF than the one shown first, and `PdfMultiViewer` already passes its own `pdfChangeHook` to `PdfRenderer` so zoom/rotation/scroll state is cached automatically; you only need to provide your own hook if you want that data for something else. + ### i18n To be able to use different i18n libraries eg. i18next or react-intl you can pass an i18n object with translated strings to the component. @@ -216,10 +316,12 @@ Props: | ------------- | -------- | ------- | ------------------ | -------------------------------------------------------------- | | pdfDoc | true | | {PDFDocumentProxy} | A proxy of the pdf document to display | | autoZoom | | true | {boolean} | enables/disables autoZoom on component mount and window resize | +| className | | | {string} | Adds a class to the renderer container | | controls | | true | {boolean} | enables/disables controls to e.g. change renderer zoom | | downloadBtn | | true | {boolean} | enables/disables download button | | icons | | | {IconConfig} | Override default icons and texture | | i18nData | | {}\* | {I18nDataRenderer} | An object of translated strings, default language is en | +| style | | | {CSSProperties} | Inline styles for the renderer container | | zoom | | null | {number} | Initial Zoom | | rotation | | null | {number} | Initial Rotation | | scrollTop | | null | {number} | Initial ScrollTop | diff --git a/apps/example/package.json b/apps/example/package.json index fefd2bc..06c1d3f 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "lucide-react": "^0.563.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-pdfjs-multi": "workspace:*" diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 627b344..e0280dc 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -1,21 +1,67 @@ -import { PdfMultiViewer } from 'react-pdfjs-multi'; +import { useState } from 'react'; import workerSrc from 'react-pdfjs-multi/dist/pdf.worker.min.mjs?url'; +import { ContrastExample, DefaultExample } from './examples'; const baseUrl = import.meta.env.BASE_URL ?? '/'; -const pdfs = [`${baseUrl}pdfs/sample-a.pdf`, `${baseUrl}pdfs/sample-b.pdf`]; +const pdfs = [ + { title: 'Sample PDF A', source: `${baseUrl}pdfs/sample-a.pdf` }, + `${baseUrl}pdfs/sample-b.pdf`, +]; -const App = () => ( -
-
-
-

react-pdfjs-multi

-

Example Multi-Viewer (Vite App)

-
-
-
- -
-
-); +const examples = [ + { + id: 'default', + label: 'Default', + description: 'Baseline styling with the stock texture and palette.', + Component: DefaultExample, + }, + { + id: 'contrast', + label: 'Custom Styles', + description: 'Customized example of the Multiviewer', + Component: ContrastExample, + }, +]; + +const App = () => { + const [activeExample, setActiveExample] = useState(examples[0].id); + const selectedExample = + examples.find((example) => example.id === activeExample) ?? examples[0]; + const ExampleComponent = selectedExample.Component; + + return ( +
+
+
+

react-pdfjs-multi

+

Example Multi-Viewer (Vite App)

+
+
+ {examples.map((example) => ( + + ))} +
+
+
+ {selectedExample.label} + + {selectedExample.description} + +
+ +
+ ); +}; export default App; diff --git a/apps/example/src/examples/ContrastExample.css b/apps/example/src/examples/ContrastExample.css new file mode 100644 index 0000000..44121ec --- /dev/null +++ b/apps/example/src/examples/ContrastExample.css @@ -0,0 +1,276 @@ +.viewer-shell.theme-contrast { + border-radius: 30px; + background: + radial-gradient( + circle at top right, + rgba(34, 211, 238, 0.18), + transparent 55% + ), + radial-gradient( + circle at bottom left, + rgba(59, 130, 246, 0.18), + transparent 60% + ), + linear-gradient(160deg, #020617 0%, #0f172a 55%, #111827 100%); + padding: 12px; + box-shadow: + 0 40px 90px rgba(2, 6, 23, 0.6), + inset 0 0 0 1px rgba(148, 163, 184, 0.15); + position: relative; + overflow: hidden; +} + +.viewer-shell.theme-contrast::after { + content: ""; + position: absolute; + inset: -40% 55% 40% -30%; + background: radial-gradient( + circle, + rgba(94, 234, 212, 0.18), + transparent 70% + ); + pointer-events: none; +} + +.viewer-shell.theme-contrast .pdf-multi-viewer { + border-radius: 24px; + background: linear-gradient(160deg, #0b1120, #111827); + box-shadow: + 0 22px 60px rgba(2, 6, 23, 0.55), + inset 0 0 0 1px rgba(148, 163, 184, 0.12); +} + +.viewer-shell.theme-contrast .pdf-multi-viewer-option-bar { + display: flex; + align-items: center; + gap: 10px; + position: relative; + z-index: 4; +} + +.viewer-shell.theme-contrast .viewer-controls-button { + width: 40px; + height: 32px; + border-radius: 12px; + border: 1px solid rgba(94, 234, 212, 0.35); + background: rgba(15, 23, 42, 0.9); + box-shadow: + 0 8px 18px rgba(2, 6, 23, 0.5), + inset 0 0 0 1px rgba(94, 234, 212, 0.2); +} + +.viewer-shell.theme-contrast .viewer-controls-button .toggle-list-label:before { + background-size: 18px 18px; +} + +.viewer-shell.theme-contrast .viewer-controls-button:hover, +.viewer-shell.theme-contrast .viewer-controls-button:focus { + background: rgba(15, 23, 42, 0.98); + box-shadow: + 0 12px 24px rgba(2, 6, 23, 0.6), + 0 0 0 1px rgba(94, 234, 212, 0.45); +} + +.viewer-shell.theme-contrast .pdf-viewer-list { + border-right: 1px solid rgba(148, 163, 184, 0.2); + padding: 12px; + background: linear-gradient(180deg, #0b1120, #0f172a); + list-style: none; + margin: 0; + padding-left: 12px; +} + +.viewer-shell.theme-contrast .pdf-viewer-list.hidden { + margin-left: calc(-1 * var(--pdfjs-multi-list-width)); +} + +.viewer-shell.theme-contrast .pdf-viewer-list.hidden.overlay { + margin-left: -100%; +} + +.viewer-shell.theme-contrast .pdf-viewer-list-item { + border-radius: 14px; + margin-bottom: 12px; + border: 1px solid rgba(94, 234, 212, 0.15); + background: rgba(15, 23, 42, 0.9); + box-shadow: + 0 14px 26px rgba(2, 6, 23, 0.5), + inset 0 0 0 1px rgba(148, 163, 184, 0.1); + transition: + transform 180ms ease, + box-shadow 180ms ease, + border-color 180ms ease; + list-style: none; +} + +.viewer-shell.theme-contrast .pdf-viewer-list-item.loaded:hover { + transform: translateY(-2px); + border-color: rgba(94, 234, 212, 0.6); + box-shadow: + 0 18px 30px rgba(2, 6, 23, 0.6), + 0 0 0 1px rgba(94, 234, 212, 0.25); +} + +.viewer-shell.theme-contrast .pdf-viewer-list-item.active { + border-color: rgba(94, 234, 212, 0.8); + box-shadow: + 0 22px 34px rgba(2, 6, 23, 0.68), + 0 0 0 1px rgba(94, 234, 212, 0.4); +} + +.viewer-shell.theme-contrast .pdf-viewer-list-item-meta { + color: #7dd3fc; +} + +.viewer-shell.theme-contrast .pdf-viewer-multi-renderer { + background: linear-gradient(180deg, #0a0f1c, #0b1220); +} + +.viewer-shell.theme-contrast .pdf-viewer-loading { + color: #94a3b8; +} + +.viewer-shell.theme-contrast .pdf-viewer-loading-spinner { + border-color: rgba(94, 234, 212, 0.3); + border-top-color: #22d3ee; +} + +.viewer-shell.theme-contrast .renderer-controls { + --pdfjs-multi-control-icon-zoom-in: inherit; + --pdfjs-multi-control-icon-zoom-out: inherit; + --pdfjs-multi-control-icon-rotate-left: inherit; + --pdfjs-multi-control-icon-rotate-right: inherit; + --pdfjs-multi-control-icon-download: inherit; + --pdfjs-multi-controls-height: 46px; + background-image: linear-gradient(90deg, #0b1120, #111827); + border-bottom: 1px solid rgba(94, 234, 212, 0.25); + box-shadow: + 0 12px 28px rgba(2, 6, 23, 0.7), + inset 0 -1px 0 rgba(148, 163, 184, 0.14); + gap: 14px; + padding: 0 8px; +} + +.viewer-shell.theme-contrast .renderer-controls .button-group { + padding: 0; + height: 32px; + align-items: center; + border-radius: 999px; + background: rgba(15, 23, 42, 0.92); + box-shadow: + inset 0 0 0 1px rgba(94, 234, 212, 0.18), + 0 6px 12px rgba(2, 6, 23, 0.45); +} + +.viewer-shell.theme-contrast .renderer-controls .split-button-seperator { + background-color: rgba(94, 234, 212, 0.35); + box-shadow: none; + height: 18px; + margin: 0 6px; + padding: 0; +} + +.viewer-shell.theme-contrast .renderer-controls .renderer-controls-button { + width: 36px; + height: 32px; + border-radius: 999px; + border: 1px solid rgba(15, 23, 42, 0.4); + color: #f8fafc; +} + +.viewer-shell.theme-contrast + .renderer-controls + .button-group:hover + .renderer-controls-button { + border-color: rgba(94, 234, 212, 0.55); + background: rgba(15, 23, 42, 0.75); + box-shadow: + 0 8px 16px rgba(2, 6, 23, 0.5), + inset 0 0 0 1px rgba(94, 234, 212, 0.2); +} + +.viewer-shell.theme-contrast .renderer-controls .dropdown-toolbar-container { + --pdfjs-multi-select-icon: inherit; + height: 32px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.9); + box-shadow: + inset 0 0 0 1px rgba(94, 234, 212, 0.18), + 0 6px 12px rgba(2, 6, 23, 0.45); +} + +.viewer-shell.theme-contrast .renderer-controls .dropdown-toolbar { + width: 190px; + padding: 0 28px 0 12px; + background-position: 92%; +} + +.viewer-shell.theme-contrast + .renderer-controls + .dropdown-toolbar-container:hover { + box-shadow: + inset 0 0 0 1px rgba(94, 234, 212, 0.35), + 0 8px 16px rgba(2, 6, 23, 0.55); +} + +.viewer-shell.theme-contrast + .renderer-controls + .renderer-controls-button:focus { + outline: 2px solid rgba(56, 189, 248, 0.6); + outline-offset: 1px; +} + +.viewer-shell.theme-contrast .renderer-controls select { + color: #e2e8f0; + font-weight: 600; +} + +@media (max-width: 700px) { + .viewer-shell.theme-contrast { + padding: 8px; + border-radius: 22px; + } + + .viewer-shell.theme-contrast .pdf-multi-viewer { + border-radius: 18px; + } + + .viewer-shell.theme-contrast .pdf-viewer-list.overlay { + left: 8px; + right: 8px; + width: calc(100% - 16px); + min-width: 0; + max-width: none; + top: calc(var(--pdfjs-multi-option-bar-height) + 8px); + bottom: 8px; + height: auto; + max-height: none; + border-radius: 16px; + transform: translateX(0); + box-shadow: + 0 18px 30px rgba(2, 6, 23, 0.6), + inset 0 0 0 1px rgba(148, 163, 184, 0.12); + } + + .viewer-shell.theme-contrast .pdf-viewer-list.overlay.hidden { + margin-left: 0; + transform: translateX(-110%); + } + + .viewer-shell.theme-contrast .pdf-viewer-list { + padding: 10px; + } + + .viewer-shell.theme-contrast .pdf-viewer-list-item { + margin-bottom: 10px; + } + + .viewer-shell.theme-contrast .renderer-controls { + gap: 10px; + padding: 0 6px; + } + + .viewer-shell.theme-contrast .renderer-controls .dropdown-toolbar { + width: 160px; + } +} diff --git a/apps/example/src/examples/ContrastExample.tsx b/apps/example/src/examples/ContrastExample.tsx new file mode 100644 index 0000000..ac17a3b --- /dev/null +++ b/apps/example/src/examples/ContrastExample.tsx @@ -0,0 +1,107 @@ +import { + ChevronDown, + Download, + PanelLeft, + RotateCcw, + RotateCw, + ZoomIn, + ZoomOut, +} from 'lucide-react'; +import { createElement, useMemo } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { PdfMultiViewer, type PdfSource } from 'react-pdfjs-multi'; +import './ContrastExample.css'; + +type Props = { + pdfs: PdfSource[]; + workerSrc: string; +}; + +const ContrastExample = ({ pdfs, workerSrc }: Props) => { + const contrastIcons = useMemo(() => { + const toDataUrl = ( + Icon: typeof ZoomIn, + color: string, + size = 18, + strokeWidth = 2, + ) => { + const svg = renderToStaticMarkup( + createElement(Icon, { + color, + size, + strokeWidth, + absoluteStrokeWidth: true, + }), + ); + const encoded = encodeURIComponent(svg) + .replace(/'/g, '%27') + .replace(/"/g, '%22'); + return `data:image/svg+xml,${encoded}`; + }; + + const iconColor = '#f8fafc'; + const accentColor = '#38bdf8'; + const accentAlt = '#22d3ee'; + + return { + zoomIn: toDataUrl(ZoomIn, accentColor), + zoomOut: toDataUrl(ZoomOut, accentColor), + rotateRight: toDataUrl(RotateCw, accentAlt), + rotateLeft: toDataUrl(RotateCcw, accentAlt), + download: toDataUrl(Download, accentAlt), + toggleList: toDataUrl(PanelLeft, accentAlt), + selectArrow: toDataUrl(ChevronDown, iconColor, 20, 2.2), + }; + }, []); + + const viewerStyle = useMemo( + () => + ({ + '--pdfjs-multi-bg': '#0a0f1c', + '--pdfjs-multi-texture': 'none', + '--pdfjs-multi-text': '#e2e8f0', + '--pdfjs-multi-surface': 'rgba(15, 23, 42, 0.9)', + '--pdfjs-multi-surface-active': 'rgba(30, 41, 59, 0.92)', + '--pdfjs-multi-muted-text': '#94a3b8', + '--pdfjs-multi-divider': 'rgba(148, 163, 184, 0.18)', + '--pdfjs-multi-list-width': '300px', + '--pdfjs-multi-list-item-padding': '20px 24px', + '--pdfjs-multi-option-bar-height': '46px', + '--pdfjs-multi-option-bar-padding': '10px 14px', + '--pdfjs-multi-toolbar-button-color': '#e2e8f0', + }) as const, + [], + ); + + const rendererStyle = useMemo( + () => + ({ + '--pdfjs-multi-bg': '#0a0f1c', + '--pdfjs-multi-texture': 'none', + '--pdfjs-multi-controls-bg': '#0f172a', + '--pdfjs-multi-controls-text': '#f8fafc', + '--pdfjs-multi-controls-shadow': '0 12px 24px rgba(15, 23, 42, 0.45)', + '--pdfjs-multi-control-icon-size': '18px', + '--pdfjs-multi-select-text': '#f8fafc', + '--pdfjs-multi-select-bg': 'rgba(15, 23, 42, 0.9)', + '--pdfjs-multi-select-option-bg': '#0b1120', + '--pdfjs-multi-select-shadow': '0 8px 16px rgba(15, 23, 42, 0.4)', + }) as const, + [], + ); + + return ( +
+ +
+ ); +}; + +export default ContrastExample; diff --git a/apps/example/src/examples/DefaultExample.tsx b/apps/example/src/examples/DefaultExample.tsx new file mode 100644 index 0000000..462176e --- /dev/null +++ b/apps/example/src/examples/DefaultExample.tsx @@ -0,0 +1,14 @@ +import { PdfMultiViewer, type PdfSource } from 'react-pdfjs-multi'; + +type Props = { + pdfs: PdfSource[]; + workerSrc: string; +}; + +const DefaultExample = ({ pdfs, workerSrc }: Props) => ( +
+ +
+); + +export default DefaultExample; diff --git a/apps/example/src/examples/index.ts b/apps/example/src/examples/index.ts new file mode 100644 index 0000000..6794e61 --- /dev/null +++ b/apps/example/src/examples/index.ts @@ -0,0 +1,2 @@ +export { default as ContrastExample } from './ContrastExample'; +export { default as DefaultExample } from './DefaultExample'; diff --git a/apps/example/src/main.css b/apps/example/src/main.css index 1f84a2d..8ae9945 100644 --- a/apps/example/src/main.css +++ b/apps/example/src/main.css @@ -32,6 +32,8 @@ body { display: flex; justify-content: space-between; align-items: center; + gap: 16px; + flex-wrap: wrap; } .app-header h1 { @@ -52,6 +54,65 @@ body { box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18); } +.example-switcher { + display: inline-flex; + border-radius: 999px; + background: rgba(15, 23, 42, 0.08); + padding: 4px; + gap: 6px; + box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.12); +} + +.example-switcher-button { + border: 0; + background: transparent; + font: inherit; + font-size: 13px; + letter-spacing: 0.02em; + padding: 6px 16px; + border-radius: 999px; + cursor: pointer; + color: #475569; + transition: + background 150ms ease, + color 150ms ease, + transform 150ms ease; +} + +.example-switcher-button:hover, +.example-switcher-button:focus-visible { + background: rgba(15, 23, 42, 0.12); + color: #0f172a; + outline: none; +} + +.example-switcher-button.active { + background: #0f172a; + color: #f8fafc; + transform: translateY(-1px); +} + +.example-meta { + display: grid; + gap: 6px; + background: linear-gradient(135deg, #ffffff, #f8fafc); + border-radius: 18px; + padding: 16px 20px; + box-shadow: + 0 10px 24px rgba(15, 23, 42, 0.08), + inset 0 0 0 1px rgba(148, 163, 184, 0.2); +} + +.example-meta-title { + font-weight: 600; + letter-spacing: -0.01em; +} + +.example-meta-description { + color: #64748b; + font-size: 14px; +} + @media (max-width: 900px) { .app { padding: 16px; @@ -60,4 +121,13 @@ body { .viewer-shell { min-height: 360px; } + + .example-switcher { + width: 100%; + justify-content: center; + } + + .example-meta { + border-radius: 12px; + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9eedcf1..527756c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: apps/example: dependencies: + lucide-react: + specifier: ^0.563.0 + version: 0.563.0(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -1411,6 +1414,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@0.563.0: + resolution: {integrity: sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -3085,6 +3093,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@0.563.0(react@18.3.1): + dependencies: + react: 18.3.1 + lz-string@1.5.0: {} magic-string@0.30.21: diff --git a/src/components/PdfMultiViewer/PdfMultiViewer.tsx b/src/components/PdfMultiViewer/PdfMultiViewer.tsx index 5f30db1..aa9be7c 100644 --- a/src/components/PdfMultiViewer/PdfMultiViewer.tsx +++ b/src/components/PdfMultiViewer/PdfMultiViewer.tsx @@ -1,3 +1,5 @@ +import type { CSSProperties } from 'react'; +import { useMemo } from 'react'; import '@/components/PdfMultiViewer/PdfMultiViewer.css'; import PdfMultiViewerView from '@/components/PdfMultiViewer/PdfMultiViewerView'; import { @@ -10,10 +12,15 @@ import type { IconConfig } from '@/types/iconConfig'; type DefaultProps = { autoZoom?: boolean; + className?: string; controls?: boolean; icons?: IconConfig; lazyLoad?: boolean; initialLoadIndex?: string | number; + rendererIcons?: IconConfig; + rendererClassName?: string; + rendererStyle?: CSSProperties; + style?: CSSProperties; startIndex?: string; i18nData?: I18nData; workerSrc?: string; @@ -23,15 +30,22 @@ type Props = { pdfs: PdfSource[]; } & Partial; +const DEFAULT_I18N_DATA: I18nData = { loading: 'Loading...', pages: 'Pages' }; + const PdfMultiViewer = ({ pdfs, autoZoom = true, + className, controls = true, icons, lazyLoad = true, initialLoadIndex, + rendererIcons, + rendererClassName, + rendererStyle, + style, startIndex = '0', - i18nData = { loading: 'Loading...', pages: 'Pages' }, + i18nData = DEFAULT_I18N_DATA, workerSrc, }: Props) => { const { @@ -53,15 +67,20 @@ const PdfMultiViewer = ({ workerSrc, }); - const iconStyles = buildIconStyles(icons); + const iconStyles = useMemo(() => buildIconStyles(icons), [icons]); + const mergedStyles = useMemo(() => { + if (!iconStyles && !style) return undefined; + return { ...(iconStyles ?? {}), ...(style ?? {}) }; + }, [iconStyles, style]); return ( ); diff --git a/src/components/PdfMultiViewer/PdfMultiViewerView.tsx b/src/components/PdfMultiViewer/PdfMultiViewerView.tsx index d6ed914..3546df3 100644 --- a/src/components/PdfMultiViewer/PdfMultiViewerView.tsx +++ b/src/components/PdfMultiViewer/PdfMultiViewerView.tsx @@ -1,15 +1,18 @@ import type { PDFDocumentProxy } from 'pdfjs-dist'; import type { CSSProperties, RefObject } from 'react'; +import { memo, useMemo } from 'react'; import type { I18nData, PdfFile, } from '@/components/PdfMultiViewer/usePdfMultiViewer'; import PdfRenderer from '@/components/PdfRenderer/PdfRenderer'; import type { RendererDocumentPosition } from '@/components/PdfRenderer/usePdfRenderer'; +import type { IconConfig } from '@/types/iconConfig'; type Props = { activeIndex: string; autoZoom: boolean; + className?: string; controls: boolean; files: PdfFile[]; iconStyles?: CSSProperties; @@ -17,6 +20,9 @@ type Props = { listVisible: boolean; overlayMode: boolean; pdfToShow?: PdfFile; + rendererIcons?: IconConfig; + rendererClassName?: string; + rendererStyle?: CSSProperties; viewerContainerRef: RefObject; onRememberPosition: ( index: string, @@ -26,9 +32,95 @@ type Props = { onToggleList: () => void; }; +type ListProps = { + activeIndex: string; + files: PdfFile[]; + i18nData: I18nData; + listVisible: boolean; + overlayMode: boolean; + onSelectPdf: (nextIndex: string, file: PdfFile) => () => void; +}; + +type ListItemProps = { + file: PdfFile; + i18nData: I18nData; + index: number; + isActive: boolean; + onSelectPdf: (nextIndex: string, file: PdfFile) => () => void; +}; + +const PdfMultiViewerListItem = memo( + ({ file, i18nData, index, isActive, onSelectPdf }: ListItemProps) => { + const handleSelect = useMemo( + () => onSelectPdf(String(index), file), + [file, index, onSelectPdf], + ); + const isLoading = Boolean(file.isLoading && !file.pdfProxy); + + return ( +
  • + +
  • + ); + }, +); + +PdfMultiViewerListItem.displayName = 'PdfMultiViewerListItem'; + +const PdfMultiViewerList = memo( + ({ + activeIndex, + files, + i18nData, + listVisible, + overlayMode, + onSelectPdf, + }: ListProps) => ( +
      + {files.map((file, index) => ( + + ))} +
    + ), +); + +PdfMultiViewerList.displayName = 'PdfMultiViewerList'; + const PdfMultiViewerView = ({ activeIndex, autoZoom, + className, controls, files, iconStyles, @@ -36,13 +128,16 @@ const PdfMultiViewerView = ({ listVisible, overlayMode, pdfToShow, + rendererIcons, + rendererClassName, + rendererStyle, viewerContainerRef, onRememberPosition, onSelectPdf, onToggleList, }: Props) => (
    @@ -56,49 +151,23 @@ const PdfMultiViewerView = ({
    -
      - {files.map((file, index) => { - const handleSelect = onSelectPdf(String(index), file); - const isLoading = Boolean(file.isLoading && !file.pdfProxy); - - return ( -
    • - -
    • - ); - })} -
    +
    {pdfToShow?.pdfProxy ? ( (null); const loadingIndicesRef = useRef>(new Set()); const initialLoadDoneRef = useRef(false); + const positionsRef = useRef>(new Map()); const [files, setFiles] = useState(() => { const mapped = pdfs.map(createPdfFile); @@ -67,6 +68,7 @@ export const usePdfMultiViewer = ({ return mapped; }); const [activeIndex, setActiveIndex] = useState(() => `${startIndex}`); + const [_pendingIndex, setPendingIndex] = useState(null); const [listVisible, setListVisible] = useState(true); const [overlayMode, setOverlayMode] = useState(false); @@ -131,6 +133,14 @@ export const usePdfMultiViewer = ({ }; }), ); + + setPendingIndex((current) => { + if (current === String(index)) { + setActiveIndex(current); + return null; + } + return current; + }); } catch (_error) { setFileLoading(index, false); } finally { @@ -142,9 +152,12 @@ export const usePdfMultiViewer = ({ const selectPdf = useCallback( (nextIndex: string, file: PdfFile) => () => { - setActiveIndex(nextIndex); if (lazyLoad && !file.pdfProxy) { + setPendingIndex(nextIndex); void loadPdfDocument(file, Number(nextIndex)); + } else { + setPendingIndex(null); + setActiveIndex(nextIndex); } if (overlayMode && listVisible) toggleList(); }, @@ -186,15 +199,7 @@ export const usePdfMultiViewer = ({ const rememberPosition = useCallback( (index: string, position: RendererDocumentPosition) => { - setFiles((state) => - state.map((pdfFile, pdfIndex) => { - if (pdfIndex !== Number(index)) return pdfFile; - return { - ...pdfFile, - ...position, - }; - }), - ); + positionsRef.current.set(Number(index), position); }, [], ); @@ -236,7 +241,13 @@ export const usePdfMultiViewer = ({ void loadPdfDocument(nextFile, nextIndex); }, [activeIndex, files, lazyLoad, loadPdfDocument, resolveIndex]); - const pdfToShow = files[Number(activeIndex)]; + const pdfToShow = useMemo(() => { + const resolvedIndex = Number(activeIndex); + const file = files[resolvedIndex]; + if (!file) return undefined; + const position = positionsRef.current.get(resolvedIndex); + return position ? { ...file, ...position } : file; + }, [activeIndex, files]); return { activeIndex, diff --git a/src/components/PdfRenderer/PdfRenderer.tsx b/src/components/PdfRenderer/PdfRenderer.tsx index eec4bbe..aa72595 100644 --- a/src/components/PdfRenderer/PdfRenderer.tsx +++ b/src/components/PdfRenderer/PdfRenderer.tsx @@ -1,6 +1,7 @@ import type { PDFDocumentProxy } from 'pdfjs-dist'; import type { PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'; -import { forwardRef, useImperativeHandle } from 'react'; +import type { CSSProperties } from 'react'; +import { forwardRef, useImperativeHandle, useMemo } from 'react'; import 'pdfjs-dist/web/pdf_viewer.css'; import '@/components/PdfRenderer/PdfRenderer.css'; import PdfRendererView from '@/components/PdfRenderer/PdfRendererView'; @@ -17,11 +18,13 @@ export type { RendererDocumentPosition } from '@/components/PdfRenderer/usePdfRe type DefaultProps = { activeIndex?: string; autoZoom?: boolean; + className?: string; controls?: boolean; downloadBtn: boolean; icons?: IconConfig; i18nData?: I18nDataRenderer; pdfChangeHook?: PdfChangeHook | null; + style?: CSSProperties; zoom?: number; rotation?: number; scrollTop?: number | null; @@ -42,11 +45,13 @@ const PdfRenderer = forwardRef( pdfDoc, activeIndex = '0', autoZoom = true, + className, controls = true, downloadBtn = true, icons, i18nData = defaultI18n, pdfChangeHook = null, + style, zoom, rotation = 0, scrollTop = 0, @@ -82,15 +87,20 @@ const PdfRenderer = forwardRef( }, })); - const iconStyles = buildIconStyles(icons); + const iconStyles = useMemo(() => buildIconStyles(icons), [icons]); + const mergedStyles = useMemo(() => { + if (!iconStyles && !style) return undefined; + return { ...(iconStyles ?? {}), ...(style ?? {}) }; + }, [iconStyles, style]); return ( void; onZoomIn: () => void; onZoomOut: () => void; @@ -18,6 +20,7 @@ type Props = { const PdfControls = ({ autoZoom, downloadBtn, + iconStyles, onDownload, onZoomIn, onZoomOut, @@ -26,7 +29,7 @@ const PdfControls = ({ scale, setScale, }: Props) => ( -
    +
    {({ scaleDown, scaleUp }) => ( diff --git a/src/components/PdfRenderer/PdfRendererView.tsx b/src/components/PdfRenderer/PdfRendererView.tsx index 4762063..32b1434 100644 --- a/src/components/PdfRenderer/PdfRendererView.tsx +++ b/src/components/PdfRenderer/PdfRendererView.tsx @@ -1,9 +1,11 @@ import type { CSSProperties, RefObject } from 'react'; +import { useMemo } from 'react'; import PdfRendererControls from '@/components/PdfRenderer/PdfRendererControls'; import { defaultI18n, I18nContext, type I18nDataRenderer } from '@/contexts'; type Props = { autoZoom: boolean; + className?: string; containerRef: RefObject; controls: boolean; downloadBtn: boolean; @@ -21,6 +23,7 @@ type Props = { const PdfRendererView = ({ autoZoom, + className, containerRef, controls, downloadBtn, @@ -34,32 +37,46 @@ const PdfRendererView = ({ onRotateRight, onZoomIn, onZoomOut, -}: Props) => ( -
    - {controls && ( - - - - )} -
    -
    -
    +}: Props) => { + const i18nValue = useMemo( + () => ({ ...defaultI18n, ...(i18nData ?? {}) }), + [i18nData], + ); + + return ( +
    + {controls && ( + + + + )} +
    +
    +
    +
    -
    -); + ); +}; export default PdfRendererView;