diff options
Diffstat (limited to 'src/client/views/KeywordBox.tsx')
-rw-r--r-- | src/client/views/KeywordBox.tsx | 364 |
1 files changed, 364 insertions, 0 deletions
diff --git a/src/client/views/KeywordBox.tsx b/src/client/views/KeywordBox.tsx new file mode 100644 index 000000000..fc9c38a11 --- /dev/null +++ b/src/client/views/KeywordBox.tsx @@ -0,0 +1,364 @@ +import { Button, Colors, IconButton } from 'browndash-components'; +import { action, computed, makeObservable, observable, reaction } from 'mobx'; +import { observer } from 'mobx-react'; +import React from 'react'; +import { returnFalse, setupMoveUpEvents } from '../../ClientUtils'; +import { Doc, DocListCast } from '../../fields/Doc'; +import { DocData } from '../../fields/DocSymbols'; +import { List } from '../../fields/List'; +import { NumCast, StrCast } from '../../fields/Types'; +import { emptyFunction, Utils } from '../../Utils'; +import { DocumentType } from '../documents/DocumentTypes'; +import { DragManager } from '../util/DragManager'; +import { SnappingManager } from '../util/SnappingManager'; +import { DocumentView } from './nodes/DocumentView'; +import { ObservableReactComponent } from './ObservableReactComponent'; + +interface KeywordItemProps { + doc: Doc; + keyword: string; + keywordDoc: Doc; + setToEditing: () => void; + isEditing: boolean; +} + +/** + * A component that handles individual keywords. + */ +@observer +export class KeywordItem extends ObservableReactComponent<KeywordItemProps> { + constructor(props: any) { + super(props); + makeObservable(this); + this.ref = React.createRef(); + } + + private ref: React.RefObject<HTMLDivElement>; + + /** + * Gets the documents that a keyword is associated with. + * @returns An array of documents that contain the keyword. + */ + getKeywordCollectionDocs = () => { + for (const doc of DocListCast(Doc.ActiveDashboard?.myKeywordCollections)) { + if (doc.title === this._props.keyword) { + return doc[DocData].docs; + } + } + return null; + }; + + /** + * Creates a smart collection. + * @returns + */ + createCollection = () => { + // Get the documents that contain the keyword. + const selected = DocListCast(this.getKeywordCollectionDocs()!); + const newEmbeddings = selected.map(doc => Doc.MakeEmbedding(doc)); + + // Create a new collection and set up configurations. + const newCollection = ((doc: Doc) => { + const docData = doc[DocData]; + docData.data = new List<Doc>(newEmbeddings); + docData.title = this._props.keyword; + doc._freeform_panX = doc._freeform_panY = 0; + return doc; + })(Doc.MakeCopy(Doc.UserDoc().emptyCollection as Doc, true)); + newEmbeddings.forEach(embed => (embed.embedContainer = newCollection)); + newCollection._width = 900; + newCollection._height = 900; + newCollection.layout_fitWidth = true; + + // Add the collection to the keyword document's list of associated smart collections. + this._props.keywordDoc.collections = new List<Doc>([...DocListCast(this._props.keywordDoc.collections), newCollection]); + newCollection[DocData].data_labels = new List<string>([this._props.keyword]); + newCollection[DocData][`${this._props.keyword}`] = true; + newCollection[DocData].showLabels = true; + return newCollection; + }; + + @action + handleDragStart = (e: React.PointerEvent) => { + if (this._props.isEditing) { + const clone = this.ref.current?.cloneNode(true) as HTMLElement; + if (!clone) return; + + setupMoveUpEvents( + this, + e, + () => { + const dragData = new DragManager.DocumentDragData([this.createCollection()]); + DragManager.StartDocumentDrag([this.ref.current!], dragData, e.clientX, e.clientY, {}); + return true; + }, + returnFalse, + emptyFunction + ); + e.preventDefault(); + } + }; + + @action + removeLabel = () => { + if (this._props.doc[DocData].data_labels) { + if (this._props.doc.type === DocumentType.COL) { + const filtered_collections = new List<Doc>(DocListCast(this._props.keywordDoc.collections).filter(doc => doc !== this._props.doc)); + this._props.keywordDoc.collections = filtered_collections; + + for (const cur_doc of DocListCast(this.getKeywordCollectionDocs()!, [])) { + this._props.doc[DocData].data = new List<Doc>(DocListCast(this._props.doc[DocData].data).filter(doc => !Doc.AreProtosEqual(cur_doc, doc))); + } + } else { + const filtered_docs = new List<Doc>(DocListCast(this.getKeywordCollectionDocs()!).filter(doc => doc !== this._props.doc)); + this._props.keywordDoc[DocData].docs = filtered_docs; + + for (const collection of DocListCast(this._props.keywordDoc.collections)) { + collection[DocData].data = new List<Doc>(DocListCast(collection[DocData].data).filter(doc => !Doc.AreProtosEqual(this._props.doc, doc))); + } + } + } + this._props.doc[DocData].data_labels = (this._props.doc[DocData].data_labels as List<string>).filter(label => label !== this._props.keyword) as List<string>; + this._props.doc![DocData][`${this._props.keyword}`] = false; + }; + + render() { + return ( + <div className="keyword" onClick={this._props.setToEditing} onPointerDown={this.handleDragStart} ref={this.ref} key={Utils.GenerateGuid()}> + {this._props.keyword} + {this.props.isEditing && <IconButton tooltip={'Remove label'} onPointerDown={this.removeLabel} icon={'X'} style={{ width: '8px', height: '8px', marginLeft: '10px' }} />} + </div> + ); + } +} + +interface KeywordBoxProps { + doc: Doc; + isEditing: boolean; +} + +/** + * A component that handles the keyword display for documents. + */ +@observer +export class KeywordBox extends ObservableReactComponent<KeywordBoxProps> { + @observable _currentInput: string = ''; + private height: number = 0; + private ref: React.RefObject<HTMLDivElement>; + + @computed + get currentScale() { + return NumCast((this._props.doc.embedContainer as Doc)?._freeform_scale, 1); + } + + @computed + get cur_height() { + return this.ref.current?.offsetHeight ? this.ref.current?.offsetHeight : 0; + } + + constructor(props: any) { + super(props); + makeObservable(this); + this.ref = React.createRef(); + + reaction( + () => this.cur_height, + () => { + this._props.doc[DocData].keywordHeight = this.height; + } + ); + } + + componentDidMount(): void { + this.height = this.ref.current?.offsetHeight ? this.ref.current?.offsetHeight : 0; + this._props.doc[DocData].keywordHeight = this.height; + } + + componentDidUpdate(prevProps: Readonly<KeywordBoxProps>): void { + this.height = this.ref.current?.offsetHeight ? this.ref.current?.offsetHeight : 0; + this._props.doc[DocData].keywordHeight = this.height; + } + + @action + setToEditing = () => { + this._props.isEditing = true; + }; + + @action + setToView = () => { + this._props.isEditing = false; + }; + + /** + * Gets the document associated with a keyword. + * @param keyword The keyword being searched for + * @returns A Doc containing keyword information + */ + getKeywordCollection = (keyword: string) => { + // Look for the keyword document. + for (const doc of DocListCast(Doc.ActiveDashboard!.myKeywordCollections)) { + if (doc.title === keyword) { + return doc; + } + } + + // If not contained, create a new document and add it to the active Dashboard's keyword list. + const keywordCollection = new Doc(); + keywordCollection.title = keyword; + keywordCollection[DocData].docs = new List<Doc>(); + keywordCollection.collections = new List<Doc>(); + if (Doc.ActiveDashboard) { + Doc.ActiveDashboard.myKeywordCollections = new List<Doc>([...DocListCast(Doc.ActiveDashboard.myKeywordCollections), keywordCollection]); + } + + return keywordCollection; + }; + + /** + * Adds the keyword to the document. + * @param keyword + */ + submitLabel = (keyword: string) => { + // If the active Dashboard does not have a keyword collection, create it. + if (Doc.ActiveDashboard && !Doc.ActiveDashboard.myKeywordCollections) { + Doc.ActiveDashboard.myKeywordCollections = new List<Doc>(); + } + + const submittedLabel = keyword.trim(); + if (submittedLabel && !this._props.doc![DocData][`${submittedLabel}`]) { + // If the keyword collection is not in active Dashboard, add it as a new doc, with the keyword as its title. + const keywordCollection = this.getKeywordCollection(submittedLabel); + + // If the document has no keywords field, create the field. + if (!this._props.doc[DocData].data_labels) { + this._props.doc[DocData].data_labels = new List<string>(); + } + + // If the document is of type COLLECTION, make it a smart collection, otherwise, add the keyword to the document. + if (this._props.doc.type === DocumentType.COL) { + keywordCollection.collections = new List<Doc>([...DocListCast(keywordCollection.collections), this._props.doc]); + + // Iterate through the keyword document's collections and add a copy of the document to each collection + for (const doc of DocListCast(keywordCollection[DocData].docs)) { + const newEmbedding = Doc.MakeEmbedding(doc); + this._props.doc[DocData].data = new List<Doc>([...DocListCast(this._props.doc[DocData].data), newEmbedding]); + newEmbedding.embedContainer = this._props.doc; + } + } else { + // Add this document to the keyword's collection of associated documents. + keywordCollection[DocData].docs = new List<Doc>([...DocListCast(keywordCollection[DocData].docs), this._props.doc]); + + // Iterate through the keyword document's collections and add a copy of the document to each collection + for (const collection of DocListCast(keywordCollection.collections)) { + const newEmbedding = Doc.MakeEmbedding(this._props.doc); + collection[DocData].data = new List<Doc>([...DocListCast(collection.data), newEmbedding]); + newEmbedding.embedContainer = collection; + } + } + + // Push the keyword to the document's keyword list field. + (this._props.doc![DocData].data_labels! as List<string>).push(submittedLabel); + this._props.doc![DocData][`${submittedLabel}`] = true; + this._currentInput = ''; // Clear the input box + } + }; + + @action + onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + this._currentInput = e.target.value; + }; + + render() { + const keywordsList = this._props.doc[DocData].data_labels ? this._props.doc[DocData].data_labels : new List<string>(); + const seldoc = DocumentView.SelectedDocs().lastElement(); + if (SnappingManager.IsDragging || !(seldoc === this._props.doc) || !this._props.isEditing) { + setTimeout( + action(() => { + if ((keywordsList as List<string>).length === 0) { + this._props.doc[DocData].showLabels = false; + } + this.setToView(); + }) + ); + } + + return ( + <div + className="keywords-container" + ref={this.ref} + style={{ + transformOrigin: 'top left', + overflow: 'hidden', + transform: `scale(${1 / this.currentScale})`, + backgroundColor: this._props.isEditing ? Colors.LIGHT_GRAY : Colors.TRANSPARENT, + borderColor: this._props.isEditing ? Colors.BLACK : Colors.TRANSPARENT, + maxWidth: `400px`, + }}> + <div className="keywords-content"> + <div className="keywords-list"> + {(keywordsList as List<string>).map(keyword => { + return <KeywordItem key={Utils.GenerateGuid()} doc={this._props.doc} keyword={keyword} keywordDoc={this.getKeywordCollection(keyword)} setToEditing={this.setToEditing} isEditing={this._props.isEditing}></KeywordItem>; + })} + </div> + {this._props.isEditing ? ( + <div className="keyword-editing-box"> + <div className="keyword-input-box"> + <input + value={this._currentInput} + autoComplete="off" + onChange={this.onInputChange} + onKeyDown={e => { + e.key === 'Enter' ? this.submitLabel(this._currentInput) : null; + e.stopPropagation(); + }} + type="text" + placeholder="Input keywords for document..." + aria-label="keyword-input" + className="keyword-input" + style={{ width: '100%', borderRadius: '5px' }} + /> + </div> + {Doc.ActiveDashboard?.myKeywordCollections ? ( + <div className="keyword-suggestions-box"> + {DocListCast(Doc.ActiveDashboard?.myKeywordCollections).map(doc => { + const keyword = StrCast(doc.title); + return ( + <Button + style={{ margin: '2px 2px', border: '1px solid black', backgroundColor: 'lightblue', color: 'black' }} + text={keyword} + color={SnappingManager.userVariantColor} + tooltip={'Add existing keyword'} + onClick={() => { + this.submitLabel(keyword); + }} + key={Utils.GenerateGuid()} + /> + ); + })} + </div> + ) : ( + <div></div> + )} + <div className="keyword-buttons"> + <IconButton + tooltip={'Close Menu'} + onPointerDown={() => { + if ((keywordsList as List<string>).length === 0) { + this._props.doc[DocData].showLabels = false; + } else { + this.setToView(); + } + }} + icon={'x'} + style={{ width: '4px' }} + /> + </div> + </div> + ) : ( + <div></div> + )} + </div> + </div> + ); + } +} |