import { action, computed, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { Doc, DocListCast, DocListCastAsync, Field } from '../../../fields/Doc'; import { documentSchema } from "../../../fields/documentSchemas"; import { Id } from '../../../fields/FieldSymbols'; import { createSchema, makeInterface } from '../../../fields/Schema'; import { StrCast } from '../../../fields/Types'; import { DocumentType } from "../../documents/DocumentTypes"; import { CollectionDockingView } from "../collections/CollectionDockingView"; import { ViewBoxBaseComponent } from "../DocComponent"; import { FieldView, FieldViewProps } from '../nodes/FieldView'; import "./SearchBox.scss"; import { DocumentManager } from '../../util/DocumentManager'; import { DocUtils } from '../../documents/Documents'; import { Tooltip } from "@material-ui/core"; export const searchSchema = createSchema({ Document: Doc }); type SearchBoxDocument = makeInterface<[typeof documentSchema, typeof searchSchema]>; const SearchBoxDocument = makeInterface(documentSchema, searchSchema); 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(SearchBoxDocument) { 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: [Doc, string[]][] = []; @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) => { console.log(linkTo.title); if (this.props.linkFrom) { const linkFrom = this.props.linkFrom(); if (linkFrom) { console.log(linkFrom.title); DocUtils.MakeLink({ doc: linkFrom }, { doc: linkTo }, "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; 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 = []; 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)); Array.from(hlights.keys()).length > 0 && this._results.push([doc, Array.from(hlights.keys())]); } docIDs.push(doc[Id]); }); } } /** * @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 = []; 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(result => { Doc.UnBrushDoc(result[0]); Doc.UnHighlightDoc(result[0]); 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 results = this._results.map(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++; return (
{title}
}>
this.makeLink(result[0]) : e => { this.onResultClick(result[0]); e.stopPropagation(); }} className={className}>
{title}
{formattedType}
{result[1].join(", ")}
); } return null; }); results.filter(result => result); 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")}
{results}
); } }