diff --git a/app/frontend/src/Controller/Search/Result.js b/app/frontend/src/Controller/Search/Result.js new file mode 100644 index 00000000..2d54b260 --- /dev/null +++ b/app/frontend/src/Controller/Search/Result.js @@ -0,0 +1,136 @@ +import React, { forwardRef, useContext } from 'react' +import { string, number, shape, bool, func } from 'prop-types' +import classNames from 'classnames' +import { ListItem } from '@material-ui/core' + +import controller from '../../lib/controller' +import { getTranslation, getTransliteration, customiseLine } from '../../lib/utils' +import { WritersContext, RecommendedSourcesContext, SettingsContext } from '../../lib/contexts' +import { LANGUAGE_NAMES, SOURCE_ABBREVIATIONS } from '../../lib/consts' + +/** + * Renders a single result, highlighting the match. + * @param {string} gurmukhi The shabad line to display. + * @param {int} typeId The type id of line. + * @param {string} lineId The id of the line. + * @param {string} shabadId The id of the shabad. + * @param {Component} ref The ref to the component. + * @param {int} sourceId The id of source. + * @param {Object} shabad The object containng section information and other metadata. + * @param {int} sourcePage The page number of shabad in source. + * @param {string} translations The translations of shabad line to display. + * @param {string} transliterations The transliterations of shabad line to display. + */ +const Result = forwardRef( ( { + gurmukhi, + typeId, + id: lineId, + shabadId, + sourceId, + shabad, + focused, + highlighter, + sourcePage, + translations, + transliterations, +}, ref ) => { + const { local: { + sources, + search: { + showResultCitations, + resultTransliterationLanguage, + resultTranslationLanguage, + lineEnding, + }, + } = {} } = useContext( SettingsContext ) + + const writers = useContext( WritersContext ) + const recommendedSources = useContext( RecommendedSourcesContext ) + + const transliteration = resultTransliterationLanguage && transliterations && customiseLine( + getTransliteration( + { transliterations }, + resultTransliterationLanguage, + ), + { lineEnding, typeId }, + ) + + const translation = resultTranslationLanguage && translations && customiseLine( + getTranslation( { + line: { translations }, + shabad: { sourceId }, + recommendedSources, + sources, + languageId: resultTranslationLanguage, + } ), + { lineEnding, typeId }, + ) + + // Separate the line into words before the match, the match, and after the match + const highlight = highlighter( { gurmukhi } ) + const [ beforeMatch, match, afterMatch ] = highlight( gurmukhi ) + const [ translitBeforeMatch, translitMatch, translitAfterMatch ] = highlight( transliteration ) + + // Send the shabad id and line id to the server on click + const onClick = () => controller.shabad( { shabadId, lineId } ) + + // Helper render functions for citation + const showCitation = showResultCitations && shabad && shabad.section + const getEnglish = ( { nameEnglish } ) => nameEnglish + const getWriterName = () => getEnglish( writers[ shabad.writerId ] ) + const getPageName = () => recommendedSources[ shabad.sourceId ].pageNameEnglish + + return ( + +
+ + {beforeMatch && {beforeMatch}} + {match && {match}} + {afterMatch && {afterMatch}} + + + + {translation && ( +
+ {translation} +
+ )} + + {transliteration && ( +
+ {translitBeforeMatch && {translitBeforeMatch}} + {translitMatch && {translitMatch}} + {translitAfterMatch && {translitAfterMatch}} +
+ )} +
+ + {showCitation && ( + + {[ + getWriterName(), + SOURCE_ABBREVIATIONS[ sourceId ], + `${getPageName()} ${sourcePage}`, + ].reduce( ( prev, curr ) => [ prev, ' - ', curr ] )} + + )} +
+
+ ) +} ) + +Result.propTypes = { + gurmukhi: string.isRequired, + id: string.isRequired, + typeId: string.isRequired, + shabadId: string.isRequired, + focused: bool.isRequired, + highlighter: func.isRequired, + sourceId: number.isRequired, + shabad: shape( { } ).isRequired, + sourcePage: number.isRequired, + translations: string.isRequired, + transliterations: string.isRequired, +} + +export default Result diff --git a/app/frontend/src/Controller/Search/Results.js b/app/frontend/src/Controller/Search/Results.js deleted file mode 100644 index b5b0b9cb..00000000 --- a/app/frontend/src/Controller/Search/Results.js +++ /dev/null @@ -1,177 +0,0 @@ -import React, { useContext } from 'react' -import classNames from 'classnames' -import { ListItem, List } from '@material-ui/core' -import { string, oneOfType, number, instanceOf, shape, arrayOf, func } from 'prop-types' - -import controller from '../../lib/controller' -import { getTranslation, getTransliteration, customiseLine } from '../../lib/utils' -import { WritersContext, RecommendedSourcesContext, SettingsContext } from '../../lib/contexts' -import { SEARCH_TYPES, LANGUAGE_NAMES, SEARCH_ANCHORS, SOURCE_ABBREVIATIONS } from '../../lib/consts' - -import highlightMatches from './highlight-matches' - -const Result = ( { results, searchedValue, anchor, register, focused } ) => { - const { local: { - sources, - search: { - showResultCitations, - resultTransliterationLanguage, - resultTranslationLanguage, - lineEnding, - }, - } = {} } = useContext( SettingsContext ) - - const writers = useContext( WritersContext ) - const recommendedSources = useContext( RecommendedSourcesContext ) - - /** - * Renders a single result, highlighting the match. - * @param {string} gurmukhi The shabad line to display. - * @param {int} typeId The type id of line. - * @param {string} lineId The id of the line. - * @param {string} shabadId The id of the shabad. - * @param {Component} ref The ref to the component. - * @param {int} sourceId The id of source. - * @param {Object} shabad The object containng section information and other metadata. - * @param {int} sourcePage The page number of shabad in source. - * @param {string} translations The translations of shabad line to display. - * @param {string} transliterations The transliterations of shabad line to display. - */ - const ResultList = ( { - gurmukhi, - typeId, - id: lineId, - shabadId, - ref, - sourceId, - shabad, - sourcePage, - translations, - transliterations, - } ) => { - const transliteration = resultTransliterationLanguage && transliterations && customiseLine( - getTransliteration( - { transliterations }, - resultTransliterationLanguage, - ), - { lineEnding, typeId }, - ) - - const translation = resultTranslationLanguage && translations && customiseLine( - getTranslation( { - line: { translations }, - shabad: { sourceId }, - recommendedSources, - sources, - languageId: resultTranslationLanguage, - } ), - { lineEnding, typeId }, - ) - - // Grab the search mode or assume it's first letter - const mode = SEARCH_ANCHORS[ anchor ] || SEARCH_TYPES.firstLetter - - // Separate the line into words before the match, the match, and after the match - const getMatches = highlightMatches( gurmukhi ) - - const [ beforeMatch, match, afterMatch ] = getMatches( - gurmukhi, - searchedValue, - mode, - ) - const [ translitBeforeMatch, translitMatch, translitAfterMatch ] = getMatches( - transliteration, - searchedValue, - mode, - ) - - // Send the shabad id and line id to the server on click - const onClick = () => controller.shabad( { shabadId, lineId } ) - - // Helper render functions for citation - const showCitation = showResultCitations && shabad && shabad.section - const getEnglish = ( { nameEnglish } ) => nameEnglish - const getWriterName = () => getEnglish( writers[ shabad.writerId ] ) - const getPageName = () => recommendedSources[ shabad.sourceId ].pageNameEnglish - - return ( - -
- - {beforeMatch && {beforeMatch}} - {match && {match}} - {afterMatch && {afterMatch}} - - - - {translation && ( -
- {translation} -
- )} - - {transliteration && ( -
- {translitBeforeMatch && {translitBeforeMatch}} - {translitMatch && {translitMatch}} - {translitAfterMatch && {translitAfterMatch}} -
- )} -
- - {showCitation && ( - - {[ - getWriterName(), - SOURCE_ABBREVIATIONS[ sourceId ], - `${getPageName()} ${sourcePage}`, - ].reduce( ( prev, curr ) => [ prev, ' - ', curr ] )} - - )} -
-
- ) - } - - ResultList.propTypes = { - gurmukhi: string.isRequired, - id: string.isRequired, - typeId: string.isRequired, - shabadId: string.isRequired, - ref: instanceOf( Result ).isRequired, - sourceId: number.isRequired, - shabad: shape( { } ).isRequired, - sourcePage: number.isRequired, - translations: string.isRequired, - transliterations: string.isRequired, - } - - return ( - - {results - ? results.map( ( props, i ) => ResultList( { - ...props, - ref: c => register( i, c ), - focused: focused === i, - } ) ) - : ''} - - ) -} - -Result.propTypes = { - results: arrayOf( shape( {} ) ), - searchedValue: string, - anchor: string, - register: func.isRequired, - focused: oneOfType( [ string, number ] ), -} - -Result.defaultProps = { - results: [], - searchedValue: undefined, - anchor: undefined, - focused: undefined, -} - -export default Result diff --git a/app/frontend/src/Controller/Search/highlight-matches.js b/app/frontend/src/Controller/Search/highlight-matches.js deleted file mode 100644 index b5146471..00000000 --- a/app/frontend/src/Controller/Search/highlight-matches.js +++ /dev/null @@ -1,57 +0,0 @@ -const { stripVishraams, toAscii, firstLetters, stripAccents, toUnicode } = require( 'gurmukhi-utils' ) -const { SEARCH_TYPES } = require( '../../lib/consts' ) - -const fullWordMatches = ( line, query ) => { - const sanitisedQuery = query.trim() - - const foundPosition = line.search( sanitisedQuery ) - const matchStartPosition = line.lastIndexOf( ' ', foundPosition ) - - const wordEndPosition = line.indexOf( ' ', foundPosition + sanitisedQuery.length ) - // If the match finishes in the last word, no space will be deteced, and wordEndPosition - // will be -1. In this case, we want to end at the last position in the line. - const matchEndPosition = wordEndPosition === -1 ? line.length - 1 : wordEndPosition - - return [ - line.substring( 0, matchStartPosition ), - line.substring( matchStartPosition, matchEndPosition ), - line.substring( matchEndPosition ), - ] -} - -const firstLetterMatches = ( line, query ) => { - const baseLine = stripVishraams( line ) - - const letters = toAscii( firstLetters( stripAccents( toUnicode( baseLine ) ) ) ) - const words = baseLine.split( ' ' ) - - const startPosition = letters.search( stripAccents( query ) ) - const endPosition = startPosition + query.length - - return [ - `${words.slice( 0, startPosition ).join( ' ' )} `, - `${words.slice( startPosition, endPosition ).join( ' ' )} `, - `${words.slice( endPosition ).join( ' ' )} `, - ] -} - -/** - * Separates the line into words before the first match, the first match, and after the match. - * @param value The full line. - * @param input The string inputted by the user. - * @param mode The type of search being performed, either first word or full word. - * @return An array of [ beforeMatch, match, afterMatch ], - * with `match` being the highlighted section.`. - */ -const highlightMatches = gurmukhi => ( value, input, mode ) => { - if ( !value ) return [ '', '', '' ] - - // Account for wildcard characters - const sanitizedInput = input.replace( new RegExp( '_', 'g' ), '.' ) - - return mode === SEARCH_TYPES.fullWord - ? fullWordMatches( gurmukhi, sanitizedInput ) - : firstLetterMatches( value, sanitizedInput ) -} - -export default highlightMatches diff --git a/app/frontend/src/Controller/Search/index.js b/app/frontend/src/Controller/Search/index.js index 80485a9f..564d7085 100644 --- a/app/frontend/src/Controller/Search/index.js +++ b/app/frontend/src/Controller/Search/index.js @@ -5,7 +5,7 @@ import { useLocation, useHistory } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faTimes } from '@fortawesome/free-solid-svg-icons' import { stringify } from 'querystring' -import { Input, InputAdornment, IconButton } from '@material-ui/core' +import { Input, InputAdornment, IconButton, List } from '@material-ui/core' import { getUrlState } from '../../lib/utils' import { SettingsContext } from '../../lib/contexts' @@ -18,7 +18,8 @@ import { MIN_SEARCH_CHARS, } from '../../lib/consts' -import Result from './Results' +import Result from './Result' +import getHighlighter from './match-highlighter' import './index.css' // Generate the regex for capturing anchor chars, optionally @@ -147,6 +148,10 @@ const Search = ( { updateFocus, register, focused } ) => { useEffect( () => { highlightSearch() }, [] ) + // Get match highlighter for the current search mode + const searchMode = SEARCH_ANCHORS[ anchor ] || SEARCH_TYPES.firstLetter + const highlighter = getHighlighter( searchedValue, searchMode ) + return (
{ autoComplete: 'off', }} /> - + + + {results && results.map( ( props, i ) => ( + register( i, c )} + focused={focused === i} + highlighter={highlighter} + /> + ) )} +
) } diff --git a/app/frontend/src/Controller/Search/match-highlighter.js b/app/frontend/src/Controller/Search/match-highlighter.js new file mode 100644 index 00000000..1fab8181 --- /dev/null +++ b/app/frontend/src/Controller/Search/match-highlighter.js @@ -0,0 +1,77 @@ +import { stripVishraams, toAscii, firstLetters, stripAccents, toUnicode } from 'gurmukhi-utils' + +import { SEARCH_TYPES } from '../../lib/consts' + +const fullWordMatches = query => ( { target, gurmukhi } ) => { + const baseGurmukhi = stripVishraams( gurmukhi ) + const baseTarget = stripVishraams( target ) + + const sanitisedQuery = query.trim() + + const foundPosition = baseGurmukhi.search( sanitisedQuery ) + const matchStartPosition = baseGurmukhi.lastIndexOf( ' ', foundPosition ) + + const wordEndPosition = baseGurmukhi.indexOf( ' ', foundPosition + sanitisedQuery.length ) + // If the match finishes in the last word, no space will be deteced, and wordEndPosition + // will be -1. In this case, we want to end at the last position in the line. + const matchEndPosition = wordEndPosition === -1 ? baseGurmukhi.length - 1 : wordEndPosition + + // Grab the word indexes in gurmukhi + const [ wordMatchStart, wordMatchLength ] = [ + gurmukhi.substring( 0, matchStartPosition ).trim().split( ' ' ).length - 1, + gurmukhi.substring( matchStartPosition, matchEndPosition ).trim().split( ' ' ).length, + ] + + const words = baseTarget.split( ' ' ) + + return [ + `${words.slice( 0, wordMatchStart ).join( ' ' )} `, + `${words.slice( wordMatchStart, wordMatchStart + wordMatchLength ).join( ' ' )} `, + `${words.slice( wordMatchStart + wordMatchLength ).join( ' ' )} `, + ] +} + +const firstLetterMatches = query => ( { target, gurmukhi } ) => { + const baseGurmukhi = stripVishraams( gurmukhi ) + const baseLine = stripVishraams( target ) + + const letters = toAscii( firstLetters( stripAccents( toUnicode( baseGurmukhi ) ) ) ) + const words = baseLine.split( ' ' ) + + const startPosition = letters.search( stripAccents( query ) ) + const endPosition = startPosition + query.length + + return [ + `${words.slice( 0, startPosition ).join( ' ' )} `, + `${words.slice( startPosition, endPosition ).join( ' ' )} `, + `${words.slice( endPosition ).join( ' ' )} `, + ] +} + +const highlighters = { + [ SEARCH_TYPES.fullWord ]: fullWordMatches, + [ SEARCH_TYPES.firstLetter ]: firstLetterMatches, +} + +/** + * Separates the line into words before the first match, the first match, and after the match. + * @param target The text to highlight. + * @param context Contains gurmukhi and other contextual information required by all highlighters. + * @param searchQuery The string inputted by the user. + * @param searchMode The type of search being performed, either first word or full word. + * @return An array of [ beforeMatch, match, afterMatch ], + * with `match` being the highlighted section.`. + */ +const getHighlighter = ( searchQuery, searchMode ) => context => target => { + if ( !target ) return [ '', '', '' ] + + // Account for wildcard characters + const sanitizedQuery = searchQuery.replace( new RegExp( '_', 'g' ), '.' ) + + // Select the right higlighter + const highlight = highlighters[ searchMode ] + + return highlight( sanitizedQuery )( { target, ...context } ) +} + +export default getHighlighter