mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(reader): add PDF reader with lazy rendering, zoom, and progress
This commit is contained in:
Generated
+271
@@ -15,6 +15,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.577.0",
|
"lucide-vue-next": "^0.577.0",
|
||||||
|
"pdfjs-dist": "^5.5.207",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"reka-ui": "^2.9.2",
|
"reka-ui": "^2.9.2",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
@@ -1485,6 +1486,256 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.97",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.97",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -5570,6 +5821,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-readable-to-web-readable-stream": {
|
||||||
|
"version": "0.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||||
|
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.36",
|
"version": "2.0.36",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||||
@@ -5890,6 +6148,19 @@
|
|||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "5.5.207",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz",
|
||||||
|
"integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0 || >=22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.95",
|
||||||
|
"node-readable-to-web-readable-stream": "^0.4.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/perfect-debounce": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
|
|||||||
+4
-3
@@ -23,8 +23,9 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.562.0",
|
"lucide-vue-next": "^0.562.0",
|
||||||
|
"pdfjs-dist": "^5.4.449",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"reka-ui": "^2.6.1",
|
"reka-ui": "^2.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
"@types/jsdom": "^27.0.0",
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/node": "^22.19.3",
|
"@types/node": "^22.19.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@vitest/eslint-plugin": "^1.6.1",
|
"@vitest/eslint-plugin": "^1.6.4",
|
||||||
"@vue/eslint-config-typescript": "^14.6.0",
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
"eslint-plugin-oxlint": "~1.35.0",
|
"eslint-plugin-oxlint": "~1.35.0",
|
||||||
"eslint-plugin-vue": "~10.6.2",
|
"eslint-plugin-vue": "~10.6.2",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"jsdom": "^27.3.0",
|
"jsdom": "^27.4.0",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"oxlint": "~1.35.0",
|
"oxlint": "~1.35.0",
|
||||||
"prettier": "3.7.4",
|
"prettier": "3.7.4",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ReaderSettingsPanel from './components/ReaderSettingsPanel.vue'
|
|||||||
import SelectionPopup from './components/SelectionPopup.vue'
|
import SelectionPopup from './components/SelectionPopup.vue'
|
||||||
import ReaderSearchPanel from './components/ReaderSearchPanel.vue'
|
import ReaderSearchPanel from './components/ReaderSearchPanel.vue'
|
||||||
import NoteDialog from './components/NoteDialog.vue'
|
import NoteDialog from './components/NoteDialog.vue'
|
||||||
|
import PdfReaderView from './components/PdfReaderView.vue'
|
||||||
import type { ReaderState } from './composables/useReaderState'
|
import type { ReaderState } from './composables/useReaderState'
|
||||||
import type { FoliateRenderer } from './composables/useFoliate'
|
import type { FoliateRenderer } from './composables/useFoliate'
|
||||||
|
|
||||||
@@ -219,7 +220,9 @@ function navigateSearch(cfiTarget: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<PdfReaderView v-if="fileFormat === 'pdf'" :bookId="bookId" :fileId="fileId" />
|
||||||
<div
|
<div
|
||||||
|
v-else
|
||||||
class="fixed inset-0 overflow-hidden"
|
class="fixed inset-0 overflow-hidden"
|
||||||
:style="{
|
:style="{
|
||||||
background: activeMode.bg,
|
background: activeMode.bg,
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft, Minus, Plus, Maximize2, AlignJustify } from 'lucide-vue-next'
|
||||||
|
import { usePdf, type ZoomMode } from '../composables/usePdf'
|
||||||
|
import { useReaderProgress } from '../composables/useReaderProgress'
|
||||||
|
import { useVisibility } from '../composables/useVisibility'
|
||||||
|
|
||||||
|
const props = defineProps<{ bookId: number; fileId: number }>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { pdfDoc, totalPages, loading, error, load, renderPageToCanvas, getPageViewport } = usePdf()
|
||||||
|
const progress = useReaderProgress(props.bookId, props.fileId)
|
||||||
|
const { headerVisible, showHeader } = useVisibility()
|
||||||
|
|
||||||
|
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||||
|
const scrollRef = ref<HTMLElement | null>(null)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const zoomMode = ref<ZoomMode>('fit-width')
|
||||||
|
const customScale = ref(1.0)
|
||||||
|
const PAGE_GAP = 12 // px between pages
|
||||||
|
|
||||||
|
// Page dimension cache: pageNum → { width, height } at scale=1
|
||||||
|
const pageDims = ref<{ width: number; height: number }[]>([])
|
||||||
|
|
||||||
|
// ── Scale computation ─────────────────────────────────────────────────────────
|
||||||
|
const containerWidth = ref(0)
|
||||||
|
const containerHeight = ref(0)
|
||||||
|
|
||||||
|
const scale = computed(() => {
|
||||||
|
const first = pageDims.value[0]
|
||||||
|
if (!first) return 1
|
||||||
|
if (zoomMode.value === 'fit-width') return (containerWidth.value - 32) / first.width
|
||||||
|
if (zoomMode.value === 'fit-page') {
|
||||||
|
const sw = (containerWidth.value - 32) / first.width
|
||||||
|
const sh = (containerHeight.value - 48) / first.height
|
||||||
|
return Math.min(sw, sh)
|
||||||
|
}
|
||||||
|
return customScale.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const zoomPercent = computed(() => Math.round(scale.value * 100))
|
||||||
|
|
||||||
|
// ── Page placeholder heights at current scale ─────────────────────────────────
|
||||||
|
const pageHeights = computed(() => pageDims.value.map((d) => Math.round(d.height * scale.value)))
|
||||||
|
|
||||||
|
// ── Canvas render tracking ────────────────────────────────────────────────────
|
||||||
|
const canvasRefs = ref<(HTMLCanvasElement | null)[]>([])
|
||||||
|
const rendered = new Set<number>() // 1-based page numbers already rendered
|
||||||
|
let renderQueue: number[] = []
|
||||||
|
let rendering = false
|
||||||
|
|
||||||
|
async function flushQueue() {
|
||||||
|
if (rendering || renderQueue.length === 0) return
|
||||||
|
rendering = true
|
||||||
|
while (renderQueue.length > 0) {
|
||||||
|
const pageNum = renderQueue.shift()!
|
||||||
|
if (!pdfDoc.value) break
|
||||||
|
const canvas = canvasRefs.value[pageNum - 1]
|
||||||
|
if (!canvas) continue
|
||||||
|
await renderPageToCanvas(pdfDoc.value as Parameters<typeof renderPageToCanvas>[0], pageNum, canvas, scale.value)
|
||||||
|
rendered.add(pageNum)
|
||||||
|
}
|
||||||
|
rendering = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueuePage(pageNum: number) {
|
||||||
|
if (rendered.has(pageNum) || renderQueue.includes(pageNum)) return
|
||||||
|
renderQueue.push(pageNum)
|
||||||
|
flushQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// When scale changes, clear rendered cache and re-render visible pages.
|
||||||
|
watch(scale, () => {
|
||||||
|
rendered.clear()
|
||||||
|
renderQueue = []
|
||||||
|
rendering = false
|
||||||
|
nextTick(() => observerCallback(currentObserverEntries))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── IntersectionObserver — detects visible pages ──────────────────────────────
|
||||||
|
let currentObserverEntries: IntersectionObserverEntry[] = []
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
function observerCallback(entries: IntersectionObserverEntry[]) {
|
||||||
|
currentObserverEntries = entries
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) continue
|
||||||
|
const idx = Number((entry.target as HTMLElement).dataset.page)
|
||||||
|
enqueuePage(idx)
|
||||||
|
// Preload adjacent pages
|
||||||
|
if (idx > 1) enqueuePage(idx - 1)
|
||||||
|
if (idx < totalPages.value) enqueuePage(idx + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupObserver() {
|
||||||
|
observer?.disconnect()
|
||||||
|
observer = new IntersectionObserver(observerCallback, {
|
||||||
|
root: scrollRef.value,
|
||||||
|
rootMargin: '200px',
|
||||||
|
threshold: 0,
|
||||||
|
})
|
||||||
|
nextTick(() => {
|
||||||
|
const items = scrollRef.value?.querySelectorAll('[data-page]') ?? []
|
||||||
|
items.forEach((el) => observer!.observe(el))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scroll → current page tracking ───────────────────────────────────────────
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const el = scrollRef.value
|
||||||
|
if (!el || !pageHeights.value.length) return
|
||||||
|
|
||||||
|
let offset = el.scrollTop
|
||||||
|
let pg = 1
|
||||||
|
for (let i = 0; i < pageHeights.value.length; i++) {
|
||||||
|
const h = (pageHeights.value[i] ?? 0) + PAGE_GAP
|
||||||
|
if (offset < h) {
|
||||||
|
pg = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset -= h
|
||||||
|
pg = i + 2
|
||||||
|
}
|
||||||
|
currentPage.value = Math.min(pg, totalPages.value)
|
||||||
|
|
||||||
|
// Debounced progress save
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
progress.pageNumber.value = currentPage.value
|
||||||
|
progress.percentage.value = (currentPage.value / totalPages.value) * 100
|
||||||
|
progress.save()
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboard navigation ───────────────────────────────────────────────────────
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (!scrollRef.value) return
|
||||||
|
const pageH = pageHeights.value[currentPage.value - 1] ?? containerHeight.value
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'PageDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
scrollRef.value.scrollBy({ top: pageH + PAGE_GAP, behavior: 'smooth' })
|
||||||
|
} else if (e.key === 'ArrowUp' || e.key === 'PageUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
scrollRef.value.scrollBy({ top: -(pageH + PAGE_GAP), behavior: 'smooth' })
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
goToPage(1)
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
goToPage(totalPages.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation helpers ────────────────────────────────────────────────────────
|
||||||
|
function goToPage(n: number) {
|
||||||
|
const el = scrollRef.value
|
||||||
|
if (!el) return
|
||||||
|
const clamped = Math.max(1, Math.min(n, totalPages.value))
|
||||||
|
let top = 0
|
||||||
|
for (let i = 0; i < clamped - 1; i++) top += (pageHeights.value[i] ?? 0) + PAGE_GAP
|
||||||
|
el.scrollTo({ top, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageInput = ref(1)
|
||||||
|
watch(currentPage, (v) => {
|
||||||
|
pageInput.value = v
|
||||||
|
})
|
||||||
|
|
||||||
|
function onPageInputCommit() {
|
||||||
|
const n = parseInt(String(pageInput.value))
|
||||||
|
if (!isNaN(n)) goToPage(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zoom helpers ──────────────────────────────────────────────────────────────
|
||||||
|
function setZoom(mode: ZoomMode) {
|
||||||
|
zoomMode.value = mode
|
||||||
|
if (mode !== 'custom') return
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustZoom(delta: number) {
|
||||||
|
const next = Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta))
|
||||||
|
customScale.value = Math.round(next * 20) / 20 // snap to 5% increments
|
||||||
|
zoomMode.value = 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resize observer ───────────────────────────────────────────────────────────
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function measureContainer() {
|
||||||
|
if (!scrollRef.value) return
|
||||||
|
containerWidth.value = scrollRef.value.clientWidth
|
||||||
|
containerHeight.value = scrollRef.value.clientHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initialisation ────────────────────────────────────────────────────────────
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
|
||||||
|
await progress.load()
|
||||||
|
await load(props.fileId)
|
||||||
|
if (!pdfDoc.value) return
|
||||||
|
|
||||||
|
// Fetch all page natural dimensions (scale=1) upfront
|
||||||
|
const dims: { width: number; height: number }[] = []
|
||||||
|
for (let i = 1; i <= totalPages.value; i++) {
|
||||||
|
const vp = await getPageViewport(i, 1)
|
||||||
|
dims.push(vp ? { width: vp.width, height: vp.height } : { width: 595, height: 842 })
|
||||||
|
}
|
||||||
|
pageDims.value = dims
|
||||||
|
canvasRefs.value = new Array(totalPages.value).fill(null)
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
measureContainer()
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
measureContainer()
|
||||||
|
rendered.clear()
|
||||||
|
renderQueue = []
|
||||||
|
rendering = false
|
||||||
|
nextTick(() => observerCallback(currentObserverEntries))
|
||||||
|
})
|
||||||
|
if (scrollRef.value) resizeObserver.observe(scrollRef.value)
|
||||||
|
|
||||||
|
setupObserver()
|
||||||
|
|
||||||
|
// Restore saved page
|
||||||
|
if (progress.pageNumber.value && progress.pageNumber.value > 1) {
|
||||||
|
await nextTick()
|
||||||
|
goToPage(progress.pageNumber.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
observer?.disconnect()
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Derived ───────────────────────────────────────────────────────────────────
|
||||||
|
const progressPercent = computed(() => (totalPages.value ? Math.round((currentPage.value / totalPages.value) * 100) : 0))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-[#404040] flex flex-col overflow-hidden" @mousemove="showHeader()">
|
||||||
|
<!-- Header -->
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-0 right-0 z-50 h-12 flex items-center px-3 gap-1 transition-all duration-300"
|
||||||
|
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
|
||||||
|
style="background: rgba(30, 30, 30, 0.92); backdrop-filter: blur(8px); border-bottom: 1px solid rgba(255, 255, 255, 0.1)"
|
||||||
|
>
|
||||||
|
<!-- Back -->
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-8 h-8 rounded-md text-white/80 hover:text-white hover:bg-white/10 transition-colors shrink-0"
|
||||||
|
@click="router.back()"
|
||||||
|
title="Go back"
|
||||||
|
>
|
||||||
|
<ArrowLeft :size="18" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="w-px h-5 mx-1 bg-white/15 shrink-0" />
|
||||||
|
|
||||||
|
<!-- Page navigation -->
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors text-sm font-medium disabled:opacity-30"
|
||||||
|
:disabled="currentPage <= 1"
|
||||||
|
@click="goToPage(currentPage - 1)"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-1 text-sm text-white/80">
|
||||||
|
<input
|
||||||
|
v-model.number="pageInput"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:max="totalPages"
|
||||||
|
class="w-10 text-center bg-white/10 rounded px-1 py-0.5 text-white text-sm outline-none focus:bg-white/20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
@keydown.enter="onPageInputCommit"
|
||||||
|
@blur="onPageInputCommit"
|
||||||
|
/>
|
||||||
|
<span class="text-white/50">/</span>
|
||||||
|
<span>{{ totalPages }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors text-sm font-medium disabled:opacity-30"
|
||||||
|
:disabled="currentPage >= totalPages"
|
||||||
|
@click="goToPage(currentPage + 1)"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1" />
|
||||||
|
|
||||||
|
<!-- Zoom controls -->
|
||||||
|
<div class="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||||
|
@click="adjustZoom(-0.1)"
|
||||||
|
title="Zoom out"
|
||||||
|
>
|
||||||
|
<Minus :size="14" />
|
||||||
|
</button>
|
||||||
|
<span class="w-12 text-center text-xs text-white/70 font-mono tabular-nums">{{ zoomPercent }}%</span>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||||
|
@click="adjustZoom(0.1)"
|
||||||
|
title="Zoom in"
|
||||||
|
>
|
||||||
|
<Plus :size="14" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="w-px h-4 mx-1 bg-white/15 shrink-0" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-7 h-7 rounded transition-colors text-xs font-bold"
|
||||||
|
:class="zoomMode === 'fit-width' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white hover:bg-white/10'"
|
||||||
|
@click="setZoom('fit-width')"
|
||||||
|
title="Fit width"
|
||||||
|
>
|
||||||
|
<AlignJustify :size="14" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-7 h-7 rounded transition-colors"
|
||||||
|
:class="zoomMode === 'fit-page' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white hover:bg-white/10'"
|
||||||
|
@click="setZoom('fit-page')"
|
||||||
|
title="Fit page"
|
||||||
|
>
|
||||||
|
<Maximize2 :size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hover zone to re-show header -->
|
||||||
|
<div class="absolute top-0 left-0 right-0 z-40 h-16 pointer-events-auto" @mouseenter="showHeader()" />
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
||||||
|
<p class="text-sm text-white/60">Loading PDF…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="error" class="flex-1 flex items-center justify-center p-8">
|
||||||
|
<div class="text-center max-w-sm">
|
||||||
|
<p class="text-sm font-medium text-white/90 mb-2">Failed to load PDF</p>
|
||||||
|
<p class="text-xs text-white/50">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pages -->
|
||||||
|
<div v-else ref="scrollRef" class="flex-1 overflow-y-auto overflow-x-auto" @scroll="onScroll">
|
||||||
|
<div class="flex flex-col items-center py-4" :style="{ gap: `${PAGE_GAP}px` }">
|
||||||
|
<div
|
||||||
|
v-for="n in totalPages"
|
||||||
|
:key="n"
|
||||||
|
:data-page="n"
|
||||||
|
class="bg-white shadow-lg"
|
||||||
|
:style="{
|
||||||
|
width: `${Math.round((pageDims[n - 1]?.width ?? 595) * scale)}px`,
|
||||||
|
height: `${pageHeights[n - 1] ?? 0}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
:ref="
|
||||||
|
(el) => {
|
||||||
|
canvasRefs[n - 1] = el as HTMLCanvasElement | null
|
||||||
|
}
|
||||||
|
"
|
||||||
|
class="block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer progress bar -->
|
||||||
|
<div v-if="!loading && !error && totalPages > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-white/10">
|
||||||
|
<div class="h-full bg-white/40 transition-all duration-300" :style="{ width: `${progressPercent}%` }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { onUnmounted, ref } from 'vue'
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist'
|
||||||
|
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
|
||||||
|
|
||||||
|
export type ZoomMode = 'fit-width' | 'fit-page' | 'custom'
|
||||||
|
|
||||||
|
// Renders one page onto the given canvas at the given scale (pdfjs-dist v5 API).
|
||||||
|
async function renderPageToCanvas(doc: pdfjsLib.PDFDocumentProxy, pageNum: number, canvas: HTMLCanvasElement, scale: number) {
|
||||||
|
const page = await doc.getPage(pageNum)
|
||||||
|
const viewport = page.getViewport({ scale })
|
||||||
|
canvas.width = viewport.width
|
||||||
|
canvas.height = viewport.height
|
||||||
|
await page.render({ canvas, viewport }).promise
|
||||||
|
page.cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePdf() {
|
||||||
|
const pdfDoc = ref<pdfjsLib.PDFDocumentProxy | null>(null)
|
||||||
|
const totalPages = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function load(fileId: number) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/books/files/${fileId}/serve`)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data = await res.arrayBuffer()
|
||||||
|
const doc = await pdfjsLib.getDocument({ data }).promise
|
||||||
|
pdfDoc.value = doc
|
||||||
|
totalPages.value = doc.numPages
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load PDF'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the natural width/height of a page at scale=1 (used to size placeholder divs).
|
||||||
|
async function getPageViewport(pageNum: number, scale: number) {
|
||||||
|
if (!pdfDoc.value) return null
|
||||||
|
const page = await pdfDoc.value.getPage(pageNum)
|
||||||
|
const vp = page.getViewport({ scale })
|
||||||
|
page.cleanup()
|
||||||
|
return vp
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
pdfDoc.value?.destroy()
|
||||||
|
pdfDoc.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return { pdfDoc, totalPages, loading, error, load, renderPageToCanvas, getPageViewport }
|
||||||
|
}
|
||||||
@@ -5,28 +5,28 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1766913138285,
|
"when": 1766926208614,
|
||||||
"tag": "0000_init_schema",
|
"tag": "0000_init_schema",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1766913138285,
|
"when": 1766926208614,
|
||||||
"tag": "0001_lethal_pixie",
|
"tag": "0001_lethal_pixie",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1766913138285,
|
"when": 1766926208614,
|
||||||
"tag": "0002_powerful_thundra",
|
"tag": "0002_powerful_thundra",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 3,
|
"idx": 3,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1766913138285,
|
"when": 1766926208614,
|
||||||
"tag": "0003_reading_progress_by_file",
|
"tag": "0003_reading_progress_by_file",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user