@elibrary-inno/bookreader
Version:
The Internet Archive BookReader.
487 lines (436 loc) • 16.5 kB
JavaScript
// @ts-check
/* global BookReader */
/**
* Plugin for Archive.org book search
* Events fired at various points throughout search processing are published
* on the document DOM element. These can be subscribed to using jQuery's event
* binding method `$.fn.on`. All of the events are prefixed with a BookReader
* namespace. The events are:
*
* @event BookReader:SearchStarted - When a search form is submitted, immediately
* before an AJAX call is made to request search results
* @event BookReader:SearchCallback - When the search AJAX call is returned and at
* least one result is returned. The event callback receives an object
* with the `results`, plugin `options`, and the BookReader `instance`
* @event BookReader:SearchCallbackError - When the AJAX request returns an error.
* Receives the `results` and `instance`
* @event BookReader:SearchCallbackNotIndexed - When a message is received that
* the book has not had OCR text indexed yet. Receives `instance`
* @event BookReader:SearchCallbackEmpty - When no results found. Receives
* `instance`
* @event BookReader:SearchCanceled - When no results found. Receives
* `instance`
*/
import { poll } from '../../BookReader/utils.js';
import { renderBoxesInPageContainerLayer } from '../../BookReader/PageContainer.js';
import SearchView from './view.js';
import { marshallSearchResults } from './utils.js';
/** @typedef {import('../../BookReader/PageContainer').PageContainer} PageContainer */
/** @typedef {import('../../BookReader/BookModel').PageIndex} PageIndex */
/** @typedef {import('../../BookReader/BookModel').LeafNum} LeafNum */
/** @typedef {import('../../BookReader/BookModel').PageNumString} PageNumString */
jQuery.extend(BookReader.defaultOptions, {
server: 'ia600609.us.archive.org',
bookId: '',
subPrefix: '',
bookPath: '',
enableSearch: true,
searchInsideProtocol: 'https',
searchInsideUrl: '/fulltext/inside.php',
searchInsidePreTag: '{{{',
searchInsidePostTag: '}}}',
initialSearchTerm: null,
});
/** @override */
BookReader.prototype.setup = (function (super_) {
return function (options) {
super_.call(this, options);
this.searchTerm = '';
this.searchResults = null;
this.searchInsideUrl = options.searchInsideUrl;
this.enableSearch = options.enableSearch;
// Base server used by some api calls
this.bookId = options.bookId;
this.server = options.server;
this.subPrefix = options.subPrefix;
this.bookPath = options.bookPath;
this.searchXHR = null;
this._cancelSearch.bind(this);
this.cancelSearchRequest.bind(this);
/** @type { {[pageIndex: number]: SearchInsideMatchBox[]} } */
this._searchBoxesByIndex = {};
this.searchView = undefined;
};
})(BookReader.prototype.setup);
/** @override */
BookReader.prototype.init = (function (super_) {
return function () {
super_.call(this);
// give SearchView the most complete bookreader state
this.searchView = new SearchView({
br: this,
searchCancelledCallback: () => {
this._cancelSearch();
this.trigger('SearchCanceled', { term: this.searchTerm, instance: this });
}
});
if (this.options.enableSearch && this.options.initialSearchTerm) {
/**
* this.search() take two parameter
* 1. this.options.initialSearchTerm - search term
* 2. {
* goToFirstResult: this.options.goToFirstResult,
* suppressFragmentChange: false // always want to change fragment in URL
* }
*/
this.search(
this.options.initialSearchTerm,
{ goToFirstResult: this.options.goToFirstResult, suppressFragmentChange: false }
);
}
};
})(BookReader.prototype.init);
/** @override */
BookReader.prototype.buildToolbarElement = (function (super_) {
return function () {
const $el = super_.call(this);
if (!this.enableSearch) { return; }
if (this.searchView.dom.toolbarSearch) {
$el.find('.BRtoolbarSectionInfo').after(this.searchView.dom.toolbarSearch);
}
return $el;
};
})(BookReader.prototype.buildToolbarElement);
/** @override */
BookReader.prototype._createPageContainer = (function (super_) {
return function (index) {
const pageContainer = super_.call(this, index);
if (this.enableSearch && pageContainer.page && index in this._searchBoxesByIndex) {
const pageIndex = pageContainer.page.index;
const boxes = this._searchBoxesByIndex[pageIndex];
renderBoxesInPageContainerLayer(
'searchHiliteLayer',
boxes,
pageContainer.page,
pageContainer.$container[0],
boxes.map(b => `match-index-${b.matchIndex}`),
);
}
return pageContainer;
};
})(BookReader.prototype._createPageContainer);
/**
* @typedef {object} SearchOptions
* @property {boolean} goToFirstResult
* @property {boolean} disablePopup
* @property {(null|function)} error - @deprecated at v.5.0
* @property {(null|function)} success - @deprecated at v.5.0
*/
/**
* Submits search request
*
* @param {string} term
* @param {SearchOptions} overrides
*/
BookReader.prototype.search = async function(term = '', overrides = {}) {
/** @type {SearchOptions} */
const defaultOptions = {
goToFirstResult: false, /* jump to the first result (default=false) */
disablePopup: false, /* don't show the modal progress (default=false) */
suppressFragmentChange: false, /* don't change the URL on initial load */
error: null, /* optional error handler (default=null) */
success: null, /* optional success handler (default=null) */
};
const options = jQuery.extend({}, defaultOptions, overrides);
this.suppressFragmentChange = options.suppressFragmentChange;
this.searchCancelled = false;
// strip slashes, since this goes in the url
this.searchTerm = term.replace(/\//g, ' ');
if (!options.suppressFragmentChange) {
this.trigger(BookReader.eventNames.fragmentChange);
}
// Add quotes to the term. This is to compenstate for the backends default OR query
// term = term.replace(/['"]+/g, '');
// term = '"' + term + '"';
// Remove the port and userdir
const serverPath = this.server.replace(/:.+/, '');
const baseUrl = `${this.options.searchInsideProtocol}://${serverPath}${this.searchInsideUrl}?`;
// Remove subPrefix from end of path
let path = this.bookPath;
const subPrefixWithSlash = `/${this.subPrefix}`;
if (this.bookPath.length - this.bookPath.lastIndexOf(subPrefixWithSlash) == subPrefixWithSlash.length) {
path = this.bookPath.substr(0, this.bookPath.length - subPrefixWithSlash.length);
}
const urlParams = {
item_id: this.bookId,
doc: this.subPrefix,
path,
q: term,
pre_tag: this.options.searchInsidePreTag,
post_tag: this.options.searchInsidePostTag,
};
// NOTE that the API does not expect / (slashes) to be encoded. (%2F) won't work
const paramStr = $.param(urlParams).replace(/%2F/g, '/');
const url = `${baseUrl}${paramStr}`;
const callSearchResultsCallback = (searchInsideResults) => {
if (this.searchCancelled) {
return;
}
const responseHasError = searchInsideResults.error || !searchInsideResults.matches.length;
const hasCustomError = typeof options.error === 'function';
const hasCustomSuccess = typeof options.success === 'function';
if (responseHasError) {
console.error('Search Inside Response Error', searchInsideResults.error || 'matches.length == 0');
hasCustomError
? options.error.call(this, searchInsideResults, options)
: this.BRSearchCallbackError(searchInsideResults, options);
} else {
hasCustomSuccess
? options.success.call(this, searchInsideResults, options)
: this.BRSearchCallback(searchInsideResults, options);
}
};
this.trigger('SearchStarted', { term: this.searchTerm, instance: this });
callSearchResultsCallback(await $.ajax({
url: url,
dataType: 'jsonp',
cache: true,
beforeSend: xhr => { this.searchXHR = xhr; },
}));
};
/**
* cancels AJAX Call
* emits custom event
*/
BookReader.prototype._cancelSearch = function () {
this.searchXHR?.abort();
this.searchView.clearSearchFieldAndResults(false);
this.searchTerm = '';
this.searchXHR = null;
this.searchCancelled = true;
this.searchResults = [];
};
/**
* External function to cancel search
* checks for term & xhr in flight before running
*/
BookReader.prototype.cancelSearchRequest = function () {
this.searchCancelled = true;
if (this.searchXHR !== null) {
this._cancelSearch();
this.searchView.toggleSearchPending();
this.trigger('SearchCanceled', { term: this.searchTerm, instance: this });
}
};
/**
* @typedef {object} SearchInsideMatchBox
* @property {number} page
* @property {number} r
* @property {number} l
* @property {number} b
* @property {number} t
* @property {HTMLDivElement} [div]
* @property {number} matchIndex This is a fake field! not part of the API response. The index of the match that contains this box in total search results matches.
*/
/**
* @typedef {object} SearchInsideMatch
* @property {number} matchIndex This is a fake field! Not part of the API response. It is added by the JS.
* @property {string} displayPageNumber (fake field) The page number as it should be displayed in the UI.
* @property {string} html (computed field) The html-escaped raw html to display in the UI.
* @property {string} text
* @property {Array<{ page: number, boxes: SearchInsideMatchBox[] }>} par
*/
/**
* @typedef {object} SearchInsideResults
* @property {string} error
* @property {SearchInsideMatch[]} matches
* @property {boolean} indexed
*/
/**
* Search Results return handler
* @param {SearchInsideResults} results
* @param {object} options
* @param {boolean} options.goToFirstResult
*/
BookReader.prototype.BRSearchCallback = function(results, options) {
marshallSearchResults(
results,
pageNum => this.book.getPageNum(this.book.leafNumToIndex(pageNum)),
this.options.searchInsidePreTag,
this.options.searchInsidePostTag,
);
this.searchResults = results || [];
this.updateSearchHilites();
this.removeProgressPopup();
if (options.goToFirstResult) {
this._searchPluginGoToResult(0);
}
this.trigger('SearchCallback', { results, options, instance: this });
};
/**
* Main search results error handler
* @callback
* @param {SearchInsideResults} results
*/
BookReader.prototype.BRSearchCallbackError = function(results) {
this._BRSearchCallbackError(results);
};
/**
* @private draws search results error
* @callback
* @param {SearchInsideResults} results
* @param {jQuery} $el
* @param {boolean} fade
*/
BookReader.prototype._BRSearchCallbackError = function(results) {
this.searchResults = results;
const basePayload = {
term: this.searchTerm,
instance: this,
};
if (results.error) {
const payload = Object.assign({}, basePayload, { results });
this.trigger('SearchCallbackError', payload);
} else if (0 == results.matches.length) {
if (false === results.indexed) {
this.trigger('SearchCallbackBookNotIndexed', basePayload);
return;
}
this.trigger('SearchCallbackEmpty', basePayload);
}
};
/**
* updates search on-page highlights controller
*/
BookReader.prototype.updateSearchHilites = function() {
/** @type {SearchInsideMatch[]} */
const matches = this.searchResults?.matches || [];
/** @type { {[pageIndex: number]: SearchInsideMatchBox[]} } */
const boxesByIndex = {};
// Clear any existing svg layers
this.removeSearchHilites();
// Group by pageIndex
for (const match of matches) {
for (const box of match.par[0].boxes) {
const pageIndex = this.book.leafNumToIndex(box.page);
const pageBoxes = boxesByIndex[pageIndex] || (boxesByIndex[pageIndex] = []);
pageBoxes.push(box);
}
}
// update any already created pages
for (const [pageIndexString, boxes] of Object.entries(boxesByIndex)) {
const pageIndex = parseFloat(pageIndexString);
const page = this.book.getPage(pageIndex);
const pageContainers = this.getActivePageContainerElementsForIndex(pageIndex);
for (const container of pageContainers) {
renderBoxesInPageContainerLayer('searchHiliteLayer', boxes, page, container, boxes.map(b => `match-index-${b.matchIndex}`));
}
}
this._searchBoxesByIndex = boxesByIndex;
};
/**
* remove search highlights
*/
BookReader.prototype.removeSearchHilites = function() {
$(this.getActivePageContainerElements()).find('.searchHiliteLayer').remove();
};
/**
* @private
* Goes to the page specified. If the page is not viewable, tries to load the page
* FIXME Most of this logic is IA specific, and should be less integrated into here
* or at least more configurable.
* @param {number} matchIndex
*/
BookReader.prototype._searchPluginGoToResult = async function (matchIndex) {
const match = this.searchResults?.matches[matchIndex];
const book = this.book;
const pageIndex = book.leafNumToIndex(match.par[0].page);
const page = book.getPage(pageIndex);
const onNearbyPage = Math.abs(this.currentIndex() - pageIndex) < 3;
let makeUnviewableAtEnd = false;
if (!page.isViewable) {
const resp = await fetch('/services/bookreader/request_page?' + new URLSearchParams({
id: this.options.bookId,
subprefix: this.options.subPrefix,
leafNum: page.leafNum,
})).then(r => r.json());
for (const leafNum of resp.value) {
book.getPage(book.leafNumToIndex(leafNum)).makeViewable();
}
// not able to show page; make the page viewable anyways so that it can
// actually open. On IA, it has a fallback to a special error page.
if (!resp.value.length) {
book.getPage(pageIndex).makeViewable();
makeUnviewableAtEnd = true;
}
// Trigger an update of book
this._modes.mode1Up.mode1UpLit.updatePages();
if (this.activeMode == this._modes.mode1Up) {
await this._modes.mode1Up.mode1UpLit.updateComplete;
}
}
/* this updates the URL */
if (!this._isIndexDisplayed(pageIndex)) {
this.suppressFragmentChange = false;
this.jumpToIndex(pageIndex);
}
// Reset it to unviewable if it wasn't resolved
if (makeUnviewableAtEnd) {
book.getPage(pageIndex).makeViewable(false);
}
// Scroll/flash in the ui
const $boxes = await poll(() => $(`rect.match-index-${match.matchIndex}`), { until: result => result.length > 0 });
if ($boxes.length) {
$boxes.css('animation', 'none');
$boxes[0].scrollIntoView({
// Only vertically center the highlight if we're in 1up or in full screen. In
// 2up, if we're not fullscreen, the whole body gets scrolled around to try to
// center the highlight 🙄 See:
// https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move/11041376
// Note: nearest doesn't quite work great, because the ReadAloud toolbar is now
// full-width, and covers up the last line of the highlight.
block: this.constMode1up == this.mode || this.isFullscreenActive ? 'center' : 'nearest',
inline: 'center',
behavior: onNearbyPage ? 'smooth' : 'auto',
});
// wait for animation to start
await new Promise(resolve => setTimeout(resolve, 100));
$boxes.removeAttr("style");
}
};
/**
* Removes all search pins
*/
BookReader.prototype.removeSearchResults = function(suppressFragmentChange = false) {
this.removeSearchHilites(); //be sure to set all box.divs to null
this.searchTerm = null;
this.searchResults = null;
if (!suppressFragmentChange) {
this.trigger(BookReader.eventNames.fragmentChange);
}
};
/**
* Returns true if a search highlight is currently being displayed
* @returns {boolean}
*/
BookReader.prototype.searchHighlightVisible = function() {
const results = this.searchResults;
let visiblePages = [];
if (null == results) return false;
if (this.constMode2up == this.mode) {
visiblePages = [this.twoPage.currentIndexL, this.twoPage.currentIndexR];
} else if (this.constMode1up == this.mode) {
visiblePages = [this.currentIndex()];
} else {
return false;
}
results.matches.some(match => {
return match.par[0].boxes.some(box => {
const pageIndex = this.book.leafNumToIndex(box.page);
if (jQuery.inArray(pageIndex, visiblePages) >= 0) {
return true;
}
});
});
return false;
};