import { Tooltip } from "@material-ui/core"; import { action, computed, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { DirectLinksSym, Doc, DocListCast, DocListCastAsync, Field } from '../../../fields/Doc'; import { Id } from '../../../fields/FieldSymbols'; import { StrCast } from '../../../fields/Types'; import { DocUtils } from '../../documents/Documents'; import { DocumentType } from "../../documents/DocumentTypes"; import { DocumentManager } from '../../util/DocumentManager'; 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; } /** * 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 _onlyAliases: 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)); }); makeLink = action((linkTo: Doc) => { if (this.props.linkFrom) { const linkFrom = this.props.linkFrom(); if (linkFrom) { DocUtils.MakeLink({ doc: linkFrom }, { doc: linkTo }); } } }); /** * @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; while (docs.length > 0) { newarray = []; docs.filter(d => d).forEach(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)); 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) { const blockedTypes = [DocumentType.PRESELEMENT, DocumentType.KVP, DocumentType.FILTER, DocumentType.SEARCH, DocumentType.SEARCHITEM, DocumentType.FONTICON, DocumentType.BUTTON, DocumentType.SCRIPTING]; const blockedKeys = ["x", "y", "proto", "width", "autoHeight", "acl-Override", "acl-Public", "context", "zIndex", "height", "text-scrollHeight", "text-height", "cloneFieldFilter", "isPrototype", "text-annotations", "dragFactory-count", "text-noTemplate", "aliases", "system", "layoutKey", "baseProto", "xMargin", "yMargin", "links", "layout", "layout_keyValue", "fitWidth", "viewType", "title-custom", "panX", "panY", "viewScale"]; const collection = CollectionDockingView.Instance; query = query.toLowerCase(); this._results.clear(); this._selectedResult = undefined; if (collection !== undefined) { const docs = DocListCast(collection.rootDoc[Doc.LayoutFieldKey(collection.rootDoc)]); const docIDs: String[] = []; SearchBox.foreachRecursiveDoc(docs, (depth: number, doc: Doc) => { const dtype = StrCast(doc.type, "string") 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) { this._results.set(doc, Array.from(hlights.keys())); } } docIDs.push(doc[Id]); }); } this.computePageRanks(); } /** * 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)[DirectLinksSym].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)[DirectLinksSym].forEach((link) => { const d1 = link?.anchor1 as Doc; const d2 = link?.anchor2 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); 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.jumpToDocument(doc, true, undefined, undefined, undefined, undefined, undefined, 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(); 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}
}>
this.makeLink(result[0]) : e => { this.onResultClick(result[0]); e.stopPropagation(); }} className={className}>
{title}
{formattedType}
{result[1].join(", ")}
); } }); return (
{isLinkSearch ? (null) : } e.key === "Enter" ? this.submitSearch() : null} 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}
); } }