├── .github
└── workflows
│ └── release.yaml
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── LICENSE
├── README.md
├── examples
└── basic
│ ├── .gitignore
│ ├── README.md
│ ├── eslint.config.js
│ ├── index.html
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── postcss.config.js
│ ├── public
│ ├── sample.pdf
│ └── vite.svg
│ ├── src
│ ├── App.css
│ ├── App.tsx
│ ├── assets
│ │ └── react.svg
│ ├── index.css
│ ├── main.tsx
│ └── vite-env.d.ts
│ ├── tailwind.config.js
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── package.json
├── packages
├── docs
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── README.md
│ ├── app
│ │ ├── (home)
│ │ │ ├── _components
│ │ │ │ ├── anara.tsx
│ │ │ │ ├── annotations.tsx
│ │ │ │ ├── default-annotation-tooltip.tsx
│ │ │ │ ├── document-menu.tsx
│ │ │ │ ├── footer.tsx
│ │ │ │ ├── github-stars-button.tsx
│ │ │ │ ├── page-navigation.tsx
│ │ │ │ ├── useAnnotationActions.ts
│ │ │ │ └── zoom-menu.tsx
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ ├── api
│ │ │ └── search
│ │ │ │ └── route.ts
│ │ ├── docs
│ │ │ ├── [[...slug]]
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ │ ├── global.css
│ │ ├── icon.png
│ │ ├── layout.config.tsx
│ │ └── layout.tsx
│ ├── components.json
│ ├── components
│ │ ├── basic-text-layer.tsx
│ │ ├── basic.tsx
│ │ ├── custom-search.tsx
│ │ ├── custom-select.tsx
│ │ ├── highlight-layer.tsx
│ │ ├── highlight-select.tsx
│ │ ├── page-navigation-example.tsx
│ │ ├── pdf-form-layer.tsx
│ │ ├── search-control.tsx
│ │ ├── thumbnails.tsx
│ │ ├── ui
│ │ │ ├── button.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── feature-card.tsx
│ │ │ ├── icon.tsx
│ │ │ ├── page-navigation-buttons.tsx
│ │ │ └── popover.tsx
│ │ └── zoom-control.tsx
│ ├── content
│ │ └── docs
│ │ │ ├── basic-usage.mdx
│ │ │ ├── code
│ │ │ ├── basic.mdx
│ │ │ ├── highlight.mdx
│ │ │ ├── page-navigation.mdx
│ │ │ ├── pdf-form.mdx
│ │ │ ├── search.mdx
│ │ │ ├── select.mdx
│ │ │ ├── thumbnails.mdx
│ │ │ └── zoom-control.mdx
│ │ │ ├── dark-mode.mdx
│ │ │ ├── installation.mdx
│ │ │ └── meta.json
│ ├── lib
│ │ ├── metadata.ts
│ │ ├── setup.ts
│ │ ├── shiki.ts
│ │ ├── source.ts
│ │ └── utils.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── postcss.config.js
│ ├── public
│ │ ├── banner.png
│ │ ├── favicon.ico
│ │ ├── logo.png
│ │ └── pdf
│ │ │ ├── brochure.pdf
│ │ │ ├── expensive.pdf
│ │ │ ├── form.pdf
│ │ │ ├── large.pdf
│ │ │ └── pathways.pdf
│ ├── source.config.ts
│ ├── tailwind.config.js
│ └── tsconfig.json
└── lector
│ ├── .gitignore
│ ├── .npmignore
│ ├── .prettierrc
│ ├── .tsbuildinfo
│ ├── banner.png
│ ├── capture.png
│ ├── eslint.config.mjs
│ ├── esm-only.cjs
│ ├── image.jpeg
│ ├── package.json
│ ├── scripts
│ └── prepack.sh
│ ├── size.json
│ ├── src
│ ├── components
│ │ ├── annotation-tooltip.tsx
│ │ ├── layers
│ │ │ ├── annotation-highlight-layer.tsx
│ │ │ ├── annotation-layer.tsx
│ │ │ ├── canvas-layer.tsx
│ │ │ ├── colored-highlight
│ │ │ │ ├── color-selection-tool.tsx
│ │ │ │ ├── colored-highlight-layer.tsx
│ │ │ │ └── colored-highlight.tsx
│ │ │ ├── custom-layer.tsx
│ │ │ ├── highlight-layer.tsx
│ │ │ └── text-layer.tsx
│ │ ├── outline.tsx
│ │ ├── page-number.tsx
│ │ ├── page.tsx
│ │ ├── pages.tsx
│ │ ├── primitive.tsx
│ │ ├── root.tsx
│ │ ├── search.tsx
│ │ ├── selection-tooltip.tsx
│ │ ├── selection
│ │ │ ├── custom-selection-trigger.tsx
│ │ │ └── custom-selection.tsx
│ │ ├── thumbnails.tsx
│ │ └── zoom.tsx
│ ├── hooks
│ │ ├── document
│ │ │ └── document.ts
│ │ ├── layers
│ │ │ ├── useAnnotationLayer.tsx
│ │ │ ├── useCanvasLayer.tsx
│ │ │ └── useTextLayer.tsx
│ │ ├── pages
│ │ │ ├── useFitWidth.tsx
│ │ │ ├── useObserveElement.tsx
│ │ │ ├── usePdfJump.tsx
│ │ │ ├── useScrollFn.tsx
│ │ │ ├── useVirtualizerVelocity.tsx
│ │ │ └── useVisiblePage.tsx
│ │ ├── search
│ │ │ ├── useSearch.tsx
│ │ │ └── useSearchPosition.tsx
│ │ ├── useAnnotationTooltip.ts
│ │ ├── useAnnotations.ts
│ │ ├── useDpr.tsx
│ │ ├── usePDFLinkService.tsx
│ │ ├── usePdfOutline.tsx
│ │ ├── usePdfPageNumber.tsx
│ │ ├── useSelectionDimensions.tsx
│ │ ├── useThumbnail.tsx
│ │ ├── useVisibility.tsx
│ │ └── viewport
│ │ │ └── useViewportContainer.tsx
│ ├── index.ts
│ ├── internal.ts
│ ├── lib
│ │ ├── cancellable.ts
│ │ ├── clamp.ts
│ │ ├── memo.ts
│ │ ├── zoom.ts
│ │ └── zustand.tsx
│ ├── static
│ │ └── form.pdf
│ ├── tests
│ │ └── basic.test.tsx
│ └── utils
│ │ └── selectionUtils.ts
│ ├── tailwind.config.ts
│ ├── tsconfig.build.json
│ ├── tsconfig.json
│ ├── tsup.config.ts
│ └── vitest.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.json
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | name: CI/CD
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | types: [opened, reopened, synchronize]
9 | workflow_dispatch:
10 |
11 | env:
12 | FORCE_COLOR: 3
13 |
14 | jobs:
15 | lint:
16 | name: Linting
17 | runs-on: ubuntu-latest
18 | steps:
19 | - uses: actions/create-github-app-token@v1
20 | id: app-token
21 | with:
22 | app-id: ${{ secrets.APP_ID }}
23 | private-key: ${{ secrets.APP_PRIVATE_KEY }}
24 |
25 | - uses: actions/checkout@v4
26 | with:
27 | token: ${{ steps.app-token.outputs.token }}
28 | fetch-depth: 0
29 |
30 | - uses: pnpm/action-setup@v2
31 | with:
32 | version: latest
33 |
34 | - uses: actions/setup-node@v4
35 | with:
36 | node-version: 20
37 | cache: pnpm
38 | registry-url: "https://registry.npmjs.org"
39 | cache-dependency-path: ./pnpm-lock.yaml
40 |
41 | - name: Install dependencies
42 | run: pnpm install
43 |
44 | - name: Run lint
45 | run: pnpm lint
46 |
47 | test:
48 | name: Testing
49 | runs-on: ubuntu-latest
50 | steps:
51 | - uses: actions/create-github-app-token@v1
52 | id: app-token
53 | with:
54 | app-id: ${{ secrets.APP_ID }}
55 | private-key: ${{ secrets.APP_PRIVATE_KEY }}
56 |
57 | - uses: actions/checkout@v4
58 | with:
59 | token: ${{ steps.app-token.outputs.token }}
60 | fetch-depth: 0
61 |
62 | - uses: pnpm/action-setup@v2
63 | with:
64 | version: latest
65 |
66 | - uses: actions/setup-node@v4
67 | with:
68 | node-version: 20
69 | cache: pnpm
70 | registry-url: "https://registry.npmjs.org"
71 | cache-dependency-path: ./pnpm-lock.yaml
72 |
73 | - name: Install dependencies
74 | run: pnpm install
75 |
76 | - name: Run tests
77 | run: pnpm test
78 |
79 | - name: Build package
80 | run: pnpm build
81 |
82 | release:
83 | name: Release
84 | needs: [lint, test]
85 | if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
86 | runs-on: ubuntu-latest
87 | permissions:
88 | contents: write
89 | pages: write
90 | id-token: write
91 | issues: write
92 | pull-requests: write
93 |
94 | steps:
95 | - uses: actions/create-github-app-token@v1
96 | id: app-token
97 | with:
98 | app-id: ${{ secrets.APP_ID }}
99 | private-key: ${{ secrets.APP_PRIVATE_KEY }}
100 |
101 | - uses: actions/checkout@v4
102 | with:
103 | token: ${{ steps.app-token.outputs.token }}
104 | fetch-depth: 0
105 |
106 | - uses: pnpm/action-setup@v2
107 | with:
108 | version: latest
109 |
110 | - uses: actions/setup-node@v4
111 | with:
112 | node-version: 20
113 | cache: pnpm
114 | registry-url: "https://registry.npmjs.org"
115 | cache-dependency-path: ./pnpm-lock.yaml
116 |
117 | - name: Install dependencies
118 | run: pnpm install --ignore-scripts --frozen-lockfile --filter lector...
119 |
120 | - name: Build package
121 | run: pnpm build --filter @anaralabs/lector
122 |
123 | - name: Set execute permissions
124 | run: chmod +x ./packages/lector/scripts/prepack.sh
125 |
126 | - name: Semantic Release
127 | run: ../../node_modules/.bin/semantic-release
128 | working-directory: packages/lector
129 | env:
130 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
131 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
132 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Packages
2 | node_modules
3 |
4 | # Log files
5 | logs
6 | *.log
7 | npm-debug.log*
8 |
9 | # Runtime data
10 | pids
11 | *.pid
12 | *.seed
13 |
14 | # Env
15 | .env
16 |
17 | # Dist
18 | dist
19 | dist-docs
20 |
21 | # Miscellaneous
22 | .tmp
23 | .vscode/*
24 | !.vscode/extensions.json
25 | !.vscode/settings.json
26 | .idea
27 | .DS_Store
28 | .turbo
29 | tsconfig.tsbuildinfo
30 | coverage
31 | out
32 | package.tgz
33 | tsup.config.bundled*
34 | vitest.config.ts.timestamp*
35 |
36 | # Deno
37 | deno.lock
38 |
39 | # Bun
40 | bun.lockb
41 |
42 | # yarn
43 | .pnp.*
44 | .yarn/*
45 | !.yarn/patches
46 | !.yarn/plugins
47 | !.yarn/releases
48 | !.yarn/sdks
49 | !.yarn/versions
50 |
51 | # Cache
52 | .prettiercache
53 | .eslintcache
54 | .vercel
55 |
56 | # Mac
57 | .DS_Store
58 |
59 | # Cursor
60 | .cursorrules
61 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | pnpm commitlint --edit $1
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/.husky/pre-commit
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Anara
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Simple primitives to compose powerful PDF viewing experiences.
powered by PDF.js
and React
4 |
5 |
6 |
7 | # `lector`
8 |
9 | A composable, headless PDF viewer toolkit for React applications, powered by `PDF.js`. Build feature-rich PDF viewing experiences with full control over the UI and functionality.
10 |
11 | [](https://www.npmjs.com/package/@anaralabs/lector)
12 | [](https://opensource.org/licenses/MIT)
13 |
14 | ## Installation
15 |
16 | ```bash
17 | npm install @anaralabs/lector pdfjs-dist
18 |
19 | # or with yarn
20 | yarn add @anaralabs/lector pdfjs-dist
21 |
22 | # or with pnpm
23 | pnpm add @anaralabs/lector pdfjs-dist
24 | ```
25 |
26 | ## Basic Usage
27 |
28 | Here's a simple example of how to create a basic PDF viewer:
29 |
30 | ```tsx
31 | import { CanvasLayer, Page, Pages, Root, TextLayer } from "@anaralabs/lector";
32 | import "pdfjs-dist/web/pdf_viewer.css";
33 |
34 | export default function PDFViewer() {
35 | return (
36 | Loading...}
40 | >
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 | ```
51 |
52 | ## Local Development using PNPM and Yalc
53 |
54 | When you are using "pnpm link", you are bound to use pnpm on your consumer project when you are developing locally.
55 | With yalc, we are decoupling the need for pnpm and now the package can be tested with any package managers. Any
56 | changes should be automatically published to yalc on save, forcing a rebuilt and updating the consumer project.
57 |
58 | Install yalc globally:
59 |
60 | ```
61 | pnpm i yalc -g
62 | ```
63 |
64 | From lector:
65 |
66 | ```bash
67 | # navigate to lector package folder and install dependencies
68 | pnpm i
69 | # when you first start development, make sure you publish the package locally
70 | yalc publish
71 | # and run the project in development mode to start a watcher that rebuilds the project and pushes the changes locally on save
72 | pnpm dev
73 | ```
74 |
75 | From consumer project:
76 | (It doesn't really matter what package manager you are using)
77 |
78 | ```bash
79 | # add local package to your package.json of the consumer project using yalc
80 | yalc add @anaralabs/lector
81 | # or if you don't want to add the yalc package in your package.json
82 | yalc link @anaralabs/lector
83 | ```
84 |
85 | ## Features
86 |
87 | - 📱 Responsive and mobile-friendly
88 | - 🎨 Fully customizable UI components
89 | - 🔍 Text selection and search functionality
90 | - 📑 Page thumbnails and outline navigation
91 | - 🌗 First-class dark mode support
92 | - 🖱️ Pan and zoom controls
93 | - 📝 Form filling support
94 | - 🔗 Internal and external link handling
95 |
96 | ## Contributing
97 |
98 | We welcome contributions! Key areas we're focusing on:
99 |
100 | 1. Performance optimizations
101 | 2. Accessibility improvements
102 | 3. Mobile/touch interactions
103 | 4. Documentation and examples
104 |
105 | ## Thanks
106 |
107 | Special thanks to these open-source projects that provided inspiration:
108 |
109 | - [react-pdf-headless](https://github.com/jkgenser/react-pdf-headless)
110 | - [pdfreader](https://github.com/OnedocLabs/pdfreader)
111 |
112 | ## License
113 |
114 | MIT © [Anara](https://anara.com)
115 |
--------------------------------------------------------------------------------
/examples/basic/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/examples/basic/README.md:
--------------------------------------------------------------------------------
1 | # Lector Basic Example
2 |
3 | This is a basic example of using Lector, a headless PDF viewer for React.
4 |
5 | [](https://stackblitz.com/github/anaralabs/lector/tree/main/examples/basic)
6 |
7 | ## Features
8 |
9 | - PDF rendering with canvas layer
10 | - Text selection and copying
11 | - Responsive layout
12 | - Dark mode support
13 | - Loading state handling
14 |
15 | ## Getting Started
16 |
17 | 1. Clone the repository:
18 |
19 | ```bash
20 | git clone https://github.com/anaralabs/lector.git
21 | cd lector/examples/basic
22 | ```
23 |
24 | 2. Install dependencies:
25 |
26 | ```bash
27 | pnpm install
28 | ```
29 |
30 | 3. Start the development server:
31 |
32 | ```bash
33 | pnpm dev
34 | ```
35 |
36 | 4. Open [http://localhost:5173](http://localhost:5173) in your browser.
37 |
38 | ## Learn More
39 |
40 | - [Lector Documentation](https://lector-weld.vercel.app/)
41 | - [GitHub Repository](https://github.com/anaralabs/lector)
42 |
--------------------------------------------------------------------------------
/examples/basic/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/examples/basic/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/examples/basic/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "basic",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@anaralabs/lector": "latest",
14 | "pdfjs-dist": "^4.9.155",
15 | "react": "^18.2.0",
16 | "react-dom": "^18.2.0"
17 | },
18 | "devDependencies": {
19 | "@types/react": "^18.2.43",
20 | "@types/react-dom": "^18.2.17",
21 | "@typescript-eslint/eslint-plugin": "^6.14.0",
22 | "@typescript-eslint/parser": "^6.14.0",
23 | "@vitejs/plugin-react": "^4.2.1",
24 | "autoprefixer": "^10.4.16",
25 | "eslint": "^8.55.0",
26 | "eslint-plugin-react-hooks": "^4.6.0",
27 | "eslint-plugin-react-refresh": "^0.4.5",
28 | "postcss": "^8.4.32",
29 | "tailwindcss": "^3.4.0",
30 | "typescript": "^5.2.2",
31 | "vite": "^5.0.8"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/examples/basic/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/examples/basic/public/sample.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/examples/basic/public/sample.pdf
--------------------------------------------------------------------------------
/examples/basic/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/basic/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/examples/basic/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { CanvasLayer, Page, Pages, Root, TextLayer } from "@anaralabs/lector";
2 | import { GlobalWorkerOptions } from "pdfjs-dist";
3 | import "pdfjs-dist/web/pdf_viewer.css";
4 |
5 | // Configure PDF.js worker
6 | GlobalWorkerOptions.workerSrc = new URL(
7 | "pdfjs-dist/build/pdf.worker.mjs",
8 | import.meta.url
9 | ).toString();
10 |
11 | export default function App() {
12 | return (
13 |
14 |
15 |
26 |
27 | Loading...
}
31 | >
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/examples/basic/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/basic/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/examples/basic/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/examples/basic/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/examples/basic/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/examples/basic/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["src"]
26 | }
27 |
--------------------------------------------------------------------------------
/examples/basic/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/examples/basic/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/examples/basic/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | server: {
8 | port: 5173,
9 | host: true,
10 | },
11 | optimizeDeps: {
12 | include: ["@anaralabs/lector", "pdfjs-dist"],
13 | },
14 | });
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lector-monorepo",
3 | "version": "0.0.0",
4 | "description": "Headless PDF viewer for React",
5 | "author": "andrewdr (https://github.com/andrewdoro)",
6 | "type": "module",
7 | "private": "true",
8 | "scripts": {
9 | "build": "turbo run build ",
10 | "lint": "turbo run lint",
11 | "format": "turbo run format",
12 | "prepare": "husky",
13 | "test": "turbo run test",
14 | "dev": "turbo run dev"
15 | },
16 | "devDependencies": {
17 | "@commitlint/config-conventional": "^19.6.0",
18 | "commitlint": "^19.6.1",
19 | "turbo": "^2.3.3"
20 | },
21 | "keywords": [
22 | "pdf",
23 | "react",
24 | "headless",
25 | "viewer",
26 | "react-pdf",
27 | "anaralabs",
28 | "typescript"
29 | ],
30 | "repository": {
31 | "type": "git",
32 | "url": "https://github.com/anaralabs/lector.git"
33 | },
34 | "bugs": {
35 | "url": "https://github.com/anaralabs/lector/issues"
36 | },
37 | "homepage": "https://github.com/anaralabs/lector",
38 | "packageManager": "pnpm@9.5.0",
39 | "dependencies": {
40 | "husky": "^9.1.7",
41 | "semantic-release": "^24.2.0"
42 | },
43 | "commitlint": {
44 | "extends": [
45 | "@commitlint/config-conventional"
46 | ],
47 | "rules": {
48 | "type-enum": [
49 | 2,
50 | "always",
51 | [
52 | "build",
53 | "chore",
54 | "ci",
55 | "clean",
56 | "doc",
57 | "feat",
58 | "fix",
59 | "perf",
60 | "ref",
61 | "revert",
62 | "style",
63 | "test"
64 | ]
65 | ],
66 | "subject-case": [
67 | 0,
68 | "always",
69 | "sentence-case"
70 | ],
71 | "body-leading-blank": [
72 | 2,
73 | "always",
74 | true
75 | ]
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/packages/docs/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "next/typescript"]
3 | }
4 |
--------------------------------------------------------------------------------
/packages/docs/.gitignore:
--------------------------------------------------------------------------------
1 | # deps
2 | /node_modules
3 |
4 | # generated content
5 | .contentlayer
6 | .content-collections
7 | .source
8 |
9 | # test & build
10 | /coverage
11 | /.next/
12 | /out/
13 | /build
14 | *.tsbuildinfo
15 |
16 | # misc
17 | .DS_Store
18 | *.pem
19 | /.pnp
20 | .pnp.js
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # others
26 | .env*.local
27 | .vercel
28 | next-env.d.ts
--------------------------------------------------------------------------------
/packages/docs/README.md:
--------------------------------------------------------------------------------
1 | # docs
2 |
3 | This is a Next.js application generated with
4 | [Create Fumadocs](https://github.com/fuma-nama/fumadocs).
5 |
6 | Run development server:
7 |
8 | ```bash
9 | npm run dev
10 | # or
11 | pnpm dev
12 | # or
13 | yarn dev
14 | ```
15 |
16 | Open http://localhost:3000 with your browser to see the result.
17 |
18 | ## Learn More
19 |
20 | To learn more about Next.js and Fumadocs, take a look at the following
21 | resources:
22 |
23 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
24 | features and API.
25 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
26 | - [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs
27 |
--------------------------------------------------------------------------------
/packages/docs/app/(home)/_components/default-annotation-tooltip.tsx:
--------------------------------------------------------------------------------
1 | import { useAnnotationActions } from "./useAnnotationActions";
2 | import type { Annotation } from "@anaralabs/lector";
3 |
4 | interface DefaultAnnotationTooltipContentProps {
5 | annotation: Annotation;
6 | onClose?: () => void;
7 | }
8 |
9 | export const DefaultAnnotationTooltipContent = ({
10 | annotation,
11 | onClose,
12 | }: DefaultAnnotationTooltipContentProps) => {
13 | const {
14 | comment,
15 | isEditing,
16 | setComment,
17 | setIsEditing,
18 | handleSaveComment,
19 | handleColorChange,
20 | handleCancelEdit,
21 | colors,
22 | } = useAnnotationActions({
23 | annotation,
24 | onClose,
25 | });
26 |
27 | return (
28 |
29 | {/* Color picker */}
30 |
31 | {colors.map((color) => (
32 |
40 |
41 | {/* Comment section */}
42 | {isEditing ? (
43 |
66 | ) : (
67 |
68 | {annotation.comment ? (
69 |
{annotation.comment}
70 | ) : (
71 |
77 | )}
78 |
79 | )}
80 |
81 | );
82 | };
--------------------------------------------------------------------------------
/packages/docs/app/(home)/_components/document-menu.tsx:
--------------------------------------------------------------------------------
1 | import { usePdf } from "@anaralabs/lector";
2 | import { DownloadCloud, Ellipsis, Link } from "lucide-react";
3 | import { useState } from "react";
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuItem,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 | import { Icon } from "@/components/ui/icon";
12 |
13 | interface DocumentMenuProps {
14 | documentUrl: string;
15 | }
16 |
17 | const DocumentMenu = ({ documentUrl }: DocumentMenuProps) => {
18 | const [isDownloading, setIsDownloading] = useState(false);
19 | const pdfDocumentProxy = usePdf((state) => state.pdfDocumentProxy);
20 |
21 | const handleDownload = async () => {
22 | if (!pdfDocumentProxy || isDownloading) return;
23 |
24 | try {
25 | setIsDownloading(true);
26 |
27 | // Get the PDF data
28 | const pdfData = await pdfDocumentProxy.getData();
29 | const blob = new Blob([pdfData], { type: "application/pdf" });
30 |
31 | // Create download link
32 | const url = window.URL.createObjectURL(blob);
33 | const link = document.createElement("a");
34 | link.href = url;
35 |
36 | // Extract filename from URL or use default
37 | const filename = documentUrl.split("/").pop() || "document.pdf";
38 | link.download = filename;
39 |
40 | // Trigger download
41 | document.body.appendChild(link);
42 | link.click();
43 |
44 | // Cleanup
45 | document.body.removeChild(link);
46 | window.URL.revokeObjectURL(url);
47 | } catch (error) {
48 | console.error("Error downloading PDF:", error);
49 | // You might want to add proper error handling/notification here
50 | } finally {
51 | setIsDownloading(false);
52 | }
53 | };
54 |
55 | const handleOpenClick = () => {
56 | window.open(documentUrl, "_blank");
57 | };
58 |
59 | return (
60 |
61 |
62 |
65 |
66 |
67 |
71 |
72 | {isDownloading ? "Downloading..." : "Download"}
73 |
74 |
75 | Open
76 |
77 |
78 |
79 | );
80 | };
81 |
82 | export default DocumentMenu;
83 |
--------------------------------------------------------------------------------
/packages/docs/app/(home)/_components/footer.tsx:
--------------------------------------------------------------------------------
1 | import { Github, Twitter } from "lucide-react";
2 | import { Icon } from "@/components/ui/icon";
3 |
4 | export function Footer(): React.ReactElement {
5 | return (
6 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/packages/docs/app/(home)/_components/github-stars-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Icon } from "@/components/ui/icon";
5 | import clsx from "clsx";
6 | import { Star } from "lucide-react";
7 | import Link from "next/link";
8 | import React, { useEffect, useState } from "react";
9 |
10 | type Props = {
11 | className?: string;
12 | };
13 |
14 | export const GithubStarsButton = ({ className }: Props) => {
15 | const [stars, setStars] = useState();
16 |
17 | const fetchStars = async () => {
18 | const res = await fetch("https://api.github.com/repos/anaralabs/lector");
19 | const data = (await res.json()) as { stargazers_count: number };
20 | if (typeof data?.stargazers_count === "number") {
21 | setStars(new Intl.NumberFormat().format(data.stargazers_count));
22 | }
23 | };
24 |
25 | useEffect(() => {
26 | fetchStars().catch(console.error);
27 | }, []);
28 |
29 | return (
30 |
31 |
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/packages/docs/app/(home)/_components/page-navigation.tsx:
--------------------------------------------------------------------------------
1 | import { usePdf, usePdfJump } from "@anaralabs/lector";
2 | import { ChevronLeft, ChevronRight } from "lucide-react";
3 | import { Button } from "@/components/ui/button";
4 | import { Icon } from "@/components/ui/icon";
5 | import { useEffect, useState } from "react";
6 |
7 | export const PageNavigation = () => {
8 | const pages = usePdf((state) => state.pdfDocumentProxy.numPages);
9 | const currentPage = usePdf((state) => state.currentPage);
10 |
11 | const [pageNumber, setPageNumber] = useState(currentPage);
12 | const { jumpToPage } = usePdfJump();
13 |
14 | const handlePreviousPage = () => {
15 | if (currentPage > 1) {
16 | jumpToPage(currentPage - 1, { behavior: "auto" });
17 | }
18 | };
19 |
20 | const handleNextPage = () => {
21 | if (currentPage < pages) {
22 | jumpToPage(currentPage + 1, { behavior: "auto" });
23 | }
24 | };
25 |
26 | useEffect(() => {
27 | setPageNumber(currentPage);
28 | }, [currentPage]);
29 |
30 | return (
31 |
32 |
42 |
43 |
44 | setPageNumber(e.target.value)}
48 | onBlur={(e) => {
49 | if (currentPage !== Number(e.target.value)) {
50 | jumpToPage(Number(e.target.value), {
51 | behavior: "auto",
52 | });
53 | }
54 | }}
55 | onKeyDown={(e) => {
56 | if (e.key === "Enter") {
57 | e.currentTarget.blur();
58 | }
59 | }}
60 | className="[appearance:textfield] w-10 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none text-center bg-accent border-none text-sm font-medium focus:outline-none focus:ring-2 focus:ring-primary/20 rounded-md"
61 | />
62 |
63 | / {pages}
64 |
65 |
66 |
76 |
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/packages/docs/app/(home)/_components/useAnnotationActions.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from "react";
2 |
3 | import type { Annotation } from "@anaralabs/lector";
4 | import { useAnnotations } from "@anaralabs/lector";
5 |
6 | interface UseAnnotationActionsProps {
7 | annotation: Annotation;
8 | onClose?: () => void;
9 | }
10 |
11 | interface UseAnnotationActionsReturn {
12 | comment: string;
13 | isEditing: boolean;
14 | setComment: (comment: string) => void;
15 | setIsEditing: (isEditing: boolean) => void;
16 | handleSaveComment: () => void;
17 | handleColorChange: (color: string) => void;
18 | handleCancelEdit: () => void;
19 | colors: string[];
20 | }
21 |
22 | export const useAnnotationActions = ({
23 | annotation,
24 | onClose,
25 | }: UseAnnotationActionsProps): UseAnnotationActionsReturn => {
26 | const { updateAnnotation } = useAnnotations();
27 | const [comment, setComment] = useState(annotation.comment || "");
28 | const [isEditing, setIsEditing] = useState(false);
29 |
30 | const handleSaveComment = useCallback(() => {
31 | updateAnnotation(annotation.id, { comment });
32 | setIsEditing(false);
33 | onClose?.();
34 | }, [annotation.id, comment, updateAnnotation, onClose]);
35 |
36 | const handleColorChange = useCallback(
37 | (color: string) => {
38 | updateAnnotation(annotation.id, { color, borderColor: color });
39 | onClose?.();
40 | },
41 | [annotation.id, updateAnnotation, onClose]
42 | );
43 |
44 | const handleCancelEdit = useCallback(() => {
45 | setIsEditing(false);
46 | onClose?.();
47 | }, [onClose]);
48 |
49 | const colors = [
50 | "rgba(255, 255, 0, 0.3)", // Yellow
51 | "rgba(0, 255, 0, 0.3)", // Green
52 | "rgba(255, 182, 193, 0.3)", // Pink
53 | "rgba(135, 206, 235, 0.3)", // Sky Blue
54 | ];
55 |
56 | return {
57 | comment,
58 | isEditing,
59 | setComment,
60 | setIsEditing,
61 | handleSaveComment,
62 | handleColorChange,
63 | handleCancelEdit,
64 | colors,
65 | };
66 | };
--------------------------------------------------------------------------------
/packages/docs/app/(home)/_components/zoom-menu.tsx:
--------------------------------------------------------------------------------
1 | import { usePdf } from "@anaralabs/lector";
2 | import { ChevronUpIcon, MinusIcon, PlusIcon } from "lucide-react";
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | DropdownMenu,
6 | DropdownMenuContent,
7 | DropdownMenuItem,
8 | DropdownMenuTrigger,
9 | } from "@/components/ui/dropdown-menu";
10 | import { Icon } from "@/components/ui/icon";
11 |
12 | const ZoomMenu = () => {
13 | const zoom = usePdf((state) => state.zoom);
14 | const setCustomZoom = usePdf((state) => state.updateZoom);
15 | const fitToWidth = usePdf((state) => state.zoomFitWidth);
16 |
17 | const handleZoomDecrease = () => setCustomZoom((zoom) => zoom * 0.9);
18 | const handleZoomIncrease = () => setCustomZoom((zoom) => zoom * 1.1);
19 |
20 | return (
21 |
22 |
23 |
32 |
33 |
34 |
35 |
36 | {`${Math.round(zoom * 100)}%`}
37 |
38 |
39 |
47 |
48 |
56 |
57 | fitToWidth()}>
58 | Page fit
59 |
60 |
61 | {[0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4].map((zoomLevel) => (
62 | setCustomZoom(zoomLevel)}
65 | >
66 | {`${zoomLevel * 100}%`}
67 |
68 | ))}
69 |
70 |
71 | );
72 | };
73 |
74 | export default ZoomMenu;
75 |
--------------------------------------------------------------------------------
/packages/docs/app/(home)/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from "react";
2 | import { HomeLayout } from "fumadocs-ui/layouts/home";
3 | import { baseOptions } from "@/app/layout.config";
4 | import { Footer } from "./_components/footer";
5 |
6 | export default function Layout({
7 | children,
8 | }: {
9 | children: ReactNode;
10 | }): React.ReactElement {
11 | return (
12 |
13 |
17 |
18 |
19 | {children}
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/packages/docs/app/(home)/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { AnaraViewer } from "./_components/anara";
3 | import { ArrowRight, Github, AlertTriangle } from "lucide-react";
4 | import { Icon } from "@/components/ui/icon";
5 | import { GithubStarsButton } from "./_components/github-stars-button";
6 | import Link from "next/link";
7 |
8 | export default function Home() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 | Breaking Change: Repository Name Update
16 |
17 |
18 | We've changed our name from unriddle-ai to{" "}
19 | anaralabs. Please update your dependencies from{" "}
20 |
21 | @unriddle-ai/lector
22 |
{" "}
23 | to{" "}
24 |
25 | @anaralabs/lector
26 |
27 | .
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
41 |
42 | Open Source
43 |
44 |
45 |
46 | Primitives for your PDF viewer
47 |
48 |
49 |
50 | Build your perfect PDF viewer with our headless UI components.
51 | Fully customizable, accessible, and easy to integrate.
52 |
53 |
54 |
55 |
56 |
57 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | Composition pattern
70 |
71 |
72 |
73 | Virtualization
74 |
75 |
76 |
77 | Panning/Zooming
78 |
79 |
80 |
81 |
82 |
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/packages/docs/app/api/search/route.ts:
--------------------------------------------------------------------------------
1 | import { source } from '@/lib/source';
2 | import { createFromSource } from 'fumadocs-core/search/server';
3 |
4 | export const { GET } = createFromSource(source);
5 |
--------------------------------------------------------------------------------
/packages/docs/app/docs/[[...slug]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { source } from '@/lib/source';
2 | import {
3 | DocsPage,
4 | DocsBody,
5 | DocsDescription,
6 | DocsTitle,
7 | } from 'fumadocs-ui/page';
8 | import { notFound } from 'next/navigation';
9 | import defaultMdxComponents from 'fumadocs-ui/mdx';
10 |
11 | export default async function Page(props: {
12 | params: Promise<{ slug?: string[] }>;
13 | }) {
14 | const params = await props.params;
15 | const page = source.getPage(params.slug);
16 | if (!page) notFound();
17 |
18 | const MDX = page.data.body;
19 |
20 | return (
21 |
22 | {page.data.title}
23 | {page.data.description}
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
31 | export async function generateStaticParams() {
32 | return source.generateParams();
33 | }
34 |
35 | export async function generateMetadata(props: {
36 | params: Promise<{ slug?: string[] }>;
37 | }) {
38 | const params = await props.params;
39 | const page = source.getPage(params.slug);
40 | if (!page) notFound();
41 |
42 | return {
43 | title: page.data.title,
44 | description: page.data.description,
45 | };
46 | }
47 |
--------------------------------------------------------------------------------
/packages/docs/app/docs/layout.tsx:
--------------------------------------------------------------------------------
1 | import { DocsLayout } from "fumadocs-ui/layouts/docs";
2 | import type { ReactNode } from "react";
3 | import { baseOptions } from "@/app/layout.config";
4 | import { source } from "@/lib/source";
5 |
6 | export default function Layout({ children }: { children: ReactNode }) {
7 | return (
8 |
9 | {children}
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/packages/docs/app/global.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 0 0% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 0 0% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 0 0% 3.9%;
13 | --primary: 0 0% 9%;
14 | --primary-foreground: 0 0% 98%;
15 | --secondary: 0 0% 96.1%;
16 | --secondary-foreground: 0 0% 9%;
17 | --muted: 0 0% 96.1%;
18 | --muted-foreground: 0 0% 45.1%;
19 | --accent: 0 0% 96.1%;
20 | --accent-foreground: 0 0% 9%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 0 0% 89.8%;
24 | --input: 0 0% 89.8%;
25 | --ring: 0 0% 3.9%;
26 | --chart-1: 12 76% 61%;
27 | --chart-2: 173 58% 39%;
28 | --chart-3: 197 37% 24%;
29 | --chart-4: 43 74% 66%;
30 | --chart-5: 27 87% 67%;
31 | --radius: 0.5rem
32 | }
33 | .dark {
34 | --background: 0 0% 3.9%;
35 | --foreground: 0 0% 98%;
36 | --card: 0 0% 3.9%;
37 | --card-foreground: 0 0% 98%;
38 | --popover: 0 0% 3.9%;
39 | --popover-foreground: 0 0% 98%;
40 | --primary: 0 0% 98%;
41 | --primary-foreground: 0 0% 9%;
42 | --secondary: 0 0% 14.9%;
43 | --secondary-foreground: 0 0% 98%;
44 | --muted: 0 0% 14.9%;
45 | --muted-foreground: 0 0% 63.9%;
46 | --accent: 0 0% 14.9%;
47 | --accent-foreground: 0 0% 98%;
48 | --destructive: 0 62.8% 30.6%;
49 | --destructive-foreground: 0 0% 98%;
50 | --border: 0 0% 14.9%;
51 | --input: 0 0% 14.9%;
52 | --ring: 0 0% 83.1%;
53 | --chart-1: 220 70% 50%;
54 | --chart-2: 160 60% 45%;
55 | --chart-3: 30 80% 55%;
56 | --chart-4: 280 65% 60%;
57 | --chart-5: 340 75% 55%
58 | }
59 | }
60 |
61 | @layer base {
62 | * {
63 | @apply border-border;
64 | }
65 | }
--------------------------------------------------------------------------------
/packages/docs/app/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/docs/app/icon.png
--------------------------------------------------------------------------------
/packages/docs/app/layout.config.tsx:
--------------------------------------------------------------------------------
1 | import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
2 | import Image from "next/image";
3 |
4 | import Logo from "@/public/logo.png";
5 |
6 | /**
7 | * Shared layout configurations
8 | */
9 | export const baseOptions: BaseLayoutProps = {
10 | githubUrl: "https://github.com/anaralabs/lector",
11 |
12 | nav: {
13 | title: (
14 | <>
15 |
21 |
22 | Lector
23 |
24 | >
25 | ),
26 | transparentMode: "top",
27 | },
28 | links: [
29 | {
30 | text: "Documentation",
31 | url: "/docs/basic-usage",
32 | active: "nested-url",
33 | },
34 | ],
35 | };
36 |
--------------------------------------------------------------------------------
/packages/docs/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { baseUrl, createMetadata } from "@/lib/metadata";
2 | import "./global.css";
3 | import { RootProvider } from "fumadocs-ui/provider";
4 | import { Inter } from "next/font/google";
5 | import type { ReactNode } from "react";
6 | import { Viewport } from "next";
7 |
8 | const inter = Inter({
9 | subsets: ["latin"],
10 | });
11 |
12 | export default function Layout({ children }: { children: ReactNode }) {
13 | return (
14 |
15 |
16 | {children}
17 |
18 |
19 | );
20 | }
21 |
22 | export const metadata = createMetadata({
23 | title: {
24 | template: "%s | Lector",
25 | default: "Lector",
26 | },
27 | description: "Headless React PDF viewer for the web",
28 | metadataBase: baseUrl,
29 | });
30 |
31 | export const viewport: Viewport = {
32 | themeColor: [
33 | { media: "(prefers-color-scheme: dark)", color: "#0A0A0A" },
34 | { media: "(prefers-color-scheme: light)", color: "#fff" },
35 | ],
36 | };
37 |
--------------------------------------------------------------------------------
/packages/docs/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/global.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/packages/docs/components/basic-text-layer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CanvasLayer, Page, Pages, Root, TextLayer } from "@anaralabs/lector";
4 | import "@/lib/setup";
5 |
6 | const fileUrl = "/pdf/large.pdf";
7 |
8 | const BasicTextLayer = () => {
9 | return (
10 | Loading...}
14 | >
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default BasicTextLayer;
26 |
--------------------------------------------------------------------------------
/packages/docs/components/basic.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CanvasLayer, Page, Pages, Root, TextLayer } from "@anaralabs/lector";
4 | import React from "react";
5 | import "@/lib/setup";
6 |
7 | const fileUrl = "/pdf/pathways.pdf";
8 |
9 | const Basic = () => {
10 | return (
11 | Loading...}
15 | >
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default Basic;
27 |
--------------------------------------------------------------------------------
/packages/docs/components/custom-select.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { SelectionTooltip } from "@anaralabs/lector";
3 |
4 | export const CustomSelect = ({ onHighlight }: { onHighlight: () => void }) => {
5 | return (
6 |
7 |
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/packages/docs/components/highlight-layer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useState } from "react";
4 | import {
5 | CanvasLayer,
6 | HighlightLayer,
7 | Page,
8 | Pages,
9 | Root,
10 | TextLayer,
11 | usePdfJump,
12 | } from "@anaralabs/lector";
13 | import "@/lib/setup";
14 |
15 | const fileUrl = "/pdf/pathways.pdf";
16 |
17 | const examples = [
18 | {
19 | id: 1,
20 | title: "Results",
21 | text: "Results: C-10, D-12, and E-13 tumors disseminated primarily by the hematogenous route and developed pulmonary metastases",
22 | highlights: [
23 | {
24 | height: 10.888885498046875,
25 | left: 63.06942749023375,
26 | pageNumber: 1,
27 | top: 438.73612976074,
28 | width: 465.831176757812,
29 | },
30 | {
31 | height: 10.666656494140625,
32 | left: 63.06942749023375,
33 | pageNumber: 1,
34 | top: 450.75001525878906,
35 | width: 45.268432617187,
36 | },
37 | ],
38 | },
39 | {
40 | id: 2,
41 | title: "Methods",
42 | text: "Methods: Two patient-derived xenograft (PDX) models (E-13, N-15) and four cell line-derived xenografts (CDX) models (C-10, D-12, R-18, T-22) of human melanoma were included in the study.",
43 | highlights: [
44 | {
45 | height: 10.888885498046875,
46 | left: 63.06942749023375,
47 | pageNumber: 1,
48 | top: 351.83332824707,
49 | width: 441.37243652343,
50 | },
51 | {
52 | height: 10.666687011718,
53 | left: 63.06942749023375,
54 | pageNumber: 1,
55 | top: 363.84721374511,
56 | width: 318.05914306640625,
57 | },
58 | ],
59 | },
60 | {
61 | id: 3,
62 | title: "Metastatic Frequency",
63 | text: "Metastatic frequency was determined by studying a fifty tumor-bearing mice of each melanoma model.",
64 | highlights: [
65 | {
66 | height: 10.666656494140625,
67 | left: 56.63888549804687,
68 | pageNumber: 4,
69 | top: 266.5972290039062,
70 | width: 233.84039306640625,
71 | },
72 | {
73 | height: 10.694458007812,
74 | left: 56.63888549804687,
75 | pageNumber: 4,
76 | top: 278.52777099609375,
77 | width: 233.8784179687,
78 | },
79 | ],
80 | },
81 | {
82 | id: 4,
83 | title: "High IFP",
84 | text: "High IFP is mainly a consequence of high resistance to blood flow caused by tumor-induced vessel abnormalities and is likely facilitated by high expression of NRP2.",
85 | highlights: [
86 | {
87 | height: 10.666656494140625,
88 | left: 304.5694274902344,
89 | pageNumber: 10,
90 | top: 86.79124450683594,
91 | width: 233.48394775390625,
92 | },
93 | {
94 | height: 10.694458007812,
95 | left: 304.5694274902344,
96 | pageNumber: 10,
97 | top: 98.72178649902344,
98 | width: 233.86926269531,
99 | },
100 | {
101 | height: 10.666687011718,
102 | left: 304.5694274902344,
103 | pageNumber: 10,
104 | top: 110.73567199707031,
105 | width: 204.37457275390625,
106 | },
107 | ],
108 | },
109 | ];
110 |
111 | const HighlightLayerContent = () => {
112 | const { jumpToHighlightRects } = usePdfJump();
113 | const [selectedExample, setSelectedExample] = useState(null);
114 | const handleExampleClick = async (example: (typeof examples)[0]) => {
115 | try {
116 | setSelectedExample(example.text);
117 |
118 | jumpToHighlightRects(example.highlights, "pixels", "center");
119 | } catch (error) {
120 | console.error("Error highlighting text:", error);
121 | }
122 | };
123 |
124 | return (
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
Important Sections
137 |
138 | {examples.map((example) => (
139 |
handleExampleClick(example)}
142 | className={`p-3 border rounded cursor-pointer ${
143 | selectedExample === example.text
144 | ? "bg-yellow-100"
145 | : "hover:bg-gray-50"
146 | }`}
147 | >
148 |
{example.title}
149 |
{example.text}
150 |
151 | ))}
152 |
153 |
154 |
155 | );
156 | };
157 |
158 | const PdfHighlightLayer = () => (
159 | Loading...}
163 | >
164 |
165 |
166 | );
167 |
168 | export default PdfHighlightLayer;
169 |
--------------------------------------------------------------------------------
/packages/docs/components/highlight-select.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import {
5 | CanvasLayer,
6 | HighlightLayer,
7 | Page,
8 | Pages,
9 | Root,
10 | TextLayer,
11 | useSelectionDimensions,
12 | usePdf,
13 | } from "@anaralabs/lector";
14 |
15 | import "@/lib/setup";
16 | import { CustomSelect } from "./custom-select";
17 |
18 | const fileUrl = "/pdf/pathways.pdf";
19 |
20 | const HighlightLayerContent = () => {
21 | const selectionDimensions = useSelectionDimensions();
22 | const setHighlights = usePdf((state) => state.setHighlight);
23 |
24 | const handleHighlight = () => {
25 | const dimension = selectionDimensions.getDimension();
26 | if (dimension && !dimension.isCollapsed) {
27 | setHighlights(dimension.highlights);
28 | }
29 | };
30 |
31 | return (
32 |
33 |
34 | {selectionDimensions && }
35 |
36 |
37 |
38 |
39 |
40 | );
41 | };
42 |
43 | const PdfHighlightSelect = () => (
44 | Loading...}
48 | >
49 |
50 |
51 | );
52 |
53 | export default PdfHighlightSelect;
54 |
--------------------------------------------------------------------------------
/packages/docs/components/page-navigation-example.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CanvasLayer, Page, Pages, Root, TextLayer } from "@anaralabs/lector";
4 | import PageNavigationButtons from "./ui/page-navigation-buttons";
5 | import "@/lib/setup";
6 |
7 | const fileUrl = "/pdf/large.pdf";
8 |
9 | const PageNavigation = () => {
10 | return (
11 | Loading...}
15 | >
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | };
28 |
29 | export default PageNavigation;
30 |
--------------------------------------------------------------------------------
/packages/docs/components/pdf-form-layer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import {
3 | AnnotationLayer,
4 | CanvasLayer,
5 | Page,
6 | Pages,
7 | Root,
8 | TextLayer,
9 | } from "@anaralabs/lector";
10 | import React, { useState, FormEvent } from "react";
11 | import "@/lib/setup";
12 |
13 | const fileUrl = "/pdf/form.pdf";
14 |
15 | type FormValues = {
16 | [key: string]: FormDataEntryValue;
17 | } | null;
18 |
19 | const PdfFormLayer = () => {
20 | const [formValues, setFormValues] = useState(null);
21 |
22 | const handleSubmit = (e: FormEvent) => {
23 | e.preventDefault();
24 | const formData = new FormData(e.currentTarget);
25 |
26 | // Filter out empty values and create a new object with only filled fields
27 | const values = Object.fromEntries(
28 | Array.from(formData.entries()).filter(([, value]) => {
29 | // Check for empty strings, undefined, or null
30 | return value !== "" && value != null;
31 | })
32 | );
33 |
34 | setFormValues(Object.keys(values).length > 0 ? values : null);
35 | };
36 |
37 | const formatFieldName = (fieldName: string) => {
38 | // Remove array notation and split by underscores
39 | return fieldName
40 | .replace(/\[\d+\]/g, "")
41 | .split("_")
42 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
43 | .join(" ");
44 | };
45 |
46 | const renderFormValues = () => {
47 | if (!formValues) return null;
48 |
49 | return Object.entries(formValues).map(([key, value]) => (
50 |
51 |
{formatFieldName(key)}
52 |
{String(value)}
53 |
54 | ));
55 | };
56 |
57 | return (
58 |
59 |
60 |
}
72 | >
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
89 |
Filled Form Values
90 | {formValues && Object.keys(formValues).length > 0 ? (
91 |
92 | {renderFormValues()}
93 |
94 | ) : (
95 |
No form values have been entered yet
96 | )}
97 |
98 |
99 | );
100 | };
101 |
102 | export default PdfFormLayer;
103 |
--------------------------------------------------------------------------------
/packages/docs/components/search-control.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | CanvasLayer,
5 | HighlightLayer,
6 | Page,
7 | Pages,
8 | Root,
9 | Search,
10 | TextLayer,
11 | } from "@anaralabs/lector";
12 |
13 | import "@/lib/setup";
14 | import { SearchUI, SearchUIFullHighlight } from "./custom-search";
15 |
16 | const fileUrl = "/pdf/pathways.pdf";
17 |
18 | const ViewerZoomControl = () => {
19 | return (
20 |
21 |
22 |
Exact Search Term Highlighting
23 |
This viewer highlights only the exact search term you type
24 |
Loading...}
28 | >
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
Full Context Highlighting
44 |
This viewer highlights the entire text chunk containing your search term
45 |
Loading... }
49 | >
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | );
64 | };
65 |
66 | export default ViewerZoomControl;
67 |
--------------------------------------------------------------------------------
/packages/docs/components/thumbnails.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | CanvasLayer,
5 | CurrentPage,
6 | CurrentZoom,
7 | Page,
8 | Pages,
9 | Root,
10 | TextLayer,
11 | Thumbnail,
12 | Thumbnails,
13 | ZoomIn,
14 | ZoomOut,
15 | } from "@anaralabs/lector";
16 | import { cn } from "fumadocs-ui/components/api";
17 | import { useState } from "react";
18 |
19 | import "@/lib/setup";
20 |
21 | const fileUrl = "/pdf/pathways.pdf";
22 |
23 | const WithThumbnails = () => {
24 | const [showThumbnails, setShowThumbnails] = useState(true);
25 |
26 | return (
27 | Loading...}
31 | >
32 |
33 |
39 |
40 | Page
41 |
42 | Zoom
43 | -
44 |
45 | +
46 |
47 |
48 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | );
71 | };
72 |
73 | export default WithThumbnails;
74 |
--------------------------------------------------------------------------------
/packages/docs/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean;
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button";
45 | return (
46 |
51 | );
52 | }
53 | );
54 | Button.displayName = "Button";
55 |
56 | export { Button, buttonVariants };
57 |
--------------------------------------------------------------------------------
/packages/docs/components/ui/feature-card.tsx:
--------------------------------------------------------------------------------
1 | import { LucideIcon } from "lucide-react";
2 |
3 | interface FeatureCardProps {
4 | icon: LucideIcon;
5 | title: string;
6 | description: string;
7 | }
8 |
9 | export function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
{title}
17 |
{description}
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/packages/docs/components/ui/icon.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import type { LucideIcon, LucideProps } from "lucide-react";
3 | import type { MouseEventHandler } from "react";
4 |
5 | export interface IconProps {
6 | as: LucideIcon | ((props: LucideProps) => React.JSX.Element) | null;
7 | size?: "xs" | "sm" | "md" | "lg" | "xl";
8 | className?: string | boolean;
9 | onClick?: MouseEventHandler;
10 | strokeWidth?: number; // Added strokeWidth prop
11 | }
12 |
13 | const sizes = {
14 | xs: 12,
15 | sm: 14,
16 | md: 15,
17 | lg: 20,
18 | xl: 24,
19 | };
20 |
21 | export const Icon = ({
22 | as: IconComponent,
23 | size = "md",
24 | className,
25 | onClick,
26 | strokeWidth = 2,
27 | }: IconProps) => {
28 | if (!IconComponent) return null;
29 | return (
30 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/packages/docs/components/ui/page-navigation-buttons.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useEffect, useState } from "react";
4 | import { usePdf, usePdfJump } from "@anaralabs/lector";
5 | import { Button } from "./button";
6 |
7 | const PageNavigationButtons = () => {
8 | const pages = usePdf((state) => state.pdfDocumentProxy?.numPages);
9 | const currentPage = usePdf((state) => state.currentPage);
10 | const [pageNumber, setPageNumber] = useState(currentPage);
11 | const { jumpToPage } = usePdfJump();
12 |
13 | const handlePreviousPage = () => {
14 | if (currentPage > 1) {
15 | jumpToPage(currentPage - 1, { behavior: "auto" });
16 | }
17 | };
18 |
19 | const handleNextPage = () => {
20 | if (currentPage < pages) {
21 | jumpToPage(currentPage + 1, { behavior: "auto" });
22 | }
23 | };
24 |
25 | useEffect(() => {
26 | setPageNumber(currentPage);
27 | }, [currentPage]);
28 |
29 | return (
30 |
31 |
53 |
54 |
55 | setPageNumber(e.target.value)}
59 | onBlur={(e) => {
60 | const value = Number(e.target.value);
61 | if (value >= 1 && value <= pages && currentPage !== value) {
62 | jumpToPage(value, { behavior: "auto" });
63 | } else {
64 | setPageNumber(currentPage);
65 | }
66 | }}
67 | onKeyDown={(e) => {
68 | if (e.key === "Enter") {
69 | e.currentTarget.blur();
70 | }
71 | }}
72 | className="w-12 h-7 text-center bg-gray-50 border border-gray-200 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
73 | />
74 |
75 | / {pages || 1}
76 |
77 |
78 |
79 |
101 |
102 | );
103 | };
104 |
105 | export default PageNavigationButtons;
106 |
--------------------------------------------------------------------------------
/packages/docs/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/packages/docs/components/zoom-control.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | CanvasLayer,
5 | CurrentZoom,
6 | Page,
7 | Pages,
8 | Root,
9 | TextLayer,
10 | ZoomIn,
11 | ZoomOut,
12 | } from "@anaralabs/lector";
13 |
14 | import "@/lib/setup";
15 |
16 | const fileUrl = "/pdf/large.pdf";
17 |
18 | const ViewerZoomControl = () => {
19 | return (
20 | Loading...}
24 | >
25 |
26 | Zoom
27 | -
28 |
29 | +
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | export default ViewerZoomControl;
42 |
--------------------------------------------------------------------------------
/packages/docs/content/docs/basic-usage.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Basic Usage
3 | description: Learn how to use Lector's core components and features
4 | ---
5 |
6 | Lector provides a set of composable components that you can use to build a custom PDF viewer. This guide will walk you through the core concepts and basic usage.
7 |
8 | ## Core Concepts
9 |
10 | Lector follows a component-based architecture with three main layers:
11 |
12 | 1. **Root Container**: Manages the PDF document state and context
13 | 2. **Pages Container**: Handles page layout and virtualization
14 | 3. **Layer Components**: Render different aspects of each page (canvas, text, annotations)
15 |
16 | ## Basic Setup
17 |
18 | First, set up the PDF.js worker:
19 |
20 | ```tsx
21 | import { GlobalWorkerOptions } from "pdfjs-dist";
22 | import "pdfjs-dist/web/pdf_viewer.css";
23 |
24 | // Set up the worker
25 | GlobalWorkerOptions.workerSrc = new URL(
26 | "pdfjs-dist/build/pdf.worker.mjs",
27 | import.meta.url
28 | ).toString();
29 | ```
30 |
31 | ## Creating a Basic Viewer
32 |
33 | Here's a minimal example of a PDF viewer:
34 |
35 | ```tsx
36 | import { Root, Pages, Page, CanvasLayer, TextLayer } from "@anaralabs/lector";
37 |
38 | function PDFViewer() {
39 | return (
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 | ```
51 |
52 | ## Core Components
53 |
54 | ### Root Component
55 |
56 | The `Root` component is the main container that manages the PDF document state:
57 |
58 | ```tsx
59 | } // Custom loading component
63 | onLoad={handleLoad} // Called when PDF is loaded
64 | onError={handleError} // Called on load error
65 | >
66 | {/* Child components */}
67 |
68 | ```
69 |
70 | ### Pages Component
71 |
72 | The `Pages` component handles page layout and virtualization:
73 |
74 | ```tsx
75 |
78 | {/* Page component */}
79 |
80 | ```
81 |
82 | ### Page Layers
83 |
84 | Each page can have multiple layers for different functionality:
85 |
86 | ```tsx
87 |
88 | {/* Renders the PDF content */}
89 | {/* Enables text selection */}
90 | {/* Renders annotations and links */}
91 | {/* Custom highlight overlay */}
92 |
93 | ```
94 |
95 | ## Common Features
96 |
97 | ### Adding Zoom Controls
98 |
99 | ```tsx
100 | import { ZoomIn, ZoomOut, CurrentZoom } from "@anaralabs/lector";
101 |
102 | function PDFViewerWithZoom() {
103 | return (
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | );
118 | }
119 | ```
120 |
121 | ### Adding Page Navigation
122 |
123 | ```tsx
124 | import { CurrentPage, TotalPages } from "@anaralabs/lector";
125 |
126 | function PDFViewerWithNavigation() {
127 | return (
128 |
129 |
130 | of
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 | );
140 | }
141 | ```
142 |
143 | ## Supported Features
144 |
145 | Lector includes support for:
146 |
147 | - 📱 Responsive layout with automatic page scaling
148 | - 🖱️ Pan and zoom with mouse/touch controls
149 | - ✨ Text selection and copying
150 | - 🔗 Internal and external link handling
151 | - 📑 Page thumbnails and outline navigation
152 | - 🎨 Custom rendering and annotations
153 | - 🌗 Dark mode support
154 |
155 | ## Next Steps
156 |
157 | Check out these guides to learn more:
158 |
159 |
160 |
165 |
170 |
171 |
172 |
--------------------------------------------------------------------------------
/packages/docs/content/docs/code/basic.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Basic Example
3 | description: A minimal PDF viewer implementation with basic rendering capabilities
4 | full: true
5 | ---
6 |
7 | import Basic from "@/components/basic.tsx";
8 |
9 |
10 |
11 | ## Implementation
12 |
13 | ```tsx
14 | "use client";
15 |
16 | import { CanvasLayer, Page, Pages, Root, TextLayer } from "@anaralabs/lector";
17 | import React from "react";
18 | import "@/lib/setup";
19 |
20 | const fileUrl = "/pdf/pathways.pdf";
21 |
22 | const Basic = () => {
23 | return (
24 | Loading...}>
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | };
37 |
38 | export default Basic;
39 | ```
40 |
41 | The example above shows:
42 |
43 | - Basic PDF viewer setup with a fixed height container
44 | - Dark mode support with color adjustments
45 | - Loading state handling
46 | - Canvas rendering of PDF pages
47 | - Text layer for text selection and copying
48 |
--------------------------------------------------------------------------------
/packages/docs/content/docs/code/highlight.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Highlight
3 | description: Add interactive highlights to your PDFs with smooth navigation and clickable annotations
4 | ---
5 |
6 | import HighlightDemo from "@/components/highlight-layer";
7 |
8 |
9 |
10 | ## Basic Usage
11 |
12 | At its core, the PDF Highlight Layer lets you highlight sections of a PDF and navigate between them:
13 |
14 | ```tsx
15 | "use client";
16 |
17 | import {
18 | Root,
19 | Pages,
20 | Page,
21 | CanvasLayer,
22 | TextLayer,
23 | HighlightLayer,
24 | } from "@anaralabs/lector";
25 |
26 | export default function MyPdfViewer() {
27 | return (
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 | ```
40 |
41 | ## Adding Interactive Highlights
42 |
43 | To make highlights interactive, use the `usePdfJump` hook. Here's a complete example:
44 |
45 | ```tsx
46 | "use client";
47 |
48 | const HighlightLayerContent = () => {
49 | const { jumpToHighlightRects } = usePdfJump();
50 | const [selectedExample, setSelectedExample] = useState(null);
51 |
52 | const handleExampleClick = async (example: (typeof examples)[0]) => {
53 | setSelectedExample(example.text);
54 | jumpToHighlightRects(example.highlights, "pixels");
55 | };
56 |
57 | return (
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | {examples.map((example) => (
70 |
handleExampleClick(example)}
73 | className={`p-3 rounded ${
74 | selectedExample === example.text
75 | ? "bg-yellow-100"
76 | : "hover:bg-gray-50"
77 | }`}
78 | >
79 |
{example.title}
80 |
{example.text}
81 |
82 | ))}
83 |
84 |
85 | );
86 | };
87 | ```
88 |
89 | ## Highlight Format
90 |
91 | Highlights are defined using pixel coordinates:
92 |
93 | ```tsx
94 | const highlight = {
95 | pageNumber: 1,
96 | left: 63.069,
97 | top: 438.736,
98 | width: 465.831,
99 | height: 10.888,
100 | };
101 | ```
102 |
103 | ## Custom Styling
104 |
105 | The HighlightLayer component accepts className props for custom styling:
106 |
107 | ```tsx
108 | // Yellow highlight with 70% opacity
109 |
110 |
111 | // Custom color
112 |
113 |
114 | // Multiple styles
115 |
116 | ```
117 |
118 | ## Loading States
119 |
120 | Add a loading state to improve user experience:
121 |
122 | ```tsx
123 | Loading PDF...}
127 | >
128 |
129 |
130 | ```
131 |
132 | ## TypeScript Support
133 |
134 | Full TypeScript support is included. Example type for highlight rectangles:
135 |
136 | ```tsx
137 | type HighlightRect = {
138 | pageNumber: number;
139 | left: number;
140 | top: number;
141 | width: number;
142 | height: number;
143 | };
144 | ```
145 |
146 | ## Best Practices
147 |
148 | - Always provide fallback content with the `loader` prop
149 | - Use relative units (pixels) for highlight coordinates
150 | - Handle errors when jumping to highlights
151 | - Keep highlight areas within document bounds
152 |
--------------------------------------------------------------------------------
/packages/docs/content/docs/code/page-navigation.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Custom Navigation buttons
3 | description: Showcase of custom navigation buttons
4 | ---
5 |
6 | import PageNavigation from "@/components/page-navigation-example.tsx";
7 |
8 |
9 |
10 | ## Basic Usage
11 |
12 | Create a PDF viewer with custom navigation controls:
13 |
14 | ```tsx
15 | "use client";
16 |
17 | import { Root, Pages, Page, CanvasLayer, TextLayer } from "@anaralabs/lector";
18 | import PageNavigationButtons from "./ui/page-navigation-buttons";
19 |
20 | const PageNavigation = () => {
21 | return (
22 | Loading...}
26 | >
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | export default PageNavigation;
41 | ```
42 |
43 | ## Navigation Buttons Implementation
44 |
45 | Create custom navigation controls with page jumping and input:
46 |
47 | ```tsx
48 | import { usePdf, usePdfJump } from "@anaralabs/lector";
49 | import { useEffect, useState } from "react";
50 | import { Button } from "./button";
51 |
52 | const PageNavigationButtons = () => {
53 | const pages = usePdf((state) => state.pdfDocumentProxy?.numPages);
54 | const currentPage = usePdf((state) => state.currentPage);
55 | const [pageNumber, setPageNumber] = useState(currentPage);
56 | const { jumpToPage } = usePdfJump();
57 |
58 | const handlePreviousPage = () => {
59 | if (currentPage > 1) {
60 | jumpToPage(currentPage - 1, { behavior: "auto" });
61 | }
62 | };
63 |
64 | const handleNextPage = () => {
65 | if (currentPage < pages) {
66 | jumpToPage(currentPage + 1, { behavior: "auto" });
67 | }
68 | };
69 |
70 | useEffect(() => {
71 | setPageNumber(currentPage);
72 | }, [currentPage]);
73 |
74 | return (
75 |
76 |
91 |
92 |
93 | setPageNumber(e.target.value)}
97 | onBlur={(e) => {
98 | const value = Number(e.target.value);
99 | if (value >= 1 && value <= pages && currentPage !== value) {
100 | jumpToPage(value, { behavior: "auto" });
101 | } else {
102 | setPageNumber(currentPage);
103 | }
104 | }}
105 | onKeyDown={(e) => {
106 | if (e.key === "Enter") {
107 | e.currentTarget.blur();
108 | }
109 | }}
110 | className="w-12 h-7 text-center bg-gray-50 border rounded-md text-sm focus:ring-2"
111 | />
112 |
113 | / {pages || 1}
114 |
115 |
116 |
117 |
132 |
133 | );
134 | };
135 |
136 | export default PageNavigationButtons;
137 | ```
138 |
139 | ## Features
140 |
141 | - Previous and next page navigation
142 | - Direct page number input
143 | - Smooth page transitions
144 | - Current page indicator
145 | - Total pages display
146 | - Keyboard navigation support
147 |
148 | ## Best Practices
149 |
150 | - Include keyboard navigation support
151 | - Use aria-labels for accessibility
152 | - Validate page number input
153 | - Implement smooth transitions
154 |
--------------------------------------------------------------------------------
/packages/docs/content/docs/code/select.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Select
3 | description: Select is a component that allows you to highlight the selected text in a PDF.
4 | ---
5 |
6 | import PdfHighlightSelect from "@/components/highlight-select";
7 |
8 |
9 |
10 | ## Basic Usage
11 |
12 | Create a PDF viewer with text selection and highlighting capabilities:
13 |
14 | ```tsx
15 | "use client";
16 |
17 | import {
18 | Root,
19 | Pages,
20 | Page,
21 | CanvasLayer,
22 | TextLayer,
23 | HighlightLayer,
24 | useSelectionDimensions,
25 | usePdf,
26 | } from "@anaralabs/lector";
27 |
28 | const PdfHighlightSelect = () => (
29 | Loading...}
33 | >
34 |
35 |
36 | );
37 |
38 | export default PdfHighlightSelect;
39 | ```
40 |
41 | ## Highlight Layer Implementation
42 |
43 | Create a component to handle text selection and highlighting:
44 |
45 | ```tsx
46 | const HighlightLayerContent = () => {
47 | const selectionDimensions = useSelectionDimensions();
48 | const setHighlights = usePdf((state) => state.setHighlight);
49 |
50 | const handleHighlight = () => {
51 | const dimension = selectionDimensions.getDimension();
52 | if (dimension && !dimension.isCollapsed) {
53 | setHighlights(dimension.highlights);
54 | }
55 | };
56 |
57 | return (
58 |
59 |
60 | {selectionDimensions && }
61 |
62 |
63 |
64 |
65 |
66 | );
67 | };
68 | ```
69 |
70 | ## Custom Selection Tooltip
71 |
72 | Add a custom tooltip that appears when text is selected:
73 |
74 | ```tsx
75 | import { SelectionTooltip } from "@anaralabs/lector";
76 |
77 | export const CustomSelect = ({ onHighlight }: { onHighlight: () => void }) => {
78 | return (
79 |
80 |
86 |
87 | );
88 | };
89 | ```
90 |
91 | ## Features
92 |
93 | - Text selection with visual feedback
94 | - Custom tooltip on selection
95 | - Highlight selected text
96 | - Persistent highlights
97 |
98 | ## Best Practices
99 |
100 | - Add visual feedback for text selection
101 | - Include hover states for better UX
102 | - Handle collapsed selections
103 | - Add keyboard navigation support
104 |
--------------------------------------------------------------------------------
/packages/docs/content/docs/code/thumbnails.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Thumbnails
3 | description: Using the basic PDF viewer with thumbnails navigation
4 | ---
5 |
6 | import WithThumbnails from "@/components/thumbnails.tsx";
7 |
8 |
9 |
10 | ## Implementation
11 |
12 | Create a PDF viewer with thumbnail navigation and zoom controls:
13 |
14 | ```tsx
15 | "use client";
16 |
17 | import {
18 | CanvasLayer,
19 | CurrentPage,
20 | CurrentZoom,
21 | Page,
22 | Pages,
23 | Root,
24 | TextLayer,
25 | Thumbnail,
26 | Thumbnails,
27 | ZoomIn,
28 | ZoomOut,
29 | } from "@anaralabs/lector";
30 | import { cn } from "fumadocs-ui/components/api";
31 | import { useState } from "react";
32 |
33 | const WithThumbnails = () => {
34 | const [showThumbnails, setShowThumbnails] = useState(true);
35 |
36 | return (
37 | Loading...}
41 | >
42 | {/* Control Bar */}
43 |
44 |
50 |
51 | Page
52 |
53 | Zoom
54 | -
55 |
56 | +
57 |
58 |
59 |
60 | {/* Main Content */}
61 |
68 | {/* Thumbnails Panel */}
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | {/* PDF Viewer */}
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | );
87 | };
88 |
89 | export default WithThumbnails;
90 | ```
91 |
92 | ## Features
93 |
94 | - Scrollable thumbnail previews
95 | - Visual feedback on hover
96 | - Automatic page synchronization
97 | - Responsive sizing
98 |
99 | ## Best Practices
100 |
101 | - Use proper state management for toggles
102 | - Include loading states for better UX
103 | - Implement smooth transitions
104 | - Add hover effects for interaction feedback
105 | - Optimize thumbnail rendering
106 |
--------------------------------------------------------------------------------
/packages/docs/content/docs/code/zoom-control.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Zoom Control
3 | description: PDF viewer with zoom controls and configurable zoom limits
4 | ---
5 |
6 | import ViewerZoomControl from "@/components/zoom-control.tsx";
7 |
8 |
9 |
10 | ## Implementation
11 |
12 | ```tsx
13 | "use client";
14 |
15 | import {
16 | CanvasLayer,
17 | CurrentZoom,
18 | Page,
19 | Pages,
20 | Root,
21 | TextLayer,
22 | ZoomIn,
23 | ZoomOut,
24 | } from "@anaralabs/lector";
25 |
26 | const ViewerZoomControl = () => {
27 | return (
28 | Loading...}
32 | zoomOptions={{
33 | minZoom: 0.5, // 50% minimum zoom
34 | maxZoom: 10, // 1000% maximum zoom
35 | }}
36 | >
37 |
38 | Zoom
39 | -
40 |
41 | +
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | );
51 | };
52 |
53 | export default ViewerZoomControl;
54 | ```
55 |
56 | ## Features
57 |
58 | - Interactive zoom controls with real-time display
59 | - Configurable zoom limits through `zoomOptions` prop
60 | - Clean, minimal interface
61 | - Text selection support
62 | - Responsive layout
63 |
64 | ## Zoom Configuration
65 |
66 | You can customize the zoom limits by passing `zoomOptions` to the Root component:
67 |
68 | ```tsx
69 |
75 | ```
76 |
77 | Default zoom limits if not specified:
78 |
79 | - Minimum zoom: 0.1 (10%)
80 | - Maximum zoom: 10 (1000%)
81 |
82 | ## Best Practices
83 |
84 | - Implement responsive zoom increments
85 | - Include proper loading states
86 | - Maintain consistent styling
87 | - Consider user experience when setting custom zoom limits
88 | - Test text readability at custom zoom levels
89 |
--------------------------------------------------------------------------------
/packages/docs/content/docs/dark-mode.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Dark Mode
3 | description: Learn about Lector's dark mode implementation using CSS filters and future plans
4 | ---
5 |
6 | ## Current Approach
7 |
8 | Lector implements dark mode support through custom CSS filters since PDF.js currently doesn't provide native dark mode support. This approach allows us to maintain compatibility while providing a good dark mode experience for users.
9 |
10 | ## Implementation Details
11 |
12 | We use CSS filters to invert colors and adjust them for better readability in dark mode. This solution, while not perfect, provides a reasonable dark mode experience without requiring modifications to PDF.js itself.
13 |
14 | ```tsx
15 | import { Root, Pages, Page, CanvasLayer } from "@anaralabs/lector";
16 |
17 | function PDFViewer() {
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | }
28 | ```
29 |
30 | ## Current Limitations
31 |
32 | The CSS filter approach has some limitations:
33 |
34 | 1. Color accuracy might not be perfect for all PDF documents
35 | 2. Some PDFs with complex color schemes might not render optimally
36 | 3. Performance impact on larger documents
37 |
38 | ## Future Plans
39 |
40 | We are actively monitoring the PDF.js development regarding native dark mode support. As referenced in [PDF.js Issue #2071](https://github.com/mozilla/pdf.js/issues/2071), there are currently no plans to implement this feature in PDF.js itself.
41 |
42 | ### Forking PDF.js
43 |
44 | **Forking PDF.js**: We might fork PDF.js in the future to implement proper dark mode support directly in the rendering pipeline.
45 |
--------------------------------------------------------------------------------
/packages/docs/content/docs/installation.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Installation
3 | description: Complete guide to installing and setting up Lector in your React application
4 | ---
5 |
6 | ## Prerequisites
7 |
8 | Before installing Lector, make sure you have:
9 |
10 | - Node.js 16.0 or later
11 | - A React project (version 16.8 or later)
12 | - npm, yarn, pnpm, or bun package manager
13 |
14 | ## Installing the Package
15 |
16 | 1. Install the main package and its peer dependency:
17 |
18 | ```bash
19 | # Using npm
20 | npm install @anaralabs/lector pdfjs-dist
21 |
22 | # Using yarn
23 | yarn add @anaralabs/lector pdfjs-dist
24 |
25 | # Using pnpm
26 | pnpm add @anaralabs/lector pdfjs-dist
27 |
28 | # Using bun
29 | bun add @anaralabs/lector pdfjs-dist
30 | ```
31 |
32 | ## Setting up PDF.js Worker
33 |
34 | 2. Configure the PDF.js worker in your application:
35 |
36 | ### App Router (default)
37 |
38 | In your PDF viewer component:
39 |
40 | ```tsx
41 | import { GlobalWorkerOptions } from "pdfjs-dist";
42 | import "pdfjs-dist/web/pdf_viewer.css";
43 |
44 | // Set up the worker
45 | GlobalWorkerOptions.workerSrc = new URL(
46 | "pdfjs-dist/build/pdf.worker.mjs",
47 | import.meta.url
48 | ).toString();
49 |
50 | export function PDFViewer() {
51 | return (
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | );
60 | }
61 | ```
62 |
63 | ### Pages Directory
64 |
65 | In your PDF viewer component:
66 |
67 | ```tsx
68 | import { Root, Pages, Page, CanvasLayer } from "@anaralabs/lector";
69 | import "pdfjs-dist/build/pdf.worker.min.mjs";
70 | import "pdfjs-dist/web/pdf_viewer.css";
71 |
72 | export function PDFViewer() {
73 | return (
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | );
82 | }
83 | ```
84 |
85 | ## Required Styles
86 |
87 | 3. Import the required CSS styles. You can do this in your main CSS file or component:
88 |
89 | ```tsx
90 | // Import the default PDF viewer styles
91 | import "pdfjs-dist/web/pdf_viewer.css";
92 | ```
93 |
94 | ## Verifying Installation
95 |
96 | To verify that Lector is properly installed, create a basic PDF viewer component:
97 |
98 | ```tsx
99 | import { Root, Pages, Page, CanvasLayer } from "@anaralabs/lector";
100 |
101 | function BasicPDFViewer() {
102 | return (
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 | );
111 | }
112 | ```
113 |
114 | ## Troubleshooting
115 |
116 | If you encounter any issues during installation:
117 |
118 | 1. Make sure all peer dependencies are installed
119 | 2. Check that the PDF.js worker is properly configured
120 | 3. Verify that the required CSS files are imported
121 | 4. Ensure your React version is compatible
122 |
123 | ## Next Steps
124 |
125 | Now that you have Lector installed, you can:
126 |
127 |
128 |
133 |
134 |
135 |
--------------------------------------------------------------------------------
/packages/docs/content/docs/meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Lector Documentation",
3 | "description": "A composable, headless PDF viewer toolkit for React applications",
4 | "root": true,
5 | "pages": [
6 | "---Getting Started---",
7 | "installation",
8 | "basic-usage",
9 | "---Components---",
10 | "code/basic",
11 | "code/thumbnails",
12 | "---Advanced---",
13 | "code",
14 | "dark-mode"
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/packages/docs/lib/metadata.ts:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next/types";
2 |
3 | export function createMetadata(override: Metadata): Metadata {
4 | return {
5 | ...override,
6 | openGraph: {
7 | title: override.title ?? undefined,
8 | description: override.description ?? undefined,
9 | url: "https://lector-weld.vercel.app/",
10 | images: "/banner.png",
11 | siteName: "Fumadocs",
12 | ...override.openGraph,
13 | },
14 | twitter: {
15 | card: "summary_large_image",
16 | creator: "@andrewdorobantu",
17 | title: override.title ?? undefined,
18 | description: override.description ?? undefined,
19 | images: "/banner.png",
20 | ...override.twitter,
21 | },
22 | };
23 | }
24 |
25 | export const baseUrl =
26 | process.env.NODE_ENV === "development" || !process.env.VERCEL_URL
27 | ? new URL("http://localhost:3000")
28 | : new URL(`https://${process.env.VERCEL_URL}`);
29 |
--------------------------------------------------------------------------------
/packages/docs/lib/setup.ts:
--------------------------------------------------------------------------------
1 | import "pdfjs-dist/web/pdf_viewer.css";
2 | import { GlobalWorkerOptions } from "pdfjs-dist";
3 |
4 | GlobalWorkerOptions.workerSrc = new URL(
5 | "pdfjs-dist/build/pdf.worker.mjs",
6 | import.meta.url
7 | ).toString();
8 |
--------------------------------------------------------------------------------
/packages/docs/lib/shiki.ts:
--------------------------------------------------------------------------------
1 | import { getHighlighter, type Highlighter } from "shiki";
2 |
3 | let highlighterPromise: Promise;
4 |
5 | export async function highlight(code: string, lang = "tsx") {
6 | if (!highlighterPromise) {
7 | highlighterPromise = getHighlighter({
8 | themes: ["github-dark"],
9 | langs: ["typescript", "tsx", "javascript", "jsx"],
10 | });
11 | }
12 |
13 | const highlighter = await highlighterPromise;
14 | return highlighter.codeToHtml(code, { lang, theme: "github-dark" });
15 | }
16 |
--------------------------------------------------------------------------------
/packages/docs/lib/source.ts:
--------------------------------------------------------------------------------
1 | import { docs, meta } from '@/.source';
2 | import { createMDXSource } from 'fumadocs-mdx';
3 | import { loader } from 'fumadocs-core/source';
4 |
5 | export const source = loader({
6 | baseUrl: '/docs',
7 | source: createMDXSource(docs, meta),
8 | });
9 |
--------------------------------------------------------------------------------
/packages/docs/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/packages/docs/next.config.mjs:
--------------------------------------------------------------------------------
1 | import { createMDX } from "fumadocs-mdx/next";
2 |
3 | const withMDX = createMDX();
4 |
5 | /** @type {import('next').NextConfig} */
6 | const config = {
7 | reactStrictMode: true,
8 | };
9 |
10 | export default withMDX(config);
11 |
--------------------------------------------------------------------------------
/packages/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "docs",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "dev": "next dev",
8 | "start": "next start",
9 | "postinstall": "fumadocs-mdx"
10 | },
11 | "dependencies": {
12 | "@anaralabs/lector": "workspace:*",
13 | "@radix-ui/react-dropdown-menu": "^2.1.4",
14 | "@radix-ui/react-popover": "^1.1.4",
15 | "@radix-ui/react-slot": "^1.1.0",
16 | "class-variance-authority": "^0.7.1",
17 | "clsx": "^2.1.1",
18 | "fumadocs-core": "14.6.2",
19 | "fumadocs-mdx": "11.1.2",
20 | "fumadocs-ui": "14.6.2",
21 | "lucide-react": "^0.469.0",
22 | "next": "15.1.3",
23 | "pdfjs-dist": "^5.0.375",
24 | "react": "^19.0.0",
25 | "react-dom": "^19.0.0",
26 | "shiki": "^1.24.4",
27 | "tailwind-merge": "^2.6.0",
28 | "tailwindcss-animate": "^1.0.7",
29 | "use-debounce": "^10.0.4",
30 | "uuid": "^11.1.0"
31 | },
32 | "devDependencies": {
33 | "@types/mdx": "^2.0.13",
34 | "@types/node": "22.10.2",
35 | "@types/react": "^19.0.1",
36 | "@types/react-dom": "^19.0.2",
37 | "autoprefixer": "^10.4.20",
38 | "eslint": "^8",
39 | "eslint-config-next": "15.1.0",
40 | "postcss": "^8.4.49",
41 | "tailwindcss": "^3.4.16",
42 | "typescript": "^5.7.2"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/packages/docs/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/packages/docs/public/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/docs/public/banner.png
--------------------------------------------------------------------------------
/packages/docs/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/docs/public/favicon.ico
--------------------------------------------------------------------------------
/packages/docs/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/docs/public/logo.png
--------------------------------------------------------------------------------
/packages/docs/public/pdf/brochure.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/docs/public/pdf/brochure.pdf
--------------------------------------------------------------------------------
/packages/docs/public/pdf/expensive.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/docs/public/pdf/expensive.pdf
--------------------------------------------------------------------------------
/packages/docs/public/pdf/form.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/docs/public/pdf/form.pdf
--------------------------------------------------------------------------------
/packages/docs/public/pdf/large.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/docs/public/pdf/large.pdf
--------------------------------------------------------------------------------
/packages/docs/public/pdf/pathways.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/docs/public/pdf/pathways.pdf
--------------------------------------------------------------------------------
/packages/docs/source.config.ts:
--------------------------------------------------------------------------------
1 | import { defineDocs, defineConfig } from 'fumadocs-mdx/config';
2 |
3 | export const { docs, meta } = defineDocs({
4 | dir: 'content/docs',
5 | });
6 |
7 | export default defineConfig();
8 |
--------------------------------------------------------------------------------
/packages/docs/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import { createPreset } from "fumadocs-ui/tailwind-plugin";
2 | import tailwindcssAnimate from "tailwindcss-animate";
3 |
4 | /** @type {import('tailwindcss').Config} */
5 | // eslint-disable-next-line import/no-anonymous-default-export
6 | export default {
7 | darkMode: ["class"],
8 | content: [
9 | "./components/**/*.{ts,tsx}",
10 | "./app/**/*.{ts,tsx}",
11 | "./content/**/*.{md,mdx}",
12 | "./mdx-components.{ts,tsx}",
13 | "./node_modules/fumadocs-ui/dist/**/*.js",
14 | "../../node_modules/fumadocs-ui/dist/**/*.js",
15 | ],
16 | presets: [
17 | createPreset({
18 | cssPrefix: "fuma-",
19 | }),
20 | ],
21 | plugins: [tailwindcssAnimate],
22 | theme: {
23 | extend: {
24 | borderRadius: {
25 | lg: "var(--radius)",
26 | md: "calc(var(--radius) - 2px)",
27 | sm: "calc(var(--radius) - 4px)",
28 | },
29 | colors: {
30 | background: "hsl(var(--background))",
31 | foreground: "hsl(var(--foreground))",
32 | card: {
33 | DEFAULT: "hsl(var(--card))",
34 | foreground: "hsl(var(--card-foreground))",
35 | },
36 | popover: {
37 | DEFAULT: "hsl(var(--popover))",
38 | foreground: "hsl(var(--popover-foreground))",
39 | },
40 | primary: {
41 | DEFAULT: "hsl(var(--primary))",
42 | foreground: "hsl(var(--primary-foreground))",
43 | },
44 | secondary: {
45 | DEFAULT: "hsl(var(--secondary))",
46 | foreground: "hsl(var(--secondary-foreground))",
47 | },
48 | muted: {
49 | DEFAULT: "hsl(var(--muted))",
50 | foreground: "hsl(var(--muted-foreground))",
51 | },
52 | accent: {
53 | DEFAULT: "hsl(var(--accent))",
54 | foreground: "hsl(var(--accent-foreground))",
55 | },
56 | destructive: {
57 | DEFAULT: "hsl(var(--destructive))",
58 | foreground: "hsl(var(--destructive-foreground))",
59 | },
60 | border: "hsl(var(--border))",
61 | input: "hsl(var(--input))",
62 | ring: "hsl(var(--ring))",
63 | chart: {
64 | 1: "hsl(var(--chart-1))",
65 | 2: "hsl(var(--chart-2))",
66 | 3: "hsl(var(--chart-3))",
67 | 4: "hsl(var(--chart-4))",
68 | 5: "hsl(var(--chart-5))",
69 | },
70 | },
71 | },
72 | },
73 | };
74 |
--------------------------------------------------------------------------------
/packages/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "target": "ESNext",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "strict": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "bundler",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "incremental": true,
18 | "paths": {
19 | "@/*": ["./*"]
20 | },
21 | "plugins": [
22 | {
23 | "name": "next"
24 | }
25 | ]
26 | },
27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28 | "exclude": ["node_modules"]
29 | }
30 |
--------------------------------------------------------------------------------
/packages/lector/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
4 | .env
5 | .env.development
6 |
7 | bun.lockb
--------------------------------------------------------------------------------
/packages/lector/.npmignore:
--------------------------------------------------------------------------------
1 | static
2 |
3 | *.jpg
4 | *.png
5 |
6 | .github
7 |
--------------------------------------------------------------------------------
/packages/lector/.prettierrc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/lector/.prettierrc
--------------------------------------------------------------------------------
/packages/lector/.tsbuildinfo:
--------------------------------------------------------------------------------
1 | {"version":"5.7.2"}
--------------------------------------------------------------------------------
/packages/lector/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/lector/banner.png
--------------------------------------------------------------------------------
/packages/lector/capture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/lector/capture.png
--------------------------------------------------------------------------------
/packages/lector/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | //eslint.config.mjs
2 |
3 | import pluginJs from "@eslint/js";
4 | import tseslint from "typescript-eslint";
5 | import pluginReact from "eslint-plugin-react";
6 | import pluginReactHooks from "eslint-plugin-react-hooks";
7 | import eslintConfigPrettier from "eslint-config-prettier";
8 | import simpleImportSort from "eslint-plugin-simple-import-sort";
9 |
10 | export default [
11 | pluginJs.configs.recommended,
12 | {
13 | files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
14 | settings: {
15 | react: {
16 | version: "detect",
17 | },
18 | },
19 | languageOptions: {
20 | parserOptions: {
21 | ecmaFeatures: {
22 | jsx: true,
23 | },
24 | },
25 | },
26 | },
27 | {
28 | ignores: [
29 | "**/dist/*",
30 | "**/*.config.{js,mjs,cjs,ts}",
31 | "**/node_modules/*",
32 | "**/.tsup/*",
33 | ],
34 | },
35 | ...tseslint.configs.recommended,
36 | pluginReact.configs.flat.recommended,
37 | eslintConfigPrettier,
38 | {
39 | plugins: {
40 | "react-hooks": pluginReactHooks,
41 | },
42 | rules: {
43 | "react/react-in-jsx-scope": "off",
44 | ...pluginReactHooks.configs.recommended.rules,
45 | },
46 | ignores: ["*.test.tsx"],
47 | },
48 | // Custom Rules should be added below so that they overwrite any defaults in the above default setup
49 | {
50 | plugins: {
51 | "simple-import-sort": simpleImportSort,
52 | },
53 | rules: {
54 | "simple-import-sort/imports": "error",
55 | "simple-import-sort/exports": "error",
56 | "@typescript-eslint/no-unused-vars": [
57 | "error",
58 | {
59 | args: "all",
60 | argsIgnorePattern: "^_",
61 | caughtErrors: "all",
62 | caughtErrorsIgnorePattern: "^_",
63 | destructuredArrayIgnorePattern: "^_",
64 | varsIgnorePattern: "^_",
65 | ignoreRestSiblings: true,
66 | },
67 | ],
68 | },
69 | },
70 | ];
71 |
--------------------------------------------------------------------------------
/packages/lector/esm-only.cjs:
--------------------------------------------------------------------------------
1 | // ESM only manifesto: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
2 |
3 | // This stub file was added to let some old ESLint plugins resolve
4 | // the import paths correctly, as setting exports.*.require: null in package.json didn't work.
5 |
6 | throw new Error(
7 | `This package is ESM only.
8 | See https://err.47ng.com/NUQS-101 for more details.`,
9 | );
10 |
--------------------------------------------------------------------------------
/packages/lector/image.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/lector/image.jpeg
--------------------------------------------------------------------------------
/packages/lector/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@anaralabs/lector",
3 | "version": "0.0.0-semantically-released",
4 | "description": "Headless PDF viewer for React",
5 | "author": "andrewdr (https://github.com/andrewdoro)",
6 | "license": "MIT",
7 | "type": "module",
8 | "sideEffects": false,
9 | "module": "dist/index.js",
10 | "types": "dist/index.d.ts",
11 | "exports": {
12 | ".": {
13 | "types": "./dist/index.d.ts",
14 | "import": "./dist/index.js",
15 | "require": "./esm-only.cjs"
16 | }
17 | },
18 | "files": [
19 | "dist"
20 | ],
21 | "publishConfig": {
22 | "access": "public"
23 | },
24 | "scripts": {
25 | "dev": "tsup --watch --onSuccess 'yalc push'",
26 | "prebuild": "rm -rf dist",
27 | "build": "tsup",
28 | "lint": "eslint .",
29 | "postbuild": "size-limit --json > size.json",
30 | "test": "pnpm run '/^test:/'",
31 | "test:unit": "vitest run",
32 | "test:size": "size-limit",
33 | "prepack": "./scripts/prepack.sh"
34 | },
35 | "devDependencies": {
36 | "@eslint/js": "^9.6.0",
37 | "@microsoft/api-extractor": "^7.48.1",
38 | "@size-limit/preset-small-lib": "^11.1.6",
39 | "@testing-library/react": "^16.0.0",
40 | "@types/eslint__js": "^8.42.3",
41 | "@types/node": "^22.9.0",
42 | "@types/react": "^19.0.1",
43 | "@types/react-dom": "^19.0.2",
44 | "@use-gesture/react": "^10.3.1",
45 | "@vitejs/plugin-react": "^4.3.4",
46 | "@vitest/browser": "^2.0.4",
47 | "clsx": "^2.1.1",
48 | "eslint": "9.17.0",
49 | "eslint-config-prettier": "^9.1.0",
50 | "eslint-plugin-react": "^7.37.2",
51 | "eslint-plugin-react-hooks": "^5.1.0",
52 | "eslint-plugin-simple-import-sort": "^12.1.1",
53 | "pdfjs-dist": "^5.0.375",
54 | "playwright": "^1.45.3",
55 | "prettier": "^3.3.2",
56 | "react": "^18.3.1",
57 | "size-limit": "^11.1.6",
58 | "tsc-alias": "^1.8.10",
59 | "tslib": "^2.6.3",
60 | "tsup": "^8.3.5",
61 | "typescript": "^5.5.3",
62 | "typescript-eslint": "^7.16.0",
63 | "vite": "^5.3.3",
64 | "vitest": "^2.0.4",
65 | "webdriverio": "^8.39.1"
66 | },
67 | "peerDependencies": {
68 | "pdfjs-dist": "^4.9",
69 | "react": ">=18"
70 | },
71 | "dependencies": {
72 | "@floating-ui/react": "^0.26.28",
73 | "@radix-ui/react-slot": "^1.1.0",
74 | "@tanstack/react-virtual": "^3.10.9",
75 | "react-dom": "18.3.1",
76 | "use-debounce": "^10.0.4",
77 | "uuid": "^11.1.0",
78 | "zustand": "^5.0.2"
79 | },
80 | "optionalDependencies": {
81 | "@rollup/rollup-linux-x64-gnu": "^4.28.1"
82 | },
83 | "keywords": [
84 | "pdf",
85 | "react",
86 | "headless",
87 | "viewer",
88 | "react-pdf",
89 | "anaralabs",
90 | "typescript"
91 | ],
92 | "repository": {
93 | "type": "git",
94 | "url": "git+https://github.com/anaralabs/lector.git",
95 | "directory": "packages/nuqs"
96 | },
97 | "bugs": {
98 | "url": "https://github.com/anaralabs/lector/issues"
99 | },
100 | "homepage": "https://github.com/anaralabs/lector",
101 | "size-limit": [
102 | {
103 | "name": "Client",
104 | "path": "dist/index.js",
105 | "limit": "150 kB",
106 | "ignore": [
107 | "react"
108 | ]
109 | }
110 | ]
111 | }
112 |
--------------------------------------------------------------------------------
/packages/lector/scripts/prepack.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e
4 |
5 | # Place ourselves in the package directory
6 | cd "$(dirname "$0")/.."
7 |
8 | # Copy the README & License from the root of the repository
9 | cp -f ../../README.md ../../LICENSE ./
10 |
11 | # Read the version from package.json
12 | # VERSION=$(cat package.json | jq -r '.version')
13 |
14 | # if [[ "$(uname)" == "Darwin" ]]; then
15 | # # macOS requires an empty string as the backup extension
16 | # sed -i '' "s/0.0.0-inject-version-here/${VERSION}/g" dist/index.js
17 | # else
18 | # # Ubuntu (CI/CD) doesn't
19 | # sed -i "s/0.0.0-inject-version-here/${VERSION}/g" dist/index.js
20 | # fi
--------------------------------------------------------------------------------
/packages/lector/size.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "Client",
4 | "passed": true,
5 | "size": 70508,
6 | "sizeLimit": 150000
7 | }
8 | ]
9 |
--------------------------------------------------------------------------------
/packages/lector/src/components/layers/annotation-highlight-layer.tsx:
--------------------------------------------------------------------------------
1 | import type { Annotation } from "../../hooks/useAnnotations";
2 | import { useAnnotations } from "../../hooks/useAnnotations";
3 | import { usePDFPageNumber } from "../../hooks/usePdfPageNumber";
4 | import { AnnotationTooltip, type AnnotationTooltipContentProps } from "../annotation-tooltip";
5 |
6 |
7 | interface AnnotationHighlightLayerProps {
8 | className?: string;
9 | style?: React.CSSProperties;
10 | renderTooltipContent: (props: AnnotationTooltipContentProps) => React.ReactNode;
11 | renderHoverTooltipContent: (props: {
12 | annotation: Annotation;
13 | onClose: () => void;
14 | }) => React.ReactNode;
15 | focusedAnnotationId?: string;
16 | focusedHoverAnnotationId?: string;
17 | onAnnotationClick?: (annotation: Annotation) => void;
18 | tooltipClassName?: string;
19 | hoverTooltipClassName?: string;
20 | highlightClassName?: string;
21 | tooltipBubbleSize?: number;
22 | }
23 |
24 | export const AnnotationHighlightLayer = ({
25 | className,
26 | style,
27 | renderTooltipContent,
28 | renderHoverTooltipContent,
29 | tooltipClassName,
30 | highlightClassName,
31 | focusedAnnotationId,
32 | focusedHoverAnnotationId,
33 | onAnnotationClick,
34 | hoverTooltipClassName,
35 | tooltipBubbleSize = 6,
36 | }: AnnotationHighlightLayerProps) => {
37 | const { annotations } = useAnnotations();
38 | const pageNumber = usePDFPageNumber();
39 |
40 | const pageAnnotations = annotations.filter(
41 | (annotation) => annotation.pageNumber === pageNumber
42 | );
43 |
44 | return (
45 |
46 | {pageAnnotations.map((annotation) => (
47 |
{
56 | if (open && onAnnotationClick) {
57 | onAnnotationClick(annotation);
58 | }
59 | }}
60 | renderTooltipContent={
61 | renderTooltipContent
62 | }
63 | hoverTooltipContent={(
64 | renderHoverTooltipContent({
65 | annotation,
66 | onClose: () => {},
67 | })
68 | )
69 | }
70 | >
71 | onAnnotationClick?.(annotation)}
74 | >
75 | {annotation.highlights.map((highlight, index) => (
76 |
89 | ))}
90 |
91 |
92 | ))}
93 |
94 | );
95 | };
--------------------------------------------------------------------------------
/packages/lector/src/components/layers/annotation-layer.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import type { HTMLProps } from "react";
3 |
4 | import {
5 | type AnnotationLayerParams,
6 | useAnnotationLayer,
7 | } from "../../hooks/layers/useAnnotationLayer";
8 |
9 | /**
10 | * AnnotationLayer renders PDF annotations like links, highlights, and form fields.
11 | *
12 | * @param renderForms - Whether to render form fields in the annotation layer.
13 | * @param externalLinksEnabled - Whether external links should be clickable. When false, external links won't open.
14 | * @param jumpOptions - Options for page navigation behavior when clicking internal links.
15 | * See `usePdfJump` hook for available options.
16 | */
17 | export const AnnotationLayer = ({
18 | renderForms = true,
19 | externalLinksEnabled = true,
20 | jumpOptions = { behavior: "smooth", align: "start" },
21 | className,
22 | style,
23 | ...props
24 | }: AnnotationLayerParams & HTMLProps) => {
25 | const { annotationLayerRef } = useAnnotationLayer({
26 | renderForms,
27 | externalLinksEnabled,
28 | jumpOptions,
29 | });
30 |
31 | return (
32 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/packages/lector/src/components/layers/canvas-layer.tsx:
--------------------------------------------------------------------------------
1 | import type { HTMLProps } from "react";
2 |
3 | import { useCanvasLayer } from "../../hooks/layers/useCanvasLayer";
4 |
5 | export const CanvasLayer = ({
6 | style,
7 | background,
8 | ...props
9 | }: HTMLProps & {
10 | background?: string;
11 | }) => {
12 | const { canvasRef } = useCanvasLayer({ background });
13 |
14 | return (
15 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/packages/lector/src/components/layers/colored-highlight/color-selection-tool.tsx:
--------------------------------------------------------------------------------
1 | import { SelectionTooltip } from "../../selection-tooltip";
2 |
3 | type ColorSelectionToolProps = {
4 | highlighterColors?: colorItem[];
5 | onColorSelection: (colorItem: colorItem) => void;
6 | };
7 |
8 | type colorItem = {
9 | color: string;
10 | localization: {
11 | id: string;
12 | defaultMessage: string;
13 | };
14 | };
15 |
16 | const defaultColors: colorItem[] = [
17 | {
18 | color: "#e3b127",
19 | localization: {
20 | id: "yellow",
21 | defaultMessage: "Yellow",
22 | },
23 | },
24 | {
25 | color: "#419931",
26 | localization: {
27 | id: "green",
28 | defaultMessage: "Green",
29 | },
30 | },
31 | {
32 | color: "#4286c9",
33 | localization: {
34 | id: "blue",
35 | defaultMessage: "Blue",
36 | },
37 | },
38 | {
39 | color: "#f246b6",
40 | localization: {
41 | id: "pink",
42 | defaultMessage: "Pink",
43 | },
44 | },
45 | {
46 | color: "#a53dd1",
47 | localization: {
48 | id: "purple",
49 | defaultMessage: "Purple",
50 | },
51 | },
52 | {
53 | color: "#f09037",
54 | localization: {
55 | id: "orange",
56 | defaultMessage: "Orange",
57 | },
58 | },
59 | {
60 | color: "#37f0d4",
61 | localization: {
62 | id: "teal",
63 | defaultMessage: "Teal",
64 | },
65 | },
66 | {
67 | color: "#3d0ff5",
68 | localization: {
69 | id: "purple",
70 | defaultMessage: "Purple",
71 | },
72 | },
73 | {
74 | color: "#f50f26",
75 | localization: {
76 | id: "red",
77 | defaultMessage: "Red",
78 | },
79 | },
80 | ];
81 |
82 | export const ColorSelectionTool = ({
83 | highlighterColors = defaultColors,
84 | onColorSelection,
85 | }: ColorSelectionToolProps) => {
86 | return (
87 |
88 |
97 | {highlighterColors.map((colorItem, index) => (
98 |
113 |
114 | );
115 | };
116 |
--------------------------------------------------------------------------------
/packages/lector/src/components/layers/colored-highlight/colored-highlight-layer.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { v4 as uuidv4 } from "uuid";
3 |
4 | import { usePDFPageNumber } from "../../../hooks/usePdfPageNumber";
5 | import { useSelectionDimensions } from "../../../hooks/useSelectionDimensions";
6 | import { type ColoredHighlight,usePdf } from "../../../internal";
7 | import { ColorSelectionTool } from "./color-selection-tool";
8 | import { ColoredHighlightComponent } from "./colored-highlight";
9 |
10 | type ColoredHighlightLayerProps = {
11 | onHighlight?: (highlight: ColoredHighlight) => void;
12 | };
13 |
14 | export const ColoredHighlightLayer = ({
15 | onHighlight,
16 | }: ColoredHighlightLayerProps) => {
17 | const pageNumber = usePDFPageNumber();
18 | const { getDimension } = useSelectionDimensions();
19 |
20 | const highlights: ColoredHighlight[] = usePdf(
21 | (state) => state.coloredHighlights,
22 | );
23 | const addColoredHighlight = usePdf((state) => state.addColoredHighlight);
24 |
25 | const handleHighlighting = useCallback((color: string) => {
26 | const dimension = getDimension();
27 | if (!dimension) return;
28 |
29 | const { highlights, text } = dimension;
30 |
31 | if (highlights[0]) {
32 | const highlight: ColoredHighlight = {
33 | uuid: uuidv4(),
34 | pageNumber: highlights[0].pageNumber, // usePDFPageNumber() doesn't return the correct page number, so i'm getting the number directly from the first highlight
35 | color,
36 | rectangles: highlights,
37 | text,
38 | };
39 | addColoredHighlight(highlight);
40 | if (onHighlight) onHighlight(highlight);
41 | }
42 | }, []);
43 |
44 | return (
45 |
46 | {highlights
47 | .filter((selection) => selection.pageNumber === pageNumber)
48 | .map((selection) => (
49 |
53 | ))}
54 | handleHighlighting(colorItem.color)}
56 | />
57 |
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/packages/lector/src/components/layers/colored-highlight/colored-highlight.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | import { type ColoredHighlight,usePdf } from "../../../internal";
4 | import {
5 | getEndOfHighlight,
6 | getMidHeightOfHighlightLine,
7 | } from "../../../utils/selectionUtils";
8 |
9 | type ColoredHighlightComponentProps = {
10 | selection: ColoredHighlight;
11 | };
12 |
13 | export const ColoredHighlightComponent = ({
14 | selection,
15 | }: ColoredHighlightComponentProps) => {
16 | const deleteColoredHighlight = usePdf(
17 | (state) => state.deleteColoredHighlight,
18 | );
19 | const [showButton, setShowButton] = useState(false);
20 |
21 | return (
22 |
23 | {selection.rectangles.map((rect, index) => (
24 |
setShowButton(!showButton)}
27 | style={{
28 | position: "absolute",
29 | top: rect.top,
30 | left: rect.left,
31 | height: rect.height,
32 | width: rect.width,
33 | cursor: "pointer",
34 | zIndex: 30,
35 | backgroundColor: selection.color,
36 | // mixBlendMode: "lighten", // changes the color of the text
37 | mixBlendMode: "darken", // best results
38 | // mixBlendMode: "multiply", // works but coloring has some inconsistencies
39 | borderRadius: "0.2rem",
40 | }}
41 | />
42 | ))}
43 | {showButton && (
44 |
81 | )}
82 |
83 | );
84 | };
85 |
--------------------------------------------------------------------------------
/packages/lector/src/components/layers/custom-layer.tsx:
--------------------------------------------------------------------------------
1 | import type { JSX } from "react";
2 |
3 | import { usePDFPageNumber } from "../../hooks/usePdfPageNumber";
4 |
5 | export const CustomLayer = ({
6 | children,
7 | }: {
8 | children: (pageNumber: number) => JSX.Element;
9 | }) => {
10 | const pageNumber = usePDFPageNumber();
11 |
12 | return children(pageNumber);
13 | };
14 |
--------------------------------------------------------------------------------
/packages/lector/src/components/layers/highlight-layer.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import {
3 | type ComponentPropsWithoutRef,
4 | type ElementRef,
5 | forwardRef,
6 | } from "react";
7 |
8 | import { usePDFPageNumber } from "../../hooks/usePdfPageNumber";
9 | import { type HighlightRect,usePdf } from "../../internal";
10 |
11 | interface HighlightLayerProps extends ComponentPropsWithoutRef<"div"> {
12 | asChild?: boolean;
13 | }
14 |
15 | const convertToPercentString = (rect: Omit) => {
16 | return {
17 | top: `${rect.top}%`,
18 | left: `${rect.left}%`,
19 | height: `${rect.height}%`,
20 | width: `${rect.width}%`,
21 | };
22 | };
23 |
24 | type Dimensions = {
25 | top: string | number;
26 | left: string | number;
27 | height: string | number;
28 | width: string | number;
29 | };
30 | export const HighlightLayer = forwardRef<
31 | ElementRef<"div">,
32 | HighlightLayerProps
33 | >(({ asChild, className, style, ...props }, ref) => {
34 | const pageNumber = usePDFPageNumber();
35 | const highlights = usePdf((state) => state.highlights);
36 |
37 | const Comp = asChild ? Slot : "div";
38 |
39 | const rects = highlights.filter((area) => area.pageNumber === pageNumber);
40 |
41 | if (!rects?.length) return null;
42 |
43 | return (
44 | <>
45 | {rects.map((rect, index) => {
46 | const { pageNumber, type, ...coordinates } = rect;
47 |
48 | let dimensions: Dimensions = coordinates;
49 | if (type === "percent") {
50 | dimensions = convertToPercentString(rect);
51 | }
52 |
53 | return (
54 |
67 | {props.children}
68 |
69 | );
70 | })}
71 | >
72 | );
73 | });
74 |
75 | HighlightLayer.displayName = "HighlightLayer";
76 |
--------------------------------------------------------------------------------
/packages/lector/src/components/layers/text-layer.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import type { HTMLProps } from "react";
3 |
4 | import { useTextLayer } from "../../hooks/layers/useTextLayer";
5 |
6 | export const TextLayer = ({
7 | className,
8 | style,
9 | ...props
10 | }: HTMLProps) => {
11 | const { textContainerRef, pageNumber } = useTextLayer();
12 |
13 | return (
14 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/packages/lector/src/components/outline.tsx:
--------------------------------------------------------------------------------
1 | import type { RefProxy } from "pdfjs-dist/types/src/display/api";
2 | import {
3 | cloneElement,
4 | type FunctionComponent,
5 | type HTMLProps,
6 | type ReactElement,
7 | useCallback,
8 | } from "react";
9 |
10 | import { usePdfJump } from "../hooks/pages/usePdfJump";
11 | import { usePDFOutline } from "../hooks/usePdfOutline";
12 | import { usePdf } from "../internal";
13 | import { Primitive } from "./primitive";
14 |
15 | type OutlineItemType = NonNullable>[number];
16 |
17 | export const OutlineChildItems = ({
18 | ...props
19 | }: HTMLProps & {
20 | children?: ReactElement[];
21 | }) => {
22 | return ;
23 | };
24 |
25 | interface OutlineItemProps extends HTMLProps {
26 | level?: number;
27 | item?: OutlineItemType;
28 | children?: ReactElement;
29 | outlineItem?: ReactElement;
30 | }
31 |
32 | export const OutlineItem: FunctionComponent = ({
33 | level = 0,
34 | item,
35 | children,
36 | outlineItem,
37 | ...props
38 | }: OutlineItemProps) => {
39 | if (!item || !outlineItem || !children) {
40 | throw new Error("Outline item is required");
41 | }
42 |
43 | const pdfDocumentProxy = usePdf((state) => state.pdfDocumentProxy);
44 | const { jumpToPage } = usePdfJump();
45 |
46 | const getDestinationPage = useCallback(
47 | async (dest: string | unknown[] | Promise) => {
48 | let explicitDest: unknown[] | null;
49 |
50 | if (typeof dest === "string") {
51 | explicitDest = await pdfDocumentProxy.getDestination(dest);
52 | } else if (Array.isArray(dest)) {
53 | explicitDest = dest;
54 | } else {
55 | explicitDest = await dest;
56 | }
57 |
58 | if (!explicitDest) {
59 | return;
60 | }
61 |
62 | const explicitRef = explicitDest[0] as RefProxy;
63 |
64 | const page = await pdfDocumentProxy.getPageIndex(explicitRef);
65 |
66 | return page;
67 | },
68 | [pdfDocumentProxy],
69 | );
70 |
71 | const navigate = useCallback(() => {
72 | if (!item.dest) {
73 | return;
74 | }
75 |
76 | getDestinationPage(item.dest).then((page) => {
77 | if (!page) {
78 | return;
79 | }
80 |
81 | jumpToPage(page, { behavior: "smooth" });
82 | });
83 | }, [item.dest, jumpToPage, getDestinationPage]);
84 |
85 | return (
86 |
87 | {
92 | if (e.key === "Enter") {
93 | navigate();
94 | }
95 | }}
96 | data-level={level}
97 | >
98 | {item.title}
99 |
100 | {item.items &&
101 | item.items.length > 0 &&
102 | cloneElement(children, {
103 | // @ts-expect-error we are missing the corect props types
104 | children: item.items.map((item, index) =>
105 | cloneElement(outlineItem, {
106 | // @ts-expect-error we are missing the corect props types
107 | level: level + 1,
108 | item,
109 | outlineItem,
110 | key: index,
111 | }),
112 | ),
113 | })}
114 |
115 | );
116 | };
117 |
118 | export const Outline = ({
119 | children,
120 | ...props
121 | }: HTMLProps & {
122 | children: ReactElement;
123 | }) => {
124 | const outline = usePDFOutline();
125 |
126 | return (
127 |
128 | {outline &&
129 | outline.map((item: OutlineItemType, idx) => {
130 | return cloneElement(children, {
131 | key: idx,
132 | item,
133 | outlineItem: children,
134 | } as OutlineItemProps);
135 | })}
136 |
137 | );
138 | };
139 |
--------------------------------------------------------------------------------
/packages/lector/src/components/page-number.tsx:
--------------------------------------------------------------------------------
1 | import { type HTMLProps, useEffect, useRef, useState } from "react";
2 |
3 | import { usePdfJump } from "../hooks/pages/usePdfJump";
4 | import { usePdf } from "../internal";
5 |
6 | export const NextPage = () => {};
7 | export const PreviousPage = () => {};
8 | export const CurrentPage = ({ ...props }: HTMLProps) => {
9 | const currentPage = usePdf((state) => state.currentPage);
10 | const pages = usePdf((state) => state.pdfDocumentProxy.numPages);
11 |
12 | const [pageNumber, setPageNumber] = useState(currentPage);
13 | const isSelected = useRef(false);
14 |
15 | const { jumpToPage } = usePdfJump();
16 |
17 | useEffect(() => {
18 | if (isSelected.current) {
19 | return;
20 | }
21 | setPageNumber(currentPage);
22 | }, [currentPage]);
23 |
24 | return (
25 | {
36 | setPageNumber(e.target.value);
37 | }}
38 | onClick={() => (isSelected.current = true)}
39 | onBlur={(e) => {
40 | if (currentPage !== Number(e.target.value)) {
41 | jumpToPage(Number(e.target.value), {
42 | behavior: "auto",
43 | });
44 | }
45 |
46 | isSelected.current = false;
47 | }}
48 | onKeyDown={(e) => {
49 | e.key === "Enter" && e.currentTarget.blur();
50 | }}
51 | min={1}
52 | max={pages}
53 | />
54 | );
55 | };
56 |
57 | export const TotalPages = ({ ...props }: HTMLProps) => {
58 | const pages = usePdf((state) => state.pdfDocumentProxy.numPages);
59 |
60 | return {pages}
;
61 | };
62 |
--------------------------------------------------------------------------------
/packages/lector/src/components/page.tsx:
--------------------------------------------------------------------------------
1 | import type { HTMLProps, ReactNode } from "react";
2 |
3 | import { PDFPageNumberContext } from "../hooks/usePdfPageNumber";
4 | import { usePdf } from "../internal";
5 | import { Primitive } from "./primitive";
6 |
7 | export const Page = ({
8 | children,
9 | pageNumber = 1,
10 | style,
11 | ...props
12 | }: HTMLProps & {
13 | children: ReactNode;
14 | pageNumber?: number;
15 | }) => {
16 | const pdfPageProxy = usePdf((state) => state.getPdfPageProxy(pageNumber));
17 |
18 | const width = (pdfPageProxy.view[2] ?? 0) - (pdfPageProxy.view[0] ?? 0);
19 | const height = (pdfPageProxy.view[3] ?? 0) - (pdfPageProxy.view[1] ?? 0);
20 |
21 | return (
22 |
23 |
28 |
41 | {children}
42 |
43 |
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/packages/lector/src/components/primitive.tsx:
--------------------------------------------------------------------------------
1 | import { type ComponentPropsWithRef, forwardRef } from "react";
2 |
3 | const HTMLTags = [
4 | "a",
5 | "button",
6 | "div",
7 | "aside",
8 | "section",
9 | "main",
10 | "ul",
11 | "li",
12 | "input",
13 | "canvas",
14 | ] as const;
15 | type HTMLTag = (typeof HTMLTags)[number];
16 |
17 | type PrimitiveProps = Omit, "ref"> &
18 | Required, "ref">>;
19 |
20 | const makePrimitive = (htmlTag: HTMLTag) => {
21 | const primitive = forwardRef(
22 | (props: Omit, "ref">, ref) => {
23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
24 | const Renderer: any = htmlTag;
25 |
26 | return ;
27 | },
28 | );
29 |
30 | primitive.displayName = `PDFReader.${htmlTag}`;
31 |
32 | return primitive;
33 | };
34 |
35 | export type Primitives = {
36 | [tag in HTMLTag]: ReturnType;
37 | };
38 |
39 | export const Primitive = HTMLTags.reduce((acc, tag) => {
40 | acc[tag] = makePrimitive(tag);
41 | return acc;
42 | }, {} as Primitives);
43 |
--------------------------------------------------------------------------------
/packages/lector/src/components/root.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, type HTMLProps, type ReactNode } from "react";
2 |
3 | import {
4 | usePDFDocumentContext,
5 | type usePDFDocumentParams,
6 | } from "../hooks/document/document";
7 | import { PDFStore } from "../internal";
8 | import { Primitive } from "./primitive";
9 |
10 | export const Root = forwardRef(
11 | (
12 | {
13 | children,
14 | source,
15 | loader,
16 | onDocumentLoad,
17 | isZoomFitWidth,
18 | zoom,
19 | zoomOptions,
20 | ...props
21 | }: HTMLProps &
22 | usePDFDocumentParams & {
23 | loader?: ReactNode;
24 | },
25 | ref,
26 | ) => {
27 | const { initialState } = usePDFDocumentContext({
28 | source,
29 | onDocumentLoad,
30 | isZoomFitWidth,
31 | zoom,
32 | zoomOptions,
33 | });
34 |
35 | return (
36 |
37 | {initialState ? (
38 |
39 | {children}
40 |
41 | ) : (
42 | loader || "Loading..."
43 | )}
44 |
45 | );
46 | },
47 | );
48 |
49 | Root.displayName = "Root";
50 |
--------------------------------------------------------------------------------
/packages/lector/src/components/search.tsx:
--------------------------------------------------------------------------------
1 | import type { PDFPageProxy } from "pdfjs-dist";
2 | import type { TextItem } from "pdfjs-dist/types/src/display/api";
3 | import { useCallback, useEffect, useState } from "react";
4 |
5 | import { usePdf } from "../internal";
6 |
7 | interface SearchProps {
8 | children: React.ReactNode;
9 | loading?: React.ReactNode;
10 | }
11 |
12 | export const Search = ({ children, loading = "Loading..." }: SearchProps) => {
13 | const [isLoading, setIsLoading] = useState(false);
14 |
15 | const proxies = usePdf((state) => state.pageProxies);
16 | const setTextContent = usePdf((state) => state.setTextContent);
17 |
18 | const getTextContent = useCallback(
19 | async (pages: PDFPageProxy[]) => {
20 | setIsLoading(true);
21 | const promises = pages.map(async (proxy) => {
22 | const content = await proxy.getTextContent();
23 | const text = content.items
24 | .map((item) => (item as TextItem)?.str || "")
25 | .join("");
26 |
27 | return Promise.resolve({
28 | pageNumber: proxy.pageNumber,
29 | text,
30 | });
31 | });
32 | const text = await Promise.all(promises);
33 |
34 | setIsLoading(false);
35 | setTextContent(text);
36 | },
37 | [setTextContent],
38 | );
39 |
40 | useEffect(() => {
41 | getTextContent(proxies);
42 | }, [proxies, getTextContent]);
43 |
44 | return isLoading ? loading : children;
45 | };
46 |
--------------------------------------------------------------------------------
/packages/lector/src/components/selection/custom-selection-trigger.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect } from "react";
2 |
3 | import { useSelectionDimensions } from "../../hooks/useSelectionDimensions";
4 | import { usePdf } from "../../internal";
5 |
6 | export const CustomSelectionTrigger = () => {
7 | const setCustomSelectionRects = usePdf(
8 | (state) => state.setCustomSelectionRects,
9 | );
10 |
11 | const { getDimension } = useSelectionDimensions();
12 |
13 | const handleSelection = useCallback(() => {
14 | const selection = window.getSelection();
15 | if (!selection || selection.isCollapsed) {
16 | setCustomSelectionRects([]);
17 | return;
18 | }
19 |
20 | const rects = getDimension();
21 | if (!rects) {
22 | setCustomSelectionRects([]);
23 | return;
24 | }
25 |
26 | setCustomSelectionRects(rects.highlights);
27 | }, [getDimension, setCustomSelectionRects]);
28 |
29 | useEffect(() => {
30 | const controller = new AbortController();
31 | const { signal } = controller;
32 | // Handle selection changes
33 | document.addEventListener("selectionchange", handleSelection, { signal });
34 |
35 | // Handle blur events on the document
36 | document.addEventListener("blur", handleSelection, {
37 | signal,
38 | capture: true,
39 | });
40 |
41 | // Handle mouseup for cases where selectionchange might not fire
42 | document.addEventListener("mouseup", handleSelection, { signal });
43 |
44 | // Clean up all listeners
45 | return () => {
46 | controller.abort();
47 | };
48 | }, [handleSelection]);
49 |
50 | return null;
51 | };
52 |
--------------------------------------------------------------------------------
/packages/lector/src/components/selection/custom-selection.tsx:
--------------------------------------------------------------------------------
1 | import { usePDFPageNumber } from "../../hooks/usePdfPageNumber";
2 | import { usePdf } from "../../internal";
3 |
4 | interface CustomSelectionProps {
5 | textColor?: string;
6 | bgColor?: string;
7 | }
8 |
9 | export const CustomSelection = ({
10 | textColor = "#017aff",
11 | bgColor = "#ebf4ff94",
12 | }: CustomSelectionProps) => {
13 | const customSelectionRects = usePdf((state) => state.customSelectionRects);
14 |
15 | const pageNumber = usePDFPageNumber();
16 |
17 | const rects = customSelectionRects.filter(
18 | (area) => area.pageNumber === pageNumber,
19 | );
20 |
21 | if (!rects.length) return null;
22 |
23 | return (
24 | <>
25 | {rects.map((rect, index) => (
26 |
40 | ))}
41 | {rects.map((rect, index) => (
42 |
55 | ))}
56 | >
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/packages/lector/src/components/thumbnails.tsx:
--------------------------------------------------------------------------------
1 | import { cloneElement, type HTMLProps, type ReactElement } from "react";
2 |
3 | import { usePdfJump } from "../hooks/pages/usePdfJump";
4 | import { useThumbnail } from "../hooks/useThumbnail";
5 | import { usePdf } from "../internal";
6 | import { Primitive } from "./primitive";
7 |
8 | export const Thumbnail = ({
9 | pageNumber = 1,
10 | ...props
11 | }: HTMLProps & { pageNumber?: number }) => {
12 | const { canvasRef, containerRef, isVisible } = useThumbnail(pageNumber, {
13 | isFirstPage: pageNumber < 5,
14 | });
15 | const { jumpToPage } = usePdfJump();
16 |
17 | return (
18 |
19 | {isVisible && (
20 |
{
26 | if (props.onClick) {
27 | props.onClick(e);
28 | }
29 |
30 | jumpToPage(pageNumber, { behavior: "auto" });
31 | }}
32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
33 | onKeyDown={(e: any) => {
34 | if (props.onKeyDown) {
35 | props.onKeyDown(e);
36 | }
37 |
38 | if (e.key === "Enter") {
39 | jumpToPage(pageNumber, { behavior: "auto" });
40 | }
41 | }}
42 | ref={canvasRef}
43 | />
44 | )}
45 |
46 | );
47 | };
48 |
49 | export const Thumbnails = ({
50 | children,
51 | ...props
52 | }: HTMLProps & {
53 | children: ReactElement;
54 | }) => {
55 | const pageCount = usePdf((state) => state.pdfDocumentProxy.numPages);
56 |
57 | return (
58 |
59 | {Array.from({
60 | length: pageCount,
61 | }).map((_, index) => {
62 | //@ts-expect-error pageNumber is not a valid react key
63 | return cloneElement(children, { key: index, pageNumber: index + 1 });
64 | })}
65 |
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/packages/lector/src/components/zoom.tsx:
--------------------------------------------------------------------------------
1 | import { type HTMLProps, useEffect, useRef, useState } from "react";
2 |
3 | import { usePdf } from "../internal";
4 | import { Primitive } from "./primitive";
5 |
6 | export const ZoomIn = ({ ...props }: HTMLProps) => {
7 | const setZoom = usePdf((state) => state.updateZoom);
8 |
9 | return (
10 | {
14 | props.onClick && props.onClick(e);
15 | setZoom((zoom) => Number((zoom + 0.1).toFixed(1)));
16 | }}
17 | />
18 | );
19 | };
20 |
21 | export const ZoomOut = ({ ...props }: HTMLProps) => {
22 | const setZoom = usePdf((state) => state.updateZoom);
23 |
24 | return (
25 | {
29 | props.onClick && props.onClick(e);
30 | setZoom((zoom) => Number((zoom - 0.1).toFixed(1)));
31 | }}
32 | />
33 | );
34 | };
35 |
36 | export const CurrentZoom = ({ ...props }: HTMLProps) => {
37 | const setRealZoom = usePdf((state) => state.updateZoom);
38 | const realZoom = usePdf((state) => state.zoom);
39 |
40 | const [zoom, setZoom] = useState((realZoom * 100).toFixed(0));
41 | const isSelected = useRef(false);
42 |
43 | useEffect(() => {
44 | if (isSelected.current) {
45 | return;
46 | }
47 |
48 | setZoom((realZoom * 100).toFixed(0));
49 | }, [realZoom]);
50 |
51 | return (
52 | (isSelected.current = true)}
56 | onChange={(e) => {
57 | setRealZoom(Number(e.target.value) / 100);
58 | setZoom(e.target.value);
59 | }}
60 | onBlur={() => {
61 | isSelected.current = false;
62 |
63 | setZoom((realZoom * 100).toFixed(0));
64 | }}
65 | />
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/document/document.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getDocument,
3 | type OnProgressParameters,
4 | type PDFDocumentProxy,
5 | type PDFPageProxy,
6 | } from "pdfjs-dist";
7 | import type {
8 | DocumentInitParameters,
9 | TypedArray,
10 | } from "pdfjs-dist/types/src/display/api";
11 | import { useEffect, useState } from "react";
12 |
13 | import type { InitialPDFState, ZoomOptions } from "../../internal";
14 |
15 | export interface usePDFDocumentParams {
16 | /**
17 | * The URL of the PDF file to load.
18 | */
19 | source: Source;
20 | onDocumentLoad?: ({
21 | proxy,
22 | source,
23 | }: {
24 | proxy: PDFDocumentProxy;
25 | source: Source;
26 | }) => void;
27 | initialRotation?: number;
28 | isZoomFitWidth?: boolean;
29 | zoom?: number;
30 | zoomOptions?: ZoomOptions;
31 | }
32 |
33 | export type Source =
34 | | string
35 | | URL
36 | | TypedArray
37 | | ArrayBuffer
38 | | DocumentInitParameters;
39 |
40 | export const usePDFDocumentContext = ({
41 | onDocumentLoad,
42 | source,
43 | initialRotation = 0,
44 | isZoomFitWidth,
45 | zoom = 1,
46 | zoomOptions,
47 | }: usePDFDocumentParams) => {
48 | const [_, setProgress] = useState(0);
49 |
50 | const [initialState, setInitialState] = useState();
51 | const [rotation] = useState(initialRotation);
52 |
53 | useEffect(() => {
54 | const generateViewports = async (pdf: PDFDocumentProxy) => {
55 | const pageProxies: Array = [];
56 | const rotations: number[] = [];
57 | const viewports = await Promise.all(
58 | Array.from({ length: pdf.numPages }, async (_, index) => {
59 | const page = await pdf.getPage(index + 1);
60 | // sometimes there is information about the default rotation of the document
61 | // stored in page.rotate. we need to always add that additional rotaton offset
62 | const deltaRotate = page.rotate || 0;
63 | const viewport = page.getViewport({
64 | scale: 1,
65 | rotation: rotation + deltaRotate,
66 | });
67 | pageProxies.push(page);
68 | rotations.push(page.rotate);
69 | return viewport;
70 | }),
71 | );
72 |
73 | const sortedPageProxies = pageProxies.sort((a, b) => {
74 | return a.pageNumber - b.pageNumber;
75 | });
76 | setInitialState((prev) => ({
77 | ...prev,
78 | isZoomFitWidth,
79 | viewports,
80 | pageProxies: sortedPageProxies,
81 | pdfDocumentProxy: pdf,
82 | zoom,
83 | zoomOptions,
84 | }));
85 | };
86 |
87 | const loadDocument = () => {
88 | setInitialState(null);
89 | setProgress(0);
90 |
91 | const loadingTask = getDocument(source);
92 |
93 | loadingTask.onProgress = (progressEvent: OnProgressParameters) => {
94 | // Added to dedupe state updates when the file is fully loaded
95 | if (progressEvent.loaded === progressEvent.total) {
96 | return;
97 | }
98 |
99 | setProgress(progressEvent.loaded / progressEvent.total);
100 | };
101 |
102 | const loadingPromise = loadingTask.promise
103 | .then((proxy) => {
104 | onDocumentLoad?.({ proxy, source });
105 | setProgress(1);
106 |
107 | generateViewports(proxy);
108 | })
109 | .catch((error) => {
110 | if (loadingTask.destroyed) {
111 | return;
112 | }
113 |
114 | console.error("Error loading PDF document", error);
115 | });
116 |
117 | return () => {
118 | loadingPromise.finally(() => loadingTask.destroy());
119 | };
120 | };
121 | loadDocument();
122 | // eslint-disable-next-line react-hooks/exhaustive-deps
123 | }, [source]);
124 |
125 |
126 | return {
127 | initialState,
128 | };
129 | };
130 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/layers/useCanvasLayer.tsx:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useRef } from "react";
2 | import { useDebounce } from "use-debounce";
3 |
4 | import { usePdf } from "../../internal";
5 | import { useDpr } from "../useDpr";
6 | import { usePDFPageNumber } from "../usePdfPageNumber";
7 |
8 | export const useCanvasLayer = ({ background }: { background?: string }) => {
9 | const canvasRef = useRef(null);
10 | const pageNumber = usePDFPageNumber();
11 |
12 | const dpr = useDpr();
13 |
14 | const bouncyZoom = usePdf((state) => state.zoom);
15 | const pdfPageProxy = usePdf((state) => state.getPdfPageProxy(pageNumber));
16 |
17 | const [zoom] = useDebounce(bouncyZoom, 100);
18 |
19 | // const { visible } = useVisibility({ elementRef: canvasRef });
20 | // const debouncedVisible = useDebounce(visible, 100);
21 |
22 | useLayoutEffect(() => {
23 | if (!canvasRef.current) {
24 | return;
25 | }
26 |
27 | const viewport = pdfPageProxy.getViewport({ scale: 1 });
28 |
29 | const canvas = canvasRef.current;
30 |
31 | const scale = dpr * zoom;
32 |
33 | canvas.height = viewport.height * scale;
34 | canvas.width = viewport.width * scale;
35 |
36 | canvas.style.height = `${viewport.height}px`;
37 | canvas.style.width = `${viewport.width}px`;
38 |
39 | const canvasContext = canvas.getContext("2d")!;
40 | canvasContext.scale(scale, scale);
41 |
42 | const renderingTask = pdfPageProxy.render({
43 | canvasContext: canvasContext,
44 | viewport,
45 | background,
46 | });
47 |
48 | renderingTask.promise.catch((error) => {
49 | if (error.name === "RenderingCancelledException") {
50 | return;
51 | }
52 |
53 | throw error;
54 | });
55 |
56 | return () => {
57 | void renderingTask.cancel();
58 | };
59 | }, [pdfPageProxy, dpr, zoom]);
60 |
61 | return {
62 | canvasRef,
63 | };
64 | };
65 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/layers/useTextLayer.tsx:
--------------------------------------------------------------------------------
1 | import { TextLayer } from "pdfjs-dist";
2 | import { useEffect, useRef } from "react";
3 |
4 | import { usePdf } from "../../internal";
5 | import { usePDFPageNumber } from "../usePdfPageNumber";
6 |
7 | export const useTextLayer = () => {
8 | const textContainerRef = useRef(null);
9 |
10 | const pageNumber = usePDFPageNumber();
11 | const pdfPageProxy = usePdf((state) => state.getPdfPageProxy(pageNumber));
12 |
13 | useEffect(() => {
14 | if (!textContainerRef.current) {
15 | return;
16 | }
17 |
18 | const textLayer = new TextLayer({
19 | textContentSource: pdfPageProxy.streamTextContent(),
20 | container: textContainerRef.current,
21 | viewport: pdfPageProxy.getViewport({ scale: 1 }),
22 | });
23 |
24 | void textLayer.render();
25 |
26 | return () => {
27 | textLayer.cancel();
28 | };
29 | }, [pdfPageProxy]);
30 |
31 | return {
32 | textContainerRef,
33 | pageNumber: pdfPageProxy.pageNumber,
34 | };
35 | };
36 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/pages/useFitWidth.tsx:
--------------------------------------------------------------------------------
1 | import React, { useLayoutEffect } from "react";
2 |
3 | import { PDFStore, usePdf } from "../../internal";
4 | import { getFitWidthZoom } from "../../lib/zoom";
5 |
6 | interface UseFitWidth {
7 | viewportRef: React.RefObject;
8 | }
9 | export const useFitWidth = ({ viewportRef }: UseFitWidth) => {
10 | const viewports = usePdf((state) => state.viewports);
11 | const zoomOptions = usePdf((state) => state.zoomOptions);
12 |
13 | const updateZoom = usePdf((state) => state.updateZoom);
14 | const store = PDFStore.useContext();
15 |
16 | useLayoutEffect(() => {
17 | if (viewportRef.current === null) return;
18 | const resizeObserver = new ResizeObserver((entries) => {
19 | for (const entry of entries) {
20 | const isFitWidth = store.getState().isZoomFitWidth;
21 |
22 | if (entry.target === viewportRef.current && isFitWidth) {
23 | const containerWidth = entry.contentRect.width;
24 | const newZoom = getFitWidthZoom(
25 | containerWidth,
26 | viewports,
27 | zoomOptions,
28 | );
29 | updateZoom(newZoom, true);
30 | }
31 | }
32 | });
33 |
34 | resizeObserver.observe(viewportRef.current);
35 |
36 | return () => {
37 | resizeObserver.disconnect();
38 | };
39 | }, [store, updateZoom, viewportRef, viewports, zoomOptions]);
40 |
41 | return null;
42 | };
43 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/pages/useObserveElement.tsx:
--------------------------------------------------------------------------------
1 | import { debounce, Virtualizer } from "@tanstack/react-virtual";
2 |
3 | import { PDFStore } from "../../internal";
4 |
5 | const supportsScrollend =
6 | typeof window == "undefined" ? true : "onscrollend" in window;
7 |
8 | type ObserveOffsetCallBack = (offset: number, isScrolling: boolean) => void;
9 |
10 | const addEventListenerOptions = {
11 | passive: true,
12 | };
13 |
14 | export const useObserveElement = () => {
15 | const store = PDFStore.useContext();
16 |
17 | const observeElementOffset = (
18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
19 | instance: Virtualizer,
20 | cb: ObserveOffsetCallBack,
21 | ) => {
22 | const element = instance.scrollElement;
23 | if (!element) {
24 | return;
25 | }
26 | const targetWindow = instance.targetWindow;
27 | if (!targetWindow) {
28 | return;
29 | }
30 |
31 | let offset = 0;
32 | const fallback =
33 | instance.options.useScrollendEvent && supportsScrollend
34 | ? () => undefined
35 | : debounce(
36 | targetWindow,
37 | () => {
38 | cb(offset, false);
39 | },
40 | instance.options.isScrollingResetDelay,
41 | );
42 |
43 | const createHandler = (isScrolling: boolean) => () => {
44 | const { horizontal, isRtl } = instance.options;
45 | offset = horizontal
46 | ? element["scrollLeft"] * ((isRtl && -1) || 1)
47 | : element["scrollTop"];
48 |
49 | const zoom = store.getState().zoom;
50 | offset = offset / zoom;
51 | fallback();
52 |
53 | cb(offset, isScrolling);
54 | };
55 | const handler = createHandler(true);
56 | const endHandler = createHandler(false);
57 | endHandler();
58 |
59 | element.addEventListener("scroll", handler, addEventListenerOptions);
60 | element.addEventListener("scrollend", endHandler, addEventListenerOptions);
61 |
62 | return () => {
63 | element.removeEventListener("scroll", handler);
64 | element.removeEventListener("scrollend", endHandler);
65 | };
66 | };
67 | return {
68 | observeElementOffset,
69 | };
70 | };
71 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/pages/usePdfJump.tsx:
--------------------------------------------------------------------------------
1 | import { type HighlightRect, usePdf } from "../../internal";
2 |
3 | export const usePdfJump = () => {
4 | const virtualizer = usePdf((state) => state.virtualizer);
5 | const setHighlight = usePdf((state) => state.setHighlight);
6 |
7 | const jumpToPage = (
8 | pageIndex: number,
9 | options?: {
10 | align?: "start" | "center" | "end" | "auto";
11 | behavior?: "auto" | "smooth";
12 | },
13 | ) => {
14 | if (!virtualizer) return;
15 |
16 | // Define default options
17 | const defaultOptions = {
18 | align: "start",
19 | behavior: "smooth",
20 | };
21 |
22 | // Merge default options with any provided options
23 |
24 | const finalOptions = { ...defaultOptions, ...options };
25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
26 | virtualizer.scrollToIndex(pageIndex - 1, finalOptions as any);
27 | };
28 |
29 | const jumpToOffset = (offset: number) => {
30 | if (!virtualizer) return;
31 | virtualizer.scrollToOffset(offset, {
32 | align: "start",
33 | behavior: "smooth",
34 | });
35 | };
36 |
37 | const scrollToHighlightRects = (
38 | rects: HighlightRect[],
39 | type: "pixels" | "percent",
40 | align: "start" | "center" = "start",
41 | additionalOffset: number = 0,
42 | ) => {
43 | if (!virtualizer) return;
44 |
45 | const firstPage = Math.min(...rects.map((rect) => rect.pageNumber));
46 |
47 | // Get the start offset of the page in the viewport
48 | const pageOffset = virtualizer.getOffsetForIndex(firstPage - 1, "start");
49 |
50 | if (pageOffset === null) return;
51 |
52 | // Find the target highlight rect (usually the first one)
53 | const targetRect = rects.find((rect) => rect.pageNumber === firstPage);
54 |
55 | if (!targetRect) return;
56 |
57 | const isNumber = pageOffset?.[0] != null;
58 | if (!isNumber) return;
59 |
60 | const pageStart = pageOffset[0] ?? 0;
61 |
62 | // Calculate the rect position and height
63 | let rectTop: number;
64 | let rectHeight: number;
65 |
66 | if (type === "percent") {
67 | const estimatePageHeight = virtualizer.options.estimateSize(
68 | firstPage - 1,
69 | );
70 | rectTop = (targetRect.top / 100) * estimatePageHeight;
71 | rectHeight = (targetRect.height / 100) * estimatePageHeight;
72 | } else {
73 | rectTop = targetRect.top;
74 | rectHeight = targetRect.height;
75 | }
76 |
77 | // Calculate the scroll offset based on alignment
78 | let scrollOffset: number;
79 |
80 | if (align === "center") {
81 | // When centering in the viewport, we need the viewport height
82 | const viewportHeight = virtualizer.scrollElement?.clientHeight || 0;
83 |
84 | // The target position is the rect's center minus half the viewport height
85 | // This places the rect in the center of the viewport
86 | const rectCenter = pageStart + rectTop + rectHeight / 2;
87 | scrollOffset = rectCenter - viewportHeight / 2;
88 | } else {
89 | // Use the top of the highlight rect
90 | scrollOffset = pageStart + rectTop;
91 | }
92 |
93 | // Apply the additional offset
94 | scrollOffset += additionalOffset;
95 |
96 | // Ensure we don't scroll to a negative offset
97 | const adjustedOffset = Math.max(0, scrollOffset);
98 |
99 | virtualizer.scrollToOffset(adjustedOffset, {
100 | align: "start", // Always use start when we've calculated our own centering
101 | behavior: "smooth",
102 | });
103 | };
104 |
105 | const jumpToHighlightRects = (
106 | rects: HighlightRect[],
107 | type: "pixels" | "percent",
108 | align: "start" | "center" = "start",
109 | additionalOffset: number = 0,
110 | ) => {
111 | if (!virtualizer) return;
112 |
113 | setHighlight(rects);
114 |
115 | scrollToHighlightRects(rects, type, align, additionalOffset);
116 | };
117 |
118 | return { jumpToPage, jumpToOffset, jumpToHighlightRects, scrollToHighlightRects};
119 | };
120 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/pages/useScrollFn.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | elementScroll,
3 | type VirtualizerOptions,
4 | } from "@tanstack/react-virtual";
5 | import { useCallback } from "react";
6 |
7 | import { PDFStore } from "../../internal";
8 |
9 | // const easeInOutSmooth = (t: number): number => {
10 | // t *= 2;
11 | // if (t < 1) {
12 | // return 0.5 * t * t * t;
13 | // }
14 | // t -= 2;
15 | // return 0.5 * (t * t * t + 2);
16 | // };
17 |
18 | export const useScrollFn = () => {
19 | // const scrollingRef = useRef(null);
20 | // const viewportRef = usePdf((state) => state.viewportRef);
21 | const store = PDFStore.useContext();
22 |
23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
24 | const scrollToFn: VirtualizerOptions["scrollToFn"] = useCallback(
25 | (_offset, canSmooth, instance) => {
26 | // const duration = 200;
27 | // const start = viewportRef?.current?.scrollTop || 0;
28 | // const startTime = (scrollingRef.current = Date.now());
29 |
30 | const zoom = store.getState().zoom;
31 | const offset = _offset * zoom;
32 |
33 | // if we are in auto scroll mode, then immediately scroll
34 | // to the offset and not display any animation. For example if scroll
35 | // immediately to a rescaled offset if zoom/scale has just been changed
36 | elementScroll(offset, canSmooth, instance);
37 | // if (canSmooth.behavior === "auto") {
38 | // elementScroll(offset, canSmooth, instance);
39 | // return;
40 | // }
41 |
42 | // // if we are in smooth mode then we scroll auto using our ease out schedule
43 | // const run = () => {
44 | // if (scrollingRef.current !== startTime) return;
45 | // const now = Date.now();
46 | // const elapsed = now - startTime;
47 | // const progress = easeInOutSmooth(Math.min(elapsed / duration, 1));
48 | // const interpolated = start + (offset - start) * progress;
49 |
50 | // if (elapsed < duration) {
51 | // elementScroll(interpolated, { behavior: "auto" }, instance);
52 | // requestAnimationFrame(run);
53 | // } else {
54 | // elementScroll(interpolated, { behavior: "auto" }, instance);
55 | // }
56 | // };
57 |
58 | // requestAnimationFrame(run);
59 | },
60 | [store],
61 | );
62 | return { scrollToFn };
63 | };
64 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/pages/useVirtualizerVelocity.tsx:
--------------------------------------------------------------------------------
1 | import { Virtualizer } from "@tanstack/react-virtual";
2 | import { useEffect, useState } from "react";
3 |
4 | const useVirtualizerVelocity = ({
5 | virtualizer,
6 | }: {
7 | virtualizer: Virtualizer | null;
8 | }) => {
9 | const [lastScrollOffset, setLastScrollOffset] = useState(0);
10 | const [velocity, setVelocity] = useState(0);
11 | const [normalizedVelocity, setNormalizedVelocity] = useState(0);
12 |
13 | useEffect(() => {
14 | if (!virtualizer) return;
15 | const interval = setInterval(() => {
16 | // Get the current scroll offset
17 | const currentScrollOffset = virtualizer.scrollOffset;
18 |
19 | if (currentScrollOffset == null) {
20 | return;
21 | }
22 |
23 | // Calculate the difference (velocity)
24 | const newVelocity = currentScrollOffset - lastScrollOffset;
25 |
26 | // velocity is normalized by the height of the element
27 | // so the interpretation is how many heights of a page
28 | // the page is scrolling per 100ms.
29 | const estimateSize = virtualizer.options.estimateSize;
30 | setNormalizedVelocity(newVelocity / estimateSize(0));
31 |
32 | // Update the state with the new values
33 | setLastScrollOffset(currentScrollOffset);
34 | setVelocity(newVelocity);
35 |
36 | // Optionally, log the velocity
37 | // console.log("Scroll offset velocity:", newVelocity);
38 | }, 50); // Adjust the interval for more/less frequent checks
39 |
40 | // Clear the interval on component unmount
41 | return () => clearInterval(interval);
42 | }, [lastScrollOffset, virtualizer]);
43 |
44 | return { velocity, normalizedVelocity };
45 | };
46 |
47 | export default useVirtualizerVelocity;
48 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/pages/useVisiblePage.tsx:
--------------------------------------------------------------------------------
1 | import type { VirtualItem } from "@tanstack/react-virtual";
2 | import { useCallback, useEffect } from "react";
3 |
4 | import { usePdf } from "../../internal";
5 |
6 | interface UseVisiblePageProps {
7 | items: VirtualItem[];
8 | }
9 |
10 | export const useVisiblePage = ({ items }: UseVisiblePageProps) => {
11 | const zoomLevel = usePdf((state) => state.zoom);
12 | const isPinching = usePdf((state) => state.isPinching);
13 | const setCurrentPage = usePdf((state) => state.setCurrentPage);
14 | const scrollElement = usePdf((state) => state.viewportRef?.current);
15 |
16 | const calculateVisiblePageIndex = useCallback(
17 | (virtualItems: VirtualItem[]) => {
18 | if (!scrollElement || virtualItems.length === 0) return 0;
19 |
20 | const scrollTop = scrollElement.scrollTop / zoomLevel;
21 | const viewportHeight = scrollElement.clientHeight / zoomLevel;
22 | const viewportCenter = scrollTop + viewportHeight / 2;
23 |
24 | // Find the page whose center is closest to viewport center
25 | let closestIndex = 0;
26 | let smallestDistance = Infinity;
27 |
28 | for (const item of virtualItems) {
29 | const itemCenter = item.start + item.size / 2;
30 | const distance = Math.abs(itemCenter - viewportCenter);
31 |
32 | // Add a 20% threshold to prevent frequent switches
33 | if (distance < smallestDistance * 0.8) {
34 | smallestDistance = distance;
35 | closestIndex = item.index;
36 | }
37 | }
38 |
39 | return closestIndex;
40 | },
41 | [scrollElement, zoomLevel],
42 | );
43 |
44 | useEffect(() => {
45 | if (!isPinching && items.length > 0) {
46 | const mostVisibleIndex = calculateVisiblePageIndex(items);
47 |
48 | setCurrentPage?.(mostVisibleIndex + 1);
49 | }
50 | }, [items, isPinching, calculateVisiblePageIndex, setCurrentPage]);
51 |
52 | return null;
53 | };
54 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/search/useSearchPosition.tsx:
--------------------------------------------------------------------------------
1 | import type { PDFPageProxy } from "pdfjs-dist";
2 | import type { TextItem } from "pdfjs-dist/types/src/display/api";
3 |
4 | import type { HighlightRect } from "../../internal";
5 | import type { SearchResult } from "./useSearch";
6 |
7 | interface TextPosition {
8 | pageNumber: number;
9 | text: string;
10 | matchIndex: number;
11 | searchText?: string; // Optional parameter to specify the exact search text to highlight
12 | }
13 |
14 | /**
15 | * Calculates the highlight rectangles for a given text match.
16 | *
17 | * @param pageProxy - The PDF page proxy object
18 | * @param textMatch - An object containing:
19 | * - pageNumber: The page number where the match is found
20 | * - text: The text content containing the match (usually a larger chunk of text)
21 | * - matchIndex: The index within the text where the match starts
22 | * - searchText: (Optional) The exact search term to highlight. If provided, only highlights
23 | * this exact term instead of the entire text. If not provided, highlights the full text.
24 | * @returns An array of HighlightRect objects representing the areas to highlight
25 | */
26 | export async function calculateHighlightRects(
27 | pageProxy: PDFPageProxy,
28 | textMatch: TextPosition,
29 | ): Promise {
30 | const textContent = await pageProxy.getTextContent();
31 | const items = textContent.items as TextItem[];
32 |
33 | const matchLength = textMatch.searchText ? textMatch.searchText.length : textMatch.text.length;
34 |
35 | const matchRects: HighlightRect[] = [];
36 | let currentIndex = 0;
37 | let remainingMatchLength = matchLength;
38 | let foundStart = false;
39 |
40 | const viewport = pageProxy.getViewport({ scale: 1 });
41 |
42 | for (let i = 0; i < items.length; i++) {
43 | const item = items[i];
44 |
45 | if (!item) continue;
46 |
47 | const itemLength = item.str.length;
48 |
49 | if (
50 | !foundStart &&
51 | currentIndex <= textMatch.matchIndex &&
52 | textMatch.matchIndex < currentIndex + itemLength
53 | ) {
54 | foundStart = true;
55 | const matchStartInItem = textMatch.matchIndex - currentIndex;
56 | const matchLengthInItem = Math.min(
57 | itemLength - matchStartInItem,
58 | remainingMatchLength,
59 | );
60 |
61 | const transform = item.transform;
62 | const y = viewport.height - (transform[5] + item.height);
63 |
64 | const rect: HighlightRect = {
65 | pageNumber: textMatch.pageNumber,
66 | left: transform[4] + matchStartInItem * (item.width / itemLength),
67 | top: y,
68 | width: matchLengthInItem * (item.width / itemLength),
69 | height: item.height,
70 | };
71 |
72 | matchRects.push(rect);
73 | remainingMatchLength -= matchLengthInItem;
74 | }
75 | else if (foundStart && remainingMatchLength > 0) {
76 | const matchLengthInItem = Math.min(itemLength, remainingMatchLength);
77 |
78 | const transform = item.transform;
79 | const y = viewport.height - (transform[5] + item.height);
80 |
81 | const rect: HighlightRect = {
82 | pageNumber: textMatch.pageNumber,
83 | left: transform[4],
84 | top: y,
85 | width: matchLengthInItem * (item.width / itemLength),
86 | height: item.height,
87 | };
88 |
89 | matchRects.push(rect);
90 | remainingMatchLength -= matchLengthInItem;
91 | }
92 |
93 | if (remainingMatchLength <= 0 && foundStart) {
94 | break;
95 | }
96 |
97 | currentIndex += itemLength;
98 | }
99 |
100 | return mergeAdjacentRects(matchRects);
101 | }
102 |
103 | function mergeAdjacentRects(rects: HighlightRect[]): HighlightRect[] {
104 | if (rects.length <= 1) return rects;
105 |
106 | const merged: HighlightRect[] = [];
107 | let current = rects[0];
108 |
109 | if (!current) return rects;
110 |
111 | for (let i = 1; i < rects.length; i++) {
112 | const next = rects[i];
113 |
114 | if (!next) continue;
115 | if (
116 | Math.abs(current.top - next.top) < 2 &&
117 | Math.abs(current.height - next.height) < 2
118 | ) {
119 | current = {
120 | ...current,
121 | width: next.left + next.width - current.left,
122 | };
123 | } else {
124 | merged.push(current);
125 | current = next;
126 | }
127 | }
128 | merged.push(current);
129 |
130 | return merged;
131 | }
132 |
133 | export async function processSearchResults(
134 | result: SearchResult,
135 | pageProxy: PDFPageProxy,
136 | searchText?: string,
137 | ) {
138 | const searchTermToHighlight = searchText || (result as { searchText?: string }).searchText;
139 |
140 | const highlights = await calculateHighlightRects(pageProxy, {
141 | pageNumber: result.pageNumber,
142 | text: result.text,
143 | matchIndex: result.matchIndex,
144 | searchText: searchTermToHighlight,
145 | });
146 |
147 | return {
148 | ...result,
149 | highlights,
150 | };
151 | }
152 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/useAnnotations.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | export interface Annotation {
4 | id: string;
5 | pageNumber: number;
6 | highlights: Array<{
7 | height: number;
8 | left: number;
9 | top: number;
10 | width: number;
11 | pageNumber: number;
12 | }>;
13 | color: string;
14 | borderColor: string;
15 | createdAt: Date;
16 | updatedAt: Date;
17 | comment?: string;
18 | metadata?: Record;
19 | }
20 |
21 | interface AnnotationState {
22 | annotations: Annotation[];
23 | addAnnotation: (annotation: Annotation) => void;
24 | updateAnnotation: (id: string, updates: Partial) => void;
25 | deleteAnnotation: (id: string) => void;
26 | setAnnotations: (annotations: Annotation[]) => void;
27 | }
28 |
29 | export const useAnnotations = create((set) => ({
30 | annotations: [],
31 | addAnnotation: (annotation) =>
32 | set((state) => ({
33 | annotations: [
34 | ...state.annotations,
35 | annotation,
36 | ],
37 | })),
38 | updateAnnotation: (id, updates) =>
39 | set((state) => ({
40 | annotations: state.annotations.map((annotation) =>
41 | annotation.id === id
42 | ? {
43 | ...annotation,
44 | ...updates,
45 | }
46 | : annotation
47 | ),
48 | })),
49 | deleteAnnotation: (id) =>
50 | set((state) => ({
51 | annotations: state.annotations.filter((annotation) => annotation.id !== id),
52 | })),
53 | setAnnotations: (annotations) => set({ annotations }),
54 | }));
--------------------------------------------------------------------------------
/packages/lector/src/hooks/useDpr.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useDpr = () => {
4 | const [dpr, setDPR] = useState(
5 | !window ? 1 : Math.min(window.devicePixelRatio, 2),
6 | );
7 |
8 | useEffect(() => {
9 | if (!window) {
10 | return;
11 | }
12 |
13 | const handleDPRChange = () => {
14 | setDPR(window.devicePixelRatio);
15 | };
16 |
17 | const windowMatch = window.matchMedia(
18 | `screen and (min-resolution: ${dpr}dppx)`,
19 | );
20 |
21 | windowMatch.addEventListener("change", handleDPRChange);
22 |
23 | return () => {
24 | windowMatch.removeEventListener("change", handleDPRChange);
25 | };
26 | // eslint-disable-next-line react-hooks/exhaustive-deps
27 | }, []);
28 |
29 | return dpr;
30 | };
31 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/usePdfOutline.tsx:
--------------------------------------------------------------------------------
1 | import type { PDFDocumentProxy } from "pdfjs-dist";
2 | import { useEffect, useState } from "react";
3 |
4 | import { usePdf } from "../internal";
5 | import { cancellable } from "../lib/cancellable";
6 |
7 | export const usePDFOutline = () => {
8 | const pdfDocumentProxy = usePdf((state) => state.pdfDocumentProxy);
9 | const [outline, setOutline] =
10 | useState>>();
11 |
12 | useEffect(() => {
13 | const { promise: outline, cancel } = cancellable(
14 | pdfDocumentProxy.getOutline(),
15 | );
16 |
17 | outline.then(
18 | (result) => {
19 | setOutline(result);
20 | },
21 | () => {},
22 | );
23 |
24 | return () => {
25 | cancel();
26 | };
27 | }, [pdfDocumentProxy]);
28 |
29 | return outline;
30 | };
31 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/usePdfPageNumber.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from "react";
2 |
3 | export interface PDFPageNumberType {
4 | pageNumber: number;
5 | }
6 |
7 | export const PDFPageNumberContext = createContext(0);
8 |
9 | export const usePDFPageNumber = () => {
10 | return useContext(PDFPageNumberContext);
11 | };
12 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/useSelectionDimensions.tsx:
--------------------------------------------------------------------------------
1 | import { type HighlightRect, PDFStore } from "../internal";
2 |
3 | const MERGE_THRESHOLD = 5; // Increased threshold for more aggressive merging
4 |
5 | type CollapsibleSelection = {
6 | highlights: HighlightRect[];
7 | text: string;
8 | isCollapsed: boolean;
9 | };
10 |
11 | const shouldMergeRects = (
12 | rect1: HighlightRect,
13 | rect2: HighlightRect,
14 | ): boolean => {
15 | // Consider vertical overlap
16 | const verticalOverlap = !(
17 | rect1.top > rect2.top + rect2.height || rect2.top > rect1.top + rect1.height
18 | );
19 |
20 | // Consider horizontal proximity more liberally
21 | const horizontallyClose =
22 | Math.abs(rect1.left + rect1.width - rect2.left) < MERGE_THRESHOLD ||
23 | Math.abs(rect2.left + rect2.width - rect1.left) < MERGE_THRESHOLD ||
24 | (rect1.left < rect2.left + rect2.width &&
25 | rect2.left < rect1.left + rect1.width); // Overlap check
26 |
27 | return verticalOverlap && horizontallyClose;
28 | };
29 |
30 | const mergeRects = (
31 | rect1: HighlightRect,
32 | rect2: HighlightRect,
33 | ): HighlightRect => {
34 | return {
35 | left: Math.min(rect1.left, rect2.left),
36 | top: Math.min(rect1.top, rect2.top),
37 | width:
38 | Math.max(rect1.left + rect1.width, rect2.left + rect2.width) -
39 | Math.min(rect1.left, rect2.left),
40 | height:
41 | Math.max(rect1.top + rect1.height, rect2.top + rect2.height) -
42 | Math.min(rect1.top, rect2.top),
43 | pageNumber: rect1.pageNumber,
44 | };
45 | };
46 |
47 | const consolidateRects = (rects: HighlightRect[]): HighlightRect[] => {
48 | if (rects.length <= 1) return rects;
49 |
50 | // Sort by vertical position primarily
51 | const sortedRects = [...rects].sort((a, b) => a.top - b.top);
52 |
53 | // Keep merging until no more merges are possible
54 | let hasChanges: boolean;
55 | do {
56 | hasChanges = false;
57 | let currentRect = sortedRects[0];
58 | const tempResult: HighlightRect[] = [];
59 |
60 | for (let i = 1; i < sortedRects.length; i++) {
61 | const sorted = sortedRects[i];
62 | if (!currentRect || !sorted) continue;
63 |
64 | if (shouldMergeRects(currentRect, sorted)) {
65 | currentRect = mergeRects(currentRect, sorted);
66 | hasChanges = true;
67 | } else {
68 | tempResult.push(currentRect);
69 | currentRect = sorted;
70 | }
71 | }
72 | if (currentRect) tempResult.push(currentRect);
73 |
74 | sortedRects.length = 0;
75 | sortedRects.push(...tempResult);
76 | } while (hasChanges);
77 |
78 | return sortedRects;
79 | };
80 |
81 | export const useSelectionDimensions = () => {
82 | const store = PDFStore.useContext();
83 |
84 | const getDimension = () => {
85 | const selection = window.getSelection();
86 | if (!selection || selection.isCollapsed) return;
87 |
88 | const range = selection.getRangeAt(0);
89 | const highlights: HighlightRect[] = [];
90 | const textLayerMap = new Map();
91 |
92 | // Get valid client rects and filter out tiny ones
93 | const clientRects = Array.from(range.getClientRects()).filter(
94 | (rect) => rect.width > 2 && rect.height > 2,
95 | );
96 |
97 | clientRects.forEach((clientRect) => {
98 | const element = document.elementFromPoint(
99 | clientRect.left + 1,
100 | clientRect.top + clientRect.height / 2,
101 | );
102 |
103 | const textLayer = element?.closest(".textLayer");
104 | if (!textLayer) return;
105 |
106 | const pageNumber = parseInt(
107 | textLayer.getAttribute("data-page-number") || "1",
108 | 10,
109 | );
110 | const textLayerRect = textLayer.getBoundingClientRect();
111 | const zoom = store.getState().zoom;
112 |
113 | const rect: HighlightRect = {
114 | width: clientRect.width / zoom,
115 | height: clientRect.height / zoom,
116 | top: (clientRect.top - textLayerRect.top) / zoom,
117 | left: (clientRect.left - textLayerRect.left) / zoom,
118 | pageNumber,
119 | };
120 |
121 | if (!textLayerMap.has(pageNumber)) {
122 | textLayerMap.set(pageNumber, []);
123 | }
124 | textLayerMap.get(pageNumber)?.push(rect);
125 | });
126 |
127 | textLayerMap.forEach((rects) => {
128 | if (rects.length > 0) {
129 | highlights.push(...consolidateRects(rects));
130 | }
131 | });
132 |
133 | return {
134 | highlights: highlights.sort((a, b) => a.pageNumber - b.pageNumber),
135 | text: range.toString().trim(),
136 | isCollapsed: false,
137 | };
138 | };
139 | const getSelection = (): CollapsibleSelection => getDimension() as CollapsibleSelection;
140 |
141 | return { getDimension, getSelection };
142 |
143 | };
144 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/useThumbnail.tsx:
--------------------------------------------------------------------------------
1 | import type { RenderTask } from "pdfjs-dist";
2 | import { useEffect, useRef } from "react";
3 | import { useDebounce } from "use-debounce";
4 |
5 | import { usePdf } from "../internal";
6 | import { useDpr } from "./useDpr";
7 | import { useVisibility } from "./useVisibility";
8 |
9 | interface ThumbnailConfig {
10 | maxHeight?: number;
11 | maxWidth?: number;
12 | isFirstPage?: boolean;
13 | }
14 |
15 | const DEFAULT_CONFIG: Required> = {
16 | maxHeight: 800,
17 | maxWidth: 400,
18 | };
19 |
20 | export const useThumbnail = (
21 | pageNumber: number,
22 | config: ThumbnailConfig = {},
23 | ) => {
24 | const {
25 | maxHeight = DEFAULT_CONFIG.maxHeight,
26 | maxWidth = DEFAULT_CONFIG.maxWidth,
27 | isFirstPage = false,
28 | } = config;
29 |
30 | const containerRef = useRef(null);
31 | const canvasRef = useRef(null);
32 | const renderTaskRef = useRef(null);
33 |
34 | const pageProxy = usePdf((state) => state.getPdfPageProxy(pageNumber));
35 | const { visible } = useVisibility({ elementRef: containerRef });
36 | const [debouncedVisible] = useDebounce(visible, 50);
37 | const dpr = useDpr();
38 |
39 | const isVisible = isFirstPage || debouncedVisible;
40 |
41 | useEffect(() => {
42 | const renderThumbnail = async () => {
43 | const canvas = canvasRef.current;
44 |
45 | if (!canvas || !pageProxy) return;
46 |
47 | try {
48 | // Cancel any existing render task
49 | if (renderTaskRef.current) {
50 | renderTaskRef.current.cancel();
51 | }
52 |
53 | // Calculate viewport and scale
54 | const viewport = pageProxy.getViewport({ scale: 1 });
55 | const scale =
56 | Math.min(maxWidth / viewport.width, maxHeight / viewport.height) *
57 | (isVisible ? dpr : 0.5);
58 |
59 | const scaledViewport = pageProxy.getViewport({ scale });
60 |
61 | // Set canvas dimensions
62 | canvas.width = scaledViewport.width;
63 | canvas.height = scaledViewport.height;
64 |
65 | // Create and store new render task
66 | const context = canvas.getContext("2d");
67 | if (!context) return;
68 |
69 | const renderTask = pageProxy.render({
70 | canvasContext: context,
71 | viewport: scaledViewport,
72 | });
73 |
74 | renderTaskRef.current = renderTask;
75 | await renderTask.promise;
76 | } catch (error: unknown) {
77 | if (
78 | error instanceof Error &&
79 | error.name === "RenderingCancelledException"
80 | ) {
81 | console.log("Rendering cancelled");
82 | } else {
83 | console.error("Failed to render PDF page:", error);
84 | }
85 | }
86 | };
87 |
88 | renderThumbnail();
89 |
90 | return () => {
91 | renderTaskRef.current?.cancel();
92 | };
93 | }, [pageNumber, pageProxy, isVisible, dpr, maxHeight, maxWidth]);
94 |
95 | return {
96 | canvasRef,
97 | containerRef,
98 | isVisible,
99 | };
100 | };
101 |
--------------------------------------------------------------------------------
/packages/lector/src/hooks/useVisibility.tsx:
--------------------------------------------------------------------------------
1 | import { type RefObject, useEffect, useState } from "react";
2 |
3 | export const useVisibility = ({
4 | elementRef,
5 | }: {
6 | elementRef: RefObject;
7 | }) => {
8 | const [visible, setVisible] = useState(false);
9 |
10 | useEffect(() => {
11 | if (!elementRef.current) {
12 | return;
13 | }
14 |
15 | const observer = new IntersectionObserver(([entry]) => {
16 | setVisible(entry?.isIntersecting ?? false);
17 | });
18 |
19 | observer.observe(elementRef.current);
20 |
21 | return () => {
22 | observer.disconnect();
23 | };
24 | }, [elementRef]);
25 |
26 | return { visible };
27 | };
28 |
--------------------------------------------------------------------------------
/packages/lector/src/index.ts:
--------------------------------------------------------------------------------
1 | export type { AnnotationTooltipContentProps } from "./components/annotation-tooltip";
2 | export { AnnotationTooltip } from "./components/annotation-tooltip";
3 | export { AnnotationHighlightLayer } from "./components/layers/annotation-highlight-layer";
4 | export { AnnotationLayer } from "./components/layers/annotation-layer";
5 | export { CanvasLayer } from "./components/layers/canvas-layer";
6 | export { ColoredHighlightLayer } from "./components/layers/colored-highlight/colored-highlight-layer";
7 | export { CustomLayer } from "./components/layers/custom-layer";
8 | export { HighlightLayer } from "./components/layers/highlight-layer";
9 | export { TextLayer } from "./components/layers/text-layer";
10 | export { Outline, OutlineChildItems, OutlineItem } from "./components/outline";
11 | export { Page } from "./components/page";
12 | export {
13 | CurrentPage,
14 | NextPage,
15 | PreviousPage,
16 | TotalPages,
17 | } from "./components/page-number";
18 | export { Pages } from "./components/pages";
19 | export { Root } from "./components/root";
20 | export { Search } from "./components/search";
21 | export { SelectionTooltip } from "./components/selection-tooltip";
22 | export { Thumbnail, Thumbnails } from "./components/thumbnails";
23 | export { CurrentZoom, ZoomIn, ZoomOut } from "./components/zoom";
24 | export { usePdfJump } from "./hooks/pages/usePdfJump";
25 | export {
26 | type SearchResult,
27 | type SearchResults,
28 | useSearch,
29 | } from "./hooks/search/useSearch";
30 | export { calculateHighlightRects } from "./hooks/search/useSearchPosition";
31 | export type { Annotation } from "./hooks/useAnnotations";
32 | export { useAnnotations } from "./hooks/useAnnotations";
33 | export { LinkService } from "./hooks/usePDFLinkService";
34 | export { usePDFPageNumber } from "./hooks/usePdfPageNumber";
35 | export { useSelectionDimensions } from "./hooks/useSelectionDimensions";
36 | export { type ColoredHighlight, type HighlightRect, usePdf } from "./internal";
--------------------------------------------------------------------------------
/packages/lector/src/lib/cancellable.ts:
--------------------------------------------------------------------------------
1 | export const cancellable = >(
2 | promise: T,
3 | ): {
4 | promise: T;
5 | cancel: () => void;
6 | } => {
7 | let isCancelled = false;
8 |
9 | const wrappedPromise = new Promise((resolve, reject) => {
10 | promise.then(
11 | (value) => {
12 | if (!isCancelled) {
13 | resolve(value);
14 | }
15 | },
16 | (error) => {
17 | if (!isCancelled) {
18 | reject(error);
19 | }
20 | },
21 | );
22 | }) as unknown as T;
23 |
24 | return {
25 | promise: wrappedPromise,
26 | cancel() {
27 | isCancelled = true;
28 | },
29 | };
30 | };
31 |
--------------------------------------------------------------------------------
/packages/lector/src/lib/clamp.ts:
--------------------------------------------------------------------------------
1 | export const clamp = (value: number, min: number, max: number) => {
2 | return Math.min(Math.max(value, min), max);
3 | };
4 |
--------------------------------------------------------------------------------
/packages/lector/src/lib/memo.ts:
--------------------------------------------------------------------------------
1 | export const firstMemo = (
2 | first: boolean,
3 | memo: unknown,
4 | initializer: () => T,
5 | ): T => {
6 | if (!first && !memo) {
7 | throw new Error(
8 | "Missing memo initialization. You likely forgot to return the result of `firstMemo` in the event function",
9 | );
10 | }
11 |
12 | if (first) {
13 | return initializer();
14 | }
15 |
16 | return memo! as T;
17 | };
18 |
--------------------------------------------------------------------------------
/packages/lector/src/lib/zoom.ts:
--------------------------------------------------------------------------------
1 | import type { PageViewport } from "pdfjs-dist";
2 |
3 | export const getFitWidthZoom = (
4 | containerWidth: number,
5 | viewports: PageViewport[],
6 | zoomOptions: { minZoom: number; maxZoom: number },
7 | ) => {
8 | const { minZoom, maxZoom } = zoomOptions;
9 | const maxPageWidth = Math.max(...viewports.map((viewport) => viewport.width));
10 |
11 | // Calculate the zoom level needed to fit the width
12 | // Subtract some padding (40px) to ensure there's a small margin
13 | const targetZoom = containerWidth / maxPageWidth;
14 |
15 | // Ensure the zoom level stays within bounds
16 | const clampedZoom = Math.min(Math.max(targetZoom, minZoom), maxZoom);
17 |
18 | return clampedZoom;
19 | };
20 |
--------------------------------------------------------------------------------
/packages/lector/src/lib/zustand.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import type { StoreApi } from "zustand";
3 |
4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
5 | export const createZustandContext = >(
6 | getStore: (initial: TInitial) => TStore,
7 | ) => {
8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
9 | const Context = React.createContext(null as any as TStore);
10 |
11 | const Provider = (props: {
12 | children?: React.ReactNode;
13 | initialValue: TInitial;
14 | }) => {
15 | const [store] = React.useState(() => getStore(props.initialValue));
16 |
17 | return {props.children};
18 | };
19 |
20 | return {
21 | useContext: () => React.useContext(Context),
22 | Context,
23 | Provider,
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/packages/lector/src/static/form.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/lector/src/static/form.pdf
--------------------------------------------------------------------------------
/packages/lector/src/tests/basic.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import { GlobalWorkerOptions } from "pdfjs-dist";
3 | import PDFWorker from "pdfjs-dist/build/pdf.worker.min.mjs?url&inline";
4 | import React from "react";
5 | import { test } from "vitest";
6 |
7 | import { Root } from "../../dist";
8 | GlobalWorkerOptions.workerSrc = PDFWorker;
9 |
10 | test("renders name", () => {
11 | render();
12 | });
13 |
--------------------------------------------------------------------------------
/packages/lector/src/utils/selectionUtils.ts:
--------------------------------------------------------------------------------
1 | import type { ColoredHighlight, HighlightRect } from "../internal";
2 |
3 | export const getEndOfHighlight = (selection: ColoredHighlight) => {
4 | const lastRectangle = selection.rectangles[
5 | selection.rectangles.length - 1
6 | ] as HighlightRect;
7 | return lastRectangle.left + lastRectangle.width + 10;
8 | };
9 |
10 | export const getMidHeightOfHighlightLine = (selection: ColoredHighlight) => {
11 | const lastRectangle = selection.rectangles[
12 | selection.rectangles.length - 1
13 | ] as HighlightRect;
14 | return lastRectangle.top + lastRectangle.height / 2;
15 | };
16 |
--------------------------------------------------------------------------------
/packages/lector/tailwind.config.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anaralabs/lector/3124c69bec10d75e06022f517e0ecbd74b1b7de4/packages/lector/tailwind.config.ts
--------------------------------------------------------------------------------
/packages/lector/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["src/**/*.ts"],
4 | "exclude": ["src/tests", "src/**/*.test.ts", "src/**/*.test.tsx"]
5 | }
6 |
--------------------------------------------------------------------------------
/packages/lector/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "compilerOptions": {
4 | "jsx": "react-jsx",
5 | // Type checking
6 | "strict": true,
7 | "noUncheckedIndexedAccess": true,
8 | "alwaysStrict": false, // Don't emit "use strict" to avoid conflicts with "use client"
9 | // Modules
10 | "module": "ESNext",
11 | "moduleResolution": "Bundler",
12 | "resolveJsonModule": true,
13 | // Language & Environment
14 | "target": "ESNext",
15 | "lib": [
16 | "DOM",
17 | "DOM.Iterable",
18 | "ESNext"
19 | ],
20 | "types": [
21 | "node"
22 | ],
23 | // Emit
24 | "noEmit": true,
25 | "declaration": true,
26 | "declarationMap": true,
27 | "verbatimModuleSyntax": true,
28 | "moduleDetection": "force",
29 | "downlevelIteration": true,
30 | // Interop
31 | "allowJs": true,
32 | "isolatedModules": true,
33 | "esModuleInterop": true,
34 | "forceConsistentCasingInFileNames": true,
35 | // Misc
36 | "skipLibCheck": true,
37 | "skipDefaultLibCheck": true,
38 | "incremental": true,
39 | "tsBuildInfoFile": ".tsbuildinfo"
40 | },
41 | "include": [
42 | "**/*.ts",
43 | "**/*.tsx"
44 | ],
45 | "exclude": [
46 | "node_modules",
47 | "dist"
48 | ]
49 | }
--------------------------------------------------------------------------------
/packages/lector/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { readFile, writeFile } from "node:fs/promises";
2 | import { join } from "node:path";
3 | import { styleText } from "node:util";
4 | import { defineConfig, type Options } from "tsup";
5 |
6 | const commonConfig = {
7 | format: ["esm"],
8 | experimentalDts: true,
9 | outDir: "dist",
10 | external: ["react", "react-dom", "pdfjs-dist"],
11 | splitting: true,
12 | treeshake: true,
13 | tsconfig: "tsconfig.build.json",
14 | } satisfies Options;
15 |
16 | const entrypoints = ["src/index.ts"];
17 |
18 | export default defineConfig({
19 | ...commonConfig,
20 | entry: entrypoints,
21 | async onSuccess() {
22 | await Promise.all(
23 | entrypoints.map(async (entry) => {
24 | const filePath = join(
25 | commonConfig.outDir,
26 | `${entry.replace(".ts", "").replace("src/", "")}.js`,
27 | );
28 | const fileContents = await readFile(filePath, "utf-8");
29 | const withUseClientDirective = `'use client';\n\n${fileContents}`;
30 | await writeFile(filePath, withUseClientDirective);
31 | console.info(
32 | [
33 | styleText("green", "USE"),
34 | styleText("bold", filePath.padEnd(29)),
35 | styleText("dim", 'prepended "use client"'),
36 | ].join(" "),
37 | );
38 | }),
39 | );
40 | },
41 | });
42 |
--------------------------------------------------------------------------------
/packages/lector/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitest/config";
2 | import react from "@vitejs/plugin-react";
3 |
4 | export default defineConfig({
5 | plugins: [react()],
6 |
7 | test: {
8 | browser: {
9 | enabled: true,
10 | name: "chrome",
11 | provider: "webdriverio",
12 | // https://playwright.dev
13 | providerOptions: {},
14 | },
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "packages/**"
3 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "remoteCache": {
4 | "enabled": true
5 | },
6 | "tasks": {
7 | "build": {
8 | "dependsOn": ["^build"],
9 | "outputs": [".next/**", "!.next/cache/**", "dist/**"]
10 | },
11 | "lint": {
12 | "dependsOn": ["^build"],
13 | "inputs": ["../../.prettierrc", "src/**", "package.json"],
14 | "outputs": []
15 | },
16 | "format": {
17 | "dependsOn": ["^format"]
18 | },
19 | "test": {
20 | "dependsOn": ["^test"]
21 | },
22 | "dev": {
23 | "persistent": true,
24 | "cache": false
25 | },
26 | "@anaralabs/lector#test": {
27 | "outputs": ["dist/**", "coverage/**"],
28 | "dependsOn": ["build"]
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------