/* This file is part of Ezra Bible App.
Copyright (C) 2019 - 2023 Ezra Bible App Development Team <contact@ezrabibleapp.net>
Ezra Bible App is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
Ezra Bible App is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Ezra Bible App. See the file LICENSE.
If not, see <http://www.gnu.org/licenses/>. */
const VerseSearch = require('./verse_search.js');
const { waitUntilIdle } = require('../../helpers/ezra_helper.js');
const eventController = require('../../controllers/event_controller.js');
const verseListController = require('../../controllers/verse_list_controller.js');
/**
* The TabSearch component implements the in-tab search functionality which is enabled with CTRL + f / CMD + f.
*
* @category Component
*/
class TabSearch {
constructor() {
}
init(parentTab,
searchForm,
searchInput,
searchOccurancesElement,
prevButton,
nextButton,
caseSensitiveCheckbox,
searchTypeSelect) {
this.parentTab = parentTab;
this.searchForm = parentTab.find(searchForm);
this.inputField = parentTab.find(searchInput);
this.searchOccurancesElement = parentTab.find(searchOccurancesElement);
this.prevButton = parentTab.find(prevButton);
this.nextButton = parentTab.find(nextButton);
this.caseSensitiveCheckbox = parentTab.find(caseSensitiveCheckbox);
this.searchTypeSelect = parentTab.find(searchTypeSelect);
this.currentOccuranceIndex = 0;
this.currentOccurancesCount = 0;
this.allOccurances = [];
this.previousOccuranceElement = null;
this.currentOccuranceElement = null;
this.lastSearchString = null;
this.mouseTrapEvent = false;
this.shiftKeyPressed = false;
this.searchTimeoutMs = 400;
if (platformHelper.isCordova()) {
this.searchTimeoutMs = 800;
}
this.verseSearch = new VerseSearch();
this.initInputField();
this.initSearchOptionControls();
this.initNavigationButtons();
var currentVerseList = parentTab.find('.verse-list');
this.setVerseList(currentVerseList);
eventController.subscribe('on-body-clicked', () => {
this.blurInputField();
});
}
initInputField() {
this.inputField.bind('keydown', (e) => {
if (e.key == 'Shift') {
this.shiftKeyPressed = true;
return;
}
});
this.inputField.bind('keyup', (e) => {
if (e.key == 'Shift') {
this.shiftKeyPressed = false;
return;
}
if (e.key == 'Escape') {
this.resetSearch();
return;
}
if (e.key == 'Enter') {
if (this.mouseTrapEvent) { // We also catch keypresses with Mousetrap (globally, see AppController.initGlobalShortCuts)
// To ensure that we do not process the key press twice we return immediately if we know
// that a mouse trap event was fired previously.
this.mouseTrapEvent = false;
return;
}
this.jumpToNextOccurance(!this.shiftKeyPressed);
return;
}
this.triggerDelayedSearch();
});
}
blurInputField() {
this.inputField.blur();
}
initSearchOptionControls() {
this.caseSensitiveCheckbox.bind('change', () => {
this.lastSearchString = null;
this.triggerDelayedSearch();
});
this.searchTypeSelect.bind('change', () => {
this.lastSearchString = null;
this.triggerDelayedSearch();
});
}
initNavigationButtons() {
this.prevButton.bind('click', () => {
this.jumpToNextOccurance(false);
});
this.nextButton.bind('click', () => {
this.jumpToNextOccurance();
});
}
show() {
var verseListFrame = verseListController.getCurrentVerseListFrame();
verseListFrame.addClass('tab-search-active');
this.searchForm.css('display', 'flex');
}
hide() {
var verseListFrame = verseListController.getCurrentVerseListFrame();
verseListFrame.removeClass('tab-search-active');
this.searchForm.hide();
}
focus() {
this.inputField.focus();
}
setVerseList(verseList) {
this.verseList = verseList;
}
getSearchType() {
var selectedValue = this.searchTypeSelect[0].options[this.searchTypeSelect[0].selectedIndex].value;
return selectedValue;
}
isCaseSensitive() {
return this.caseSensitiveCheckbox.prop("checked");
}
triggerDelayedSearch() {
clearTimeout(this.searchTimeout);
var searchString = this.inputField.val();
if (searchString.length < 3) {
this.resetOccurances();
return;
}
if (searchString == this.lastSearchString) {
return;
}
this.searchTimeout = setTimeout(async () => {
app_controller.verse_selection.clearVerseSelection(false);
await eventController.publishAsync('on-tab-search-reset');
this.lastSearchString = searchString;
await this.doSearch(searchString);
// This is necessary, beause the search "rewrites" the verse content and events
// get lost by doing that, so we have to re-bind the xref events.
verseListController.bindXrefEvents();
if (!platformHelper.isCordova()) {
this.focus();
}
}, this.searchTimeoutMs);
}
resetOccurances() {
if (this.currentOccurancesCount > 0) {
this.removeAllHighlighting();
}
this.allOccurances = [];
this.currentOccurancesCount = 0;
this.updateOccurancesLabel();
eventController.publish('on-tab-search-reset');
}
resetSearch() {
this.resetOccurances();
if (!app_controller.optionsMenu._tabSearchOption.isChecked) {
this.hide();
}
this.inputField[0].value = '';
this.lastSearchString = null;
this.mouseTrapEvent = false;
this.shiftKeyPressed = false;
}
async jumpToNextOccurance(forward=true) {
if (this.currentOccurancesCount == 0) {
return;
}
this.previousOccuranceElement = this.allOccurances[this.currentOccuranceIndex];
var increment = 1;
if (!forward) {
increment = -1;
}
var inBounds = false;
if (forward && (this.currentOccuranceIndex < (this.allOccurances.length - 1))) {
inBounds = true;
}
if (!forward && (this.currentOccuranceIndex > 0)) {
inBounds = true;
}
if (inBounds) {
this.currentOccuranceIndex += increment;
} else {
if (forward) { // jump to the beginning when going forward at the end
this.currentOccuranceIndex = 0;
} else { // jump to the end when going backwards in the beginning
this.currentOccuranceIndex = this.allOccurances.length - 1;
}
}
this.jumpToCurrentOccurance();
await this.highlightCurrentOccurance();
if (!platformHelper.isCordova()) {
this.focus();
}
await waitUntilIdle();
}
jumpToCurrentOccurance() {
// Jump to occurrence in window
this.currentOccuranceElement = this.allOccurances[this.currentOccuranceIndex];
var currentOccuranceVerseBox = this.currentOccuranceElement.closest('.verse-box');
if (currentOccuranceVerseBox != null) {
var currentOccuranceAnchor = '#' + currentOccuranceVerseBox.querySelector('a').getAttribute('name');
window.location = currentOccuranceAnchor;
}
}
async highlightCurrentOccurance() {
// Remove previous element's highlighting
if (this.previousOccuranceElement != null) {
this.previousOccuranceElement.classList.remove('current-hl');
let closestVerseBox = this.previousOccuranceElement.closest('.verse-box');
if (closestVerseBox != null) closestVerseBox.querySelector('.verse-text').classList.remove('ui-selected');
app_controller.verse_selection.clearVerseSelection(false);
}
// Highlight current element
if (this.currentOccuranceElement != null) {
this.currentOccuranceElement.classList.add('current-hl');
let verseBox = this.currentOccuranceElement.closest('.verse-box');
if (verseBox != null) {
verseBox.querySelector('.verse-text').classList.add('ui-selected');
app_controller.verse_selection.updateSelected();
app_controller.verse_selection.updateViewsAfterVerseSelection();
await app_controller.navigation_pane.updateNavigationFromVerseBox(this.currentOccuranceElement, verseBox);
}
}
this.updateOccurancesLabel();
}
updateOccurancesLabel() {
var occurrencesString = "";
if (this.currentOccurancesCount > 0) {
let currentOccuranceNumber = this.currentOccuranceIndex + 1;
occurrencesString = currentOccuranceNumber + '/' + this.currentOccurancesCount;
}
this.searchOccurancesElement[0].innerHTML = occurrencesString;
}
async doSearch(searchString) {
if (this.verseList == null) {
return;
}
var searchType = this.getSearchType();
var caseSensitive = this.isCaseSensitive();
var allVerses = this.verseList[0].querySelectorAll('.verse-text');
this.currentOccuranceIndex = 0;
this.currentOccurancesCount = 0;
this.allOccurances = [];
//console.log("Found " + allVerses.length + " verses to search in.");
this.removeHighlightingFromVerses(allVerses);
allVerses.forEach((currentVerse) => {
this.currentOccurancesCount += this.verseSearch.doVerseSearch(currentVerse, searchString, searchType, caseSensitive);
});
this.allOccurances = this.verseList[0].querySelectorAll('.search-hl.first');
this.currentOccuranceElement = this.allOccurances[this.currentOccuranceIndex];
if (this.allOccurances.length > 0) {
this.jumpToCurrentOccurance();
this.highlightCurrentOccurance();
} else {
this.resetOccurances();
}
await eventController.publishAsync('on-on-tab-search-results-available', this.allOccurances);
}
removeAllHighlighting() {
if (this.verseList != null) {
for (let i = 0; i < this.allOccurances.length; i++) {
let currentOccuranceVerseBox = this.allOccurances[i].closest('.verse-text');
this.removeHighlightingFromVerses([currentOccuranceVerseBox]);
}
}
}
removeHighlightingFromVerses(verseElements) {
if (verseElements == null) {
return;
}
var searchHl = $(verseElements).find('.search-hl, .current-hl');
for (let i = 0; i < searchHl.length; i++) {
let highlightedText = $(searchHl[i])[0];
let text = document.createTextNode(highlightedText.innerText);
if (highlightedText.parentNode.nodeName == 'SPAN') {
highlightedText.parentNode.replaceWith(highlightedText.parentNode.innerText);
} else {
highlightedText.replaceWith(text);
}
}
verseElements.forEach((element) => {
if (element != null) {
let verseElementHtml = element.innerHTML;
/* Remove line breaks between strings, that resulted from inserting the
search-hl / current-hl elements before. If these linebreaks are not removed
the search function would afterwards not work anymore.
State with highlighting:
<span class="search-hl">Christ</span>us
State after highlighting element was removed (see code above)
"Christ"
"us"
State after line break was removed: (intention of code below)
"Christus"
*/
verseElementHtml = verseElementHtml.replace("\"\n\"", "");
element.innerHTML = verseElementHtml;
}
});
}
}
module.exports = TabSearch;
Source