import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Button, Colors, IconButton, Type } from '@dash/components'; import { IReactionDisposer, action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import React from 'react'; import ResizeObserver from 'resize-observer-polyfill'; import { returnFalse, setupMoveUpEvents } from '../../ClientUtils'; import { emptyFunction } from '../../Utils'; import { Doc, DocListCast, Field, Opt, StrListCast } from '../../fields/Doc'; import { DocData } from '../../fields/DocSymbols'; import { List } from '../../fields/List'; import { DocCast, StrCast } from '../../fields/Types'; import { DocumentType } from '../documents/DocumentTypes'; import { DragManager } from '../util/DragManager'; import { SnappingManager } from '../util/SnappingManager'; import { undoable } from '../util/UndoManager'; import { ObservableReactComponent } from './ObservableReactComponent'; import './TagsView.scss'; import { DocumentView } from './nodes/DocumentView'; import { FaceRecognitionHandler } from './search/FaceRecognitionHandler'; import { IconTagBox } from './nodes/IconTagBox'; import { Id } from '../../fields/FieldSymbols'; import { StyleProp } from './StyleProp'; import { Docs } from '../documents/Documents'; /** * The TagsView is a metadata input/display panel shown at the bottom of a DocumentView in a freeform collection. * * This panel allow sthe user to add metadata tags to a Doc, and to display those tags, or any metadata field * in a panel of 'buttons' (TagItems) just below the DocumentView. TagItems are interactive - * the user can drag them off in order to display a collection of all documents that share the tag value. * * The tags that are added using the panel are the same as the #tags that can entered in a text Doc. * Note that tags starting with @ display a metadata key/value pair instead of the tag itself. * e.g., '@author' shows the document author * */ interface TagItemProps { docs: Doc[]; tag: string; tagDoc: Opt; showRemoveUI: boolean; setToEditing: () => void; } /** * Interactive component that display a single metadata tag or value. * * These items can be dragged and dropped to create a collection of Docs that * share the same metadata tag / value. */ @observer export class TagItem extends ObservableReactComponent { /** * return list of all tag Docs (ie, Doc that are collections of Docs sharing a specific tag / value) */ public static get AllTagCollectionDocs() { return DocListCast(Doc.ActiveDashboard?.myTagCollections); } /** * Find tag Doc that collects all Docs with given tag / value * @param tag tag string * @returns tag collection Doc or undefined */ public static findTagCollectionDoc = (tag: string) => TagItem.AllTagCollectionDocs.find(doc => doc.title === tag); /** * Creates a Doc that collects Docs with the specified tag / value * @param tag tag string * @returns tag collection Doc */ public static createTagCollectionDoc = (tag: string) => { const newTagCol = new Doc(); newTagCol.title = tag; newTagCol.collections = new List(); newTagCol.$docs = new List(); Doc.ActiveDashboard && Doc.AddDocToList(Doc.ActiveDashboard, 'myTagCollections', newTagCol); return newTagCol; }; /** * Gets all Docs that have the specified tag / value * @param tag tag string * @returns An array of documents that contain the tag. */ public static allDocsWithTag = (tag: string) => DocListCast(TagItem.findTagCollectionDoc(tag)?.$docs); public static docHasTag = (doc: Doc, tag: string) => StrListCast(doc?.tags).includes(tag); /** * Adds a tag to the metadata of this document and adds the Doc to the corresponding tag collection Doc (or creates it) * @param tag tag string */ public static addTagToDoc = (doc: Doc, tag: string) => { // If the tag collection is not in active Dashboard, add it as a new doc, with the tag as its title. const tagCollection = TagItem.findTagCollectionDoc(tag) ?? TagItem.createTagCollectionDoc(tag); // If the document is of type COLLECTION, make it a smart collection, otherwise, add the tag to the document. if (doc.type === DocumentType.COL && !doc.annotationOn) { Doc.AddDocToList(tagCollection, 'collections', doc); // Iterate through the tag Doc collections and add a copy of the document to each collection for (const cdoc of DocListCast(tagCollection.$docs)) { if (!DocListCast(doc.$data).find(d => Doc.AreProtosEqual(d, cdoc))) { const newEmbedding = Doc.MakeEmbedding(cdoc); Doc.AddDocToList(doc[DocData], 'data', newEmbedding); Doc.SetContainer(newEmbedding, doc); } } } else { // Add this document to the tag's collection of associated documents. Doc.AddDocToList(tagCollection[DocData], 'docs', doc); // Iterate through the tag document's collections and add a copy of the document to each collection for (const collection of DocListCast(tagCollection.collections)) { if (!DocListCast(collection.$data).find(d => Doc.AreProtosEqual(d, doc))) { const newEmbedding = Doc.MakeEmbedding(doc); Doc.AddDocToList(collection[DocData], 'data', newEmbedding); Doc.SetContainer(newEmbedding, collection); } } } if (!doc.$tags) doc.$tags = new List(); const tagList = doc.$tags as List; if (!tagList.includes(tag)) tagList.push(tag); }; /** * Removes a tag from a Doc and removes the Doc from the corresponding tag collection Doc * @param doc Doc to add tag * @param tag tag string * @param tagDoc doc that collections the Docs with the tag */ public static removeTagFromDoc = (doc: Doc, tag: string, tagDoc?: Doc) => { if (doc.$tags) { if (doc.type === DocumentType.COL) { tagDoc && Doc.RemoveDocFromList(tagDoc[DocData], 'collections', doc); for (const cur_doc of TagItem.allDocsWithTag(tag)) { doc.$data = new List(DocListCast(doc.$data).filter(d => !Doc.AreProtosEqual(cur_doc, d))); } } else { tagDoc && Doc.RemoveDocFromList(tagDoc[DocData], 'docs', doc); for (const collection of DocListCast(tagDoc?.collections)) { collection.$data = new List(DocListCast(collection.$data).filter(d => !Doc.AreProtosEqual(doc, d))); } } } doc.$tags = new List(StrListCast(doc.$tags).filter(label => label !== tag)); }; private _ref: React.RefObject; constructor(props: TagItemProps) { super(props); makeObservable(this); this._ref = React.createRef(); } /** * Creates a smart collection. * @returns */ createTagCollection = () => { if (!this._props.tagDoc) { const face = FaceRecognitionHandler.FindUniqueFaceByName(this._props.tag); return face ? Doc.MakeEmbedding(face) : undefined; } // Get the documents that contain the tag. const newEmbeddings = TagItem.allDocsWithTag(this._props.tag).map(doc => Doc.MakeEmbedding(doc)); // Create a new collection and set up configurations. const emptyCol = DocCast(Doc.UserDoc().emptyCollection); const newCollection = ((doc: Doc) => { doc.$data = new List(newEmbeddings); doc.$title = this._props.tag; doc.$tags = new List([this._props.tag]); doc.$freeform_fitContentsToBox = true; doc._freeform_panX = doc._freeform_panY = 0; doc._width = 900; doc._height = 900; doc.layout_fitWidth = true; doc._layout_showTags = true; return doc; })(emptyCol ? Doc.MakeCopy(emptyCol, true) : Docs.Create.FreeformDocument([], {})); newEmbeddings.forEach(embed => Doc.SetContainer(embed, newCollection)); // Add the collection to the tag document's list of associated smart collections. this._props.tagDoc && Doc.AddDocToList(this._props.tagDoc, 'collections', newCollection); return newCollection; }; @action handleDragStart = (e: React.PointerEvent) => { setupMoveUpEvents( this, e, () => { const dragCollection = this.createTagCollection(); if (dragCollection) { const dragData = new DragManager.DocumentDragData([dragCollection]); DragManager.StartDocumentDrag([this._ref.current!], dragData, e.clientX, e.clientY, {}); return true; } return false; }, returnFalse, clickEv => { clickEv.stopPropagation(); this._props.setToEditing(); } ); e.preventDefault(); }; @computed get doc() { return this._props.docs.lastElement(); } render() { this._props.tagDoc && setTimeout(() => this._props.docs.forEach(doc => TagItem.addTagToDoc(doc, this._props.tag))); // bcz: hack to make sure that Docs are added to their tag Doc collection since metadata can get set anywhere without a guard triggering an add to the collection const metadata = this._props.tag.startsWith('@') ? this._props.tag.replace(/^@/, '') : ''; return (
{metadata ? ( {'@' + metadata}  {typeof this.doc[metadata] === 'boolean' ? ( e.stopPropagation()} onPointerDown={e => e.stopPropagation()} onChange={undoable(() => (this.doc[metadata] = !this.doc[metadata]), 'metadata toggle')} checked={this.doc[metadata] as boolean} /> ) : ( Field.toString(this.doc[metadata]) )} ) : ( this._props.tag )} {this.props.showRemoveUI && this._props.tagDoc && ( this._props.docs.forEach(doc => TagItem.removeTagFromDoc(doc, this._props.tag, this._props.tagDoc)), `remove tag ${this._props.tag}`)} icon={} style={{ width: '8px', height: '8px', marginLeft: '10px' }} /> )}
); } } interface TagViewProps { Views: DocumentView[]; background: string; } /** * Displays a panel of tags that have been added to a Doc. Also allows for editing the applied tags through a dropdown UI. */ @observer export class TagsView extends ObservableReactComponent { constructor(props: TagViewProps) { super(props); makeObservable(this); } @observable _panelHeightDirty = 0; @observable _currentInput = ''; @observable _isEditing: boolean | undefined = undefined; _heightDisposer: IReactionDisposer | undefined; _lastXf = this.View.screenToContentsTransform(); componentDidMount() { this._heightDisposer = reaction( () => this.View.screenToContentsTransform(), xf => { if (xf.Scale === 0) return; if (this.View.ComponentView?.isUnstyledView?.() || (!this.View.showTags && this._props.Views.length === 1)) return; if (xf.TranslateX !== this._lastXf.TranslateX || xf.TranslateY !== this._lastXf.TranslateY || xf.Scale !== this._lastXf.Scale) { this._panelHeightDirty = this._panelHeightDirty + 1; } this._lastXf = xf; } ); } componentWillUnmount() { this._heightDisposer?.(); } @computed get View() { return this._props.Views.lastElement(); } @computed get isEditing() { const selected = DocumentView.Selected().length === 1 && DocumentView.Selected().includes(this.View); if (this._isEditing === undefined) return selected && this.View.TagPanelEditing; // && !StrListCast(this.View.dataDoc.tags).length && !StrListCast(this.View.dataDoc[Doc.LayoutFieldKey(this.View.Document) + '_audioAnnotations_text']).length; return this._isEditing && (this._props.Views.length > 1 || (selected && this.View.TagPanelEditing)); } /** * Shows or hides the editing UI for adding/removing Doc tags * @param editing */ @action setToEditing = (editing = true) => { this._isEditing = editing; if (this._props.Views.length === 1) { this.View.TagPanelEditing = editing; editing && this.View.select(false); } }; /** * Adds the specified tag or metadata to the Doc. If the tag is not prefixed with '#', then a '#' prefix is added. * When the tag (after the '#') begins with '@', then a metadata key/value pair is displayed instead of * just the tag. In addition, a suffix of : can be added to set a metadata value * @param tag tag string to add (format: # | #@field(:(=)?value)? ) */ submitTag = undoable( action((tag: string) => { const submittedLabel = tag.trim().replace(/^#/, '').split(':'); if (submittedLabel[0]) { this._props.Views.forEach(view => { TagItem.addTagToDoc(view.Document, (submittedLabel[0].startsWith('@') ? '' : '#') + submittedLabel[0]); if (submittedLabel.length > 1) Doc.SetField(view.Document, submittedLabel[0].replace(/^@/, ''), ':' + submittedLabel[1]); }); } this._currentInput = ''; // Clear the input box }), 'added doc label' ); /** * When 'layout_showTags' is set on a Doc, this displays a wrapping panel of tagItemViews corresponding to all the tags set on the Doc). * When the dropdown is clicked, this will toggle an extended UI that allows additional tags to be added/removed. */ render() { const tagsList = new Set(StrListCast(this.View.dataDoc.tags)); const chatTagsList = new Set(StrListCast(this.View.dataDoc.tags_chat)); const facesList = new Set( DocListCast(this.View.dataDoc[Doc.LayoutDataKey(this.View.Document) + '_annotations']) .concat(this.View.Document) .filter(d => d.face) .map(doc => StrCast(DocCast(doc.face)?.title)) ); this._panelHeightDirty; return this.View.ComponentView?.isUnstyledView?.() || (!this.View.showTags && this._props.Views.length === 1) ? null : (
r && new ResizeObserver( action(() => { if (this._props.Views.length === 1) { this.View.TagPanelHeight = Math.floor(r?.children[0].children[0].getBoundingClientRect().height ?? 0) - Math.floor(r?.children[0].children[0].children[0].getBoundingClientRect().height ?? 0); } }) ).observe(r?.children[0]) } style={{ display: SnappingManager.IsResizing === this.View.Document[Id] ? 'none' : undefined, backgroundColor: this.isEditing ? this._props.background : Colors.TRANSPARENT, borderColor: this.isEditing ? Colors.BLACK : Colors.TRANSPARENT, height: !this._props.Views.lastElement()?.isSelected() ? 0 : undefined, }}>
{this._props.Views.length === 1 && !this.View.showTags ? null : ( // setupMoveUpEvents(this, e, returnFalse, emptyFunction, upEv => { this.setToEditing(!this.isEditing); upEv.stopPropagation(); }) } type={Type.TERT} background="transparent" color={this.View._props.styleProvider?.(this.View.Document, this.View.ComponentView?._props, StyleProp.FontColor) as string} icon={} /> )} {Array.from(tagsList) .filter(tag => (tag.startsWith('#') || tag.startsWith('@')) && !Doc.MyFilterHotKeys.some(key => key.toolType === tag)) .map(tag => ( view.Document)} tag={tag} tagDoc={TagItem.findTagCollectionDoc(tag) ?? TagItem.createTagCollectionDoc(tag)} setToEditing={this.setToEditing} showRemoveUI={this.isEditing} /> ))} {Array.from(facesList).map(tag => ( view.Document)} tag={tag} tagDoc={undefined} setToEditing={this.setToEditing} showRemoveUI={this.isEditing} /> ))}
{this.isEditing ? (
(this._currentInput = e.target.value))} onKeyDown={e => { e.key === 'Enter' ? this.submitTag(this._currentInput) : null; e.stopPropagation(); }} type="text" placeholder="Enter #tags or @metadata" className="tagsView-input" style={{ width: '100%', borderRadius: '5px' }} />
{TagItem.AllTagCollectionDocs.map(doc => StrCast(doc.title)) .filter(tag => (tag.startsWith('#') || tag.startsWith('@')) && !Doc.MyFilterHotKeys.some(key => key.toolType === tag)) .map(tag => (
) : null}
); } }