diff --git a/BookReaderDemo/IADemoBr.js b/BookReaderDemo/IADemoBr.js index 9cc3b3d53..368343307 100644 --- a/BookReaderDemo/IADemoBr.js +++ b/BookReaderDemo/IADemoBr.js @@ -84,7 +84,7 @@ const initializeBookReader = (brManifest) => { enableFSLogoShortcut: true, plugins: { search: { - initialSearchTerm: searchTerm ? searchTerm : '', + initialSearchTerm: searchTerm, }, }, }; diff --git a/BookReaderDemo/demo-internetarchive.html b/BookReaderDemo/demo-internetarchive.html index ef5bc4aa6..5e0bee7cc 100644 --- a/BookReaderDemo/demo-internetarchive.html +++ b/BookReaderDemo/demo-internetarchive.html @@ -22,6 +22,7 @@ + diff --git a/package-lock.json b/package-lock.json index 30255c2d8..9f8e9b03c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "@iiif/presentation-2": "^1.0.4", "@iiif/presentation-3": "^2.1.3", "@open-wc/testing-helpers": "3.0.1", + "@playwright/test": "1.60.0", "@types/jest": "30.0.0", "@webcomponents/webcomponentsjs": "^2.6.0", "babel-loader": "10.1.1", @@ -4299,6 +4300,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -13496,6 +13513,53 @@ "node": ">=4" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "3.4.0", "dev": true, @@ -20097,6 +20161,15 @@ "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", "dev": true }, + "@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "requires": { + "playwright": "1.60.0" + } + }, "@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -26338,6 +26411,31 @@ } } }, + "playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.60.0" + }, + "dependencies": { + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + } + } + }, + "playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true + }, "pngjs": { "version": "3.4.0", "dev": true diff --git a/package.json b/package.json index b1d9c9e18..b797affaa 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@iiif/presentation-2": "^1.0.4", "@iiif/presentation-3": "^2.1.3", "@open-wc/testing-helpers": "3.0.1", + "@playwright/test": "1.60.0", "@types/jest": "30.0.0", "@webcomponents/webcomponentsjs": "^2.6.0", "babel-loader": "10.1.1", @@ -134,6 +135,8 @@ "DOCS:update:test-deps": "If CI succeeds, these should be good to update", "update:test-deps": "npm i @babel/eslint-parser@latest @open-wc/testing-helpers@latest @types/jest@latest eslint@7 eslint-plugin-testcafe@latest jest@latest jest-environment-jsdom@latest sinon@latest testcafe@latest", "DOCS:update:build-deps": "These can cause strange changes, so do an npm run build + check file size (git diff --stat), and check the site is as expected", - "update:build-deps": "npm i @babel/core@latest @babel/preset-env@latest @babel/plugin-proposal-class-properties@latest @babel/plugin-proposal-decorators@latest babel-loader@latest core-js@latest regenerator-runtime@latest sass@latest svgo@latest webpack@latest webpack-cli@latest" + "update:build-deps": "npm i @babel/core@latest @babel/preset-env@latest @babel/plugin-proposal-class-properties@latest @babel/plugin-proposal-decorators@latest babel-loader@latest core-js@latest regenerator-runtime@latest sass@latest svgo@latest webpack@latest webpack-cli@latest", + "test:playwright": "npx playwright test", + "test:playwright:ci": "npm run build && npx playwright test" } } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 000000000..ff6a080cb --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,34 @@ +// @ts-check +import { defineConfig, devices } from '@playwright/test'; + +const BASE_URL = process.env.BASE_URL?.replace(/\/+$/, '') ?? 'http://127.0.0.1:8000'; + +export default defineConfig({ + testDir: './tests/playwright', + timeout: 60_000, + retries: process.env.CI ? 1 : 0, + workers: 1, // OL mobile tests are sequential (share server state) + reporter: [['html', { open: 'never' }], ['line']], + use: { + baseURL: BASE_URL, + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'mobile-chrome', + use: { + ...devices['Pixel 5'], // Chromium-based mobile device + browserName: 'chromium', + }, + }, + ], + // Assumes `npm run build && npx http-server -p 8000 -c-1 .` is already running. + // Set reuseExistingServer so CI can start it externally. + webServer: { + command: 'npx http-server -p 8000 -c-1 .', + port: 8000, + reuseExistingServer: true, + timeout: 15_000, + }, +}); diff --git a/src/BookReader.js b/src/BookReader.js index b2f63cd0a..1e21c913d 100644 --- a/src/BookReader.js +++ b/src/BookReader.js @@ -541,7 +541,7 @@ BookReader.prototype.initParams = function() { // If we have a query string: q=[term] const searchParams = new URLSearchParams(this.readQueryString()); const searchTerm = searchParams.get('q'); - if (searchTerm) { + if (searchTerm != null) { sp.options.initialSearchTerm = utils.decodeURIComponentPlus(searchTerm); } } diff --git a/src/css/BookReader.scss b/src/css/BookReader.scss index 39b4fea9e..5b619b2c5 100644 --- a/src/css/BookReader.scss +++ b/src/css/BookReader.scss @@ -83,3 +83,4 @@ $brScope: ".BookReader, .BRfloat"; @import 'TextSelection'; @import 'BRComponent'; @import 'BRBookmarks'; +@import 'BRolMobile'; diff --git a/src/css/_BRolMobile.scss b/src/css/_BRolMobile.scss new file mode 100644 index 000000000..46f1f6003 --- /dev/null +++ b/src/css/_BRolMobile.scss @@ -0,0 +1,819 @@ +/** + * OL Mobile Search UI + * + * Activated when .BRolMobile is added to the .BookReader root by OLMobilePlugin + * (condition: ?ref=ol AND mobile viewport). + */ + +$ol-mobile-bg: #F5F1E9; +$ol-mobile-border: #D9D3C9; +$ol-mobile-text: #333; +$ol-mobile-muted: #777; +$ol-mobile-header-bg: #F5F1E9; +$ol-mobile-row-height: 44px; +$ol-mobile-input-bg: #EBEBEB; +$ol-mobile-input-radius: 8px; +$ol-mobile-action-blue: #005BBB; +$ol-mobile-bookmark-red: #C0392B; + +/* --------------------------------------------------------------- + Root: hide conflicting standard chrome + --------------------------------------------------------------- */ +.BRolMobile { + /* Prevent br-mode-1up's overflow-x:auto from making the reader + horizontally scrollable when a wide page slightly exceeds the + container width. BRcontainer already has overflow:hidden, but + clamping here too prevents swipe-scroll inside the Lit element. */ + br-mode-1up { + overflow-x: hidden; + } + + .BRtoolbar { + display: none !important; + } + + .BRsearch-navigation { + display: none; + } + + .BRtoolbarSectionSearch { + display: none !important; + } + + ia-menu-slider, + .BRsidebar, + .BRsideMenu { + display: none !important; + } +} + +/* --------------------------------------------------------------- + Collapsed state + --------------------------------------------------------------- */ +.BRolMobileWrapper--collapsed { + .BRolMobileHeader { + background: transparent; + border-bottom: none; + box-shadow: none; + } + + .BRolMobileLogo, + .BRolMobileActions { + visibility: hidden; + } + + .BRolMobileRow1 { + height: 25px; + overflow: hidden; + } + + .BRolMobileChromeToggle { + margin-top: 0; + margin-bottom: 0; + background-color: #313131; + border-left-color: #666; + border-right-color: #666; + border-bottom-color: #666; + color: #fff; + } + + .BRolMobileNoteBar, + .BRolMobileRow2, + .BRolMobileResults, + .BRolMobileBookmarkList, + .BRolMobileResultNav { + display: none !important; + } +} + +/* --------------------------------------------------------------- + Header wrapper + --------------------------------------------------------------- */ +.BRolMobileHeader { + background-color: $ol-mobile-header-bg; + border-bottom: 1px solid $ol-mobile-border; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + position: relative; + z-index: 4; + transition: background-color 0.15s ease; +} + +/* --------------------------------------------------------------- + Row 1: IA logo + chrome toggle + action buttons + --------------------------------------------------------------- */ +.BRolMobileRow1 { + display: flex; + align-items: center; + height: $ol-mobile-row-height; + padding: 0 12px; + transition: background-color 0.15s ease; +} + +.BRolMobileChromeToggle { + display: flex; + align-items: center; + justify-content: center; + align-self: stretch; + margin: 2px 0 2px 8px; + padding: 0 10px; + background: none; + border: none; + border-left: 1px solid $ol-mobile-border; + border-right: 1px solid $ol-mobile-border; + border-bottom: 1px solid $ol-mobile-border; + border-radius: 0 0 8px 8px; + cursor: pointer; + color: $ol-mobile-muted; + + &:hover, + &:focus-visible { + background-color: rgba(0, 0, 0, 0.04); + outline: none; + } +} + +.BRolMobileLogo { + display: inline-flex; + align-items: center; + text-decoration: none; + flex-shrink: 0; + border-radius: 4px; + color: #000; + + &:focus-visible { + outline: 2px solid $ol-mobile-action-blue; + outline-offset: 2px; + } + + svg { + display: block; + } +} + +.BRolMobileActions { + display: flex; + align-items: center; + gap: 2px; + margin-left: auto; +} + +.BRolMobileActionBtn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + background: none; + border: none; + border-radius: 8px; + cursor: pointer; + color: $ol-mobile-text; + + &:hover, + &:focus-visible { + background-color: rgba(0, 0, 0, 0.06); + outline: none; + } + + svg { + display: block; + } + + &--active { + background-color: rgba(0, 0, 0, 0.09); + } +} + +/* Bookmark icon: outline by default, solid red when the page is bookmarked. + fill/stroke are CSS-only (no SVG presentation attributes) so these rules + always win without specificity tricks. */ +.BRolMobileBookmarkIcon { + fill: none; + stroke: currentColor; +} + +.BRolMobileActionBtn.BRolMobileBookmarkBtn--active { + color: $ol-mobile-bookmark-red; + + .BRolMobileBookmarkIcon { + fill: $ol-mobile-bookmark-red; + stroke: $ol-mobile-bookmark-red; + } +} + +/* --------------------------------------------------------------- + Bookmark count bubble (red, to the left of bookmark icon) + --------------------------------------------------------------- */ +.BRolMobileBookmarkBubble { + display: flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 5px; + background-color: $ol-mobile-bookmark-red; + border: none; + border-radius: 10px; + color: #fff; + font-size: 11px; + font-weight: 700; + line-height: 1; + cursor: pointer; + flex-shrink: 0; + + &[hidden] { + display: none; + } + + &:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; + } + + &:hover { + background-color: darken($ol-mobile-bookmark-red, 8%); + } +} + +/* --------------------------------------------------------------- + Bookmark list header: [ ‹ ] N Bookmarks [ › ] [ ✕ ] + The nav center group is flex-centered; dismiss button is pushed + to the far right so it never shifts when count changes. + --------------------------------------------------------------- */ +.BRolMobileBmListHeader { + display: flex; + align-items: center; + gap: 8px; +} + +/* Takes all remaining space; keeps count+chevrons centered in it */ +.BRolMobileBmNavCenter { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + gap: 2px; +} + +.BRolMobileBmCount { + font-size: 15px; + font-weight: 600; + color: $ol-mobile-text; + white-space: nowrap; + padding: 0 4px; +} + +.BRolMobileBmNavBtn { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + width: 32px; + height: 32px; + padding: 0; + color: $ol-mobile-action-blue; + cursor: pointer; + border-radius: 6px; + flex-shrink: 0; + + &[disabled] { + color: $ol-mobile-muted; + cursor: default; + } + + &:not([disabled]):hover { + background-color: rgba(0, 0, 0, 0.06); + } + + &:focus-visible { + outline: 2px solid $ol-mobile-action-blue; + border-radius: 2px; + } +} + +.BRolMobileBmDismissBtn { + flex-shrink: 0; + background: none; + border: none; + padding: 4px 6px; + font-size: 14px; + line-height: 1; + font-family: inherit; + color: $ol-mobile-muted; + cursor: pointer; + border-radius: 4px; + + &:hover { + background-color: rgba(0, 0, 0, 0.06); + color: $ol-mobile-text; + } + + &:focus-visible { + outline: 2px solid $ol-mobile-action-blue; + border-radius: 2px; + } +} + +/* Highlighted entry for the current page — same alpha as BRolMobileActionBtn--active */ +.BRolMobileBookmarkItem--current { + background-color: rgba(0, 0, 0, 0.09); + font-weight: 600; +} + +/* --------------------------------------------------------------- + Note bar (shown below Row 1 when page is bookmarked) + --------------------------------------------------------------- */ +.BRolMobileNoteBar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px 8px; + background-color: $ol-mobile-header-bg; + border-top: 1px solid rgba(0, 0, 0, 0.08); + + &[hidden] { + display: none; + } +} + +.BRolMobileNoteLabel { + flex-shrink: 0; + font-size: 13px; + font-weight: 600; + color: $ol-mobile-text; + white-space: nowrap; +} + +.BRolMobileNoteInput { + flex: 1; + min-width: 0; + height: 34px; + padding: 0 10px; + background-color: $ol-mobile-input-bg; + border: 1px solid $ol-mobile-border; + border-radius: $ol-mobile-input-radius; + font-size: 15px; + font-family: inherit; + color: $ol-mobile-text; + outline: none; + -webkit-appearance: none; + appearance: none; + + &::placeholder { + color: #999; + font-style: italic; + } + + &:focus { + border-color: $ol-mobile-action-blue; + box-shadow: 0 0 0 2px rgba($ol-mobile-action-blue, 0.18); + } +} + +.BRolMobileSaveBtn { + flex-shrink: 0; + height: 34px; + padding: 0 16px; + background-color: $ol-mobile-action-blue; + border: none; + border-radius: $ol-mobile-input-radius; + color: #fff; + font-size: 14px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + white-space: nowrap; + + &:hover { + background-color: darken($ol-mobile-action-blue, 8%); + } + + &:focus-visible { + outline: 2px solid $ol-mobile-action-blue; + outline-offset: 2px; + } +} + +/* "Hide" button in note bar — matches Cancel in search row */ +.BRolMobileHideNoteBtn { + flex-shrink: 0; + background: none; + border: none; + padding: 4px 0 4px 4px; + font-size: 14px; + font-family: inherit; + color: $ol-mobile-action-blue; + cursor: pointer; + white-space: nowrap; + + &:focus-visible { + outline: 2px solid $ol-mobile-action-blue; + border-radius: 2px; + } +} + +/* --------------------------------------------------------------- + Row 2: search field + cancel button + --------------------------------------------------------------- */ +.BRolMobileRow2 { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px 8px; + background-color: $ol-mobile-header-bg; + + &[hidden] { + display: none; + } +} + +.BRolMobileSearchField { + display: flex; + align-items: center; + flex: 1; + background-color: $ol-mobile-input-bg; + border: 1px solid $ol-mobile-border; + border-radius: $ol-mobile-input-radius; + padding: 0 10px; + height: 34px; + gap: 6px; + min-width: 0; +} + +.BRolMobileSearchIcon { + flex-shrink: 0; + color: $ol-mobile-muted; + display: block; +} + +.BRolMobileInput { + flex: 1; + min-width: 0; + border: none; + background: transparent; + font-size: 16px; + line-height: 1; + color: $ol-mobile-text; + outline: none; + -webkit-appearance: none; + appearance: none; + font-family: inherit; + + &::placeholder { + color: #999; + } + + &::-webkit-search-cancel-button { + display: none; + } +} + +.BRolMobileClearBtn { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + border: none; + cursor: pointer; + color: $ol-mobile-muted; + flex-shrink: 0; + + &[hidden] { + display: none; + } + + &:focus-visible { + outline: 2px solid $ol-mobile-action-blue; + border-radius: 2px; + } +} + +.BRolMobileCancelBtn { + flex-shrink: 0; + background: none; + border: none; + padding: 4px 0 4px 4px; + font-size: 14px; + font-family: inherit; + color: $ol-mobile-action-blue; + cursor: pointer; + white-space: nowrap; + + &:focus-visible { + outline: 2px solid $ol-mobile-action-blue; + border-radius: 2px; + } +} + +/* --------------------------------------------------------------- + Drop panels: search results + bookmark list (shared layout) + --------------------------------------------------------------- */ +.BRolMobileResults, +.BRolMobileBookmarkList { + background-color: $ol-mobile-bg; + border-bottom: 1px solid $ol-mobile-border; + max-height: 55vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + font-size: 14px; + color: $ol-mobile-text; + position: relative; + z-index: 3; + + &[hidden] { + display: none; + } +} + +.BRolMobileResultsHeader { + padding: 10px 14px 8px; + font-weight: 600; + font-size: 15px; + color: $ol-mobile-text; + border-bottom: 1px solid rgba(0, 0, 0, 0.07); + position: sticky; + top: 0; + background-color: $ol-mobile-bg; +} + +.BRolMobileResultsList { + list-style: none; + margin: 0; + padding: 0 0 4px; +} + +.BRolMobileResultItem { + display: block; + width: 100%; + padding: 10px 14px; + background: none; + border: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + text-align: left; + cursor: pointer; + font-size: 13px; + line-height: 1.45; + color: $ol-mobile-text; + font-family: inherit; + + &:hover { + background-color: rgba(0, 0, 0, 0.06); + } + + &:active { + background-color: rgba(0, 0, 0, 0.09); + } + + &:focus-visible { + outline: 2px solid $ol-mobile-action-blue; + outline-offset: -2px; + } + + &:last-child { + border-bottom: none; + } + + mark { + background-color: rgba(0, 128, 215, 0.18); + color: inherit; + padding: 0 1px; + border-radius: 2px; + } +} + +.BRolMobileResultPage { + font-weight: 600; + color: #444; +} + +/* --------------------------------------------------------------- + Result navigator bar + --------------------------------------------------------------- */ +.BRolMobileResultNav { + display: flex; + align-items: center; + height: 36px; + padding: 0 2px; + background-color: $ol-mobile-header-bg; + border-top: 1px solid $ol-mobile-border; + gap: 2px; + + &[hidden] { + display: none; + } +} + +.BRolMobileResultNavBtn { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 32px; + padding: 0; + background: none; + border: none; + border-radius: 6px; + cursor: pointer; + color: $ol-mobile-text; + + &[disabled] { + opacity: 0; + pointer-events: none; + } + + &:not([disabled]):hover, + &:not([disabled]):focus-visible { + background-color: rgba(0, 0, 0, 0.06); + outline: none; + } +} + +.BRolMobileResultNavCenter { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + gap: 5px; + overflow: hidden; +} + +.BRolMobileResultNavPos { + flex-shrink: 0; + font-size: 12px; + color: $ol-mobile-muted; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.BRolMobileResultNavSnippet { + flex: 1; + min-width: 0; + font-size: 13px; + color: $ol-mobile-text; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Status messages: loading / empty / error */ +.BRolMobileStatus { + padding: 14px; + color: $ol-mobile-muted; + font-style: italic; + + &--error { + color: #c0392b; + font-style: normal; + } +} + +/* --------------------------------------------------------------- + Scrubber page info row: [‹] X / N — Page Y , Ch Z Title… + Injected into .BRnavMain .scrubber after BookReader:PostInit. + --------------------------------------------------------------- */ + +/* BookReader gives .scrubber width:100% which crowds out the controls after it. + overflow:hidden on .BRcontrols prevents inner scroll-area width leaking to the page. */ +.BRolMobile .BRnavMain .BRcontrols { + overflow: hidden; +} + +.BRolMobile .BRnavMain .BRcontrols .scrubber { + flex: 1; + width: auto; + min-width: 0; +} + +.BRolMobilePageNav { + display: flex; + align-items: center; + width: 100%; + padding: 0 2px; + gap: 4px; +} + +/* Position indicator wrapper: ‹ [leaf] › — right border separates from page/section */ +.BRolMobilePageNavPosition { + display: flex; + align-items: center; + align-self: stretch; + flex-shrink: 0; + border-right: 2px solid #444; + margin-right: 5px; +} + +/* These elements live inside .BRcontrols .controls which has a blanket + `button { width:30px; height:30px; padding:0 }` rule (specificity 0,2,1). + Use three-class scoping (0,3,0) to beat it without !important. */ + +.BRcontrols .controls .BRolMobilePageNavBtn { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 30px; + padding: 0 2px; + background: none; + border: none; + border-radius: 6px; + cursor: pointer; + color: inherit; + + &[disabled] { + opacity: 0.25; + cursor: default; + pointer-events: none; + } + + &:not([disabled]):hover, + &:not([disabled]):focus-visible, + &:not([disabled]):active { + background: none; + outline: none; + } +} + +.BRolMobilePageNavSuffix { + display: flex; + align-items: center; + align-self: stretch; + flex: 1; + min-width: 0; + padding-right: 5px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + border-right: 2px solid #444; + margin-right: 5px; + + &::-webkit-scrollbar { + display: none; + } +} + +/* Clickable chip — leaf position, book page, or chapter. Same subtle treatment + for all three so they read as a cohesive group. + Three-class scoping (0,3,0) beats `.BRcontrols .controls button` (0,2,1). */ +.BRcontrols .controls .BRolMobilePageNavChip { + display: inline-flex; + align-items: center; + flex-shrink: 0; + width: auto; + height: auto; + background: rgba(255, 255, 255, 0.12); + border: none; + border-radius: 6px; + padding: 2px 4px; + font-size: 13px; + font-family: inherit; + color: inherit; + cursor: pointer; + white-space: nowrap; + + &:hover { + background: rgba(255, 255, 255, 0.20); + } + + &:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.6); + outline-offset: 1px; + } +} + +/* Chapter chip stays full-width; the suffix scrolls horizontally when it overflows. + margin-right is included in scroll-width, giving 5px air before the border. */ +.BRcontrols .controls .BRolMobilePageNavChapter { + flex-shrink: 0; + margin-right: 5px; +} + +/* "—" / ":" separators between chips */ +.BRolMobilePageNavSep { + flex-shrink: 0; + color: inherit; + opacity: 0.6; + font-size: 13px; + padding: 0 1px; + user-select: none; +} + +/* Inline edit input — replaces a chip while the patron types a target value */ +.BRolMobilePageNavInput { + display: inline-block; + width: 8ch; + background: rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 255, 255, 0.5); + border-radius: 4px; + padding: 2px 6px; + font-size: 13px; + font-family: inherit; + color: inherit; + outline: none; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.15); +} diff --git a/src/plugins/search/plugin.search.js b/src/plugins/search/plugin.search.js index 17613376d..fc6376b38 100644 --- a/src/plugins/search/plugin.search.js +++ b/src/plugins/search/plugin.search.js @@ -104,6 +104,9 @@ export class SearchPlugin extends BookReaderPlugin { this.options.initialSearchTerm, { goToFirstResult: this.options.goToFirstResult, suppressFragmentChange: false }, ); + } else if (this.options.initialSearchTerm === '') { + // ?q= present but empty: open the search panel without running a search + this.searchView.toggleSidebar(); } } diff --git a/src/plugins/search/plugin.search.ol-mobile.js b/src/plugins/search/plugin.search.ol-mobile.js new file mode 100644 index 000000000..ed8eb165d --- /dev/null +++ b/src/plugins/search/plugin.search.ol-mobile.js @@ -0,0 +1,1125 @@ +// @ts-check +/** + * OL Mobile Search Plugin + * + * When ?ref=ol is present and the viewport is mobile-width, injects a + * native-style header (IA logo + bookmark + search + more) above the book + * content and renders inline search results below the search bar instead of + * opening the sidebar drawer. + * + * Activation: ?ref=ol AND window.innerWidth <= options.mobileBreakpoint + */ +import { BookReaderPlugin } from '../../BookReaderPlugin.js'; +/** @typedef {import('../../BookReader.js').default} BookReader */ +/** @typedef {import('../search/plugin.search.js').SearchInsideMatch} SearchInsideMatch */ + +// Grab the global BookReader class set by BookReader.js (same pattern as other plugins) +const BookReader = /** @type {typeof import('../../BookReader.js').default} */ (window.BookReader); + +// ── SVG constants ────────────────────────────────────────────────────────── +const CHEVRON_UP = ``; +const CHEVRON_DOWN = ``; +const CHEVRON_LEFT = ``; +const CHEVRON_RIGHT = ``; + +// ── Real IA logo SVG (from @internetarchive/icon-ia-logo) ────────────────── +const IA_LOGO_SVG = ``; + +export class OLMobilePlugin extends BookReaderPlugin { + static pluginName = 'olMobile'; + + options = { + mobileBreakpoint: 640, + olRef: 'ol', + }; + + // ── DOM refs ─────────────────────────────────────────────────────────────── + /** @type {HTMLElement|null} */ _wrapper = null; + /** @type {HTMLButtonElement|null} */ _chromeHandle = null; + // Search UI + /** @type {HTMLElement|null} */ _resultsPanel = null; + /** @type {HTMLElement|null} */ _searchRow = null; + /** @type {HTMLInputElement|null} */ _searchInput = null; + /** @type {HTMLButtonElement|null} */ _clearBtn = null; + /** @type {HTMLButtonElement|null} */ _searchBtn = null; + // Search result nav bar + /** @type {HTMLElement|null} */ _navBar = null; + /** @type {HTMLButtonElement|null} */ _navPrevBtn = null; + /** @type {HTMLButtonElement|null} */ _navNextBtn = null; + /** @type {HTMLElement|null} */ _navPosEl = null; + /** @type {HTMLElement|null} */ _navSnippetEl = null; + // Bookmark UI + /** @type {HTMLButtonElement|null} */ _bookmarkBtn = null; + /** @type {HTMLElement|null} */ _bookmarkCountBubble = null; + /** @type {HTMLElement|null} */ _bmNoteBar = null; + /** @type {HTMLInputElement|null} */ _bmNoteInput = null; + /** @type {HTMLButtonElement|null} */ _bmSaveBtn = null; + /** @type {HTMLElement|null} */ _bmListPanel = null; + // Page nav (injected into BRnavMain scrubber after PostInit) + /** @type {HTMLElement|null} */ _pageNavBar = null; + + // ── State ────────────────────────────────────────────────────────────────── + /** @type {SearchInsideMatch[]} */ _matches = []; + /** @type {number} */ _currentMatchIdx = -1; + /** @type {boolean} */ _active = false; + /** @type {string} */ _savedQuery = ''; + /** @type {number[]} */ _bmPageIDs = []; // sorted leaf indices of all bookmarks + /** @type {number} */ _targetResultIdx = -1; // 0-based; set from ?result=N on load + + // ── Lifecycle ────────────────────────────────────────────────────────────── + + init() { + if (!this._shouldActivate()) return; + this._active = true; + this._setupMobileUI(); + this._prefillFromUrl(); + } + + /** + * If ?q= or #search/ is in the URL, open the search bar and pre-fill it. + * If ?result=N (1-based) is also present, auto-navigate to that result once + * search results arrive. + */ + _prefillFromUrl() { + const urlParams = new URLSearchParams(this.br.readQueryString()); + + // initialSearchTerm is set by BookReader.js from #search/TERM or ?q=TERM + // before any plugin init runs. Read ?q= directly too as a belt-and-suspenders + // fallback for embedders that set the param after BookReader reads it. + const term = this.br.plugins.search?.options.initialSearchTerm + ?? (urlParams.has('q') ? urlParams.get('q') : null); + + if (term == null) return; // neither source present — do nothing + + // ?result=N (1-based) → auto-jump to the Nth result once results arrive + const resultParam = parseInt(urlParams.get('result') ?? '', 10); + if (!isNaN(resultParam) && resultParam >= 1) { + this._targetResultIdx = resultParam - 1; // store 0-based + } + + this._openSearch(); + if (term && this._searchInput) { + this._searchInput.value = term; + if (this._clearBtn) this._clearBtn.hidden = false; + this._savedQuery = term; + this._showLoading(); + } + // term === '' : search UI is open for input, no search submitted yet + } + + _shouldActivate() { + // Use br.readQueryString() so params in the URL hash (e.g. #?ref=ol) are + // also detected — window.location.search misses hash-embedded params. + const params = new URLSearchParams(this.br.readQueryString()); + return ( + params.get('ref') === this.options.olRef && + window.innerWidth <= this.options.mobileBreakpoint + ); + } + + _setupMobileUI() { + const br = this.br; + const $root = br.refs.$br; + $root[0].classList.add('BRolMobile'); + if (br.refs.$BRtoolbar) br.refs.$BRtoolbar.hide(); + $root.find('.BRtoolbarSectionSearch').hide(); + + const wrapper = document.createElement('div'); + wrapper.className = 'BRolMobileWrapper'; + this._wrapper = wrapper; + + const header = this._buildHeader(); + this._resultsPanel = this._buildDropPanel('BRolMobileResults'); + this._bmListPanel = this._buildDropPanel('BRolMobileBookmarkList'); + wrapper.append(header, this._resultsPanel, this._bmListPanel); + + const $container = br.refs.$brContainer; + if ($container?.[0]) { + $container[0].parentNode.insertBefore(wrapper, $container[0]); + } else { + $root[0].prepend(wrapper); + } + + // Push BRcontainer down by wrapper height; MutationObserver re-applies + // when BookReader overwrites style.top; ResizeObserver tracks height changes. + if ($container?.[0]) { + const container = $container[0]; + let desiredTop = ''; + + // Override BookReader's toolbar-height query so resizeBRcontainer() always + // uses our wrapper height. This is set here (in plugin.init) rather than + // PostInit because BookReader calls resizeBRcontainer() at line 692, before + // plugins are initialized. The PostInit resize below corrects the initial + // container.style.top that was set while getToolBarHeight still returned 0. + this.br.getToolBarHeight = () => this._wrapper?.offsetHeight ?? 0; + + const applyOffset = () => { + desiredTop = this._wrapper.offsetHeight + 'px'; + if (container.style.top !== desiredTop) container.style.top = desiredTop; + }; + + new MutationObserver(() => { + if (container.style.top !== desiredTop) container.style.top = desiredTop; + }).observe(container, { attributes: true, attributeFilter: ['style'] }); + + new ResizeObserver(applyOffset).observe(this._wrapper); + + // Correct the initial container.style.top (set to 0 before plugins init) + // synchronously inside PostInit so later handlers see the right layout. + // The zoom is width-bound so toolbar height changes don't affect page scale; + // only container.style.top needs to be kept in sync. + $(document).one('BookReader:PostInit', () => { + applyOffset(); + window.dispatchEvent(new Event('resize')); + }); + applyOffset(); + } + + this._suppressSidebar(); + this._bindSearchEvents(); + this._bindBookmarkEvents(); + } + + // ── Collapse/expand chrome ───────────────────────────────────────────────── + + _hideChrome() { + const handle = this._chromeHandle; + if (!this._wrapper || !handle) return; + this._wrapper.classList.add('BRolMobileWrapper--collapsed'); + handle.innerHTML = CHEVRON_DOWN; + handle.setAttribute('aria-label', 'Show reader controls'); + handle.setAttribute('aria-expanded', 'false'); + } + + _showChrome() { + const handle = this._chromeHandle; + if (!this._wrapper || !handle) return; + this._wrapper.classList.remove('BRolMobileWrapper--collapsed'); + handle.innerHTML = CHEVRON_UP; + handle.setAttribute('aria-label', 'Hide reader controls'); + handle.setAttribute('aria-expanded', 'true'); + } + + // ── Sidebar suppression ──────────────────────────────────────────────────── + + _suppressSidebar() { + const br = this.br; + + window.addEventListener('BookReader:ToggleSearchMenu', (e) => { + e.stopImmediatePropagation(); + }, /* capture= */ true); + + const patchSearchView = () => { + const sv = br.plugins.search?.searchView; + if (sv) sv.toggleSidebar = () => {}; + }; + patchSearchView(); + $(document).one('BookReader:PostInit', patchSearchView); + + const injectNavStyle = () => { + const itemNav = document.querySelector('ia-bookreader') + ?.shadowRoot?.querySelector('iaux-item-navigator'); + const root = itemNav?.shadowRoot; + if (!root) return false; + if (!root.querySelector('#ol-mobile-nav-ss')) { + const s = document.createElement('style'); + s.id = 'ol-mobile-nav-ss'; + s.textContent = 'nav .minimized { display: none !important; }'; + root.appendChild(s); + } + return true; + }; + + if (!injectNavStyle()) { + const observer = new MutationObserver(() => { + if (injectNavStyle()) observer.disconnect(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + $(document).one('BookReader:PostInit', () => { + injectNavStyle(); + setTimeout(injectNavStyle, 500); + setTimeout(injectNavStyle, 2000); + // In OL production, ia-item-navigator may open its "search" panel + // automatically when ?q= is present. Close it so our mobile UI is + // the primary search surface. + if (br.plugins.search?.options.initialSearchTerm != null) { + br.shell?.updateSideMenu?.('search', 'close'); + } + }); + } + } + + // ── Header ───────────────────────────────────────────────────────────────── + + _buildHeader() { + const header = document.createElement('div'); + header.className = 'BRolMobileHeader'; + + // ── Row 1 ──────────────────────────────────────────────────────────────── + const row1 = document.createElement('div'); + row1.className = 'BRolMobileRow1'; + const logoURL = this.br.options?.logoURL ?? 'https://archive.org'; + row1.innerHTML = ` + + +
+ + + +
+ `; + + // Chrome toggle + const chromeToggle = /** @type {HTMLButtonElement} */ (row1.querySelector('.BRolMobileChromeToggle')); + chromeToggle.addEventListener('click', () => { + if (this._wrapper?.classList.contains('BRolMobileWrapper--collapsed')) { + this._showChrome(); + } else { + this._hideChrome(); + } + }); + this._chromeHandle = chromeToggle; + + // Bookmark button: add bookmark (or remove with confirmation) + this._bookmarkBtn = /** @type {HTMLButtonElement} */ (row1.querySelector('.BRolMobileBookmarkBtn')); + this._bookmarkBtn.addEventListener('click', () => { + const iaBookmarks = this._getIaBookmarks(); + if (!iaBookmarks) return; + const pageID = this._getCurrentPageID(); + const existing = iaBookmarks.bookmarks?.[pageID] ?? null; + + if (existing) { + const note = existing.note?.trim(); + if (note) { + const ok = window.confirm(`Removing this bookmark will also remove note: "${note}"`); + if (!ok) return; + } + // Call deleteBookmark directly — avoids ia-bookmarks' own confirmation modal + iaBookmarks.deleteBookmark({ detail: { id: String(pageID) } }); + } else { + iaBookmarks.bookmarkButtonClicked(pageID); + } + // bookmarksChanged fires after LitElement's async render; refresh eagerly + setTimeout(() => this._refreshBookmarkState(), 50); + }); + + // Bookmark count bubble: toggle the bookmark list panel + this._bookmarkCountBubble = /** @type {HTMLElement} */ (row1.querySelector('.BRolMobileBookmarkBubble')); + this._bookmarkCountBubble.addEventListener('click', () => { + if (this._bmListPanel && !this._bmListPanel.hasAttribute('hidden')) { + this._closeBmList(); + } else { + this._openBmList(); + } + }); + + // Search button: toggle search row + this._searchBtn = /** @type {HTMLButtonElement} */ (row1.querySelector('.BRolMobileSearchBtn')); + this._searchBtn.addEventListener('click', () => { + if (this._searchRow?.hasAttribute('hidden')) { + this._openSearch(); + } else { + this._closeSearch(); + } + }); + + // ── Note bar (shown when current page is bookmarked) ────────────────── + this._bmNoteBar = this._buildNoteBar(); + + // ── Row 2: search field ─────────────────────────────────────────────── + const row2 = document.createElement('div'); + row2.className = 'BRolMobileRow2'; + row2.setAttribute('hidden', ''); + row2.innerHTML = ` +
+ + + +
+ + `; + + const input = /** @type {HTMLInputElement} */ (row2.querySelector('.BRolMobileInput')); + this._clearBtn = /** @type {HTMLButtonElement} */ (row2.querySelector('.BRolMobileClearBtn')); + const cancelBtn = row2.querySelector('.BRolMobileCancelBtn'); + const clearBtn = this._clearBtn; + + input.addEventListener('input', () => { clearBtn.hidden = !input.value; }); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this._submitSearch(input.value.trim()); + } + }); + + const restoreResults = () => { + this._hideResultNav(); + if (!input.value && this._savedQuery) { + input.value = this._savedQuery; + if (clearBtn) clearBtn.hidden = false; + } + if (this._resultsPanel?.innerHTML.trim() && this._resultsPanel.hasAttribute('hidden')) { + this._resultsPanel.removeAttribute('hidden'); + } + }; + input.addEventListener('click', restoreResults); + input.addEventListener('focus', restoreResults); + + clearBtn.addEventListener('click', () => { + input.value = ''; + clearBtn.hidden = true; + this._clearResults(); + input.focus(); + }); + + cancelBtn.addEventListener('click', () => this._deactivateSearch()); + + this._searchRow = row2; + this._searchInput = input; + + this._navBar = this._buildResultNav(); + header.append(row1, row2, this._navBar, this._bmNoteBar); + return header; + } + + // ── Note bar ─────────────────────────────────────────────────────────────── + + _buildNoteBar() { + const bar = document.createElement('div'); + bar.className = 'BRolMobileNoteBar'; + bar.setAttribute('hidden', ''); + bar.innerHTML = ` + Page Notes: + + + + `; + + this._bmNoteInput = /** @type {HTMLInputElement} */ (bar.querySelector('.BRolMobileNoteInput')); + this._bmSaveBtn = /** @type {HTMLButtonElement} */ (bar.querySelector('.BRolMobileSaveBtn')); + const hideNoteBtn = /** @type {HTMLButtonElement} */ (bar.querySelector('.BRolMobileHideNoteBtn')); + + this._bmSaveBtn.addEventListener('click', () => this._saveNote()); + this._bmNoteInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); this._saveNote(); } + }); + hideNoteBtn.addEventListener('click', () => this._hideNoteBar()); + + return bar; + } + + // ── Page navigation bar ─────────────────────────────────────────────────── + // Self-contained: could be extracted to its own PR. No coupling to search + // or bookmark state — only BookReader position APIs and fragmentChange. + + _buildPageNav() { + const bar = document.createElement('div'); + bar.className = 'BRolMobilePageNav'; + // Layout: ‹ [leaf] › [Page X] [: Section ...] + // ‹ and › bracket only the leaf chip; page+section follow to the right. + bar.innerHTML = ` +
+ + + +
+
+ `; + + bar.querySelector('.BRolMobilePageNavPrev')?.addEventListener('click', () => { + const idx = this.br.currentIndex(); + if (idx > 0) this.br.jumpToIndex(idx - 1); + }); + bar.querySelector('.BRolMobilePageNavNext')?.addEventListener('click', () => { + const idx = this.br.currentIndex(); + const numLeafs = this.br.book?.getNumLeafs() ?? 0; + if (idx < numLeafs - 1) this.br.jumpToIndex(idx + 1); + }); + bar.querySelector('.BRolMobilePageNavLeaf')?.addEventListener('click', (e) => { + this._startLeafEdit(/** @type {HTMLButtonElement} */ (e.currentTarget)); + }); + + this._pageNavBar = bar; + return bar; + } + + _updatePageNav() { + if (!this._pageNavBar) return; + const br = this.br; + const book = br.book; + if (!book) return; + + const idx = br.currentIndex(); + const numLeafs = book.getNumLeafs(); + const pageNum = book.getPageNum(idx); + // pageNum can be a string ("215") or a number (215); unasserted = "n{idx}" + const pageStr = String(pageNum ?? ''); + const isAsserted = pageStr !== '' && pageStr[0] !== 'n'; + + // Update leaf chip text (static element — querySelector returns null while input is shown) + const leafChip = /** @type {HTMLButtonElement|null} */ (this._pageNavBar.querySelector('.BRolMobilePageNavLeaf')); + if (leafChip) leafChip.textContent = `${idx + 1} / ${numLeafs}`; + + // Update prev/next disabled state + const prevBtn = /** @type {HTMLButtonElement|null} */ (this._pageNavBar.querySelector('.BRolMobilePageNavPrev')); + const nextBtn = /** @type {HTMLButtonElement|null} */ (this._pageNavBar.querySelector('.BRolMobilePageNavNext')); + if (prevBtn) prevBtn.disabled = (idx === 0); + if (nextBtn) nextBtn.disabled = (idx >= numLeafs - 1); + + // Chapter lookup (best-effort) + let chapterLabel = ''; + try { + const entries = /** @type {any[]} */ (br.plugins?.chapters?._tocEntries ?? []); + const sorted = entries.filter(ch => ch.pageIndex != null).reverse(); + const ch = sorted.find(ch => ch.pageIndex <= idx); + if (ch) { + chapterLabel = ((ch.label ?? '') + (ch.title ? ' ' + ch.title : '')).trim(); + } + } catch (_) { /* chapters plugin absent */ } + + // Suffix: [Page X] [:] [Section] — colon only when both page AND section present + const suffix = this._pageNavBar.querySelector('.BRolMobilePageNavSuffix'); + if (!suffix) return; + + suffix.innerHTML = ` + ${isAsserted ? `` : ''} + ${isAsserted && chapterLabel ? ':' : ''} + ${chapterLabel ? `` : ''} + `; + + suffix.querySelector('.BRolMobilePageNavPage')?.addEventListener('click', (e) => { + this._startPageEdit(/** @type {HTMLButtonElement} */ (e.currentTarget)); + }); + suffix.querySelector('.BRolMobilePageNavChapter')?.addEventListener('click', () => { + const shell = this.br.shell; + if (shell?.updateSideMenu) { + shell.updateSideMenu('chapters', 'open'); + } else { + // fallback: jump to start of chapter + const entries = /** @type {any[]} */ (this.br.plugins?.chapters?._tocEntries ?? []); + const sorted = entries.filter(ch => ch.pageIndex != null).reverse(); + const ch = sorted.find(ch => ch.pageIndex <= idx); + if (ch) this.br.jumpToIndex(ch.pageIndex); + } + }); + } + + /** Retry _updatePageNav every 2s until chapters data arrives (up to ~20s). */ + _retryUpdatePageNav(triesLeft = 10) { + this._updatePageNav(); + if (triesLeft > 0 && !this.br.plugins?.chapters?._tocEntries?.length) { + setTimeout(() => this._retryUpdatePageNav(triesLeft - 1), 2000); + } + } + + /** Replace the leaf chip with an inline input; Enter navigates, Escape restores. */ + _startLeafEdit(chipEl) { + const idx = this.br.currentIndex(); + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'BRolMobilePageNavInput'; + input.value = String(idx + 1); + input.setAttribute('aria-label', 'Leaf position'); + + let restored = false; + const restore = () => { + if (restored) return; + restored = true; + if (input.parentNode) input.parentNode.replaceChild(chipEl, input); + }; + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const val = parseInt(input.value, 10); + const numLeafs = this.br.book?.getNumLeafs() ?? 0; + if (!isNaN(val) && val >= 1 && val <= numLeafs) this.br.jumpToIndex(val - 1); + restore(); + } else if (e.key === 'Escape') { + restore(); + } + }); + input.addEventListener('blur', () => setTimeout(restore, 150)); + + chipEl.parentNode?.replaceChild(input, chipEl); + input.select(); + } + + /** Replace the page chip with an inline input; Enter navigates, Escape restores. */ + _startPageEdit(chipEl) { + const idx = this.br.currentIndex(); + const pageNum = this.br.book?.getPageNum(idx) ?? ''; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'BRolMobilePageNavInput'; + input.value = String(pageNum); + input.setAttribute('aria-label', 'Book page number'); + + let restored = false; + const restore = () => { + if (restored) return; + restored = true; + if (input.parentNode) input.parentNode.replaceChild(chipEl, input); + }; + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const val = input.value.trim(); + if (val) this.br.jumpToPage(val); + restore(); + } else if (e.key === 'Escape') { + restore(); + } + }); + input.addEventListener('blur', () => setTimeout(restore, 150)); + + chipEl.parentNode?.replaceChild(input, chipEl); + input.select(); + } + + _showNoteBar(pageID) { + if (!this._bmNoteBar || !this._bmNoteInput) return; + const bm = this._getBookmarkForPage(pageID); + this._bmNoteInput.value = bm?.note ?? ''; + this._bmNoteBar.removeAttribute('hidden'); + } + + _hideNoteBar() { + this._bmNoteBar?.setAttribute('hidden', ''); + } + + _saveNote() { + const iaBookmarks = this._getIaBookmarks(); + if (!iaBookmarks || !this._bmNoteInput) return; + const pageID = this._getCurrentPageID(); + const note = this._bmNoteInput.value; + iaBookmarks.saveBookmark({ detail: { bookmark: { id: pageID, note } } }); + if (this._bmSaveBtn) { + this._bmSaveBtn.textContent = 'Saved!'; + setTimeout(() => { if (this._bmSaveBtn) this._bmSaveBtn.textContent = 'Save'; }, 1500); + } + // If the bookmark list is open, re-render to show updated note + if (this._bmListPanel && !this._bmListPanel.hasAttribute('hidden')) { + setTimeout(() => this._renderBmList(), 100); + } + } + + // ── Bookmark list panel ──────────────────────────────────────────────────── + + /** Creates either the search results or bookmark list drop panel (shared structure). */ + _buildDropPanel(className) { + const panel = document.createElement('div'); + panel.className = className; + panel.setAttribute('hidden', ''); + panel.setAttribute('aria-live', 'polite'); + return panel; + } + + _openBmList() { + if (!this._bmListPanel) return; + this._closeSearch(); + this._hideResultNav(); + this._closeBmList(); + this._renderBmList(); + this._bmListPanel.removeAttribute('hidden'); + } + + _closeBmList() { + this._bmListPanel?.setAttribute('hidden', ''); + } + + _renderBmList() { + if (!this._bmListPanel) return; + if (this._bmPageIDs.length === 0) { + this._bmListPanel.innerHTML = `
No bookmarks yet.
`; + return; + } + + const iaBookmarks = this._getIaBookmarks(); + const allBm = iaBookmarks?.bookmarks; + const count = this._bmPageIDs.length; + const currentPageID = this._getCurrentPageID(); + + // Header: [ ‹ ] N Bookmarks [ › ] [ ✕ ] + const prevBtn = ``; + const nextBtn = ``; + + const items = this._bmPageIDs.map((pageID) => { + const bm = allBm?.[pageID] ?? null; + const pageNum = this.br.book?.getPageNum(pageID) ?? (pageID + 1); + const note = bm?.note?.trim(); + const noteSnippet = note ? note.split('\n')[0].substring(0, 60) : ''; + const isCurrent = pageID === currentPageID; + return `
  • + +
  • `; + }).join(''); + + this._bmListPanel.innerHTML = ` +
    +
    + ${prevBtn} + ${count} Bookmark${count === 1 ? '' : 's'} + ${nextBtn} +
    + +
    + + `; + + // prev/next: find where current page sits in the sorted bookmark list, + // then jump to the neighbouring bookmark + const prevEl = /** @type {HTMLButtonElement} */ (this._bmListPanel.querySelector('.BRolMobileBmNavPrev')); + const nextEl = /** @type {HTMLButtonElement} */ (this._bmListPanel.querySelector('.BRolMobileBmNavNext')); + const dismissEl = /** @type {HTMLButtonElement|null} */ (this._bmListPanel.querySelector('.BRolMobileBmDismissBtn')); + dismissEl?.addEventListener('click', () => this._closeBmList()); + + const updateNavDisabled = (activePageID) => { + const idx = this._bmPageIDs.indexOf(activePageID); + if (prevEl) prevEl.disabled = (idx <= 0); + if (nextEl) nextEl.disabled = (idx < 0 || idx >= this._bmPageIDs.length - 1); + }; + updateNavDisabled(currentPageID); + + const navigateToBookmark = (pageID) => { + this.br.jumpToIndex(pageID); + // Update highlight without full re-render + this._bmListPanel?.querySelectorAll('.BRolMobileBookmarkItem').forEach((el) => { + const pid = parseInt(/** @type {HTMLElement} */ (el).dataset.pageId, 10); + el.classList.toggle('BRolMobileBookmarkItem--current', pid === pageID); + }); + updateNavDisabled(pageID); + }; + + prevEl?.addEventListener('click', () => { + const active = /** @type {HTMLElement|null} */ (this._bmListPanel?.querySelector('.BRolMobileBookmarkItem--current')); + const activePID = active ? parseInt(active.dataset.pageId, 10) : currentPageID; + const idx = this._bmPageIDs.indexOf(activePID); + if (idx > 0) navigateToBookmark(this._bmPageIDs[idx - 1]); + }); + + nextEl?.addEventListener('click', () => { + const active = /** @type {HTMLElement|null} */ (this._bmListPanel?.querySelector('.BRolMobileBookmarkItem--current')); + const activePID = active ? parseInt(active.dataset.pageId, 10) : currentPageID; + const idx = this._bmPageIDs.indexOf(activePID); + if (idx >= 0 && idx < this._bmPageIDs.length - 1) navigateToBookmark(this._bmPageIDs[idx + 1]); + }); + + this._bmListPanel.querySelectorAll('.BRolMobileBookmarkItem').forEach((btn) => { + btn.addEventListener('click', () => { + const pageID = parseInt(/** @type {HTMLElement} */ (btn).dataset.pageId, 10); + navigateToBookmark(pageID); + this._closeBmList(); + }); + }); + } + + /** Update the current-page highlight in the list without re-rendering. */ + _updateBmListHighlight() { + if (!this._bmListPanel || this._bmListPanel.hasAttribute('hidden')) return; + const pageID = this._getCurrentPageID(); + this._bmListPanel.querySelectorAll('.BRolMobileBookmarkItem').forEach((el) => { + const pid = parseInt(/** @type {HTMLElement} */ (el).dataset.pageId, 10); + el.classList.toggle('BRolMobileBookmarkItem--current', pid === pageID); + }); + } + + // ── Bookmark state ───────────────────────────────────────────────────────── + + _getIaBookmarks() { + return document.querySelector('ia-bookreader')?.menuProviders?.bookmarks?.component ?? null; + } + + _getCurrentPageID() { + if (this.br.mode === this.br.constMode2up) { + const pagesInView = this.br.displayedIndices; + return pagesInView[pagesInView.length - 1]; + } + + // In 1up mode, BookReader's currentIndex() lags: it still reports the previous + // page when a sliver of it is visible at the top of the viewport. Instead, find + // the first page whose top edge has entered the viewport — that's the page the + // patron is looking at. + try { + const brContainer = this.br.refs?.$brContainer?.[0] ?? document.querySelector('.BRcontainer'); + if (brContainer) { + const viewH = window.innerHeight; + let bestIdx = -1; + let bestTop = Infinity; + for (const el of brContainer.querySelectorAll('.BRpagecontainer')) { + const cls = [...el.classList].find(c => c.startsWith('pagediv')); + if (!cls) continue; + const rect = el.getBoundingClientRect(); + if (rect.top >= 0 && rect.top < viewH && rect.top < bestTop) { + bestTop = rect.top; + bestIdx = parseInt(cls.slice(7), 10); + } + } + if (bestIdx >= 0) return bestIdx; + } + } catch (_) { /* fall through */ } + return this.br.currentIndex(); + } + + _getBookmarkForPage(pageID) { + return this._getIaBookmarks()?.bookmarks?.[pageID] ?? null; + } + + _refreshBookmarkState() { + const iaBookmarks = this._getIaBookmarks(); + if (!iaBookmarks) return; + const allBm = iaBookmarks.bookmarks ?? {}; + // Object.keys works for both plain objects and arrays-with-numeric-indices + const keys = Object.keys(allBm).filter(k => allBm[k] != null); + this._bmPageIDs = keys.map(Number).filter(n => !isNaN(n)).sort((a, b) => a - b); + + const count = this._bmPageIDs.length; + if (this._bookmarkCountBubble) { + this._bookmarkCountBubble.textContent = String(count); + this._bookmarkCountBubble.hidden = count === 0; + } + + this._updateBookmarkState(); + } + + /** Update bookmark icon state and note bar for the current page. */ + _updateBookmarkState() { + const pageID = this._getCurrentPageID(); + const bm = this._getBookmarkForPage(pageID); + + if (this._bookmarkBtn) { + this._bookmarkBtn.classList.toggle('BRolMobileBookmarkBtn--active', !!bm); + } + + if (bm) { + this._showNoteBar(pageID); + } else { + this._hideNoteBar(); + } + + // Keep the open bookmark list in sync with the current page + this._updateBmListHighlight(); + } + + _bindBookmarkEvents() { + // ia-bookmarks is created via document.createElement() and may not be connected + // to the DOM. Listeners added directly on the element always fire. + const bindToComponent = () => { + const comp = this._getIaBookmarks(); + if (!comp) return false; + comp.addEventListener('bookmarksChanged', () => { + this._refreshBookmarkState(); + // Re-render the open list so notes and count stay current + if (this._bmListPanel && !this._bmListPanel.hasAttribute('hidden')) { + this._renderBmList(); + } + }); + + // bookmarkButtonClicked fires at the end of createBookmark() / bookmarkEdited(). + // createBookmark() mutates bookmarks[] directly (no reactive prop update), + // so bookmarksChanged fires asynchronously via LitElement's render cycle. + // Listening here ensures the topbar refreshes even if bookmarksChanged is delayed. + comp.addEventListener('bookmarkButtonClicked', () => { + setTimeout(() => this._refreshBookmarkState(), 50); + }); + this._refreshBookmarkState(); + // Signal to tests (and any external observers) that bookmark state is live. + this._wrapper?.setAttribute('data-bookmarks-ready', ''); + return true; + }; + + const injectPageNav = () => { + // Inject page nav into BRnavMain scrubber, replacing the BRcurrentpage display + if (!this._pageNavBar) { + const scrubber = document.querySelector('.BRnavMain .scrubber'); + if (scrubber) { + const row = this._buildPageNav(); + scrubber.insertBefore(row, scrubber.firstChild); + const p = scrubber.querySelector('p'); + if (p) /** @type {HTMLElement} */ (p).hidden = true; + } + } + // Hide the duplicate flip buttons in the docked mobile scrubber bar + document.querySelectorAll('.BRnavMobile .book_left, .BRnavMobile .book_right') + .forEach(el => { /** @type {HTMLElement} */ (el).style.display = 'none'; }); + // Activate the seek slider by default (toggle_slider starts inactive/docked) + const toggleBtn = /** @type {HTMLElement|null} */ (document.querySelector('.BRicon.toggle_slider')); + if (toggleBtn && !toggleBtn.classList.contains('active')) toggleBtn.click(); + this._updatePageNav(); + }; + + if (!bindToComponent()) { + $(document).one('BookReader:PostInit', () => { + if (!bindToComponent()) setTimeout(bindToComponent, 500); + injectPageNav(); + // Chapters plugin fetches TOC async after PostInit — keep retrying until + // _tocEntries has data (slow network can take >3s; mobile can take longer). + this._retryUpdatePageNav(); + }); + } else { + $(document).one('BookReader:PostInit', () => { + injectPageNav(); + this._retryUpdatePageNav(); + }); + } + + // Page navigation: update bookmark state + page nav on every page change + $(document).on('BookReader:fragmentChange', () => { + if (!this._active) return; + this._updateBookmarkState(); + this._updatePageNav(); + }); + } + + // ── Search UI ────────────────────────────────────────────────────────────── + + _buildResultNav() { + const nav = document.createElement('div'); + nav.className = 'BRolMobileResultNav'; + nav.setAttribute('hidden', ''); + nav.innerHTML = ` + +
    + + +
    + + `; + + this._navPrevBtn = /** @type {HTMLButtonElement} */ (nav.querySelector('.BRolMobileResultNavPrev')); + this._navNextBtn = /** @type {HTMLButtonElement} */ (nav.querySelector('.BRolMobileResultNavNext')); + this._navPosEl = /** @type {HTMLElement} */ (nav.querySelector('.BRolMobileResultNavPos')); + this._navSnippetEl = /** @type {HTMLElement} */ (nav.querySelector('.BRolMobileResultNavSnippet')); + + this._navPrevBtn.addEventListener('click', () => { + if (this._currentMatchIdx > 0) this._navigateToMatch(this._currentMatchIdx - 1); + }); + this._navNextBtn.addEventListener('click', () => { + if (this._currentMatchIdx < this._matches.length - 1) this._navigateToMatch(this._currentMatchIdx + 1); + }); + + return nav; + } + + _navigateToMatch(idx) { + if (idx < 0 || idx >= this._matches.length) return; + this._currentMatchIdx = idx; + this.br.plugins.search?.jumpToMatch(idx); + this._updateNavDisplay(); + } + + _showResultNav(idx) { + if (!this._navBar) return; + this._savedQuery = this._searchInput?.value || this._savedQuery; + this._navigateToMatch(idx); + this._navBar.removeAttribute('hidden'); + this._hideResults(); + } + + _hideResultNav() { + this._navBar?.setAttribute('hidden', ''); + } + + _updateNavDisplay() { + const { _navPosEl, _navSnippetEl, _navPrevBtn, _navNextBtn } = this; + if (!_navPosEl || !_navSnippetEl || !_navPrevBtn || !_navNextBtn) return; + const total = this._matches.length; + const idx = this._currentMatchIdx; + const match = this._matches[idx]; + if (!match) return; + + _navPosEl.textContent = `(${idx + 1}/${total})`; + + const tmp = document.createElement('div'); + tmp.innerHTML = match.html || ''; + _navSnippetEl.textContent = `p. ${match.displayPageNumber} — ${(tmp.textContent || '').trim()}`; + + _navPrevBtn.disabled = (idx === 0); + _navNextBtn.disabled = (idx === total - 1); + } + + _openSearch() { + if (!this._searchRow) return; + this._closeBmList(); + // Note bar stays visible — it sits beneath search row and nav bar + this._searchRow.removeAttribute('hidden'); + if (this._searchBtn) { + this._searchBtn.classList.add('BRolMobileActionBtn--active'); + this._searchBtn.setAttribute('aria-pressed', 'true'); + } + if (this._clearBtn) this._clearBtn.hidden = !this._searchInput?.value; + if (this._resultsPanel?.innerHTML.trim()) { + this._resultsPanel.removeAttribute('hidden'); + } + this._searchInput?.focus(); + } + + _closeSearch() { + this._searchRow?.setAttribute('hidden', ''); + if (this._searchBtn) { + this._searchBtn.classList.remove('BRolMobileActionBtn--active'); + this._searchBtn.setAttribute('aria-pressed', 'false'); + } + this._hideResults(); + } + + _deactivateSearch() { + this._savedQuery = ''; + this._closeSearch(); + this._clearResults(); + if (this._searchInput) { + this._searchInput.value = ''; + if (this._clearBtn) this._clearBtn.hidden = true; + } + this.br.plugins.search?.removeSearchResults(/* suppressFragmentChange= */ true); + } + + _submitSearch(query) { + if (!query) return; + if (!this.br.plugins.search) return; + this._showLoading(); + this.br.plugins.search.search(query); + } + + _showLoading() { + if (!this._resultsPanel) return; + this._hideResultNav(); + this._matches = []; + this._currentMatchIdx = -1; + this._resultsPanel.removeAttribute('hidden'); + this._resultsPanel.innerHTML = `
    Searching…
    `; + } + + _hideResults() { + this._resultsPanel?.setAttribute('hidden', ''); + } + + _clearResults() { + if (!this._resultsPanel) return; + this._hideResultNav(); + this._matches = []; + this._currentMatchIdx = -1; + this._resultsPanel.setAttribute('hidden', ''); + this._resultsPanel.innerHTML = ''; + } + + /** @param {SearchInsideMatch[]} matches */ + _renderResults(matches) { + if (!this._resultsPanel) return; + this._matches = matches; + this._currentMatchIdx = -1; + this._hideResultNav(); + + if (!matches.length) { + this._resultsPanel.removeAttribute('hidden'); + this._resultsPanel.innerHTML = `
    No matches were found.
    `; + return; + } + + const count = matches.length; + const items = matches.map((match, i) => { + let html = match.html || ''; + if (html.length > 200) { + const start = Math.max(0, html.indexOf('') - 100); + if (start !== 0) html = '…' + html.substring(start).replace(/^\S+/, ''); + } + return `
  • + +
  • `; + }).join(''); + + this._resultsPanel.removeAttribute('hidden'); + this._resultsPanel.innerHTML = ` +
    ${count} Result${count === 1 ? '' : 's'}
    + + `; + + this._resultsPanel.querySelectorAll('.BRolMobileResultItem').forEach((btn) => { + btn.addEventListener('click', () => { + const idx = parseInt(/** @type {HTMLElement} */ (btn).dataset.matchIndex, 10); + this._showResultNav(idx); + }); + }); + + // Auto-navigate to ?result=N on initial load (consumed once, then cleared) + if (this._targetResultIdx >= 0) { + const target = Math.min(this._targetResultIdx, matches.length - 1); + this._targetResultIdx = -1; + this._showResultNav(target); + } + } + + _bindSearchEvents() { + const br = this.br; + $(document) + .on('BookReader:SearchCallback', (e, { results, instance }) => { + if (!this._active || instance !== br) return; + this._renderResults(results.matches); + }) + .on('BookReader:SearchStarted', (e, { instance }) => { + if (!this._active || instance !== br) return; + this._showLoading(); + }) + .on('BookReader:SearchCallbackEmpty', (e, { instance }) => { + if (!this._active || instance !== br) return; + this._renderResults([]); + }) + .on('BookReader:SearchCallbackError', (e, { instance }) => { + if (!this._active || instance !== br) return; + if (!this._resultsPanel) return; + this._resultsPanel.removeAttribute('hidden'); + this._resultsPanel.innerHTML = `
    Sorry, there was an error with your search. Please try again.
    `; + }); + + document.addEventListener('click', (e) => { + if (!this._active) return; + const resultsVisible = this._resultsPanel && !this._resultsPanel.hasAttribute('hidden'); + const bmListVisible = this._bmListPanel && !this._bmListPanel.hasAttribute('hidden'); + if (!resultsVisible && !bmListVisible) return; + if (this._wrapper?.contains(/** @type {Node} */ (e.target))) return; + if (resultsVisible) { + if (this._currentMatchIdx >= 0) { + this._showResultNav(this._currentMatchIdx); + } else { + this._hideResults(); + } + } + if (bmListVisible) this._closeBmList(); + }, { capture: false }); + } +} + +BookReader?.registerPlugin('olMobile', OLMobilePlugin); diff --git a/tests/jest/plugins/search/plugin.search.test.js b/tests/jest/plugins/search/plugin.search.test.js index 87ffaadde..8474e0480 100644 --- a/tests/jest/plugins/search/plugin.search.test.js +++ b/tests/jest/plugins/search/plugin.search.test.js @@ -60,6 +60,15 @@ describe('Plugin: Search', () => { .toHaveProperty('suppressFragmentChange', false); }); + test('On init with empty initialSearchTerm (""), it opens the search panel without searching', () => { + br.plugins.search.search = jest.fn(); + br.options.plugins.search.initialSearchTerm = ''; + br.init(); + + expect(br.plugins.search.searchView.toggleSidebar).toHaveBeenCalled(); + expect(br.plugins.search.search).not.toHaveBeenCalled(); + }); + test('calling `search` fires ajax call`', () => { br.init(); br.search('foo'); diff --git a/tests/playwright/ol-mobile.spec.js b/tests/playwright/ol-mobile.spec.js new file mode 100644 index 000000000..0a00f6351 --- /dev/null +++ b/tests/playwright/ol-mobile.spec.js @@ -0,0 +1,1607 @@ +// @ts-check +/** + * OL Mobile Plugin — Playwright regression tests + * + * Run with: + * npm run build && npx playwright test + * + * Requires a running http-server on port 8000 (or set BASE_URL). + * Playwright will start one automatically via playwright.config.js webServer. + * + * These tests load the IA demo with ?ref=ol and a 390px mobile viewport, + * which activates the OL mobile plugin. They test pixel-level layout, + * UI interaction, and state correctness. + * + * Search tests mock the archive.org search API so they run offline, deterministically, + * and without depending on the OCR quality of the test book. + */ + +import { test, expect } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const OCAID = 'goody'; +const DEMO_PATH = `/BookReaderDemo/demo-internetarchive.html?ocaid=${OCAID}&ref=ol`; + +// Mock search API response — 2 results, deterministic, no real network call. +const MOCK_SEARCH_TERM = 'good'; +const MOCK_SEARCH_RESPONSE = { + ia: OCAID, + q: MOCK_SEARCH_TERM, + indexed: true, + page_count: 193, + body_length: 400, + leaf0_missing: false, + matches: [ + { + text: `Once upon a time there was a {{{${MOCK_SEARCH_TERM}}}} little girl`, + par: [{ + boxes: [{ r: 500, b: 400, t: 350, page: 5, l: 100 }], + b: 500, t: 300, page_width: 800, r: 600, l: 50, + page_height: 1000, page: 5, + }], + }, + { + text: `She was a very {{{${MOCK_SEARCH_TERM}}}} child indeed`, + par: [{ + boxes: [{ r: 500, b: 400, t: 350, page: 20, l: 100 }], + b: 500, t: 300, page_width: 800, r: 600, l: 50, + page_height: 1000, page: 20, + }], + }, + ], +}; + +/** + * Install a route mock for the archive.org search API so search tests don't + * depend on network access or OCR quality of the test book. + */ +async function mockSearch(page) { + await page.route('**/fulltext/inside.php**', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_SEARCH_RESPONSE), + })); +} + +/** Wait for the plugin wrapper and BRcontainer to be present. */ +async function waitForMobileUI(page) { + await page.waitForSelector('.BRolMobileWrapper', { timeout: 30_000 }); + await page.waitForSelector('.BRcontainer', { timeout: 30_000 }); + // Slight stabilization delay for BookReader's initial layout pass + await page.waitForTimeout(500); +} + +/** Bounding-box helper (always uses the plugin's chrome toggle). */ +const chevron = (page) => page.locator('.BRolMobileChromeToggle'); + +/** Read wrapper-bottom and BRcontainer-top from the live DOM. */ +async function getLayoutYs(page) { + return page.evaluate(() => { + const wrapper = document.querySelector('.BRolMobileWrapper'); + const container = document.querySelector('.BRcontainer'); + return { + wrapperBottom: wrapper?.getBoundingClientRect().bottom ?? 0, + containerTop: container?.getBoundingClientRect().top ?? 0, + }; + }); +} + +/** + * Count bookmarks from the ia-bookmarks Lit component. + * bookmarks starts as [] (array with numeric-index mutations) and later + * becomes a plain {} object. Object.keys handles both correctly. + */ +async function bookmarkCount(page) { + return page.evaluate(() => { + const bm = /** @type {any} */ (document.querySelector('ia-bookreader')) + ?.menuProviders?.bookmarks?.component?.bookmarks; + if (!bm) return 0; + return Object.keys(bm).filter(k => bm[k] != null).length; + }); +} + +/** + * Wait until the OL mobile plugin has bound its bookmarksChanged listener. + * The plugin sets data-bookmarks-ready on the wrapper once binding succeeds + * (after the 500ms PostInit fallback that lets ia-bookreader initialise first). + */ +async function waitForBookmarks(page) { + await page.waitForSelector('.BRolMobileWrapper[data-bookmarks-ready]', { timeout: 20_000 }); +} + +/** Add a bookmark on the current page and wait for the bubble to confirm it. */ +async function addBookmark(page) { + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible({ timeout: 3_000 }); +} + +// --------------------------------------------------------------------------- +// 1. Layout: header must not occlude the book +// +// BRcontainer is position:absolute, so normal-flow siblings don't push it. +// The plugin uses a MutationObserver + ResizeObserver to keep BRcontainer.top +// equal to the wrapper height. This was a real bug on initial load: BookReader +// would set container.style.top = '0' after our value was applied, clobbering +// it. The observer approach corrects this reactively every time BR touches the +// style attribute. +// --------------------------------------------------------------------------- + +test.describe('Layout — header does not occlude the book', () => { + test('BRcontainer top >= wrapper bottom on initial load', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + const { wrapperBottom, containerTop } = await getLayoutYs(page); + + // The book's top edge must be at or below the header's bottom edge. + // Allow 2 px tolerance for sub-pixel rounding. + expect(containerTop).toBeGreaterThanOrEqual(wrapperBottom - 2); + }); + + test('BRcontainer top >= wrapper bottom after collapse then expand', async ({ page }) => { + // Collapse changes the wrapper height (to 25 px strip); ResizeObserver must + // update BRcontainer.top to match. Expand must restore it to full height. + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await chevron(page).click(); + await page.waitForSelector('.BRolMobileWrapper--collapsed'); + + await chevron(page).click(); + await page.waitForSelector('.BRolMobileWrapper:not(.BRolMobileWrapper--collapsed)'); + await page.waitForTimeout(200); + + const { wrapperBottom, containerTop } = await getLayoutYs(page); + expect(containerTop).toBeGreaterThanOrEqual(wrapperBottom - 2); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Bookmark UX — icon, bubble, note bar, list panel +// +// When a patron clicks the bookmark button on an unbookmarked page: +// 1. ia-bookmarks registers the bookmark locally (no auth needed for local state) +// 2. Our plugin fires _refreshBookmarkState via the bookmarksChanged event +// 3. The bookmark button gains .BRolMobileBookmarkBtn--active (icon turns red) +// 4. The count bubble becomes visible with count "1" +// 5. The note bar appears (with "Page Notes:" label) +// +// Clicking the bubble opens a list of all bookmarks. +// Clicking a bookmark list item navigates to that page; the list stays OPEN +// so the patron can review multiple entries before dismissing. +// Clicking bookmark again on a bookmarked page (no note) removes it immediately. +// --------------------------------------------------------------------------- + +test.describe('Bookmark — visual state after clicking', () => { + test('bookmark button click adds a bookmark in ia-bookmarks component', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + const before = await bookmarkCount(page); + await page.locator('.BRolMobileBookmarkBtn').click(); + // Wait for the bubble to appear — it only shows when count > 0 + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible({ timeout: 3_000 }); + + expect(await bookmarkCount(page)).toBe(before + 1); + }); + + test('bookmark button gains active class after clicking', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible({ timeout: 3_000 }); + + const isActive = await page.locator('.BRolMobileBookmarkBtn').evaluate( + (el) => el.classList.contains('BRolMobileBookmarkBtn--active'), + ); + expect(isActive).toBe(true); + }); + + test('bookmark icon SVG fills solid red after clicking', async ({ page }) => { + // Regression: icon must be solid-filled red, not just an outline tint. + // We check the computed fill on the SVG element (CSS-owned, no SVG attributes). + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible({ timeout: 3_000 }); + + const fill = await page.locator('.BRolMobileBookmarkIcon').evaluate( + (el) => window.getComputedStyle(el).fill, + ); + // #C0392B = rgb(192, 57, 43) — the bookmark-red variable + expect(fill).toMatch(/rgb\(192,\s*57,\s*43\)/); + }); + + test('entire top bar row does NOT turn red after clicking bookmark', async ({ page }) => { + // Regression: only the icon and bubble are red. The row background stays neutral. + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible({ timeout: 3_000 }); + + const row1BgColor = await page.locator('.BRolMobileRow1').evaluate( + (el) => window.getComputedStyle(el).backgroundColor, + ); + // Should be the neutral header background, not red (#C0392B = rgb(192,57,43)) + expect(row1BgColor).not.toMatch(/192.*57.*43|C0392B/i); + }); + + test('count bubble appears with count 1 after first bookmark', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeHidden(); + + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible({ timeout: 3_000 }); + + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible(); + expect(await page.locator('.BRolMobileBookmarkBubble').textContent()).toBe('1'); + }); + + test('note bar appears after bookmarking a page', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await expect(page.locator('.BRolMobileNoteBar')).toBeHidden(); + + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible({ timeout: 3_000 }); + + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible(); + }); + + test('note bar has "Page Notes:" label', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible({ timeout: 3_000 }); + + const labelText = await page.locator('.BRolMobileNoteLabel').textContent(); + expect(labelText?.trim()).toBe('Page Notes:'); + }); + + test('clicking bookmark again on bookmarked page (no note) removes it', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + // Add — wait for bubble to confirm it registered + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible({ timeout: 3_000 }); + expect(await bookmarkCount(page)).toBeGreaterThan(0); + + // Remove — no note so no confirm dialog + await page.locator('.BRolMobileBookmarkBtn').click(); + // Use toBeHidden() — the element stays in DOM but hidden via [hidden] attribute + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeHidden({ timeout: 3_000 }); + + expect(await bookmarkCount(page)).toBe(0); + + const isActive = await page.locator('.BRolMobileBookmarkBtn').evaluate( + (el) => el.classList.contains('BRolMobileBookmarkBtn--active'), + ); + expect(isActive).toBe(false); + + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeHidden(); + await expect(page.locator('.BRolMobileNoteBar')).toBeHidden(); + }); +}); + +test.describe('Bookmark — list panel (bubble click)', () => { + test('bubble click shows the bookmark list panel', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await addBookmark(page); + + // List is hidden before bubble click + await expect(page.locator('.BRolMobileBookmarkList')).toBeHidden(); + + await page.locator('.BRolMobileBookmarkBubble').click(); + await expect(page.locator('.BRolMobileBookmarkList')).toBeVisible(); + }); + + test('bookmark list shows one entry after one bookmark', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await addBookmark(page); + await page.locator('.BRolMobileBookmarkBubble').click(); + + await expect(page.locator('.BRolMobileBookmarkItem')).toHaveCount(1); + }); + + test('bookmark list has prev/next navigation buttons', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await addBookmark(page); + await page.locator('.BRolMobileBookmarkBubble').click(); + + await expect(page.locator('.BRolMobileBmNavPrev')).toBeVisible(); + await expect(page.locator('.BRolMobileBmNavNext')).toBeVisible(); + }); + + test('clicking bubble again hides the list', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await addBookmark(page); + await page.locator('.BRolMobileBookmarkBubble').click(); + await expect(page.locator('.BRolMobileBookmarkList')).toBeVisible(); + + // Second bubble click closes the list + await page.locator('.BRolMobileBookmarkBubble').click(); + await expect(page.locator('.BRolMobileBookmarkList')).toBeHidden(); + }); + + test('clicking outside the wrapper hides the bookmark list', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await addBookmark(page); + await page.locator('.BRolMobileBookmarkBubble').click(); + await expect(page.locator('.BRolMobileBookmarkList')).toBeVisible(); + + const bookBox = await page.locator('.BRcontainer').boundingBox(); + if (bookBox) { + await page.mouse.click(bookBox.x + bookBox.width / 2, bookBox.y + bookBox.height / 3); + await page.waitForTimeout(300); + await expect(page.locator('.BRolMobileBookmarkList')).toBeHidden(); + } + }); + + test('clicking a bookmark list item dismisses the list', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await addBookmark(page); + await page.locator('.BRolMobileBookmarkBubble').click(); + await expect(page.locator('.BRolMobileBookmarkItem').first()).toBeVisible(); + + await page.locator('.BRolMobileBookmarkItem').first().click(); + await page.waitForTimeout(300); + + // Clicking a result navigates and closes the list + await expect(page.locator('.BRolMobileBookmarkList')).toBeHidden(); + }); + + test('dismiss button (✕) in bookmark list header closes the list', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await addBookmark(page); + await page.locator('.BRolMobileBookmarkBubble').click(); + await expect(page.locator('.BRolMobileBookmarkList')).toBeVisible(); + + await page.locator('.BRolMobileBmDismissBtn').click(); + await expect(page.locator('.BRolMobileBookmarkList')).toBeHidden(); + }); + + test('bookmark list header has centered count with flanking chevrons and far-right dismiss', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await addBookmark(page); + await page.locator('.BRolMobileBookmarkBubble').click(); + + // Count and chevrons are inside the centered group + await expect(page.locator('.BRolMobileBmNavCenter')).toBeVisible(); + await expect(page.locator('.BRolMobileBmNavCenter .BRolMobileBmNavPrev')).toBeVisible(); + await expect(page.locator('.BRolMobileBmNavCenter .BRolMobileBmNavNext')).toBeVisible(); + await expect(page.locator('.BRolMobileBmCount')).toBeVisible(); + + // Dismiss button is outside the center group (separate, at far right) + await expect(page.locator('.BRolMobileBmDismissBtn')).toBeVisible(); + }); + + test('current-page bookmark item is highlighted in the list', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + // Bookmark the current (first) page, then open the list + await addBookmark(page); + await page.locator('.BRolMobileBookmarkBubble').click(); + + // The entry for the current page must have the --current modifier class + const hasCurrent = await page.locator('.BRolMobileBookmarkItem--current').count(); + expect(hasCurrent).toBeGreaterThan(0); + }); + + test('opening bookmark list hides the search panel', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + // Open search first + await page.locator('.BRolMobileSearchBtn').click(); + await expect(page.locator('.BRolMobileRow2')).toBeVisible(); + + // Add a bookmark so bubble is visible + await addBookmark(page); + + // Open bookmark list + await page.locator('.BRolMobileBookmarkBubble').click(); + + // Search row should now be hidden + await expect(page.locator('.BRolMobileRow2')).toBeHidden(); + }); + + test('saving a note auto-updates the bookmark list if open', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + // Add bookmark and open list + await addBookmark(page); + await page.locator('.BRolMobileBookmarkBubble').click(); + await expect(page.locator('.BRolMobileBookmarkList')).toBeVisible(); + + // The list entry should have no note snippet yet + const snippetBefore = await page.locator('.BRolMobileBookmarkItem .BRolMobileResultSnippet').count(); + expect(snippetBefore).toBe(0); + + // Close the list, type a note, save + await page.locator('.BRolMobileBookmarkBubble').click(); + await expect(page.locator('.BRolMobileBookmarkList')).toBeHidden(); + + await page.locator('.BRolMobileNoteInput').fill('My test note'); + await page.locator('.BRolMobileSaveBtn').click(); + + // Re-open list — note snippet should now appear + await page.locator('.BRolMobileBookmarkBubble').click(); + await expect(page.locator('.BRolMobileBookmarkList')).toBeVisible(); + const snippetAfter = await page.locator('.BRolMobileBookmarkItem .BRolMobileResultSnippet').count(); + expect(snippetAfter).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// 2b. Bookmark — ia-bookmarks native UI (page-corner) reactive update +// +// The top-right corner of each book page (rendered by ia-bookmarks) has its +// own add/remove bookmark button. Clicking it calls ia-bookmarks' internal +// API, which fires a `bookmarksChanged` CustomEvent on the component. Our +// plugin listens on that event and refreshes the topbar reactively. +// --------------------------------------------------------------------------- + +test.describe('Bookmark — page-corner (ia-bookmarks native UI) reactive update', () => { + /** + * Simulate the page-corner bookmark add by directly setting ia-bookmarks' internal state + * and firing bookmarksChanged — the same event the corner button fires after createBookmark(). + * We bypass the IA API call (which requires auth) and modal interactions that aren't + * available in the test environment. + */ + async function cornerAddBookmark(page) { + return page.evaluate(() => { + const comp = /** @type {any} */ (document.querySelector('ia-bookreader')) + ?.menuProviders?.bookmarks?.component; + if (!comp) throw new Error('ia-bookmarks component not found'); + comp.bookmarks[0] = { id: 0, leafNum: 0, note: '' }; + comp.emitBookmarksChanged(); + }); + } + + async function cornerRemoveBookmark(page) { + return page.evaluate(() => { + const comp = /** @type {any} */ (document.querySelector('ia-bookreader')) + ?.menuProviders?.bookmarks?.component; + if (!comp) throw new Error('ia-bookmarks component not found'); + const updated = { ...comp.bookmarks }; + delete updated[0]; + comp.bookmarks = updated; + comp.emitBookmarksChanged(); + }); + } + + test('topbar bubble appears when bookmark added via page-corner UI', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeHidden(); + + await cornerAddBookmark(page); + + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible({ timeout: 3_000 }); + }); + + test('topbar bookmark icon becomes active when added via page-corner UI', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await cornerAddBookmark(page); + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible({ timeout: 3_000 }); + + const isActive = await page.locator('.BRolMobileBookmarkBtn').evaluate( + (el) => el.classList.contains('BRolMobileBookmarkBtn--active'), + ); + expect(isActive).toBe(true); + }); + + test('topbar bubble disappears when bookmark removed via page-corner UI', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + // Add first so there's something to remove + await cornerAddBookmark(page); + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible({ timeout: 3_000 }); + + await cornerRemoveBookmark(page); + + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeHidden({ timeout: 3_000 }); + }); + + test('topbar bookmark icon deactivates when bookmark removed via page-corner UI', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await cornerAddBookmark(page); + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible({ timeout: 3_000 }); + + await cornerRemoveBookmark(page); + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeHidden({ timeout: 3_000 }); + + const isActive = await page.locator('.BRolMobileBookmarkBtn').evaluate( + (el) => el.classList.contains('BRolMobileBookmarkBtn--active'), + ); + expect(isActive).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// 2c. Note bar — Hide button and search interaction +// --------------------------------------------------------------------------- + +test.describe('Note bar — Hide button and search interaction', () => { + test('Hide button dismisses the note bar', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible({ timeout: 3_000 }); + + await page.locator('.BRolMobileHideNoteBtn').click(); + await expect(page.locator('.BRolMobileNoteBar')).toBeHidden(); + }); + + test('pressing Search keeps note bar visible (it sits beneath search row)', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + // Bookmark the page — note bar appears + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible({ timeout: 3_000 }); + + // Open search — note bar should remain visible below the search row + await page.locator('.BRolMobileSearchBtn').click(); + await expect(page.locator('.BRolMobileRow2')).toBeVisible(); + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible(); + }); + + test('note bar renders after the search row and nav bar in the DOM', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible({ timeout: 3_000 }); + + const order = await page.evaluate(() => { + const header = document.querySelector('.BRolMobileHeader'); + if (!header) return []; + return [...header.children].map(el => el.className); + }); + + const noteIdx = order.findIndex(c => c.includes('BRolMobileNoteBar')); + const searchIdx = order.findIndex(c => c.includes('BRolMobileRow2')); + const navIdx = order.findIndex(c => c.includes('BRolMobileResultNav')); + + // Note bar must come after both search row and result nav bar + expect(noteIdx).toBeGreaterThan(searchIdx); + expect(noteIdx).toBeGreaterThan(navIdx); + }); + + test('pressing Search hides the bookmark list', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileBookmarkBubble')).toBeVisible({ timeout: 3_000 }); + + // Open bookmark list + await page.locator('.BRolMobileBookmarkBubble').click(); + await expect(page.locator('.BRolMobileBookmarkList')).toBeVisible(); + + // Open search — bookmark list should disappear + await page.locator('.BRolMobileSearchBtn').click(); + await expect(page.locator('.BRolMobileBookmarkList')).toBeHidden(); + }); + + test('note bar stays visible while searching and after cancelling', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + // Bookmark page → note bar visible + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible({ timeout: 3_000 }); + + // Open search → note bar still visible + await page.locator('.BRolMobileSearchBtn').click(); + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible(); + + // Cancel search → note bar still visible + await page.locator('.BRolMobileCancelBtn').click(); + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible(); + }); + + test('note bar stays visible while search is toggled on and off', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForBookmarks(page); + + await page.locator('.BRolMobileBookmarkBtn').click(); + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible({ timeout: 3_000 }); + + // Toggle search open + await page.locator('.BRolMobileSearchBtn').click(); + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible(); + + // Toggle search closed + await page.locator('.BRolMobileSearchBtn').click(); + await expect(page.locator('.BRolMobileNoteBar')).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Chevron — position and size in both states +// --------------------------------------------------------------------------- + +test.describe('Chevron — position and size', () => { + test('expanded chevron is approximately 40 px tall', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + const bounds = await chevron(page).boundingBox(); + // Row height is 44 px; chevron uses margin:2px top+bottom → ~40 px. + expect(bounds?.height).toBeGreaterThanOrEqual(36); + expect(bounds?.height).toBeLessThanOrEqual(44); + }); + + test('collapsed chevron is approximately 25 px tall', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await chevron(page).click(); + await page.waitForSelector('.BRolMobileWrapper--collapsed'); + + const bounds = await chevron(page).boundingBox(); + expect(bounds?.height).toBeGreaterThanOrEqual(22); + expect(bounds?.height).toBeLessThanOrEqual(28); + }); + + test('collapsed chevron x position matches expanded chevron x position', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + const expandedBox = await chevron(page).boundingBox(); + + await chevron(page).click(); + await page.waitForSelector('.BRolMobileWrapper--collapsed'); + + const collapsedBox = await chevron(page).boundingBox(); + + // Same horizontal position ± 2 px. + expect(Math.abs((collapsedBox?.x ?? 0) - (expandedBox?.x ?? 0))).toBeLessThanOrEqual(2); + }); + + test('collapsed chevron y position is at the top of the viewport (y near 0)', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await chevron(page).click(); + await page.waitForSelector('.BRolMobileWrapper--collapsed'); + + const box = await chevron(page).boundingBox(); + // The collapsed strip is at the very top; chevron's top edge should be near 0. + expect(box?.y ?? 999).toBeLessThanOrEqual(5); + }); + + test('expand→collapse→expand restores chevron to original position and size', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + const initial = await chevron(page).boundingBox(); + + await chevron(page).click(); // collapse + await page.waitForSelector('.BRolMobileWrapper--collapsed'); + await chevron(page).click(); // expand + await page.waitForSelector('.BRolMobileWrapper:not(.BRolMobileWrapper--collapsed)'); + await page.waitForTimeout(100); + + const restored = await chevron(page).boundingBox(); + + expect(Math.abs((restored?.x ?? 0) - (initial?.x ?? 0))).toBeLessThanOrEqual(2); + expect(Math.abs((restored?.y ?? 0) - (initial?.y ?? 0))).toBeLessThanOrEqual(2); + expect(Math.abs((restored?.height ?? 0) - (initial?.height ?? 0))).toBeLessThanOrEqual(4); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Search flow +// +// All search tests install a Playwright route mock for /fulltext/inside.php +// so they run offline with deterministic results. +// --------------------------------------------------------------------------- + +test.describe('Search — magnifying glass, cancel, results, focus restore', () => { + test('search row is hidden on load', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await expect(page.locator('.BRolMobileRow2')).toBeHidden(); + }); + + test('magnifying glass click shows search row', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await page.locator('.BRolMobileSearchBtn').click(); + await expect(page.locator('.BRolMobileRow2')).toBeVisible(); + }); + + test('magnifying glass click again hides search row', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await page.locator('.BRolMobileSearchBtn').click(); + await expect(page.locator('.BRolMobileRow2')).toBeVisible(); + + await page.locator('.BRolMobileSearchBtn').click(); + await expect(page.locator('.BRolMobileRow2')).toBeHidden(); + }); + + test('Cancel hides search row without clearing a prior query', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await page.locator('.BRolMobileSearchBtn').click(); + await page.locator('.BRolMobileInput').fill('shoe'); + await page.locator('.BRolMobileCancelBtn').click(); + + await expect(page.locator('.BRolMobileRow2')).toBeHidden(); + }); + + test('Cancel clears input value and hides results', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await page.locator('.BRolMobileSearchBtn').click(); + await page.locator('.BRolMobileInput').fill(MOCK_SEARCH_TERM); + await page.locator('.BRolMobileInput').press('Enter'); + + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 10_000 }); + + await page.locator('.BRolMobileCancelBtn').click(); + + await expect(page.locator('.BRolMobileRow2')).toBeHidden(); + await expect(page.locator('.BRolMobileResults')).toBeHidden(); + expect(await page.locator('.BRolMobileInput').inputValue()).toBe(''); + }); + + test('pressing Enter shows results panel with at least one result', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await page.locator('.BRolMobileSearchBtn').click(); + await page.locator('.BRolMobileInput').fill(MOCK_SEARCH_TERM); + await page.locator('.BRolMobileInput').press('Enter'); + + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.BRolMobileResultItem').first()).toBeVisible(); + }); + + test('clicking a result hides results panel', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await page.locator('.BRolMobileSearchBtn').click(); + await page.locator('.BRolMobileInput').fill(MOCK_SEARCH_TERM); + await page.locator('.BRolMobileInput').press('Enter'); + + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 10_000 }); + await page.locator('.BRolMobileResultItem').first().click(); + await page.waitForTimeout(500); + + await expect(page.locator('.BRolMobileResults')).toBeHidden(); + }); + + test('clicking a result navigates BookReader to a different page', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + const urlBefore = page.url(); + + await page.locator('.BRolMobileSearchBtn').click(); + await page.locator('.BRolMobileInput').fill(MOCK_SEARCH_TERM); + await page.locator('.BRolMobileInput').press('Enter'); + + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 10_000 }); + + const resultPageLabel = await page.locator('.BRolMobileResultPage').first().textContent(); + + await page.locator('.BRolMobileResultItem').first().click(); + await page.waitForTimeout(1000); + + const urlAfter = page.url(); + const urlChanged = urlAfter !== urlBefore; + const resultsHidden = await page.locator('.BRolMobileResults').isHidden(); + + expect(urlChanged || resultsHidden).toBe(true); + + if (resultPageLabel) { + expect(/\d+/.test(resultPageLabel)).toBe(true); + } + }); + + test('clicking in search input after result-click restores results panel', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await page.locator('.BRolMobileSearchBtn').click(); + await page.locator('.BRolMobileInput').fill(MOCK_SEARCH_TERM); + await page.locator('.BRolMobileInput').press('Enter'); + + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 10_000 }); + + await page.locator('.BRolMobileResultItem').first().click(); + await expect(page.locator('.BRolMobileResults')).toBeHidden(); + + await page.locator('.BRolMobileInput').click(); + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 3_000 }); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Result navigator bar +// --------------------------------------------------------------------------- + +test.describe('Result nav bar — (x/y) strip after result click', () => { + /** Helper: search for mock term, wait for results, click the first one. */ + async function searchAndClickResult(page) { + await page.locator('.BRolMobileSearchBtn').click(); + await page.locator('.BRolMobileInput').fill(MOCK_SEARCH_TERM); + await page.locator('.BRolMobileInput').press('Enter'); + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 10_000 }); + await page.locator('.BRolMobileResultItem').first().click(); + await page.waitForTimeout(300); + } + + test('nav bar is hidden on load', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await expect(page.locator('.BRolMobileResultNav')).toBeHidden(); + }); + + test('nav bar is hidden before any result is clicked', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await page.locator('.BRolMobileSearchBtn').click(); + await page.locator('.BRolMobileInput').fill(MOCK_SEARCH_TERM); + await page.locator('.BRolMobileInput').press('Enter'); + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 10_000 }); + + await expect(page.locator('.BRolMobileResultNav')).toBeHidden(); + }); + + test('nav bar appears after clicking a result', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await searchAndClickResult(page); + + await expect(page.locator('.BRolMobileResultNav')).toBeVisible(); + }); + + test('nav bar shows (x/y) position label in correct format', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await searchAndClickResult(page); + + const posText = await page.locator('.BRolMobileResultNavPos').textContent(); + expect(posText).toMatch(/^\(\d+\/\d+\)$/); + expect(posText).toMatch(/^\(1\//); + }); + + test('nav bar shows a non-empty snippet', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await searchAndClickResult(page); + + const snippet = await page.locator('.BRolMobileResultNavSnippet').textContent(); + expect(snippet?.trim().length).toBeGreaterThan(0); + }); + + test('prev button is disabled (opacity 0) at first result', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await searchAndClickResult(page); + + const prevBtn = page.locator('.BRolMobileResultNavPrev'); + const isDisabled = await prevBtn.evaluate((el) => el.hasAttribute('disabled')); + expect(isDisabled).toBe(true); + }); + + test('next button is enabled when there are multiple results', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await searchAndClickResult(page); + + const total = await page.locator('.BRolMobileResultNavPos').evaluate((el) => { + const m = el.textContent?.match(/\/(\d+)\)/); + return m ? parseInt(m[1], 10) : 0; + }); + + const nextBtn = page.locator('.BRolMobileResultNavNext'); + const isDisabled = await nextBtn.evaluate((el) => el.hasAttribute('disabled')); + // Mock has 2 results, so next should be enabled + expect(total).toBe(2); + expect(isDisabled).toBe(false); + }); + + test('clicking Next advances (x/y) counter and updates snippet', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await searchAndClickResult(page); + + const total = await page.locator('.BRolMobileResultNavPos').evaluate((el) => { + const m = el.textContent?.match(/\/(\d+)\)/); + return m ? parseInt(m[1], 10) : 0; + }); + + if (total < 2) { + test.skip(); + return; + } + + await page.locator('.BRolMobileResultNavNext').click(); + await page.waitForTimeout(300); + + const posAfter = await page.locator('.BRolMobileResultNavPos').textContent(); + expect(posAfter).toMatch(/^\(2\//); + + const snippetAfter = await page.locator('.BRolMobileResultNavSnippet').textContent(); + expect(snippetAfter?.trim().length).toBeGreaterThan(0); + }); + + test('clicking input while nav bar is shown hides nav bar and restores results', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await searchAndClickResult(page); + + await expect(page.locator('.BRolMobileResultNav')).toBeVisible(); + await expect(page.locator('.BRolMobileResults')).toBeHidden(); + + await page.locator('.BRolMobileInput').click(); + await page.waitForTimeout(300); + + await expect(page.locator('.BRolMobileResultNav')).toBeHidden(); + await expect(page.locator('.BRolMobileResults')).toBeVisible(); + }); + + test('Cancel clears nav bar state', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await searchAndClickResult(page); + + await expect(page.locator('.BRolMobileResultNav')).toBeVisible(); + + await page.locator('.BRolMobileCancelBtn').click(); + await page.waitForTimeout(200); + + await expect(page.locator('.BRolMobileResultNav')).toBeHidden(); + await expect(page.locator('.BRolMobileRow2')).toBeHidden(); + }); + + test('new search clears nav bar state', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await searchAndClickResult(page); + + await expect(page.locator('.BRolMobileResultNav')).toBeVisible(); + + await page.locator('.BRolMobileInput').click(); + await page.locator('.BRolMobileInput').fill('hat'); + await page.locator('.BRolMobileInput').press('Enter'); + + await expect(page.locator('.BRolMobileResultNav')).toBeHidden(); + }); + + test('search term is preserved in the input while nav bar is showing', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await page.locator('.BRolMobileSearchBtn').click(); + await page.locator('.BRolMobileInput').fill(MOCK_SEARCH_TERM); + await page.locator('.BRolMobileInput').press('Enter'); + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 10_000 }); + await page.locator('.BRolMobileResultItem').first().click(); + await page.waitForTimeout(400); + + await expect(page.locator('.BRolMobileResultNav')).toBeVisible(); + + await page.locator('.BRolMobileInput').click(); + await page.waitForTimeout(200); + + const valueAfterClick = await page.locator('.BRolMobileInput').inputValue(); + expect(valueAfterClick).toBe(MOCK_SEARCH_TERM); + }); + + test('clicking outside search area (on book) collapses results and restores preview', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await page.locator('.BRolMobileSearchBtn').click(); + await page.locator('.BRolMobileInput').fill(MOCK_SEARCH_TERM); + await page.locator('.BRolMobileInput').press('Enter'); + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 10_000 }); + + await page.locator('.BRolMobileResultItem').first().click(); + await page.waitForTimeout(400); + await expect(page.locator('.BRolMobileResultNav')).toBeVisible(); + + await page.locator('.BRolMobileInput').click(); + await page.waitForTimeout(200); + await expect(page.locator('.BRolMobileResults')).toBeVisible(); + + const bookBox = await page.locator('.BRcontainer').boundingBox(); + if (bookBox) { + await page.mouse.click( + bookBox.x + bookBox.width / 2, + bookBox.y + bookBox.height / 3, + ); + await page.waitForTimeout(300); + + await expect(page.locator('.BRolMobileResults')).toBeHidden(); + await expect(page.locator('.BRolMobileResultNav')).toBeVisible(); + } + }); + + test('nav bar does not occlude the book (wrapper bottom <= BRcontainer top)', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await searchAndClickResult(page); + + await expect(page.locator('.BRolMobileResultNav')).toBeVisible(); + + const { wrapperBottom, containerTop } = await getLayoutYs(page); + expect(containerTop).toBeGreaterThanOrEqual(wrapperBottom - 2); + }); +}); + +// --------------------------------------------------------------------------- +// 6. URL pre-fill: ?q= on load opens search bar with pre-filled term +// --------------------------------------------------------------------------- + +test.describe('URL pre-fill — ?q= opens search bar on load', () => { + const DEMO_PATH_WITH_Q = `/BookReaderDemo/demo-internetarchive.html?ocaid=${OCAID}&ref=ol&q=${MOCK_SEARCH_TERM}`; + + test('search row is visible on load when ?q= is in the URL', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH_WITH_Q); + await waitForMobileUI(page); + await expect(page.locator('.BRolMobileRow2')).toBeVisible({ timeout: 10_000 }); + }); + + test('search input is pre-filled with the URL query term', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH_WITH_Q); + await waitForMobileUI(page); + await page.locator('.BRolMobileRow2').waitFor({ state: 'visible', timeout: 10_000 }); + const value = await page.locator('.BRolMobileInput').inputValue(); + expect(value).toBe(MOCK_SEARCH_TERM); + }); + + test('results appear automatically when ?q= is in the URL', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH_WITH_Q); + await waitForMobileUI(page); + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 15_000 }); + }); + + test('search bar stays closed when ?q= is absent', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await expect(page.locator('.BRolMobileRow2')).toBeHidden(); + }); +}); + +// --------------------------------------------------------------------------- +// 6b. Page navigation scrubber +// +// After BookReader:PostInit, the OL mobile plugin replaces the BRcurrentpage +// display in .BRnavMain .scrubber with: +// ‹ X / N › Page Y : Section Desc +// +// Five clickable elements: ‹, leaf chip, ›, page chip, section chip. +// ‹ and › bracket only the leaf chip; page+section follow to the right. +// The ":" separator only appears when BOTH page AND section are present. +// Page chip: shown only when page number is asserted (not n{idx}). +// Section chip: shown only if br.plugins.chapters._tocEntries is non-empty. +// Section chip truncates with "..." (text-overflow: ellipsis) when too long. +// +// Prev/next buttons navigate by leaf. Each chip is clickable: leaf chip opens +// an inline edit input (Enter = jumpToIndex, Escape = cancel); page chip opens +// the same for jumpToPage. The standard scrubber flip buttons (book_left, +// book_right) are hidden since ‹ › replace them. +// --------------------------------------------------------------------------- + +test.describe('Page navigation bar', () => { + /** Wait for the leaf chip to be populated (happens after BookReader:PostInit). */ + async function waitForPageNav(page) { + await page.waitForSelector('.BRolMobilePageNavLeaf', { timeout: 15_000 }); + } + + test('page nav row is present in BRnavMain scrubber on load', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + // Nav row is injected into the footer (BRnavMain scrubber), not the top bar + const count = await page.locator('.BRnavMain .BRolMobilePageNav').count(); + expect(count).toBe(1); + }); + + test('leaf chip shows 1 / N format on first page', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + const text = await page.locator('.BRolMobilePageNavLeaf').textContent(); + // Should be "1 / N" where N > 0 + expect(text?.trim()).toMatch(/^1 \/ \d+$/); + }); + + test('prev button is disabled on first page', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + const isDisabled = await page.locator('.BRolMobilePageNavPrev').evaluate( + (el) => /** @type {HTMLButtonElement} */ (el).disabled, + ); + expect(isDisabled).toBe(true); + }); + + test('next button is enabled when book has more than one page', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + const isDisabled = await page.locator('.BRolMobilePageNavNext').evaluate( + (el) => /** @type {HTMLButtonElement} */ (el).disabled, + ); + expect(isDisabled).toBe(false); + }); + + test('clicking next advances the leaf chip to 2 / N', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + await page.locator('.BRolMobilePageNavNext').click(); + + // Wait for chip text to update (happens on fragmentChange) + await page.waitForFunction( + () => document.querySelector('.BRolMobilePageNavLeaf')?.textContent?.trim().startsWith('2 /'), + { timeout: 5_000 }, + ); + const text = await page.locator('.BRolMobilePageNavLeaf').textContent(); + expect(text?.trim()).toMatch(/^2 \/ \d+$/); + }); + + test('clicking leaf chip shows an inline input pre-filled with current position', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + await page.locator('.BRolMobilePageNavLeaf').click(); + const input = page.locator('.BRolMobilePageNavInput'); + await expect(input).toBeVisible({ timeout: 2_000 }); + // Pre-filled with "1" (first leaf, 1-based) + const val = await input.inputValue(); + expect(val).toBe('1'); + }); + + test('typing a valid leaf number and pressing Enter navigates and restores chip', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + await page.locator('.BRolMobilePageNavLeaf').click(); + await expect(page.locator('.BRolMobilePageNavInput')).toBeVisible(); + + // Type "3" and press Enter — should navigate to leaf 3 (index 2) + await page.locator('.BRolMobilePageNavInput').fill('3'); + await page.locator('.BRolMobilePageNavInput').press('Enter'); + + // Input gone, chip restored + await expect(page.locator('.BRolMobilePageNavInput')).toBeHidden({ timeout: 2_000 }); + await expect(page.locator('.BRolMobilePageNavLeaf')).toBeVisible(); + }); + + test('pressing Escape cancels leaf edit and restores chip without navigating', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + const chipBefore = await page.locator('.BRolMobilePageNavLeaf').textContent(); + + await page.locator('.BRolMobilePageNavLeaf').click(); + await expect(page.locator('.BRolMobilePageNavInput')).toBeVisible(); + + // Change value but Escape — should NOT navigate + await page.locator('.BRolMobilePageNavInput').fill('99'); + await page.locator('.BRolMobilePageNavInput').press('Escape'); + + await expect(page.locator('.BRolMobilePageNavInput')).toBeHidden({ timeout: 2_000 }); + await expect(page.locator('.BRolMobilePageNavLeaf')).toBeVisible(); + + // Chip text unchanged (still on first page) + const chipAfter = await page.locator('.BRolMobilePageNavLeaf').textContent(); + expect(chipAfter?.trim()).toBe(chipBefore?.trim()); + }); + + test('scrubber flip buttons (book_left, book_right) are hidden', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + await expect(page.locator('.BRnavMobile .book_left')).toBeHidden(); + await expect(page.locator('.BRnavMobile .book_right')).toBeHidden(); + }); + + test('scrubber slider (BRpager) exists in DOM (not removed)', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + // BRnavMobile is docked/hidden by default; BRpager must still exist in the DOM + const count = await page.locator('.BRnavMobile .BRpager').count(); + expect(count).toBeGreaterThan(0); + }); + + test('chapter chip is absent when book has no TOC data', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + // The goody test book has no chapters plugin data — chip should not exist + const count = await page.locator('.BRolMobilePageNavChapter').count(); + expect(count).toBe(0); + }); + + test('bar does not occlude the book (wrapper bottom <= BRcontainer top)', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + const { wrapperBottom, containerTop } = await getLayoutYs(page); + expect(containerTop).toBeGreaterThanOrEqual(wrapperBottom - 2); + }); + + // Plato-style test: with asserted page numbers and TOC data, the full format + // ‹ X / N › Page X : Section Desc is shown with correct structure. + test('full format — Plato book: ‹ leaf › Page : Section all present with correct structure', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + // Inject asserted page numbers and TOC data (like Plato's Republic) + await page.evaluate(() => { + const br = /** @type {any} */ (window).br; + // Asserted page numbers starting from page 5 at leaf 0 + br.book.getPageNum = (idx) => String(idx + 5); + // TOC with one entry at leaf 0 + br.plugins = br.plugins ?? {}; + br.plugins.chapters = { + _tocEntries: [ + { pageIndex: 0, label: 'Book I', title: 'The Republic' }, + ], + }; + window.$?.(document).trigger('BookReader:fragmentChange'); + }); + + await page.waitForFunction( + () => document.querySelector('.BRolMobilePageNavPage') !== null, + { timeout: 5_000 }, + ); + + // 5 clickable elements in order: prev, leaf, next, page, section + await expect(page.locator('.BRolMobilePageNavPrev')).toBeVisible(); + const leafText = await page.locator('.BRolMobilePageNavLeaf').textContent(); + expect(leafText?.trim()).toMatch(/^\d+ \/ \d+$/); + await expect(page.locator('.BRolMobilePageNavNext')).toBeVisible(); + + // Page chip — "Page 5" (leaf 0, page num = 0 + 5 = 5) + const pageText = await page.locator('.BRolMobilePageNavPage').textContent(); + expect(pageText?.trim()).toBe('Page 5'); + + // Colon separator only present when BOTH page AND section exist + const sep = page.locator('.BRolMobilePageNavSep'); + await expect(sep).toBeVisible(); + expect(await sep.textContent()).toBe(':'); + + // Section chip — "Book I The Republic" + const chapterText = await page.locator('.BRolMobilePageNavChapter').textContent(); + expect(chapterText?.trim()).toBe('Book I The Republic'); + }); + + // Regression: no colon separator when only section (no asserted page) + test('colon separator absent when section present but page not asserted', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + await page.evaluate(() => { + const br = /** @type {any} */ (window).br; + // Unasserted pages (default goody book behaviour) + br.book.getPageNum = (idx) => `n${idx}`; + br.plugins = br.plugins ?? {}; + br.plugins.chapters = { _tocEntries: [{ pageIndex: 0, label: 'Gorgias', title: 'On Rhetoric' }] }; + window.$?.(document).trigger('BookReader:fragmentChange'); + }); + + await page.waitForFunction( + () => document.querySelector('.BRolMobilePageNavChapter') !== null, + { timeout: 5_000 }, + ); + + // Page chip must be absent + expect(await page.locator('.BRolMobilePageNavPage').count()).toBe(0); + // Colon separator must be absent + expect(await page.locator('.BRolMobilePageNavSep').count()).toBe(0); + // Section chip must be present + await expect(page.locator('.BRolMobilePageNavChapter')).toBeVisible(); + }); + + // Regression: leaf chip must not be squished to 30px (CSS specificity bug guard). + // BookReader's `.BRcontrols .controls button { width:30px }` must not win over + // our `.BRcontrols .controls .BRolMobilePageNavChip { width:auto }` rule. + test('leaf chip width is not squished to 30px', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + // The leaf chip "1 / 194" should have natural content width, not 30px. + const leafWidth = await page.locator('.BRolMobilePageNavLeaf').evaluate( + (el) => Math.round(el.getBoundingClientRect().width), + ); + // "1 / N" at 13px is ~50-70px. Anything > 35 means the specificity fix works. + expect(leafWidth).toBeGreaterThan(35); + }); + + // Regression: all three chips (leaf, page, section) must not be squished to 30px. + // Previously `.BRcontrols .controls button { width:30px }` (specificity 0,2,1) + // beat our `.BRcontrols .controls .BRolMobilePageNavChip { width:auto }` (0,3,0). + test('all chips properly sized with asserted pages and TOC', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + // Inject asserted pages + TOC so all three chips are rendered + await page.evaluate(() => { + const br = /** @type {any} */ (window).br; + br.book.getPageNum = (idx) => String(idx + 5); + br.plugins = br.plugins ?? {}; + br.plugins.chapters = { _tocEntries: [{ pageIndex: 0, label: 'Book I', title: 'The Republic' }] }; + window.$?.(document).trigger('BookReader:fragmentChange'); + }); + + await page.waitForFunction( + () => document.querySelector('.BRolMobilePageNavPage') !== null, + { timeout: 5_000 }, + ); + + // All chips must have natural content width, not clamped to 30px + const widths = await page.evaluate(() => { + return [...document.querySelectorAll('.BRolMobilePageNavChip')].map(el => ({ + text: el.textContent?.trim(), + width: Math.round(el.getBoundingClientRect().width), + })); + }); + + expect(widths.length).toBe(3); // leaf, page, section + for (const { text, width } of widths) { + // Each chip must be substantially wider than 30px (content + 16px padding) + expect(width).toBeGreaterThan(35); + expect((text?.length ?? 0)).toBeGreaterThan(0); + } + }); + + test('section chip scrolls suffix horizontally when title is very long', async ({ page }) => { + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + await waitForPageNav(page); + + const longTitle = 'The Very Long Chapter Title That Goes On And On Across The Whole Screen And Beyond'; + + await page.evaluate((title) => { + const br = /** @type {any} */ (window).br; + br.book.getPageNum = (idx) => String(idx + 5); + br.plugins = br.plugins ?? {}; + br.plugins.chapters = { _tocEntries: [{ pageIndex: 0, label: 'Chapter I', title }] }; + window.$?.(document).trigger('BookReader:fragmentChange'); + }, longTitle); + + await page.waitForFunction( + () => document.querySelector('.BRolMobilePageNavChapter') !== null, + { timeout: 5_000 }, + ); + + const result = await page.evaluate(() => { + const chip = document.querySelector('.BRolMobilePageNavChapter'); + const suffix = document.querySelector('.BRolMobilePageNavSuffix'); + if (!chip || !suffix) return null; + const chipRect = chip.getBoundingClientRect(); + const suffixStyle = window.getComputedStyle(suffix); + return { + chipWidth: chipRect.width, + suffixScrollWidth: suffix.scrollWidth, + suffixClientWidth: suffix.clientWidth, + overflowX: suffixStyle.overflowX, + }; + }); + + // The chip is wider than the visible suffix area — suffix scrolls to reveal it + expect(result).not.toBeNull(); + expect(result.chipWidth).toBeGreaterThan(result.suffixClientWidth); + expect(result.suffixScrollWidth).toBeGreaterThan(result.suffixClientWidth); + expect(result.overflowX).toBe('auto'); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Cancel removes in-book search highlights +// --------------------------------------------------------------------------- + +test.describe('Cancel — removes in-book search highlights', () => { + test('Cancel removes search result pins from the book', async ({ page }) => { + await mockSearch(page); + await page.goto(DEMO_PATH); + await waitForMobileUI(page); + + await page.locator('.BRolMobileSearchBtn').click(); + await page.locator('.BRolMobileInput').fill(MOCK_SEARCH_TERM); + await page.locator('.BRolMobileInput').press('Enter'); + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 10_000 }); + + const termBefore = await page.evaluate( + () => /** @type {any} */ (window).br?.plugins?.search?.searchTerm ?? '', + ); + expect(termBefore).toBe(MOCK_SEARCH_TERM); + + await page.locator('.BRolMobileCancelBtn').click(); + await page.waitForTimeout(300); + + const termAfter = await page.evaluate( + () => /** @type {any} */ (window).br?.plugins?.search?.searchTerm ?? '', + ); + expect(termAfter == null || termAfter === '').toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 8. URL-driven search pre-fill (?q= and ?result=N) +// --------------------------------------------------------------------------- + +test.describe('URL-driven search pre-fill', () => { + test('?q=TERM opens mobile search UI immediately on load', async ({ page }) => { + await mockSearch(page); + await page.goto(`${DEMO_PATH}&q=${MOCK_SEARCH_TERM}`); + await waitForMobileUI(page); + + // Search row (Row 2) must be visible without the user clicking the search button + await expect(page.locator('.BRolMobileRow2')).toBeVisible({ timeout: 5_000 }); + + // Search input must be pre-filled with the term + const inputVal = await page.locator('.BRolMobileInput').inputValue(); + expect(inputVal).toBe(MOCK_SEARCH_TERM); + + // Search button must be in the active/pressed state + const isPressed = await page.locator('.BRolMobileSearchBtn').getAttribute('aria-pressed'); + expect(isPressed).toBe('true'); + }); + + test('?q=TERM shows results panel once search completes', async ({ page }) => { + await mockSearch(page); + await page.goto(`${DEMO_PATH}&q=${MOCK_SEARCH_TERM}`); + await waitForMobileUI(page); + + // Results should appear automatically (no user interaction required) + await expect(page.locator('.BRolMobileResults')).toBeVisible({ timeout: 10_000 }); + const headerText = await page.locator('.BRolMobileResultsHeader').textContent(); + expect(headerText).toMatch(/\d+ Results?/); + }); + + test('?result=N auto-navigates to the Nth search result', async ({ page }) => { + await mockSearch(page); + // result=2 → 0-based index 1 → second match + await page.goto(`${DEMO_PATH}&q=${MOCK_SEARCH_TERM}&result=2`); + await waitForMobileUI(page); + + // Result nav bar should appear (auto-navigated to result 2) + await expect(page.locator('.BRolMobileResultNav')).toBeVisible({ timeout: 10_000 }); + + // Position indicator should show (2/2) — second of two mock results + const posText = await page.locator('.BRolMobileResultNavPos').textContent(); + expect(posText?.trim()).toBe('(2/2)'); + + // Prev button enabled (we're not at result 1) + const prevDisabled = await page.locator('.BRolMobileResultNavPrev').getAttribute('disabled'); + expect(prevDisabled).toBeNull(); + + // Next button disabled (we're at the last result) + const nextDisabled = await page.locator('.BRolMobileResultNavNext').getAttribute('disabled'); + expect(nextDisabled).not.toBeNull(); + }); + + test('?result=N out of range clamps to last result', async ({ page }) => { + await mockSearch(page); + await page.goto(`${DEMO_PATH}&q=${MOCK_SEARCH_TERM}&result=999`); + await waitForMobileUI(page); + + await expect(page.locator('.BRolMobileResultNav')).toBeVisible({ timeout: 10_000 }); + const posText = await page.locator('.BRolMobileResultNavPos').textContent(); + // Mock has 2 results — should clamp to result 2 + expect(posText?.trim()).toBe('(2/2)'); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 2d966c3e8..95d7914e1 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -54,6 +54,7 @@ export default [ 'plugins/plugin.iiif.js': { import: './src/plugins/plugin.iiif.js', dependOn: 'BookReader.js' }, 'plugins/plugin.resume.js': { import: './src/plugins/plugin.resume.js', dependOn: 'BookReader.js' }, 'plugins/plugin.search.js': { import: './src/plugins/search/plugin.search.js', dependOn: 'BookReader.js' }, + 'plugins/plugin.search.ol-mobile.js': { import: './src/plugins/search/plugin.search.ol-mobile.js', dependOn: 'BookReader.js' }, 'plugins/plugin.text_selection.js': { import: './src/plugins/plugin.text_selection.js', dependOn: 'BookReader.js' }, 'plugins/plugin.translate.js': { import: './src/plugins/translate/plugin.translate.js', dependOn: 'BookReader.js' }, 'plugins/translator-worker.js': { import: '@internetarchive/bergamot-translator/worker/translator-worker.js' },