import { Tooltip } from '@material-ui/core'; import { action, computed, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { Doc, DocListCast, DocListCastAsync, Field, Opt } from '../../../fields/Doc'; import { DirectLinks } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { DocCast, StrCast } from '../../../fields/Types'; import { DocUtils } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; import { DocumentManager } from '../../util/DocumentManager'; import { LinkManager } from '../../util/LinkManager'; import { undoBatch } from '../../util/UndoManager'; import { CollectionDockingView } from '../collections/CollectionDockingView'; import { ViewBoxBaseComponent } from '../DocComponent'; import { FieldView, FieldViewProps } from '../nodes/FieldView'; import './SearchBox.scss'; const DAMPENING_FACTOR = 0.9; const MAX_ITERATIONS = 25; const ERROR = 0.03; export interface SearchBoxProps extends FieldViewProps { linkSearch: boolean; linkFrom?: (() => Doc | undefined) | undefined; linkCreateAnchor?: () => Doc | undefined; linkCreated?: (link: Doc) => void; } /** * This is the SearchBox component. It represents the search box input and results in * the search panel on the left side of the screen. */ @observer export class SearchBox extends ViewBoxBaseComponent() { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(SearchBox, fieldKey); } public static Instance: SearchBox; private _inputRef = React.createRef(); @observable _searchString = ''; @observable _docTypeString = 'all'; @observable _results: Map = new Map(); @observable _pageRanks: Map = new Map(); @observable _linkedDocsOut: Map> = new Map>(); @observable _linkedDocsIn: Map> = new Map>(); @observable _selectedResult: Doc | undefined = undefined; @observable _deletedDocsStatus: boolean = false; @observable _onlyEmbeddings: boolean = true; /** * This is the constructor for the SearchBox class. */ constructor(props: any) { super(props); SearchBox.Instance = this; } /** * This method is called when the SearchBox component is first mounted. When the user opens * the search panel, the search input box is automatically selected. This allows the user to * type in the search input box immediately, without needing clicking on it first. */ componentDidMount = action(() => { if (this._inputRef.current) { this._inputRef.current.focus(); } }); /** * This method is called when the SearchBox component is about to be unmounted. When the user * closes the search panel, the search and its results are reset. */ componentWillUnmount() { this.resetSearch(); } /** * This method is called when the text in the search input box is modified by the user. The * _searchString is updated to the new value of the text in the input box and submitSearch * is called to update the search results accordingly. * * (Note: There is no longer a need to press enter to submit a search. Any update to the input * causes a search to be submitted automatically.) */ onInputChange = action((e: React.ChangeEvent) => { this._searchString = e.target.value; this.submitSearch(); }); /** * This method is called when the option in the select drop-down menu is changed. The * _docTypeString is updated to the new value of the option in the drop-down menu. This * is used to filter the results of the search to documents of a specific type. * * (Note: This doesn't affect the results array, so there is no need to submit a new * search here. The results of the search on the _searchString query are simply filtered * by type directly before rendering them.) */ onSelectChange = action((e: React.ChangeEvent) => { this._docTypeString = e.target.value; }); /** * @param {Doc} doc - doc of the search result that has been clicked on * * This method is called when the user clicks on a search result. The _selectedResult is * updated accordingly and the doc is highlighted with the selectElement method. */ onResultClick = action(async (doc: Doc) => { this._selectedResult = doc; this.selectElement(doc, () => DocumentManager.Instance.getFirstDocumentView(doc)?.ComponentView?.search?.(this._searchString, undefined, false)); }); @undoBatch makeLink = action((linkTo: Doc) => { const linkFrom = this.props.linkCreateAnchor?.(); if (linkFrom) { const link = DocUtils.MakeLink(linkFrom, linkTo, {}); link && this.props.linkCreated?.(link); } }); /** * @param {Doc[]} docs - docs to be searched through recursively * @param {number, Doc => void} func - function to be called on each doc * * This method iterates through an array of docs and all docs within those docs, calling * the function func on each doc. */ static foreachRecursiveDoc(docs: Doc[], func: (depth: number, doc: Doc) => void) { let newarray: Doc[] = []; var depth = 0; const visited: Doc[] = []; while (docs.length > 0) { newarray = []; docs.filter(d => d && !visited.includes(d)).forEach(d => { visited.push(d); const fieldKey = Doc.LayoutFieldKey(d); const annos = !Field.toString(Doc.LayoutField(d) as Field).includes('CollectionView'); const data = d[annos ? fieldKey + '_annotations' : fieldKey]; data && newarray.push(...DocListCast(data)); const sidebar = d[fieldKey + '_sidebar']; sidebar && newarray.push(...DocListCast(sidebar)); func(depth, d); }); docs = newarray; depth++; } } /** * @param {Doc[]} docs - docs to be searched through recursively * @param {number, Doc => void} func - function to be called on each doc * * This method iterates asynchronously through an array of docs and all docs within those * docs, calling the function func on each doc. */ static async foreachRecursiveDocAsync(docs: Doc[], func: (depth: number, doc: Doc) => void) { let newarray: Doc[] = []; var depth = 0; while (docs.length > 0) { newarray = []; await Promise.all( docs .filter(d => d) .map(async d => { const fieldKey = Doc.LayoutFieldKey(d); const annos = !Field.toString(Doc.LayoutField(d) as Field).includes('CollectionView'); const data = d[annos ? fieldKey + '_annotations' : fieldKey]; const docs = await DocListCastAsync(data); docs && newarray.push(...docs); func(depth, d); }) ); docs = newarray; depth++; } } /** * @param {String} type - string representing the type of a doc * * This method converts a doc type string of any length to a 3-letter doc type string in * which the first letter is capitalized. This is used when displaying the type on the * right side of each search result. */ static formatType(type: String): String { if (type === 'pdf') { return 'PDF'; } else if (type === 'image') { return 'Img'; } return type.charAt(0).toUpperCase() + type.substring(1, 3); } /** * @param {String} query - search query string * * This method searches the CollectionDockingView instance for a certain query and puts * the matching results in the results array. Docs are considered to be matching results * when the query is a substring of many different pieces of its metadata (title, text, * author, etc). */ @action searchCollection(query: string) { this._selectedResult = undefined; this._results = SearchBox.staticSearchCollection(CollectionDockingView.Instance?.rootDoc, query); this.computePageRanks(); } @action static staticSearchCollection(rootDoc: Opt, query: string) { const blockedTypes = [DocumentType.PRESELEMENT, DocumentType.CONFIG, DocumentType.KVP, DocumentType.FILTER, DocumentType.SEARCH, DocumentType.SEARCHITEM, DocumentType.FONTICON, DocumentType.BUTTON, DocumentType.SCRIPTING]; const blockedKeys = [ 'x', 'y', 'proto', 'width', 'layout_autoHeight', 'acl-Override', 'acl-Guest', 'embedContainer', 'zIndex', 'height', 'text_scrollHeight', 'text_height', 'cloneFieldFilter', 'isDataDoc', 'text_annotations', 'dragFactory_count', 'text_noTemplate', 'proto_embeddings', 'isSystem', 'layout_fieldKey', 'isBaseProto', 'xMargin', 'yMargin', 'links', 'layout', 'layout_keyValue', 'layout_fitWidth', 'type_collection', 'title_custom', 'freeform_panX', 'freeform_panY', 'freeform_scale', ]; query = query.toLowerCase(); const results = new Map(); if (rootDoc) { const docs = DocListCast(rootDoc[Doc.LayoutFieldKey(rootDoc)]); const docIDs: String[] = []; SearchBox.foreachRecursiveDoc(docs, (depth: number, doc: Doc) => { const dtype = StrCast(doc.type) as DocumentType; if (dtype && !blockedTypes.includes(dtype) && !docIDs.includes(doc[Id]) && depth >= 0) { const hlights = new Set(); SearchBox.documentKeys(doc).forEach( key => Field.toString(doc[key] as Field) .toLowerCase() .includes(query) && hlights.add(key) ); blockedKeys.forEach(key => hlights.delete(key)); if (Array.from(hlights.keys()).length > 0) { results.set(doc, Array.from(hlights.keys())); } } docIDs.push(doc[Id]); }); } return results; } /** * This method initializes the page rank of every document to the reciprocal * of the number of documents in the collection. */ @action initializePageRanks() { this._pageRanks.clear(); this._linkedDocsOut.clear(); this._results.forEach((_, doc) => { this._linkedDocsIn.set(doc, new Set()); }); this._results.forEach((_, doc) => { this._pageRanks.set(doc, 1.0 / this._results.size); if (Doc.GetProto(doc)[DirectLinks].size === 0) { this._linkedDocsOut.set(doc, new Set(this._results.keys())); this._results.forEach((_, linkedDoc) => { this._linkedDocsIn.get(linkedDoc)?.add(doc); }); } else { const linkedDocSet = new Set(); Doc.GetProto(doc)[DirectLinks].forEach(link => { const d1 = link?.link_anchor_1 as Doc; const d2 = link?.link_anchor_2 as Doc; if (doc === d1 && this._results.has(d2)) { linkedDocSet.add(d2); this._linkedDocsIn.get(d2)?.add(doc); } else if (doc === d2 && this._results.has(d1)) { linkedDocSet.add(d1); this._linkedDocsIn.get(d1)?.add(doc); } }); this._linkedDocsOut.set(doc, linkedDocSet); } }); } /** * This method runs one complete iteration of the page rank algorithm. It * returns true iff all page ranks have converged (i.e. changed by less than * the _error value), which means that the algorithm should terminate. * * @return true if page ranks have converged; false otherwise */ @action pageRankIteration(): boolean { let converged = true; const pageRankFromAll = (1 - DAMPENING_FACTOR) / this._results.size; const nextPageRanks = new Map(); this._results.forEach((_, doc) => { let nextPageRank = pageRankFromAll; this._linkedDocsIn.get(doc)?.forEach(linkedDoc => { nextPageRank += (DAMPENING_FACTOR * (this._pageRanks.get(linkedDoc) ?? 0)) / (this._linkedDocsOut.get(linkedDoc)?.size ?? 1); }); nextPageRanks.set(doc, nextPageRank); if (Math.abs(nextPageRank - (this._pageRanks.get(doc) ?? 0)) > ERROR) { converged = false; } }); this._pageRanks = nextPageRanks; return converged; } /** * This method performs the page rank algorithm on the graph of documents * that match the search query. Vertices are documents and edges are links * between documents. */ @action computePageRanks() { this.initializePageRanks(); for (let i = 0; i < MAX_ITERATIONS; i++) { if (this.pageRankIteration()) { break; } } } /** * @param {Doc} doc - doc for which keys are returned * * This method returns a list of a document doc's keys. */ static documentKeys(doc: Doc) { const keys: { [key: string]: boolean } = {}; Doc.GetAllPrototypes(doc).map(proto => Object.keys(proto).forEach(key => (keys[key] = false))); return Array.from(Object.keys(keys)); } /** * This method submits a search with the _searchString as its query and updates * the results array accordingly. */ @action submitSearch = async () => { this.resetSearch(); const query = StrCast(this._searchString); Doc.SetSearchQuery(query); if (!this.props.linkSearch) Array.from(this._results.keys()).forEach(doc => DocumentManager.Instance.getFirstDocumentView(doc)?.ComponentView?.search?.(this._searchString, undefined, true)); this._results.clear(); if (query) { this.searchCollection(query); } }; /** * This method resets the search by iterating through each result and removing all * brushes and highlights. All search matches are cleared as well. */ resetSearch = action(() => { this._results.forEach((_, doc) => { Doc.UnBrushDoc(doc); Doc.UnHighlightDoc(doc); Doc.ClearSearchMatches(); }); }); /** * @param {Doc} doc - doc to be selected * * This method selects a doc by either jumping to it (centering/zooming in on it) * or opening it in a new tab. */ selectElement = async (doc: Doc, finishFunc: () => void) => { await DocumentManager.Instance.showDocument(doc, { willZoomCentered: true }, finishFunc); }; /** * This method returns a JSX list of the options in the select drop-down menu, which * is used to filter the types of documents that appear in the search results. */ @computed public get selectOptions() { const selectValues = ['all', 'rtf', 'image', 'pdf', 'web', 'video', 'audio', 'collection']; return selectValues.map(value => ( )); } /** * This method renders the search input box, select drop-down menu, and search results. */ render() { var validResults = 0; const isLinkSearch: boolean = this.props.linkSearch; const sortedResults = Array.from(this._results.entries()).sort((a, b) => (this._pageRanks.get(b[0]) ?? 0) - (this._pageRanks.get(a[0]) ?? 0)); // sorted by page rank const resultsJSX = Array(); const fromDoc = this.props.linkFrom?.(); sortedResults.forEach(result => { var className = 'searchBox-results-scroll-view-result'; if (this._selectedResult === result[0]) { className += ' searchBox-results-scroll-view-result-selected'; } const formattedType = SearchBox.formatType(StrCast(result[0].type)); const title = result[0].title; if (this._docTypeString === 'all' || this._docTypeString === result[0].type) { validResults++; resultsJSX.push( {title as string}}>
this.makeLink(result[0]) : e => { this.onResultClick(result[0]); e.stopPropagation(); } } style={{ fontWeight: LinkManager.Links(fromDoc).find( link => Doc.AreProtosEqual(LinkManager.getOppositeAnchor(link, fromDoc!), result[0] as Doc) || Doc.AreProtosEqual(DocCast(LinkManager.getOppositeAnchor(link, fromDoc!)?.annotationOn), result[0] as Doc) ) ? 'bold' : '', }} className={className}>
{title as string}
{formattedType}
{result[1].join(', ')}
); } }); return (
{isLinkSearch ? null : ( )} { e.key === 'Enter' ? this.submitSearch() : null; e.stopPropagation(); }} type="text" placeholder="Search..." id="search-input" className="searchBox-input" style={{ width: isLinkSearch ? '100%' : undefined, borderRadius: isLinkSearch ? '5px' : undefined }} ref={this._inputRef} />
{`${validResults}` + ' result' + (validResults === 1 ? '' : 's')}
{resultsJSX}
); } }