diff --git a/example/index.tsx b/example/index.tsx index 049132dc..467bdde8 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -25,7 +25,8 @@ import useSWR, { Fetcher } from 'swr'; import readiumAfter from 'url:../src/HtmlReader/ReadiumCss/ReadiumCSS-after.css'; import readiumBefore from 'url:../src/HtmlReader/ReadiumCss/ReadiumCSS-before.css'; import readiumDefault from 'url:../src/HtmlReader/ReadiumCss/ReadiumCSS-default.css'; -import WebReader, { addTocToManifest } from '../src'; +import WebReader from '../src'; +import addTocToManifest from '../src/PdfReader/addTocToManifest'; import { Injectable } from '../src/Readium/Injectable'; import { WebpubManifest } from '../src/types'; import { getTheme } from '../src/ui/theme'; diff --git a/package-lock.json b/package-lock.json index 7d354138..e60a34b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7974,55 +7974,43 @@ } }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, - "node_modules/axios/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "dev": true, "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 6.0.0" } }, - "node_modules/axios/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/axios/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "dev": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "agent-base": "6", + "debug": "4" }, "engines": { - "node": ">= 0.6" + "node": ">= 6" } }, "node_modules/axios/node_modules/proxy-from-env": { @@ -8233,19 +8221,22 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz", - "integrity": "sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw==", + "version": "2.11.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/basic-ftp": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.0.tgz", - "integrity": "sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", "dev": true, "license": "MIT", "engines": { @@ -8292,22 +8283,36 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -8317,9 +8322,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -8603,9 +8608,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001765", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", - "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", "dev": true, "funding": [ { @@ -10835,9 +10840,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -11023,6 +11028,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -11588,9 +11633,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -11913,9 +11958,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "dev": true, "license": "MIT", "engines": { @@ -14107,9 +14152,9 @@ } }, "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "version": "17.13.6", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.6.tgz", + "integrity": "sha512-ImNZaq/LSysofih+xIGYfR0WUXMA9GLUNB//YTCSrZptoRmVgaNAdJyi6K1kXi9pkLEoSkoI8I4UwtNiu/D7nw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -14137,9 +14182,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -15373,9 +15418,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -16589,9 +16634,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -16611,7 +16656,7 @@ "optional": true, "peer": true, "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -16893,13 +16938,14 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -18051,15 +18097,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -18071,14 +18117,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -19666,18 +19712,36 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dev": true, "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typed-array-buffer": { @@ -20468,9 +20532,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { diff --git a/playwright/pageobjects/web-reader.page.ts b/playwright/pageobjects/web-reader.page.ts index 96ba9126..57f108f8 100644 --- a/playwright/pageobjects/web-reader.page.ts +++ b/playwright/pageobjects/web-reader.page.ts @@ -204,30 +204,35 @@ class PdfReaderPage extends WebReaderPage { } async getZoomValue(): Promise { - return await this.page - .locator('canvas:visible') - .first() - .evaluate((el) => { - return Number( - window.getComputedStyle(el).getPropertyValue('--scale-factor') + const textLayer = this.pageOne.locator('.textLayer'); + let value = 0; + await expect + .poll(async () => { + value = await textLayer.evaluate((el) => + Number(window.getComputedStyle(el).getPropertyValue('--scale-factor')) ); - }); + return value; + }) + .toBeGreaterThan(0); + return value; } async zoomIn(): Promise { const beforeScaleFactor = await this.getZoomValue(); await expect(this.zoomInButton).toBeVisible(); await this.zoomInButton.click(); - const afterScaleFactor = await this.getZoomValue(); - expect(afterScaleFactor).toBeGreaterThan(beforeScaleFactor); + await expect + .poll(() => this.getZoomValue()) + .toBeGreaterThan(beforeScaleFactor); } async zoomOut(): Promise { const beforeScaleFactor = await this.getZoomValue(); await expect(this.zoomOutButton).toBeVisible(); await this.zoomOutButton.click(); - const afterScaleFactor = await this.getZoomValue(); - expect(afterScaleFactor).toBeLessThan(beforeScaleFactor); + await expect + .poll(() => this.getZoomValue()) + .toBeLessThan(beforeScaleFactor); } async scrollDown(): Promise { diff --git a/src/PdfReader/ChakraPage.tsx b/src/PdfReader/ChakraPage.tsx deleted file mode 100644 index 12dac558..00000000 --- a/src/PdfReader/ChakraPage.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { chakra, shouldForwardProp } from '@chakra-ui/react'; -import React from 'react'; -import { Page, PageProps } from 'react-pdf'; -import { FitMode } from '../types'; - -interface ChakraPageProps extends Omit { - width?: number; - height?: number; - fitMode: FitMode; -} - -// Wrap Page component so that we can pass it styles -const ChakraPage = chakra( - ({ width, height, fitMode, ...rest }: ChakraPageProps) => ( - - ), - { - shouldForwardProp: (prop) => { - // Forward width, height, scale, fitMode, and react-pdf props - if ( - [ - 'width', - 'height', - 'scale', - 'fitMode', - 'pageNumber', - 'rotate', - 'onLoadSuccess', - 'renderAnnotationLayer', - 'renderTextLayer', - 'loading', - ].includes(prop) - ) - return true; - // don't forward the rest of Chakra's props - const isChakraProp = !shouldForwardProp(prop); - if (isChakraProp) return false; - return true; - }, - baseStyle: { - outline: '1px', - outlineColor: 'ui.gray.light-cool', - }, - } -); - -export default ChakraPage; diff --git a/src/PdfReader/PdfPage.tsx b/src/PdfReader/PdfPage.tsx new file mode 100644 index 00000000..1c763bfa --- /dev/null +++ b/src/PdfReader/PdfPage.tsx @@ -0,0 +1,292 @@ +import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'; +import { AnnotationLayer, TextLayer } from 'pdfjs-dist'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { PageSize, RefProxy } from './types'; +import { getRotatedSize, toError } from './utils'; + +interface PdfPageProps { + pdfDoc: PDFDocumentProxy; + pageNumber: number; + scale: number; + rotation: number; + baseSize: PageSize; + isVisible: boolean; + registerContainer: (pageNumber: number, el: HTMLDivElement | null) => void; + goToPage: (pageNumber: number) => void; + onError?: (error: Error) => void; +} + +/** + * A single page: a fixed-size placeholder (so the scrollbar/layout never + * jumps) whose canvas/text/annotation layers only get painted once the + * page scrolls near the viewport (`isVisible`), and re-painted whenever + * `scale`/`rotation` change. + */ +const PdfPage = React.memo(function PdfPage({ + pdfDoc, + pageNumber, + scale, + rotation, + baseSize, + isVisible, + registerContainer, + goToPage, + onError, +}: PdfPageProps) { + const canvasRef = useRef(null); + const textLayerRef = useRef(null); + const annotationLayerRef = useRef(null); + const renderTaskRef = useRef(null); + const textLayerTaskRef = useRef<{ cancel: () => void } | null>(null); + const renderedKeyRef = useRef(null); + + const releaseRenderedContent = useCallback(() => { + const canvas = canvasRef.current; + if (canvas) { + canvas.width = 0; + canvas.height = 0; + canvas.style.width = '0px'; + canvas.style.height = '0px'; + } + if (textLayerRef.current) { + textLayerRef.current.innerHTML = ''; + textLayerRef.current.style.removeProperty('--scale-factor'); + } + if (annotationLayerRef.current) { + annotationLayerRef.current.innerHTML = ''; + } + renderedKeyRef.current = null; + }, []); + + const rotated = useMemo( + () => getRotatedSize(baseSize, rotation), + [baseSize, rotation] + ); + const displayWidth = rotated.width * scale; + const displayHeight = rotated.height * scale; + + useEffect(() => { + if (!isVisible) { + renderTaskRef.current?.cancel(); + textLayerTaskRef.current?.cancel(); + textLayerTaskRef.current = null; + releaseRenderedContent(); + return undefined; + } + + const key = `${scale}|${rotation}`; + if (renderedKeyRef.current === key) return undefined; + + let cancelled = false; + + const run = async () => { + const page = await pdfDoc.getPage(pageNumber); + if (cancelled) return; + + const viewport = page.getViewport({ scale, rotation }); + const canvas = canvasRef.current; + const textLayerDiv = textLayerRef.current; + const annotationLayerDiv = annotationLayerRef.current; + if (!canvas || !textLayerDiv || !annotationLayerDiv) return; + + const outputScale = window.devicePixelRatio || 1; + canvas.width = Math.floor(viewport.width * outputScale); + canvas.height = Math.floor(viewport.height * outputScale); + canvas.style.width = `${viewport.width}px`; + canvas.style.height = `${viewport.height}px`; + textLayerDiv.style.width = `${viewport.width}px`; + textLayerDiv.style.height = `${viewport.height}px`; + annotationLayerDiv.style.width = `${viewport.width}px`; + annotationLayerDiv.style.height = `${viewport.height}px`; + + if (renderTaskRef.current) { + renderTaskRef.current.cancel(); + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const transform = + outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined; + + const task = page.render({ + canvasContext: ctx, + viewport, + transform, + }); + renderTaskRef.current = task; + + try { + await task.promise; + } catch (err: unknown) { + if ( + err instanceof Error && + err.name === 'RenderingCancelledException' + ) { + return; + } + const nextError = toError(err, `Failed to render page ${pageNumber}`); + onError?.(nextError); + return; + } + if (cancelled) return; + + textLayerDiv.innerHTML = ''; + textLayerDiv.style.setProperty('--scale-factor', String(viewport.scale)); + if (textLayerTaskRef.current) { + textLayerTaskRef.current.cancel(); + textLayerTaskRef.current = null; + } + try { + const textContent = await page.getTextContent(); + const textLayer = new TextLayer({ + textContentSource: textContent, + container: textLayerDiv, + viewport, + }); + textLayerTaskRef.current = textLayer; + await textLayer.render(); + } catch (err: unknown) { + // TextLayer.cancel() rejects the in-flight render promise by design, + // not a real error. + if ( + !(err instanceof Error && err.name === 'RenderingCancelledException') + ) { + // Non-fatal: some pages have no extractable text. + } + } + if (cancelled) return; + + annotationLayerDiv.innerHTML = ''; + try { + const annotations = await page.getAnnotations({ intent: 'display' }); + const linkService = { + goToDestination: async (dest: string | unknown[] | null) => { + let d = dest; + if (typeof d === 'string') d = await pdfDoc.getDestination(d); + if (Array.isArray(d)) { + const idx = await pdfDoc.getPageIndex(d[0] as RefProxy); + goToPage(idx + 1); + } + }, + goToPage: (n: number) => goToPage(n), + getDestinationHash: () => '#', + getAnchorUrl: () => '#', + addLinkAttributes: () => { + void 0; + }, + setHash: () => { + void 0; + }, + executeNamedAction: () => { + void 0; + }, + executeSetOCGState: () => { + void 0; + }, + get pagesCount() { + return 0; + }, + get page() { + return 0; + }, + set page(_: number) { + void 0; + }, + get rotation() { + return 0; + }, + set rotation(_: number) { + void 0; + }, + get isInPresentationMode() { + return false; + }, + get externalLinkEnabled() { + return true; + }, + set externalLinkEnabled(_: boolean) { + void 0; + }, + }; + + const layer = new AnnotationLayer({ + div: annotationLayerDiv, + accessibilityManager: null, + annotationCanvasMap: null, + annotationEditorUIManager: null, + structTreeLayer: null, + page, + viewport: viewport.clone({ dontFlip: true }), + }); + + await layer.render({ + viewport: viewport.clone({ dontFlip: true }), + div: annotationLayerDiv, + annotations, + page, + linkService, + annotationStorage: undefined, + renderForms: true, + }); + } catch { + // Non-fatal: annotation rendering support varies by pdf.js version. + } + + if (!cancelled) renderedKeyRef.current = key; + }; + + run(); + + return () => { + cancelled = true; + renderTaskRef.current?.cancel(); + textLayerTaskRef.current?.cancel(); + textLayerTaskRef.current = null; + }; + }, [ + isVisible, + scale, + rotation, + pdfDoc, + pageNumber, + goToPage, + onError, + releaseRenderedContent, + ]); + + useEffect( + () => () => { + renderTaskRef.current?.cancel(); + textLayerTaskRef.current?.cancel(); + releaseRenderedContent(); + }, + [releaseRenderedContent] + ); + + return ( +
registerContainer(pageNumber, el)} + style={{ width: displayWidth, height: displayHeight, overflow: 'hidden' }} + > + +
+
+
+ ); +}); + +export default PdfPage; diff --git a/src/PdfReader/PdfReaderContent.tsx b/src/PdfReader/PdfReaderContent.tsx new file mode 100644 index 00000000..55945df3 --- /dev/null +++ b/src/PdfReader/PdfReaderContent.tsx @@ -0,0 +1,544 @@ +import { Icon } from '@chakra-ui/react'; +import type { PDFDocumentProxy } from 'pdfjs-dist'; +import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist'; +import React, { + KeyboardEvent, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { MAIN_CONTENT_ID } from '../constants'; +import PdfPage from './PdfPage'; +import { + PAGE_GAP, + PAGE_PADDING, + RENDER_ROOT_MARGIN, + SCROLLSPY_ANCHOR_RATIO, +} from './constants'; +import { + FitMode, + PageSize, + PdfOutlineEntry, + PdfReaderContentProps, + ViewportAnchor, +} from './types'; +import { + getDisplayPageHeight, + getPageTop, + getRotatedSize, + resolveOutline, + toError, +} from './utils'; + +const PdfReaderContent = ({ + fileUrl, + pdfWorkerSrc, + pageNumber, + navigationRequestId, + scale, + fitMode, + rotation, + dispatch, + pendingAction, + clearPendingAction, + onOutlineLoad, + onPageSizesReady, +}: PdfReaderContentProps): React.ReactElement => { + const [pdfDoc, setPdfDoc] = useState(null); + + useEffect(() => { + if (pdfWorkerSrc) GlobalWorkerOptions.workerSrc = pdfWorkerSrc; + }, [pdfWorkerSrc]); + + const [pageBaseSizes, setPageBaseSizes] = useState([]); + const [visiblePages, setVisiblePages] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const rootRef = useRef(null); + const viewportWrapRef = useRef(null); + const containerRefs = useRef>(new Map()); + const renderObserverRef = useRef(null); + const currentPageRef = useRef(pageNumber); + const pendingScrollTargetRef = useRef(null); + const lastHandledNavigationRequestRef = useRef(0); + const pendingViewportAnchorRef = useRef(null); + const suppressNextResizeFitRef = useRef(false); + const onPageSizesReadyRef = useRef(onPageSizesReady); + + useEffect(() => { + onPageSizesReadyRef.current = onPageSizesReady; + }, [onPageSizesReady]); + + useEffect(() => { + currentPageRef.current = pageNumber; + }, [pageNumber]); + + useEffect(() => { + return () => { + pdfDoc?.destroy(); + }; + }, [pdfDoc]); + + // Load doc + useEffect(() => { + let cancelled = false; + let loadingTask: ReturnType | null = null; + setLoading(true); + setError(null); + setPdfDoc(null); + onOutlineLoad([]); + setPageBaseSizes([]); + setVisiblePages(new Set()); + containerRefs.current.clear(); + + const loadDocument = async () => { + try { + if (!fileUrl) { + throw new Error('A PDF fileUrl is required'); + } + + loadingTask = getDocument({ url: fileUrl, withCredentials: false }); + const doc = await loadingTask.promise; + if (cancelled) return; + + setPdfDoc(doc); + dispatch({ type: 'PAGES_LOADED', numPages: doc.numPages }); + + try { + const rawOutline = (await doc.getOutline()) as + | PdfOutlineEntry[] + | null; + if (rawOutline && !cancelled) { + onOutlineLoad(await resolveOutline(doc, rawOutline)); + } + } catch { + // Outline is optional, ignore failures. + } + } catch (err: unknown) { + if (cancelled) return; + const nextError = toError(err, 'Failed to load PDF document'); + setError(nextError); + } finally { + if (!cancelled) setLoading(false); + } + }; + + loadDocument(); + + return () => { + cancelled = true; + loadingTask?.destroy?.(); + }; + }, [fileUrl, dispatch, onOutlineLoad]); + + // Read every page's intrinsic size to determine scroll container's height + // before any page has actually been painted. + // TODO: for very large documents consider windowing this fetch + useEffect(() => { + if (!pdfDoc) return undefined; + let cancelled = false; + + (async () => { + try { + const sizes: PageSize[] = []; + for (let i = 1; i <= pdfDoc.numPages; i += 1) { + if (cancelled) return; + const page = await pdfDoc.getPage(i); + const vp = page.getViewport({ scale: 1, rotation: 0 }); + sizes.push({ width: vp.width, height: vp.height }); + // Hint to pdf.js to release temporary resources for this page. + page.cleanup(); + } + if (cancelled) return; + setPageBaseSizes(sizes); + onPageSizesReadyRef.current?.(); + } catch (err: unknown) { + if (cancelled) return; + const nextError = toError(err, 'Failed to read page dimensions'); + setError(nextError); + } + })(); + + return () => { + cancelled = true; + }; + }, [pdfDoc]); + + // Lazy render: observe which pages are near the viewport + useEffect(() => { + const wrap = viewportWrapRef.current; + if (!wrap || !pageBaseSizes.length) return undefined; + + const observer = new IntersectionObserver( + (entries) => { + setVisiblePages((prev) => { + let changed = false; + const next = new Set(prev); + entries.forEach((entry) => { + const pn = Number((entry.target as HTMLElement).dataset.pageNumber); + if (!pn) return; + if (entry.isIntersecting && !next.has(pn)) { + next.add(pn); + changed = true; + } else if (!entry.isIntersecting && next.has(pn)) { + next.delete(pn); + changed = true; + } + }); + return changed ? next : prev; + }); + }, + { root: wrap, rootMargin: RENDER_ROOT_MARGIN, threshold: 0 } + ); + + renderObserverRef.current = observer; + containerRefs.current.forEach((el) => observer.observe(el)); + + return () => { + observer.disconnect(); + renderObserverRef.current = null; + }; + }, [pageBaseSizes.length]); + + const registerContainer = useCallback( + (pageNumber: number, el: HTMLDivElement | null) => { + if (el) { + containerRefs.current.set(pageNumber, el); + renderObserverRef.current?.observe(el); + } else { + const existing = containerRefs.current.get(pageNumber); + if (existing && renderObserverRef.current) { + renderObserverRef.current.unobserve(existing); + } + containerRefs.current.delete(pageNumber); + } + }, + [] + ); + + // Scrollspy: derive "current page" from scroll position + useEffect(() => { + const wrap = viewportWrapRef.current; + if (!wrap || !pageBaseSizes.length) return undefined; + + let ticking = false; + + const update = () => { + ticking = false; + if (pendingScrollTargetRef.current != null) { + const targetEl = containerRefs.current.get( + pendingScrollTargetRef.current + ); + if (targetEl && Math.abs(targetEl.offsetTop - wrap.scrollTop) < 4) { + pendingScrollTargetRef.current = null; + } + } + + const anchor = + wrap.scrollTop + wrap.clientHeight * SCROLLSPY_ANCHOR_RATIO; + let best = currentPageRef.current; + let bestTop = -Infinity; + containerRefs.current.forEach((el, pn) => { + if (el.offsetTop <= anchor && el.offsetTop > bestTop) { + bestTop = el.offsetTop; + best = pn; + } + }); + + if (best !== currentPageRef.current) + dispatch({ type: 'PAGE_IN_VIEW', page: best }); + }; + + const onScroll = () => { + if (!ticking) { + ticking = true; + requestAnimationFrame(update); + } + }; + + wrap.addEventListener('scroll', onScroll); + update(); + + return () => wrap.removeEventListener('scroll', onScroll); + }, [dispatch, pageBaseSizes.length]); + + useEffect(() => { + const wrap = viewportWrapRef.current; + if (!wrap || !pageBaseSizes.length || navigationRequestId <= 0) return; + if (navigationRequestId === lastHandledNavigationRequestRef.current) return; + + const target = containerRefs.current.get(pageNumber); + if (!target) return; + + // Ensure the destination page (and immediate neighbors) are considered + // visible right away so rendering starts before IntersectionObserver settles. + setVisiblePages((prev) => { + const next = new Set(prev); + next.add(pageNumber); + if (pageNumber > 1) next.add(pageNumber - 1); + if (pageNumber < pageBaseSizes.length) next.add(pageNumber + 1); + return next; + }); + + pendingScrollTargetRef.current = pageNumber; + target.scrollIntoView({ block: 'start', behavior: 'auto' }); + lastHandledNavigationRequestRef.current = navigationRequestId; + }, [navigationRequestId, pageBaseSizes.length, pageNumber]); + + // Viewport anchor capture + // Records what content is currently visible so that after a layout-affecting + // change (scale/rotation) the scroll position is restored. + const captureViewportAnchor = useCallback(() => { + if (pendingViewportAnchorRef.current) return; + + const wrap = viewportWrapRef.current; + if (!wrap || !pageBaseSizes.length) return; + + const viewportOffset = wrap.clientHeight * SCROLLSPY_ANCHOR_RATIO; + const anchorY = wrap.scrollTop + viewportOffset; + + let anchorPage = 1; + let intraPageRatio = 0; + + for (let i = 1; i <= pageBaseSizes.length; i += 1) { + const top = getPageTop(pageBaseSizes, i, scale, rotation); + const height = getDisplayPageHeight(pageBaseSizes, i, scale, rotation); + if (anchorY <= top + height || i === pageBaseSizes.length) { + anchorPage = i; + intraPageRatio = height > 0 ? (anchorY - top) / height : 0; + break; + } + } + + pendingViewportAnchorRef.current = { + pageNumber: anchorPage, + intraPageRatio: Math.max(0, Math.min(1, intraPageRatio)), + viewportOffset, + }; + }, [pageBaseSizes, scale, rotation]); + + // Fit-to-width/height + const computeFitScaleValue = useCallback( + (mode: FitMode, rotationForCalc: number): number | null => { + if (!mode || !pageBaseSizes.length) return null; + const wrap = viewportWrapRef.current; + if (!wrap) return null; + + const refPageNumber = Math.min( + Math.max(currentPageRef.current, 1), + pageBaseSizes.length + ); + const base = pageBaseSizes[refPageNumber - 1]; + const rotated = getRotatedSize(base, rotationForCalc); + + const widestRotatedPageWidth = pageBaseSizes.reduce((maxWidth, page) => { + const nextWidth = getRotatedSize(page, rotationForCalc).width; + return Math.max(maxWidth, nextWidth); + }, 0); + + const availWidth = wrap.clientWidth - PAGE_PADDING; + const availHeight = wrap.clientHeight - PAGE_PADDING; + + const newScale = + mode === 'width' + ? availWidth / widestRotatedPageWidth + : availHeight / rotated.height; + return newScale > 0 ? newScale : null; + }, + [pageBaseSizes] + ); + + const scaleRef = useRef(scale); + useEffect(() => { + scaleRef.current = scale; + }, [scale]); + + const applyFitScale = useCallback( + (mode: FitMode) => { + const newScale = computeFitScaleValue(mode, rotation); + if (newScale == null) return; + + const relativeDelta = + scaleRef.current > 0 + ? Math.abs(scaleRef.current - newScale) / scaleRef.current + : 1; + if (relativeDelta < 0.005) return; + + captureViewportAnchor(); + + suppressNextResizeFitRef.current = true; + dispatch({ type: 'SET_SCALE', scale: newScale }); + }, + [computeFitScaleValue, rotation, captureViewportAnchor, dispatch] + ); + + useLayoutEffect(() => { + const anchor = pendingViewportAnchorRef.current; + const wrap = viewportWrapRef.current; + if (!anchor || !wrap || !pageBaseSizes.length) return; + + const top = getPageTop(pageBaseSizes, anchor.pageNumber, scale, rotation); + const height = getDisplayPageHeight( + pageBaseSizes, + anchor.pageNumber, + scale, + rotation + ); + const anchorY = top + height * anchor.intraPageRatio; + const nextScrollTop = Math.max(0, anchorY - anchor.viewportOffset); + wrap.scrollTop = nextScrollTop; + + pendingViewportAnchorRef.current = null; + }, [scale, rotation, pageBaseSizes]); + + useEffect(() => { + if (!pendingAction) return; + + if (pendingAction.type === 'ZOOM_IN' || pendingAction.type === 'ZOOM_OUT') { + captureViewportAnchor(); + dispatch({ type: pendingAction.type }); + } + + if (pendingAction.type === 'ROTATE_CCW') { + captureViewportAnchor(); + + const nextRotation = (rotation + 270) % 360; + const nextScale = fitMode + ? computeFitScaleValue(fitMode, nextRotation) + : undefined; + if (nextScale != null) suppressNextResizeFitRef.current = true; + dispatch({ type: 'ROTATE_CCW', nextScale: nextScale ?? undefined }); + } + + clearPendingAction(); + }, [ + captureViewportAnchor, + clearPendingAction, + computeFitScaleValue, + fitMode, + pendingAction, + rotation, + dispatch, + ]); + + useEffect(() => { + if (fitMode) applyFitScale(fitMode); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fitMode, pageBaseSizes]); + + // Re-apply fit scale on container resize. + const fitModeRef = useRef(fitMode); + useEffect(() => { + fitModeRef.current = fitMode; + }, [fitMode]); + + const applyFitScaleRef = useRef(applyFitScale); + useEffect(() => { + applyFitScaleRef.current = applyFitScale; + }, [applyFitScale]); + + useEffect(() => { + const wrap = viewportWrapRef.current; + if (!wrap) return undefined; + const ro = new ResizeObserver(() => { + if (suppressNextResizeFitRef.current) { + suppressNextResizeFitRef.current = false; + return; + } + if (fitModeRef.current) applyFitScaleRef.current(fitModeRef.current); + }); + ro.observe(wrap); + return () => ro.disconnect(); + }, []); + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!rootRef.current?.contains(document.activeElement)) return; + if (e.target instanceof HTMLInputElement) return; + if (e.key === 'ArrowRight' || e.key === 'PageDown') { + dispatch({ type: 'GO_FORWARD' }); + } + if (e.key === 'ArrowLeft' || e.key === 'PageUp') { + dispatch({ type: 'GO_BACKWARD' }); + } + }; + window.addEventListener('keydown', handler as unknown as EventListener); + return () => + window.removeEventListener( + 'keydown', + handler as unknown as EventListener + ); + }, [captureViewportAnchor, dispatch, pageNumber]); + + const requestGoToPage = useCallback( + (page: number) => dispatch({ type: 'GO_TO_PAGE', page }), + [dispatch] + ); + + return ( +
+
+
+ {loading && ( +
+
Loading
+
+ )} + {!loading && error && ( +
+
+ + + + {error.message || 'Failed to load PDF'} +
+
+ )} + {!loading && !error && pdfDoc && pageBaseSizes.length > 0 && ( +
+ {pageBaseSizes.map((size, idx) => { + const pageNumber = idx + 1; + return ( + + ); + })} +
+ )} +
+
+
+ ); +}; + +export default PdfReaderContent; diff --git a/src/PdfReader/ScrollPage.tsx b/src/PdfReader/ScrollPage.tsx deleted file mode 100644 index 56fbd690..00000000 --- a/src/PdfReader/ScrollPage.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React, { FC } from 'react'; -import { useInView } from 'react-intersection-observer'; -import { PageProps } from 'react-pdf'; -import { FitMode } from '../types'; -import ChakraPage from './ChakraPage'; - -type ScrollPageProps = { - pageNumber: number; - width: number | undefined; - height: number | undefined; - scale: number; - onLoadSuccess: (page: PageProps) => void; - onPageRef?: (pageNumber: number, element: HTMLElement | null) => void; - placeholderHeight: number; - placeholderWidth: number; - allowInView?: boolean; - onInView?: (pageNumber: number, ratio: number) => void; - fitMode: FitMode; - rotate: number; -}; - -type PlaceholderProps = { - height: number; - width: number | undefined; - pageNumber: number; -}; - -const Placeholder: FC = ({ width, height, pageNumber }) => { - return ( -
- ); -}; - -const ScrollPage: FC = ({ - scale, - pageNumber, - width, - height, - onLoadSuccess, - onPageRef, - placeholderHeight, - placeholderWidth, - allowInView, - onInView, - fitMode, - rotate, -}) => { - const { ref: loadRef, inView: loadInView } = useInView({ - threshold: 0, - triggerOnce: true, - }); - - const { ref: visibilityRef, entry } = useInView({ - threshold: Array.from({ length: 11 }, (_, i) => i * 0.1), - triggerOnce: false, - }); - - const setRefs = React.useCallback( - (el: Element | null) => { - if (typeof loadRef === 'function') loadRef(el); - if (typeof visibilityRef === 'function') visibilityRef(el); - if (onPageRef) onPageRef(pageNumber, el as HTMLElement | null); - }, - [loadRef, onPageRef, pageNumber, visibilityRef] - ); - - const handleLoadSuccess = React.useCallback( - (page: PageProps) => { - onLoadSuccess(page); - }, - [onLoadSuccess] - ); - - React.useEffect(() => { - if (allowInView && onInView && entry) { - onInView(pageNumber, entry.intersectionRatio || 0); - } - }, [allowInView, entry, onInView, pageNumber]); - - return ( -
- {loadInView ? ( - - ) : ( - - )} -
- ); -}; - -export default ScrollPage; diff --git a/src/PdfReader/constants.ts b/src/PdfReader/constants.ts new file mode 100644 index 00000000..edbaf57c --- /dev/null +++ b/src/PdfReader/constants.ts @@ -0,0 +1,7 @@ +export const MIN_SCALE = 0.25; +export const MAX_SCALE = 5; +export const SCALE_STEP = 0.1; +export const PAGE_GAP = 16; +export const PAGE_PADDING = 32; +export const RENDER_ROOT_MARGIN = '300px 0px 300px 0px'; +export const SCROLLSPY_ANCHOR_RATIO = 0.3; diff --git a/src/PdfReader/index.tsx b/src/PdfReader/index.tsx index 82b1eb86..f9365f85 100644 --- a/src/PdfReader/index.tsx +++ b/src/PdfReader/index.tsx @@ -1,536 +1,201 @@ -import { Flex } from '@chakra-ui/react'; -import * as React from 'react'; -import { Document, PageProps, pdfjs } from 'react-pdf'; +import { GlobalWorkerOptions, version } from 'pdfjs-dist'; +import React, { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; import { + DEFAULT_FIT_MODE, DEFAULT_HEIGHT, - DEFAULT_SHOULD_GROW_WHEN_SCROLLING, - IN_VIEW_DELAY_MS, - MAIN_CONTENT_ID, - READER_MARGIN, + DEFAULT_SETTINGS, } from '../constants'; -import { FitMode, ReaderReturn } from '../types'; +import { + PdfNavigator, + ReaderReturn, + ReaderState, + WebpubManifest, +} from '../types'; import LoadingSkeleton from '../ui/LoadingSkeleton'; -import ChakraPage from './ChakraPage'; -import { fetchAsUint8Array, getResourceUrl, SCALE_STEP } from './lib'; import './pdfReader.css'; -import { makePdfReducer } from './reducer'; -import ScrollPage from './ScrollPage'; -import { PdfReaderArguments } from './types'; -import useMeasure from './useMeasure'; - -/** - * The PDF reader - * - * The PDF reader loads resources in two stages: First, it fetches the PDF resource as an Uint8Array - * Then, it passes this array into the object, which loads the PDF inside an iframe - * - * @param args T - * @returns - */ -export default function usePdfReader(args: PdfReaderArguments): ReaderReturn { - // use a passed in src for the pdf worker - if (args?.pdfWorkerSrc) { - pdfjs.GlobalWorkerOptions.workerSrc = args.pdfWorkerSrc; - } - - const { - webpubManifestUrl, - manifest, - proxyUrl, - getContent = fetchAsUint8Array, - injectablesReflowable, - injectablesFixed, - height = DEFAULT_HEIGHT, - growWhenScrolling = DEFAULT_SHOULD_GROW_WHEN_SCROLLING, - toggleFullScreen, - } = args ?? {}; - - const [state, dispatch] = React.useReducer(makePdfReducer(args), { - state: 'INACTIVE', - resourceIndex: 0, - resource: null, - pageNumber: 1, - numPages: null, - scale: 1, - pdfWidth: 0, - pdfHeight: 0, - pageHeight: undefined, - pageWidth: undefined, - atStart: true, - atEnd: false, - settings: undefined, - rendered: false, - fitMode: 'width', - }); - - // state we can derive from the state above - const isFetching = !state.resource; - const isParsed = typeof state.numPages === 'number'; - const [containerRef, containerSize] = useMeasure(); - - const documentContainerRef = React.useRef(null); - const pageRefs = React.useRef>(new Map()); - const setPageRef = React.useCallback( - (pageNumber: number, element: HTMLElement | null) => { - if (element) { - pageRefs.current.set(pageNumber, element); - return; - } - pageRefs.current.delete(pageNumber); - }, - [] - ); - - const scrollState = React.useRef({ - ratios: new Map(), - lastVisiblePage: state.pageNumber, - isInViewUpdate: false, - lastProgrammaticNavAt: 0, - }); - - // dispatch action when arguments change - React.useEffect(() => { - if (!webpubManifestUrl || !manifest) { - return dispatch({ type: 'ARGS_CHANGED', args: undefined }); - } - dispatch({ - type: 'ARGS_CHANGED', - args: { - webpubManifestUrl, - manifest, - getContent, - injectablesReflowable, - injectablesFixed, - height, - growWhenScrolling, - }, - }); - }, [ - webpubManifestUrl, - manifest, - getContent, - injectablesReflowable, - injectablesFixed, - height, - growWhenScrolling, - ]); - - /** - * Load the current resource and set it in state, - * and reload whenever it changes (via navigation) - */ - React.useEffect(() => { - // bail out if there is not manifest passed in, - // that indicates that this format is inactive - if (!manifest) return; - // throw an error on a badly formed manifest - if (!manifest.readingOrder || !manifest.readingOrder.length) { - throw new Error('Manifest has no Reading Order'); - } - - const currentResource = getResourceUrl( - state.resourceIndex, - manifest.readingOrder - ); - - const fetchResource = async () => { - getContent(currentResource, proxyUrl).then((data) => { - dispatch({ - type: 'RESOURCE_FETCH_SUCCESS', - resource: { data }, - }); - }); - }; - if (manifest.readingOrder && manifest.readingOrder.length) { - fetchResource(); +import PdfReaderContent from './PdfReaderContent'; +import { PdfReaderAction, pdfReaderReducer, PdfReaderState } from './reducer'; +import { OutlineItem, PdfReaderProps } from './types'; +import { + getManifestTitle, + getManifestTocFromOutline, + getPageNumberFromHref, + resolveResourceUrl, +} from './utils'; + +GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.min.mjs`; + +const usePdfReader = ({ + webpubManifestUrl, + manifest: inputManifest, + proxyUrl, + pdfWorkerSrc, + height = DEFAULT_HEIGHT, + toggleFullScreen, +}: PdfReaderProps): Exclude => { + // Resolve fileUrl from manifest + const fileUrl = useMemo(() => { + if (webpubManifestUrl && inputManifest) { + return resolveResourceUrl(inputManifest, proxyUrl); } - }, [state.resourceIndex, manifest, proxyUrl, getContent]); - - /** - * calculate the height or width of the pdf page to fit to dimensions. - * - if the page's aspect ratio is taller than the container's, we will constrain - * the page to the height of the container. - * - if the page's aspect ratio is wider than the container's, we will constrain - * the page to the width of the container - */ - const resizePage = React.useCallback( - ( - containerSize: { width: number; height: number }, - fitMode: FitMode, - rotation: number, - scale: number - ) => { - if (!fitMode) return; - - let width, height, aspectRatio; - const isRotated = rotation % 180 !== 0; - const pdfWidth = isRotated ? state.pdfHeight : state.pdfWidth; - const pdfHeight = isRotated ? state.pdfWidth : state.pdfHeight; - - if (fitMode === 'width' && containerSize.width) { - width = Math.round(containerSize.width - READER_MARGIN); - aspectRatio = pdfHeight / pdfWidth; - height = Math.round(width * aspectRatio); - } else if ( - fitMode === 'height' && - pdfWidth && - pdfHeight && - containerSize.height - ) { - aspectRatio = pdfHeight / pdfWidth; - height = Math.round((containerSize.height - READER_MARGIN) * scale); - width = Math.round((height / aspectRatio) * scale); + return undefined; + }, [webpubManifestUrl, inputManifest, proxyUrl]); + + // Extract initial page from resource href if present + const resolvedInitialPage = useMemo(() => { + if (webpubManifestUrl && inputManifest) { + const originalHref = inputManifest?.readingOrder?.[0]?.href; + if (originalHref) { + const pageFromUrl = getPageNumberFromHref(originalHref); + if (pageFromUrl) return pageFromUrl; } - if (width || height) { - dispatch({ type: 'RESIZE_PAGE', width, height }); - } - }, - [state.pdfWidth, state.pdfHeight] - ); - - React.useEffect(() => { - resizePage(containerSize, state.fitMode, state.rotation ?? 0, state.scale); - }, [containerSize, resizePage, state.fitMode, state.rotation, state.scale]); - - /** - * Update the atStart/atEnd state to tell the UI whether to show the prev/next buttons - * Whether to have the next/prev buttons enabled. We disable them: - * - When on the first or last page of the first or last resource - */ - React.useEffect(() => { - const isFirstResource = state.resourceIndex === 0; - const isFirstResourceStart = isFirstResource && state.pageNumber === 1; - const showPrevButton = !isFirstResourceStart; - - const isLastResource = - state.resourceIndex === (manifest?.readingOrder?.length ?? 1) - 1; - const isLastResourceEnd = - isLastResource && state.pageNumber === state.numPages; - const showNextButton = !isLastResourceEnd; - - dispatch({ - type: 'BOOK_BOUNDARY_CHANGED', - atStart: !showPrevButton, - atEnd: !showNextButton, - }); - }, [ - manifest?.readingOrder?.length, - state.pageNumber, - state.resourceIndex, - state.settings?.isScrolling, - state.numPages, - ]); - - /** - * In scrolling mode, manually scroll the user when the page changes - */ - React.useEffect(() => { - // if the resource is not yet loaded, don't do anything yet - if (!state.settings?.isScrolling || !state.rendered) return; - - // If the change came from a scroll event, don't trigger a manual scroll back - if (scrollState.current.isInViewUpdate) { - scrollState.current.isInViewUpdate = false; - return; - } - - const documentContainer = documentContainerRef.current; - const pageRef = pageRefs.current.get(state.pageNumber); - - if (documentContainer && pageRef) { - const containerRect = documentContainer.getBoundingClientRect(); - const pageRect = pageRef.getBoundingClientRect(); - - documentContainer.scrollTo({ - top: documentContainer.scrollTop + (pageRect.top - containerRect.top), - }); } - }, [state.pageNumber, state.settings?.isScrolling, state.rendered]); - - const beginProgrammaticNavigation = React.useCallback( - (pendingPage: number) => { - const currentScrollState = scrollState.current; - currentScrollState.lastVisiblePage = pendingPage; - currentScrollState.lastProgrammaticNavAt = Date.now(); - currentScrollState.ratios.clear(); - }, - [] - ); + return 1; + }, [webpubManifestUrl, inputManifest]); - const goForward = React.useCallback(async () => { - beginProgrammaticNavigation( - state.numPages - ? Math.min(state.pageNumber + 1, state.numPages) - : state.pageNumber + 1 - ); - dispatch({ type: 'GO_FORWARD' }); - }, [beginProgrammaticNavigation, state.numPages, state.pageNumber]); - - const goBackward = React.useCallback(async () => { - beginProgrammaticNavigation(Math.max(1, state.pageNumber - 1)); - dispatch({ type: 'GO_BACKWARD' }); - }, [beginProgrammaticNavigation, state.pageNumber]); - - const setScroll = React.useCallback( - async (val: 'scrolling' | 'paginated') => { - const isScrolling = val === 'scrolling'; - dispatch({ type: 'SET_SCROLL', isScrolling }); - }, - [] + const [viewerState, dispatch] = useReducer(pdfReaderReducer, { + pageNumber: resolvedInitialPage, + numPages: 0, + scale: 1, + fitMode: DEFAULT_FIT_MODE, + rotation: 0, + navigationRequestId: 0, + } satisfies PdfReaderState); + const { + pageNumber, + numPages, + scale, + fitMode, + rotation, + navigationRequestId, + } = viewerState; + const [outline, setOutline] = useState([]); + const [pendingAction, setPendingAction] = useState( + null ); - - const zoomIn = React.useCallback(async () => { - dispatch({ - type: 'SET_SCALE', - scale: state.scale + SCALE_STEP, - }); - }, [state.scale]); - - const zoomOut = React.useCallback(async () => { - dispatch({ - type: 'SET_SCALE', - scale: state.scale - SCALE_STEP, - }); - }, [state.scale]); - - const rotateCounterClockwise = React.useCallback(async () => { - dispatch({ type: 'ROTATE_COUNTER_CLOCKWISE' }); + const [pdfLoadFailed, setPdfLoadFailed] = useState(false); + const [pageSizesReady, setPageSizesReady] = useState(false); + const hasNavigatedToInitialPageRef = useRef(false); + + useEffect(() => { + setPdfLoadFailed(false); + setPageSizesReady(false); + hasNavigatedToInitialPageRef.current = false; + }, [fileUrl]); + + const wrappedOnPageSizesReady = useCallback(() => { + setPageSizesReady(true); }, []); - const goToPage = React.useCallback(async (href: string) => { - dispatch({ type: 'GO_TO_HREF', href }); - }, []); - - const goToPageNumber = React.useCallback( - (page: number) => { - beginProgrammaticNavigation(page); - dispatch({ type: 'GO_TO_PAGE', page: page }); - }, - [beginProgrammaticNavigation] + useEffect(() => { + if ( + pageSizesReady && + numPages > 0 && + !hasNavigatedToInitialPageRef.current + ) { + hasNavigatedToInitialPageRef.current = true; + const validPage = Math.min(Math.max(resolvedInitialPage, 1), numPages); + dispatch({ type: 'GO_TO_PAGE', page: validPage }); + } + }, [pageSizesReady, numPages, resolvedInitialPage]); + + const clearPendingAction = useCallback(() => setPendingAction(null), []); + + const state: ReaderState = useMemo( + () => ({ + atStart: pageNumber <= 1, + atEnd: pageNumber >= numPages, + settings: DEFAULT_SETTINGS, + fitMode: fitMode ?? DEFAULT_FIT_MODE, + rotation, + }), + [fitMode, numPages, pageNumber, rotation] ); - // const resetSettings = React.useCallback(async () => { - // dispatch({ type: 'RESET_SETTINGS' }); - // }, []); - - const setFitMode = React.useCallback((mode: FitMode) => { - dispatch({ type: 'SET_FIT_MODE', fitMode: mode }); - }, []); - - const onInView = React.useCallback( - (pageNum: number, ratio: number) => { - const currentScrollState = scrollState.current; - - if ( - !state.settings?.isScrolling || - Date.now() - currentScrollState.lastProgrammaticNavAt < IN_VIEW_DELAY_MS - ) - return; - - if (ratio <= 0) { - currentScrollState.ratios.delete(pageNum); - return; - } - - currentScrollState.ratios.set(pageNum, ratio); - - let mostVisiblePage = currentScrollState.lastVisiblePage; - let maxRatio = -1; - - for (const [p, r] of currentScrollState.ratios) { - if (r > maxRatio) { - maxRatio = r; - mostVisiblePage = p; - } - if (r > 0.8) break; - } - - if (mostVisiblePage !== currentScrollState.lastVisiblePage) { - currentScrollState.lastVisiblePage = mostVisiblePage; - if (state.pageNumber !== mostVisiblePage) { - currentScrollState.isInViewUpdate = true; - dispatch({ type: 'PAGE_IN_VIEW', page: mostVisiblePage }); - } - } - }, - [state.settings?.isScrolling, state.pageNumber] + const navigator: PdfNavigator = useMemo( + () => ({ + goForward: () => dispatch({ type: 'GO_FORWARD' }), + goBackward: () => dispatch({ type: 'GO_BACKWARD' }), + setScroll: async () => undefined, + goToPage: (href: string) => dispatch({ type: 'GO_TO_HREF', href }), + goToPageNumber: (page: number) => dispatch({ type: 'GO_TO_PAGE', page }), + setFitMode: (mode) => dispatch({ type: 'SET_FIT', mode }), + zoomIn: async () => setPendingAction({ type: 'ZOOM_IN' }), + zoomOut: async () => setPendingAction({ type: 'ZOOM_OUT' }), + rotateCounterClockwise: () => setPendingAction({ type: 'ROTATE_CCW' }), + }), + [] ); - // this format is inactive, return null - if (!webpubManifestUrl || !manifest) return null; - - if (state.state === 'INACTIVE' || isFetching) { + const fileName: string | undefined = undefined; + const manifestTitle = getManifestTitle('PDF Document', fileName); + + const manifest: WebpubManifest = useMemo(() => { + if (inputManifest) { + return { + ...inputManifest, + toc: + outline.length > 0 + ? getManifestTocFromOutline(outline) + : (inputManifest.toc ?? []), + }; + } return { - type: null, - isLoading: true, - content: , - navigator: null, - manifest: null, - state: null, + metadata: { + title: manifestTitle, + }, + links: [], + readingOrder: [], + toc: getManifestTocFromOutline(outline), }; - } - - if (state.state === 'ERROR') throw state.error; - - // if (isFetching) { - // // The Reader is fetching a PDF resource - // return { - // type: 'PDF', - // isLoading: false, - // content: ( - // - // PDF is loading - // - // ), - // state, - // manifest, - // navigator: { - // goForward, - // goBackward, - // zoomIn, - // zoomOut, - // setScroll, - // goToPage, - // }, - // }; - // } - - const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => { - dispatch({ - type: 'PDF_PARSED', - numPages: numPages, - }); - }; - - const onDocumentLoadError = (error: Error) => { - dispatch({ - type: 'PDF_LOAD_ERROR', - error: error, - }); - }; - - function onRenderSuccess(page: PageProps) { - if (!page.height || !page.width) - throw new Error( - 'Error rendering page from Reader, please refresh your page.' - ); - if ( - Math.round(page.height) !== state.pdfHeight || - Math.round(page.width) > state.pdfWidth - ) { - dispatch({ - type: 'PAGE_LOAD_SUCCESS', - height: Math.round(page.height), - width: Math.round(page.width), - }); - - resizePage( - containerSize, - state.fitMode, - state.rotation ?? 0, - state.scale - ); - } - } + }, [inputManifest, manifestTitle, outline]); + + const hasSource = !!fileUrl; + const isDocLoading = hasSource && !pageSizesReady && !pdfLoadFailed; + const heightValue = typeof height === 'number' ? `${height}px` : height; + + const content = ( +
+ {isDocLoading && ( +
+ +
+ )} + +
+ ); - // the reader is active but loading a page return { type: 'PDF', - isLoading: false, - content: ( - - - {isParsed && state.numPages && ( - <> - {state.settings.isScrolling && - Array.from(new Array(state.numPages), (_, index) => ( - - ))} - {!state.settings.isScrolling && ( - } - fitMode={state.fitMode} - /> - )} - - )} - - - ), + isLoading: isDocLoading, + content, state, + navigator, manifest, - navigator: { - goForward, - goBackward, - setScroll, - zoomIn, - zoomOut, - rotateCounterClockwise, - goToPage, - goToPageNumber, - // resetSettings, - setFitMode, - }, - currentPage: state.pageNumber, - totalPages: state.numPages ?? 0, + currentPage: pageNumber, + totalPages: numPages, toggleFullScreen, }; -} +}; + +export default usePdfReader; diff --git a/src/PdfReader/lib.ts b/src/PdfReader/lib.ts deleted file mode 100644 index e11e070a..00000000 --- a/src/PdfReader/lib.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { WebpubManifest } from '../types'; -import { ReadiumLink } from '../WebpubManifestTypes/ReadiumLink'; - -export const SCALE_STEP = 0.1; -export const START_QUERY = 'start'; - -export const getResourceUrl = ( - index: number, - readingOrder: ReadiumLink[] | undefined -): string => { - if (!readingOrder || !readingOrder.length) { - throw new Error('A manifest has been returned, but has no reading order'); - } - - // If it has no children, return the link href - return readingOrder[index].href; -}; - -export const fetchAsUint8Array = async ( - resourceUrl: string, - proxyUrl?: string -): Promise => { - // Generate the resource URL using the proxy - const url: string = proxyUrl - ? `${proxyUrl}${encodeURIComponent(resourceUrl)}` - : resourceUrl; - const response = await fetch(url, { mode: 'cors' }); - const array = new Uint8Array(await response.arrayBuffer()); - - if (!response.ok) { - throw new Error('Response not Ok for URL: ' + url); - } - return array; -}; - -/** - * Gets the index of the provided href in the readingOrder, or throws an error if one - * is not found. - */ -export function getIndexFromHref( - href: string, - manifest: WebpubManifest -): number { - const input = new URL(href); - const index = manifest?.readingOrder?.findIndex((link) => { - return doHrefsMatch(link.href, input); - }); - if (index < 0) { - throw new Error(`Cannot find resource in readingOrder: ${href}`); - } - return index; -} - -/** - * Compares two hrefs without query params or hash - */ -export function doHrefsMatch( - href1: string | URL, - href2: string | URL -): boolean { - const input1 = new URL(href1); - const input2 = new URL(href2); - return ( - input1.pathname === input2.pathname && - input1.hostname === input2.hostname && - input1.protocol === input2.protocol - ); -} - -/** - * Extracts a start page from a href if it exists and is in the format - * `?startPage=1`. Returns undefined if none found. - */ -export const getStartPageFromHref = (href: string): number | undefined => { - const params = new URL(href).searchParams; - const startPage = params.get(START_QUERY); - return startPage ? parseInt(startPage) : undefined; -}; - -/** - * Extracts a page number from a href if it exists and is in - * the format of `#page=1` - */ -export const getPageNumberFromHref = (href: string): number | undefined => { - const hash = new URL(href).hash; - try { - const strPageNumber = hash.replace('#page=', ''); - if (!strPageNumber || strPageNumber === 'NaN') return undefined; - const pageNumber = parseInt(strPageNumber); - return pageNumber; - } catch (e) { - console.warn(`Failed to parse page number from hash ${hash}`); - return undefined; - } -}; diff --git a/src/PdfReader/pdfReader.css b/src/PdfReader/pdfReader.css index c509fc8e..a19ad0ae 100644 --- a/src/PdfReader/pdfReader.css +++ b/src/PdfReader/pdfReader.css @@ -1,25 +1,369 @@ -.react-pdf__Document { - scrollbar-color: #8d8d8d #424242; +.pdf-root { display: flex; flex-direction: column; - background: #252525; + width: 100%; + height: 100%; + min-height: 400px; + background: #525659; + color: #e8eaed; + overflow: hidden; +} + +.pdf-root:fullscreen { + width: 100vw; + height: 100vh; +} + +.pdf-toolbar { + display: flex; + align-items: center; + justify-content: space-between; gap: 8px; + padding: 6px 10px; + background: #2b2d30; + border-bottom: 1px solid #1c1d1f; + flex-wrap: wrap; + flex-shrink: 0; +} - > div { - background: #252525; - } +.pdf-toolbar-group { + display: flex; + align-items: center; + gap: 4px; +} + +.pdf-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background: transparent; + color: #e8eaed; + cursor: pointer; + transition: background 0.12s ease; +} + +.pdf-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); +} + +.pdf-btn:disabled { + opacity: 0.35; + cursor: default; +} + +.pdf-page-form { + display: flex; + align-items: center; + gap: 4px; + margin: 0 2px; +} + +.pdf-page-input { + width: 40px; + text-align: center; + background: #1c1d1f; + border: 1px solid #3c4043; + border-radius: 4px; + color: #e8eaed; + font-size: 13px; + padding: 4px 2px; +} + +.pdf-page-input:focus { + outline: 1px solid #8ab4f8; +} + +.pdf-page-total, +.pdf-zoom-label { + font-size: 13px; + color: #b0b3b8; + min-width: 42px; + text-align: center; + user-select: none; +} + +/* Body: sidebar + viewport */ +.pdf-body { + display: flex; + flex: 1; + min-height: 0; +} + +.pdf-sidebar { + width: 260px; + flex-shrink: 0; + background: #323639; + border-right: 1px solid #1c1d1f; + overflow-y: auto; } -.react-pdf__Page { +.pdf-sidebar-header { + padding: 12px 14px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #9aa0a6; + border-bottom: 1px solid #1c1d1f; +} + +.pdf-sidebar-empty { + padding: 14px; + font-size: 13px; + color: #7a7d81; +} + +.pdf-toc-list { + list-style: none; + margin: 0; + padding: 0; +} + +.pdf-toc-item { display: block; - background: #252525 !important; + width: 100%; + text-align: left; + background: none; + border: none; + color: #d3d6db; + font-size: 13px; + padding: 7px 12px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pdf-toc-item:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); +} + +.pdf-toc-item.active { + background: rgba(66, 133, 244, 0.25); + color: #8ab4f8; +} + +.pdf-toc-item:disabled { + color: #6a6d71; + cursor: default; +} + +/* Viewport (scroll container) */ +.pdf-viewport { + flex: 1; + overflow: auto; + position: relative; + background: #252525; + color-scheme: dark; +} + +/* Loading / error states */ +.pdf-status-container { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.pdf-status { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + color: #b0b3b8; + font-size: 14px; +} + +.pdf-error { + color: #f28b82; +} + +.pdf-pages-stack { + display: flex; + flex-direction: column; + align-items: flex-start; + width: max-content; + min-width: 100%; + padding-top: 16px; + background: #252525; } -.react-pdf__Page { - canvas, - .textLayer, - .annotationLayer { - display: block; - margin: 0 auto; +.pdf-page-wrap { + position: relative; + flex-shrink: 0; + margin-inline: auto; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); + background: #252525; +} + +.pdf-canvas { + display: block; +} + +/* Text and annotation layer styles adapted from react-pdf/pdf.js */ +.pdf-root { + --react-pdf-text-layer: 1; +} + +[data-main-rotation='90'] .pdf-text-layer.textLayer { + transform: rotate(90deg) translateY(-100%); +} + +[data-main-rotation='180'] .pdf-text-layer.textLayer { + transform: rotate(180deg) translate(-100%, -100%); +} + +[data-main-rotation='270'] .pdf-text-layer.textLayer { + transform: rotate(270deg) translateX(-100%); +} + +.pdf-text-layer.textLayer { + position: absolute; + text-align: initial; + inset: 0; + overflow: clip; + opacity: 1; + line-height: 1; + text-size-adjust: none; + forced-color-adjust: none; + transform-origin: 0 0; + caret-color: CanvasText; + z-index: 0; + + --min-font-size: 1; + --total-scale-factor: var(--scale-factor, 1); + --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size)); + --min-font-size-inv: calc(1 / var(--min-font-size)); +} + +.pdf-text-layer.textLayer :is(span, br) { + color: transparent; + position: absolute; + white-space: pre; + cursor: text; + margin: 0; + transform-origin: 0 0; +} + +.pdf-text-layer.textLayer > :not(.markedContent), +.pdf-text-layer.textLayer .markedContent span:not(.markedContent) { + z-index: 1; + + --font-height: 0; + font-size: calc(var(--text-scale-factor) * var(--font-height)); + + --scale-x: 1; + --rotate: 0deg; + transform: rotate(var(--rotate)) scaleX(var(--scale-x)) + scale(var(--min-font-size-inv)); +} + +.pdf-text-layer.textLayer .markedContent { + display: contents; +} + +.pdf-text-layer.textLayer span[role='img'] { + user-select: none; + cursor: default; +} + +.pdf-text-layer.textLayer .highlight { + margin: -1px; + padding: 1px; + border-radius: 4px; +} + +.pdf-text-layer.textLayer .highlight.appended { + position: initial; +} + +.pdf-text-layer.textLayer .highlight.begin { + border-radius: 4px 0 0 4px; +} + +.pdf-text-layer.textLayer .highlight.end { + border-radius: 0 4px 4px 0; +} + +.pdf-text-layer.textLayer .highlight.middle { + border-radius: 0; +} + +.pdf-text-layer.textLayer br::selection { + background: transparent; +} + +.pdf-text-layer.textLayer .endOfContent { + display: block; + position: absolute; + inset: 100% 0 0; + z-index: 0; + cursor: default; + user-select: none; +} + +.pdf-text-layer.textLayer.selecting .endOfContent { + top: 0; +} + +.pdf-text-layer.textLayer .hiddenCanvasElement { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + display: none; +} + +.pdf-annotation-layer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; +} + +.pdf-annotation-layer section { + position: absolute; +} + +.pdf-annotation-layer .linkAnnotation > a { + position: absolute; + font-size: 1em; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.pdf-annotation-layer .linkAnnotation > a:hover { + background: rgba(255, 255, 0, 0.15); + box-shadow: 0 0 0 1px rgba(255, 255, 0, 0.4); +} + +.pdf-annotation-layer input, +.pdf-annotation-layer textarea, +.pdf-annotation-layer select { + background: rgba(255, 255, 255, 0.6); + border: 1px solid #8ab4f8; + box-sizing: border-box; + font: inherit; +} + +@media (max-width: 640px) { + .pdf-sidebar { + position: absolute; + z-index: 5; + height: 100%; + } + .pdf-page-total, + .pdf-zoom-label { + min-width: unset; } } diff --git a/src/PdfReader/reducer.ts b/src/PdfReader/reducer.ts index fe43a32a..92e0e455 100644 --- a/src/PdfReader/reducer.ts +++ b/src/PdfReader/reducer.ts @@ -1,255 +1,118 @@ -import { DEFAULT_FIT_MODE, DEFAULT_SETTINGS } from '../constants'; -import { - getIndexFromHref, - getPageNumberFromHref, - getStartPageFromHref, -} from './lib'; -import { PdfReaderAction, PdfReaderArguments, PdfState } from './types'; - -export function makePdfReducer( - args: PdfReaderArguments -): (state: PdfState, action: PdfReaderAction) => PdfState { - /** - * If there are no args, it's an inactive hook, or you are pre-first render. - * just use a function that returns the state in most cases so you don't have - * to keep checking if args is defined. - */ - if (!args) return (state: PdfState, _action: PdfReaderAction) => state; - const { manifest } = args; +import { MAX_SCALE, MIN_SCALE, SCALE_STEP } from './constants'; +import { FitMode } from './types'; + +export type PdfReaderState = { + pageNumber: number; + numPages: number; + scale: number; + fitMode: FitMode; + rotation: number; + navigationRequestId: number; +}; + +export type PdfReaderAction = + | { type: 'GO_FORWARD' } + | { type: 'GO_BACKWARD' } + | { type: 'GO_TO_PAGE'; page: number } + | { type: 'GO_TO_HREF'; href: string } + | { type: 'PAGE_IN_VIEW'; page: number } + | { type: 'PAGES_LOADED'; numPages: number } + | { type: 'ZOOM_IN' } + | { type: 'ZOOM_OUT' } + | { type: 'ROTATE_CCW'; nextScale?: number } + | { type: 'SET_FIT'; mode: FitMode } + | { type: 'SET_SCALE'; scale: number }; + +function handleInvalidTransition( + state: PdfReaderState, + action: PdfReaderAction +): PdfReaderState { + console.trace( + `Invalid state transition attempted with action: ${action.type}` + ); + return state; +} - return function reducer(state: PdfState, action: PdfReaderAction): PdfState { - if (state.state !== 'ACTIVE' && action.type !== 'ARGS_CHANGED') { - return handleInvalidTransition(state, action); +export function pdfReaderReducer( + state: PdfReaderState, + action: PdfReaderAction +): PdfReaderState { + switch (action.type) { + case 'GO_FORWARD': { + const next = state.pageNumber + 1; + if (next > state.numPages) return state; + return { + ...state, + pageNumber: next, + navigationRequestId: state.navigationRequestId + 1, + }; } - /** - * Utility function to generate state navigating us to a given resource and page. - * Used by multiple cases below. - */ - function goToLocation(index: number, page = 1): PdfState { - // only set the resource to null if you're actually changing resources (not just - // navigating to a different page in the same resource) - const shouldResetResource = state.resourceIndex !== index; + case 'GO_BACKWARD': { + const prev = state.pageNumber - 1; + if (prev < 1) return state; + return { + ...state, + pageNumber: prev, + navigationRequestId: state.navigationRequestId + 1, + }; + } - const newState = { + case 'GO_TO_PAGE': { + const numPages = state.numPages || 1; + const page = Math.max(1, Math.min(action.page, numPages)); + return { ...state, - resourceIndex: index, pageNumber: page, + navigationRequestId: state.navigationRequestId + 1, }; - if (shouldResetResource) { - return { - ...newState, - resource: null, - numPages: null, - rendered: false, - pageHeight: undefined, - pageWidth: undefined, - pdfHeight: 0, - pdfWidth: 0, - fitMode: DEFAULT_FIT_MODE, - rotation: 0, - }; - } - return newState; } - switch (action.type) { - case 'ARGS_CHANGED': { - return { - state: 'ACTIVE', - settings: DEFAULT_SETTINGS, - resourceIndex: 0, - resource: null, - pageNumber: 1, - numPages: null, - scale: 1, - pdfWidth: 0, - pdfHeight: 0, - pageHeight: undefined, - pageWidth: undefined, - atStart: true, - atEnd: false, - rendered: false, - fitMode: DEFAULT_FIT_MODE, - rotation: 0, - }; - } - - case 'GO_FORWARD': { - /** - * Navigate forward one page or one resource if at the end of the current - * resource. Do nothing at the end of the last resource. - */ - // do nothing if we have not parsed the number of pages yet. - if (!state.numPages) return state; - const atEndOfResource = state.pageNumber === state.numPages; - const atEndOfBook = - state.resourceIndex === args.manifest.readingOrder.length - 1; - - if (atEndOfResource) { - if (atEndOfBook) return state; - // go to next resource - return goToLocation(state.resourceIndex + 1); - } - // go to next page - return goToLocation(state.resourceIndex, state.pageNumber + 1); - } - - case 'GO_BACKWARD': { - /** - * Navigate backward one page or to the end of the previous resource - * if at the beginning of the current resource. Do nothing at the - * beginning of the first resource. - */ - // do nothing if we have not parsed the number of pages yet. - if (!state.numPages) return state; - const atStartOfResource = state.pageNumber === 1; - const atStartOfBook = state.resourceIndex === 0; - if (atStartOfResource) { - if (atStartOfBook) return state; - // go to end of prev resource - return { - ...goToLocation(state.resourceIndex - 1, -1), - }; - } - // go to prev page, allowing navigation below startPage within the resource - return goToLocation( - state.resourceIndex, - Math.max(1, state.pageNumber - 1) - ); - } - - case 'GO_TO_HREF': { - const resourceIndex = getIndexFromHref(action.href, args.manifest); - const startPage = getStartPageFromHref(action.href); - const pageNumber = getPageNumberFromHref(action.href); - - const page = pageNumber ?? startPage ?? 1; - return goToLocation(resourceIndex, page); - } - - case 'GO_TO_PAGE': { - const numPages = state.numPages || 1; - const page = Math.max(1, Math.min(action.page, numPages)); - return goToLocation(state.resourceIndex, page); - } - - case 'PAGE_IN_VIEW': { - if (state.pageNumber === action.page) return state; - return { - ...state, - pageNumber: action.page, - }; - } - - case 'RESOURCE_FETCH_SUCCESS': - return { - ...state, - resource: action.resource, - }; - - // called when the resource has been parsed by react-pdf - // and we know the number of pages - case 'PDF_PARSED': { - const { numPages } = action; - const { pageNumber: currentPage, resourceIndex } = state; - - const currentHref = manifest.readingOrder[resourceIndex]?.href; - const startPage = getStartPageFromHref(currentHref) ?? 1; - - // 1. If -1, go to the end. - // 2. Otherwise, ensure we don't fall below startPage. - const pageNumber = - currentPage === -1 ? numPages : Math.max(currentPage, startPage); - - return { - ...state, - numPages, - pageNumber, - }; - } - - case 'PDF_LOAD_ERROR': - return { - ...state, - state: 'ERROR', - error: action.error, - settings: DEFAULT_SETTINGS, - }; - - case 'SET_SCROLL': - if (state.state !== 'ACTIVE') { - return handleInvalidTransition(state, action); - } - return { - ...state, - settings: { - ...state.settings, - isScrolling: action.isScrolling, - }, - }; - - case 'SET_SCALE': - return { - ...state, - scale: action.scale, - }; - - // case 'RESET_SETTINGS': - // if (state.state === 'INACTIVE') { - // return handleInvalidTransition(state, action); - // } - - // return { - // ...state, - // settings: DEFAULT_SETTINGS, - // scale: 1, - // }; + case 'GO_TO_HREF': { + const parsed = parseInt(action.href, 10); + if (Number.isNaN(parsed)) return handleInvalidTransition(state, action); + const numPages = state.numPages || 1; + const page = Math.max(1, Math.min(parsed, numPages)); + return { + ...state, + pageNumber: page, + navigationRequestId: state.navigationRequestId + 1, + }; + } - case 'PAGE_LOAD_SUCCESS': - return { - ...state, - rendered: true, - pdfWidth: action.width, - pdfHeight: action.height, - pageWidth: action.width, - pageHeight: action.height, - }; + case 'PAGE_IN_VIEW': { + if (state.pageNumber === action.page) return state; + return { ...state, pageNumber: action.page }; + } - case 'RESIZE_PAGE': - return { - ...state, - pageWidth: action.width, - pageHeight: action.height, - }; + case 'PAGES_LOADED': + return { ...state, numPages: action.numPages }; - case 'SET_FIT_MODE': - return { ...state, fitMode: action.fitMode, scale: 1 }; + case 'ZOOM_IN': + return { + ...state, + fitMode: null, + scale: Math.min(MAX_SCALE, +(state.scale + SCALE_STEP).toFixed(2)), + }; - case 'ROTATE_COUNTER_CLOCKWISE': { - const newRotation = ((state.rotation ?? 0) - 90 + 360) % 360; + case 'ZOOM_OUT': + return { + ...state, + fitMode: null, + scale: Math.max(MIN_SCALE, +(state.scale - SCALE_STEP).toFixed(2)), + }; - return { - ...state, - rotation: newRotation, - fitMode: - newRotation === 0 || newRotation === 180 ? 'height' : 'width', - }; - } + case 'ROTATE_CCW': + return { + ...state, + rotation: (state.rotation + 270) % 360, + scale: action.nextScale ?? state.scale, + }; - case 'BOOK_BOUNDARY_CHANGED': - return { - ...state, - atStart: action.atStart, - atEnd: action.atEnd, - }; - } - }; -} + case 'SET_FIT': + return { ...state, fitMode: action.mode }; -function handleInvalidTransition(state: PdfState, action: PdfReaderAction) { - console.trace( - `Inavlid state transition attempted: ${state} with ${action.type}` - ); - return state; + case 'SET_SCALE': + return { ...state, scale: action.scale }; + } } diff --git a/src/PdfReader/types.ts b/src/PdfReader/types.ts index 4255a645..17c7caf6 100644 --- a/src/PdfReader/types.ts +++ b/src/PdfReader/types.ts @@ -1,68 +1,63 @@ -import { - ActiveReaderArguments, - FitMode, - InactiveReaderArguments, - ReaderSettings, - ReaderState, -} from '../types'; +import { Dispatch } from 'react'; +import { WebpubManifest } from '../types'; +import { PdfReaderAction } from './reducer'; -export type InternalState = { - resourceIndex: number; - resource: { data: Uint8Array } | null; - // we only know the numPages once the resource has been parsed - numPages: number | null; - // if pageNumber is -1, we will navigate to the end of the - // resource once it is parsed +export type FitMode = 'width' | 'height' | null; + +export interface OutlineItem { + title: string; + pageNumber: number | null; + items: OutlineItem[]; +} + +export interface PageSize { + width: number; + height: number; +} + +export interface ViewportAnchor { pageNumber: number; - scale: number; - pdfHeight: number; - pdfWidth: number; - pageHeight: number | undefined; - pageWidth: number | undefined; - rendered: boolean; -}; + intraPageRatio: number; + viewportOffset: number; +} -export type InactiveState = ReaderState & - InternalState & { state: 'INACTIVE'; settings: undefined }; +export interface PdfReaderProps { + fileUrl?: string; + webpubManifestUrl?: string; + manifest?: WebpubManifest; + proxyUrl?: string; + pdfWorkerSrc?: string; + height?: string | number; + className?: string; + toggleFullScreen?: () => void; +} -export type ActiveState = ReaderState & - InternalState & { state: 'ACTIVE'; settings: ReaderSettings }; +export interface PdfReaderContentProps { + fileUrl: string | undefined; + pdfWorkerSrc?: string; + pageNumber: number; + navigationRequestId: number; + scale: number; + fitMode: FitMode; + rotation: number; + dispatch: Dispatch; + pendingAction: PdfReaderAction | null; + clearPendingAction: () => void; + onOutlineLoad: (outlineItems: OutlineItem[]) => void; + onPageSizesReady?: () => void; +} -export type ErrorState = ReaderState & - InternalState & { - state: 'ERROR'; - error: Error; - settings: ReaderSettings; - }; +export interface RenderTask { + promise: Promise; + cancel: () => void; +} -export type PdfState = InactiveState | ActiveState | ErrorState; +export interface PdfOutlineEntry { + title: string; + dest: string | unknown[] | null; + items?: PdfOutlineEntry[]; +} -export type PdfReaderArguments = - | ActiveReaderArguments - | InactiveReaderArguments; +export type RefProxy = import('pdfjs-dist/types/src/display/api').RefProxy; -export type PdfReaderAction = - | { - type: 'ARGS_CHANGED'; - args: PdfReaderArguments; - } - | { type: 'GO_FORWARD' } - | { type: 'GO_BACKWARD' } - | { type: 'GO_TO_HREF'; href: string } - | { type: 'GO_TO_PAGE'; page: number } - | { type: 'PAGE_IN_VIEW'; page: number } - | { type: 'RESOURCE_FETCH_SUCCESS'; resource: { data: Uint8Array } } - | { type: 'PDF_PARSED'; numPages: number } - | { type: 'PDF_LOAD_ERROR'; error: Error } - | { type: 'SET_SCALE'; scale: number } - // | { type: 'RESET_SETTINGS' } may be needed in future - | { type: 'SET_SCROLL'; isScrolling: boolean } - | { type: 'PAGE_LOAD_SUCCESS'; height: number; width: number } - | { - type: 'RESIZE_PAGE'; - height: number | undefined; - width: number | undefined; - } - | { type: 'SET_FIT_MODE'; fitMode: FitMode } - | { type: 'ROTATE_COUNTER_CLOCKWISE' } - | { type: 'BOOK_BOUNDARY_CHANGED'; atStart: boolean; atEnd: boolean }; +export type PdfBinaryInput = Blob | File | Uint8Array | ArrayBuffer; diff --git a/src/PdfReader/useMeasure.tsx b/src/PdfReader/useMeasure.tsx deleted file mode 100644 index 6aec227b..00000000 --- a/src/PdfReader/useMeasure.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from 'react'; - -declare const window: Window & - typeof globalThis & { - ResizeObserver: ResizeObserver; - }; -export type Dimensions = Pick< - DOMRectReadOnly, - 'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width' ->; -export type UseMeasureRef = (element: E) => void; -export type UseMeasureResult = [ - UseMeasureRef | null, - Dimensions, -]; - -const DEFAULT_DIMENSION = { - x: 0, - y: 0, - width: 0, - height: 0, - top: 0, - left: 0, - bottom: 0, - right: 0, -}; - -export default function useMeasure< - E extends Element = Element, ->(): UseMeasureResult { - // this is a little trick to get a reference to an HTML element. Using useRef wouldn't - // work because we actually need rerenders when it changes, to update the useLayoutEffect - const [element, ref] = React.useState(null); - const [rect, setRect] = React.useState(DEFAULT_DIMENSION); - const observer = React.useMemo( - () => - new window.ResizeObserver( - ( - entries: { - contentRect: { - x: number; - y: number; - width: number; - height: number; - top: number; - left: number; - bottom: number; - right: number; - }; - }[] - ) => { - if (entries[0]) { - const { x, y, width, height, top, left, bottom, right } = - entries[0].contentRect; - setRect({ x, y, width, height, top, left, bottom, right }); - } - } - ), - [] - ); - - React.useLayoutEffect(() => { - if (!element) return; - observer.observe(element); - return () => { - observer.disconnect(); - }; - }, [element, observer]); - - return [ref, rect]; -} diff --git a/src/PdfReader/utils.ts b/src/PdfReader/utils.ts new file mode 100644 index 00000000..b8c743bb --- /dev/null +++ b/src/PdfReader/utils.ts @@ -0,0 +1,136 @@ +import type { PDFDocumentProxy } from 'pdfjs-dist'; +import { WebpubManifest } from '../types'; +import { ReadiumLink } from '../WebpubManifestTypes/ReadiumLink'; +import { PAGE_GAP } from './constants'; +import { + OutlineItem, + PageSize, + PdfBinaryInput, + PdfOutlineEntry, + RefProxy, +} from './types'; + +export const toError = (err: unknown, fallbackMessage: string): Error => { + if (err instanceof Error) return err; + return new Error(fallbackMessage); +}; + +export const toWorkerSafePdfBytes = async ( + input: PdfBinaryInput +): Promise => { + if (input instanceof Blob) { + return new Uint8Array(await input.arrayBuffer()); + } + + if (input instanceof ArrayBuffer) { + return new Uint8Array(input.slice(0)); + } + + return new Uint8Array(input); +}; + +export const getRotatedSize = (base: PageSize, rotation: number): PageSize => { + return rotation % 180 !== 0 + ? { width: base.height, height: base.width } + : base; +}; + +export const getDisplayPageHeight = ( + pageBaseSizes: PageSize[], + pageNumber: number, + scale: number, + rotation: number +): number => { + const base = pageBaseSizes[pageNumber - 1]; + if (!base) return 0; + const rotated = getRotatedSize(base, rotation); + return rotated.height * scale; +}; + +export const getPageTop = ( + pageBaseSizes: PageSize[], + pageNumber: number, + scale: number, + rotation: number +): number => { + let top = PAGE_GAP; + for (let i = 1; i < pageNumber; i += 1) { + top += getDisplayPageHeight(pageBaseSizes, i, scale, rotation) + PAGE_GAP; + } + return top; +}; + +/** + * Recursively resolves a pdf.js outline (TOC) into page numbers. + */ +export const resolveOutline = async ( + pdfDoc: PDFDocumentProxy, + items: PdfOutlineEntry[] +): Promise => { + const resolved: OutlineItem[] = []; + for (const item of items) { + let pageNumber: number | null = null; + try { + let dest = item.dest; + if (typeof dest === 'string') { + dest = await pdfDoc.getDestination(dest); + } + if (Array.isArray(dest)) { + const pageRef = dest[0] as RefProxy; + const pageIndex = await pdfDoc.getPageIndex(pageRef); + pageNumber = pageIndex + 1; + } + } catch { + // Some destinations can't be resolved (e.g. external links), skip. + } + resolved.push({ + title: item.title, + pageNumber, + items: item.items?.length ? await resolveOutline(pdfDoc, item.items) : [], + }); + } + + return resolved; +}; + +export const getManifestTitle = (fallback: string, title?: string): string => { + if (!title || !title.trim()) return fallback; + return title.trim(); +}; + +export const getManifestTocFromOutline = ( + outline: OutlineItem[] +): ReadiumLink[] => { + return outline + .filter((item) => item.pageNumber != null) + .map((item) => ({ + href: String(item.pageNumber), + title: item.title, + })); +}; + +export const resolveResourceUrl = ( + manifest: WebpubManifest, + proxyUrl?: string +): string | undefined => { + const href = manifest?.readingOrder?.[0]?.href; + if (!href) return undefined; + return proxyUrl ? `${proxyUrl}${encodeURIComponent(href)}` : href; +}; + +/** + * Extracts a page number from a href if it exists and is in + * the format of `#page=1` + */ +export const getPageNumberFromHref = (href: string): number | undefined => { + const hash = new URL(href).hash; + try { + const strPageNumber = hash.replace('#page=', ''); + if (!strPageNumber || strPageNumber === 'NaN') return undefined; + const pageNumber = parseInt(strPageNumber); + return pageNumber; + } catch (e) { + console.warn(`Failed to parse page number from hash ${hash}`); + return undefined; + } +}; diff --git a/src/types.ts b/src/types.ts index 2e893a0c..c09e5ae2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,7 +72,7 @@ export type LoadingReader = { }; type CommonReader = { - isLoading: false; + isLoading: boolean; content: JSX.Element; manifest: WebpubManifest; currentPage: number; diff --git a/src/ui/LoadingSkeleton.tsx b/src/ui/LoadingSkeleton.tsx index d8b86f08..8d095e1c 100644 --- a/src/ui/LoadingSkeleton.tsx +++ b/src/ui/LoadingSkeleton.tsx @@ -31,10 +31,12 @@ const LoadingSkeletonContent = ({ role="progressbar" > - + - - + + + + ); diff --git a/src/useWebReader.tsx b/src/useWebReader.tsx index 82402c1a..268f2753 100644 --- a/src/useWebReader.tsx +++ b/src/useWebReader.tsx @@ -70,12 +70,11 @@ export default function useWebReader( manifest ? manifest.metadata.conformsTo : null ); const getContentHtml = getContent as GetContent | undefined; - const getContentPdf = getContent as GetContent | undefined; /** - * Our HTML reader and PDf Reader. Note that we cannot conditionally + * Our HTML reader and PDF Reader. Note that we cannot conditionally * call a React hook, so we must _always_ call the hook, but allow for the - * case where we call the hook with `undefined`, which tells the hook that - * that format is inactive, and it will in turn return the InactiveState. + * case where we call the hook with no source, which tells the hook that + * that format is inactive. */ const htmlReader = useHtmlReader( readerType === 'HTML' && manifest @@ -99,16 +98,12 @@ export default function useWebReader( ? { webpubManifestUrl, manifest, - getContent: getContentPdf, proxyUrl, pdfWorkerSrc, height, - growWhenScrolling, - persistLastLocation, - persistSettings, toggleFullScreen, } - : undefined + : {} ); // fetch the manifest and set it in state diff --git a/tests/PdfReducer.test.ts b/tests/PdfReducer.test.ts index d0d7afd3..e0297331 100644 --- a/tests/PdfReducer.test.ts +++ b/tests/PdfReducer.test.ts @@ -1,84 +1,152 @@ -import { makePdfReducer } from '../src/PdfReader/reducer'; -import { PdfReaderArguments, PdfState } from '../src/PdfReader/types'; -import { DEFAULT_FIT_MODE, DEFAULT_SETTINGS } from '../src/constants'; - -function makeArgs(href: string): PdfReaderArguments { - return { - manifest: { - metadata: { title: 'Test' }, - readingOrder: [{ href, type: 'application/pdf' }], - }, - } as unknown as PdfReaderArguments; -} - -const baseState: PdfState = { - state: 'ACTIVE', - settings: DEFAULT_SETTINGS, - resourceIndex: 0, - resource: null, +import { pdfReaderReducer, PdfReaderState } from '../src/PdfReader/reducer'; + +const baseState: PdfReaderState = { pageNumber: 1, - numPages: null, + numPages: 0, scale: 1, - pdfWidth: 0, - pdfHeight: 0, - pageHeight: undefined, - pageWidth: undefined, - atStart: true, - atEnd: false, - rendered: false, - fitMode: DEFAULT_FIT_MODE, + fitMode: 'width', rotation: 0, + navigationRequestId: 0, }; -describe('makePdfReducer — PDF_PARSED', () => { - it('keeps page 1 when no start query param is present', () => { - const args = makeArgs('https://example.com/doc.pdf'); - const reducer = makePdfReducer(args); - const state = reducer(baseState, { type: 'PDF_PARSED', numPages: 100 }); - expect(state.pageNumber).toBe(1); +describe('pdfReaderReducer — GO_FORWARD', () => { + it('advances to the next page', () => { + const state = { ...baseState, pageNumber: 5, numPages: 100 }; + const result = pdfReaderReducer(state, { type: 'GO_FORWARD' }); + expect(result.pageNumber).toBe(6); + expect(result.navigationRequestId).toBe(1); }); - it('jumps to start on initial load when pageNumber is below start', () => { - const args = makeArgs('https://example.com/doc.pdf?start=15'); - const reducer = makePdfReducer(args); - const state = reducer(baseState, { type: 'PDF_PARSED', numPages: 100 }); - expect(state.pageNumber).toBe(15); + it('does not advance past the last page', () => { + const state = { ...baseState, pageNumber: 100, numPages: 100 }; + const result = pdfReaderReducer(state, { type: 'GO_FORWARD' }); + expect(result.pageNumber).toBe(100); + expect(result.navigationRequestId).toBe(0); }); +}); - it('does not override pageNumber when user has already navigated past start', () => { - const args = makeArgs('https://example.com/doc.pdf?start=15'); - const reducer = makePdfReducer(args); - const navigatedState = { ...baseState, pageNumber: 20 }; - const state = reducer(navigatedState, { - type: 'PDF_PARSED', - numPages: 100, - }); - expect(state.pageNumber).toBe(20); +describe('pdfReaderReducer — GO_BACKWARD', () => { + it('goes back a page', () => { + const state = { ...baseState, pageNumber: 5, numPages: 100 }; + const result = pdfReaderReducer(state, { type: 'GO_BACKWARD' }); + expect(result.pageNumber).toBe(4); + expect(result.navigationRequestId).toBe(1); + }); + + it('does not go below page 1', () => { + const state = { ...baseState, pageNumber: 1, numPages: 100 }; + const result = pdfReaderReducer(state, { type: 'GO_BACKWARD' }); + expect(result.pageNumber).toBe(1); + expect(result.navigationRequestId).toBe(0); + }); +}); + +describe('pdfReaderReducer — GO_TO_PAGE', () => { + it('navigates to a valid page', () => { + const state = { ...baseState, pageNumber: 1, numPages: 100 }; + const result = pdfReaderReducer(state, { type: 'GO_TO_PAGE', page: 42 }); + expect(result.pageNumber).toBe(42); + expect(result.navigationRequestId).toBe(1); }); it('navigates to last page when pageNumber is -1', () => { - const args = makeArgs('https://example.com/doc.pdf'); - const reducer = makePdfReducer(args); - const endState = { ...baseState, pageNumber: -1 }; - const state = reducer(endState, { type: 'PDF_PARSED', numPages: 100 }); - expect(state.pageNumber).toBe(100); + const state = { ...baseState, pageNumber: 50, numPages: 100 }; + const result = pdfReaderReducer(state, { type: 'GO_TO_PAGE', page: -1 }); + expect(result.pageNumber).toBe(1); }); }); -describe('makePdfReducer — GO_BACKWARD', () => { - it('allows navigating back below start page within the same resource', () => { - const args = makeArgs('https://example.com/doc.pdf?start=5'); - const reducer = makePdfReducer(args); - const onStartPage = { ...baseState, pageNumber: 5, numPages: 100 }; - const state = reducer(onStartPage, { type: 'GO_BACKWARD' }); - expect(state.pageNumber).toBe(4); +describe('pdfReaderReducer — PAGE_IN_VIEW', () => { + it('updates the current page without bumping navigationRequestId', () => { + const state = { ...baseState, pageNumber: 1, numPages: 100 }; + const result = pdfReaderReducer(state, { + type: 'PAGE_IN_VIEW', + page: 7, + }); + expect(result.pageNumber).toBe(7); + expect(result.navigationRequestId).toBe(0); }); - it('does not go below page 1', () => { - const args = makeArgs('https://example.com/doc.pdf'); - const reducer = makePdfReducer(args); - const onFirstPage = { ...baseState, pageNumber: 1, numPages: 100 }; - const state = reducer(onFirstPage, { type: 'GO_BACKWARD' }); - expect(state.pageNumber).toBe(1); + it('is a no-op when already on that page', () => { + const state = { ...baseState, pageNumber: 7, numPages: 100 }; + const result = pdfReaderReducer(state, { + type: 'PAGE_IN_VIEW', + page: 7, + }); + expect(result).toBe(state); + }); +}); + +describe('pdfReaderReducer — PAGES_LOADED', () => { + it('sets numPages', () => { + const result = pdfReaderReducer(baseState, { + type: 'PAGES_LOADED', + numPages: 250, + }); + expect(result.numPages).toBe(250); + }); +}); + +describe('pdfReaderReducer — ZOOM_IN / ZOOM_OUT', () => { + it('increases scale and clears fitMode', () => { + const state = { ...baseState, scale: 1, fitMode: 'width' as const }; + const result = pdfReaderReducer(state, { type: 'ZOOM_IN' }); + expect(result.scale).toBeCloseTo(1.1); + expect(result.fitMode).toBeNull(); + }); + + it('does not zoom in past the max scale', () => { + const state = { ...baseState, scale: 5, fitMode: null }; + const result = pdfReaderReducer(state, { type: 'ZOOM_IN' }); + expect(result.scale).toBe(5); + }); + + it('decreases scale and clears fitMode', () => { + const state = { ...baseState, scale: 1, fitMode: 'width' as const }; + const result = pdfReaderReducer(state, { type: 'ZOOM_OUT' }); + expect(result.scale).toBeCloseTo(0.9); + expect(result.fitMode).toBeNull(); + }); + + it('does not zoom out past the min scale', () => { + const state = { ...baseState, scale: 0.25, fitMode: null }; + const result = pdfReaderReducer(state, { type: 'ZOOM_OUT' }); + expect(result.scale).toBe(0.25); + }); +}); + +describe('pdfReaderReducer — ROTATE_CCW', () => { + it('rotates counter-clockwise by 270 degrees, wrapping at 360', () => { + const state = { ...baseState, rotation: 90 }; + const result = pdfReaderReducer(state, { type: 'ROTATE_CCW' }); + expect(result.rotation).toBe(0); + }); + + it('applies an explicit nextScale when provided', () => { + const state = { ...baseState, rotation: 0, scale: 1 }; + const result = pdfReaderReducer(state, { + type: 'ROTATE_CCW', + nextScale: 1.5, + }); + expect(result.rotation).toBe(270); + expect(result.scale).toBe(1.5); + }); +}); + +describe('pdfReaderReducer — SET_FIT / SET_SCALE', () => { + it('sets the fit mode', () => { + const result = pdfReaderReducer(baseState, { + type: 'SET_FIT', + mode: 'height', + }); + expect(result.fitMode).toBe('height'); + }); + + it('sets the scale', () => { + const result = pdfReaderReducer(baseState, { + type: 'SET_SCALE', + scale: 2, + }); + expect(result.scale).toBe(2); }); });