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 = `
+
+ ${IA_LOGO_SVG}
+
+
+
+
+
+
+
+ `;
+
+ // 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 = `
+
+
+ `;
+
+ // 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 = `
+
+
+ `;
+
+ 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' },