diff options
author | bobzel <zzzman@gmail.com> | 2024-08-26 10:43:43 -0400 |
---|---|---|
committer | bobzel <zzzman@gmail.com> | 2024-08-26 10:43:43 -0400 |
commit | a52cda0a098716c24b3c6aafe23d2bd9267c72d9 (patch) | |
tree | 68267a356686ef705e498a948fe97e02d0cd3811 /src | |
parent | ac580dab29fc5867680a54b2fbfd68f9d4e2a895 (diff) |
moved KeywordsBox to TagsView. changed imagellable to use tags
Diffstat (limited to 'src')
-rw-r--r-- | src/client/documents/Documents.ts | 8 | ||||
-rw-r--r-- | src/client/views/DocumentButtonBar.tsx | 2 | ||||
-rw-r--r-- | src/client/views/DocumentDecorations.tsx | 6 | ||||
-rw-r--r-- | src/client/views/KeywordBox.tsx | 316 | ||||
-rw-r--r-- | src/client/views/StyleProvider.scss | 64 | ||||
-rw-r--r-- | src/client/views/StyleProvider.tsx | 6 | ||||
-rw-r--r-- | src/client/views/TagsView.scss | 63 | ||||
-rw-r--r-- | src/client/views/TagsView.tsx | 344 | ||||
-rw-r--r-- | src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx | 58 | ||||
-rw-r--r-- | src/client/views/nodes/DocumentView.tsx | 1 | ||||
-rw-r--r-- | src/client/views/nodes/formattedText/RichTextRules.ts | 2 | ||||
-rw-r--r-- | src/fields/Doc.ts | 4 | ||||
-rw-r--r-- | src/fields/DocSymbols.ts | 1 |
13 files changed, 447 insertions, 428 deletions
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 6b5469cca..c0e4e961c 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -43,7 +43,7 @@ export class FInfo { description: string = ''; readOnly: boolean = false; fieldType?: FInfoFieldType; - values?: FieldType[] | Map<any, any>; + values?: FieldType[]; filterable?: boolean = true; // can be used as a Filter in FilterPanel // format?: string; // format to display values (e.g, decimal places, $, etc) @@ -144,10 +144,6 @@ class ListInfo extends FInfo { fieldType? = FInfoFieldType.list; values?: List<FieldType>[] = []; } -class MapInfo extends FInfo { - fieldType? = FInfoFieldType.map; - values?: Map<any, any> = new Map(); -} type BOOLt = BoolInfo | boolean; type NUMt = NumInfo | number; type STRt = StrInfo | string; @@ -160,7 +156,6 @@ type COLLt = CTypeInfo | CollectionViewType; type DROPt = DAInfo | dropActionType; type DATEt = DateInfo | number; type DTYPEt = DTypeInfo | string; -type MAPt = MapInfo | Map<any, any>; export class DocumentOptions { // coordinate and dimensions depending on view x?: NUMt = new NumInfo('horizontal coordinate in freeform view', false); @@ -489,7 +484,6 @@ export class DocumentOptions { cardSort?: STRt = new StrInfo('way cards are sorted in deck view'); cardSort_customField?: STRt = new StrInfo('field key used for sorting cards'); cardSort_visibleSortGroups?: List<number>; // which sorting values are being filtered (shown) - keywords?: MAPt = new MapInfo('keywords', true); } export const DocOptions = new DocumentOptions(); diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx index eb157b9ab..58b7f207c 100644 --- a/src/client/views/DocumentButtonBar.tsx +++ b/src/client/views/DocumentButtonBar.tsx @@ -284,7 +284,7 @@ export class DocumentButtonBar extends ObservableReactComponent<{ views: () => ( get keywordButton() { return !DocumentView.Selected().length ? null : ( <Tooltip title={<div className="dash-keyword-button">Open keyword menu</div>}> - <div className="documentButtonBar-icon" style={{ color: 'white' }} onClick={() => DocumentView.Selected().map(dv => (dv.dataDoc.showLabels = !dv.dataDoc.showLabels))}> + <div className="documentButtonBar-icon" style={{ color: 'white' }} onClick={() => DocumentView.Selected().map(dv => (dv.dataDoc.showTags = !dv.dataDoc.showTags))}> <FontAwesomeIcon className="documentdecorations-icon" icon="tag" /> </div> </Tooltip> diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 19c4a097b..da35459bb 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -1,3 +1,4 @@ +import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; import { IconButton } from 'browndash-components'; @@ -9,7 +10,7 @@ import { lightOrDark, returnFalse, setupMoveUpEvents } from '../../ClientUtils'; import { Utils, emptyFunction, numberValue } from '../../Utils'; import { DateField } from '../../fields/DateField'; import { Doc, DocListCast, Field, FieldType, HierarchyMapping, ReverseHierarchyMap } from '../../fields/Doc'; -import { AclAdmin, AclAugment, AclEdit, DocData, KeywordsHeight } from '../../fields/DocSymbols'; +import { AclAdmin, AclAugment, AclEdit, DocData } from '../../fields/DocSymbols'; import { Id } from '../../fields/FieldSymbols'; import { InkField } from '../../fields/InkField'; import { ScriptField } from '../../fields/ScriptField'; @@ -34,7 +35,6 @@ import { DocumentView } from './nodes/DocumentView'; import { ImageBox } from './nodes/ImageBox'; import { OpenWhere, OpenWhereMod } from './nodes/OpenWhere'; import { FormattedTextBox } from './nodes/formattedText/FormattedTextBox'; -import { IconProp } from '@fortawesome/fontawesome-svg-core'; interface DocumentDecorationsProps { PanelWidth: number; @@ -835,7 +835,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora <div className="link-button-container" style={{ - top: `${doc[DocData].showLabels ? 4 + doc[KeywordsHeight] : 4}px`, + top: `${doc[DocData].showTags ? 4 + seldocview.TagPanelHeight : 4}px`, transform: `translate(${-this._resizeBorderWidth / 2 + 10}px, ${this._resizeBorderWidth + bounds.b - bounds.y + this._titleHeight}px) `, }}> <DocumentButtonBar views={() => DocumentView.Selected()} /> diff --git a/src/client/views/KeywordBox.tsx b/src/client/views/KeywordBox.tsx deleted file mode 100644 index 841c5394b..000000000 --- a/src/client/views/KeywordBox.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Button, Colors, IconButton } from 'browndash-components'; -import { action, computed, makeObservable, observable } from 'mobx'; -import { observer } from 'mobx-react'; -import React from 'react'; -import ResizeObserver from 'resize-observer-polyfill'; -import { returnFalse, setupMoveUpEvents } from '../../ClientUtils'; -import { Utils, emptyFunction } from '../../Utils'; -import { Doc, DocListCast, StrListCast } from '../../fields/Doc'; -import { DocData, KeywordsHeight } from '../../fields/DocSymbols'; -import { List } from '../../fields/List'; -import { NumCast, 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 { DocumentView } from './nodes/DocumentView'; -import { FontIconBox } from './nodes/FontIconBox/FontIconBox'; - -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> { - /** - * return list of all Docs that collect Docs with specified keywords - */ - public static get AllKeywordCollections() { - return DocListCast(Doc.ActiveDashboard?.myKeywordCollections); - } - /** - * Find Doc that collects all Docs with given keyword - * @param keyword keyword string - * @returns keyword collection Doc or undefined - */ - public static findKeywordCollectionDoc = (keyword: String) => KeywordItem.AllKeywordCollections.find(doc => doc.title === keyword); - - /** - * Creates a Doc that collects Docs with the specified keyword - * @param keyword keyword string - * @returns collection Doc - */ - public static createKeywordCollectionDoc = (keyword: string) => { - const newKeywordCol = new Doc(); - newKeywordCol.title = keyword; - newKeywordCol.collections = new List<Doc>(); - newKeywordCol[DocData].docs = new List<Doc>(); - // If the active Dashboard does not have a keyword collection, create it. - if (Doc.ActiveDashboard) { - if (!Doc.ActiveDashboard.myKeywordCollections) Doc.ActiveDashboard.myKeywordCollections = new List<Doc>(); - Doc.AddDocToList(Doc.ActiveDashboard, 'myKeywordCollections', newKeywordCol); - } - - return newKeywordCol; - }; - /** - * Gets all Docs that have the specified keyword - * @param keyword keyword string - * @returns An array of documents that contain the keyword. - */ - public static allDocsWithKeyword = (keyword: string) => DocListCast(KeywordItem.findKeywordCollectionDoc(keyword)?.[DocData].docs); - - /** - * Adds a keyword to the metadata of this document - * @param keyword keyword string - */ - public static addLabelToDoc = (doc: Doc, keyword: string) => { - // If the keyword collection is not in active Dashboard, add it as a new doc, with the keyword as its title. - const keywordCollection = KeywordItem.findKeywordCollectionDoc(keyword) ?? KeywordItem.createKeywordCollectionDoc(keyword); - - // If the document is of type COLLECTION, make it a smart collection, otherwise, add the keyword to the document. - if (doc.type === DocumentType.COL) { - Doc.AddDocToList(keywordCollection[DocData], 'collections', doc); - - // Iterate through the keyword Doc collections and add a copy of the document to each collection - for (const cdoc of DocListCast(keywordCollection[DocData].docs)) { - if (!DocListCast(doc[DocData].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 keyword's collection of associated documents. - Doc.AddDocToList(keywordCollection[DocData], 'docs', 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)) { - if (!DocListCast(collection[DocData].data).find(d => Doc.AreProtosEqual(d, doc))) { - const newEmbedding = Doc.MakeEmbedding(doc); - Doc.AddDocToList(collection[DocData], 'data', newEmbedding); - Doc.SetContainer(newEmbedding, collection); - } - } - } - - if (!doc[DocData].tags) doc[DocData].tags = new List<string>(); - const tagList = doc[DocData].tags as List<string>; - if (!tagList.includes(keyword)) tagList.push(keyword); - }; - - public static RemoveLabel = (doc: Doc, keyword: string, keywordDoc?: Doc) => { - if (doc[DocData].tags) { - if (doc.type === DocumentType.COL) { - keywordDoc && Doc.RemoveDocFromList(keywordDoc[DocData], 'collections', doc); - - for (const cur_doc of KeywordItem.allDocsWithKeyword(keyword)) { - doc[DocData].data = new List<Doc>(DocListCast(doc[DocData].data).filter(d => !Doc.AreProtosEqual(cur_doc, d))); - } - } else { - keywordDoc && Doc.RemoveDocFromList(keywordDoc[DocData], 'docs', doc); - - for (const collection of DocListCast(keywordDoc?.collections)) { - collection[DocData].data = new List<Doc>(DocListCast(collection[DocData].data).filter(d => !Doc.AreProtosEqual(doc, d))); - } - } - } - doc[DocData].tags = new List<string>((doc[DocData].tags as List<string>).filter(label => label !== keyword)); - }; - - private _ref: React.RefObject<HTMLDivElement>; - - constructor(props: any) { - super(props); - makeObservable(this); - this._ref = React.createRef(); - } - - /** - * Creates a smart collection. - * @returns - */ - createCollection = () => { - // Get the documents that contain the keyword. - const newEmbeddings = KeywordItem.allDocsWithKeyword(this._props.keyword).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; - docData.tags = new List<string>([this._props.keyword]); - docData.showLabels = true; - docData.freeform_fitContentsToBox = true; - doc._freeform_panX = doc._freeform_panY = 0; - doc._width = 900; - doc._height = 900; - doc.layout_fitWidth = true; - return doc; - })(Doc.MakeCopy(Doc.UserDoc().emptyCollection as Doc, true)); - newEmbeddings.forEach(embed => Doc.SetContainer(embed, newCollection)); - - // Add the collection to the keyword document's list of associated smart collections. - this._props.keywordDoc && Doc.AddDocToList(this._props.keywordDoc, 'collections', newCollection); - return newCollection; - }; - - @action - handleDragStart = (e: React.PointerEvent) => { - 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(); - }; - - render() { - setTimeout(() => KeywordItem.addLabelToDoc(this._props.doc, this._props.keyword)); // bcz: hack to make sure that Docs are added to their keyword Doc collection since metadata can get set anywhere without a guard triggering an add to the collection - const keyword = this._props.keyword.replace(/^#/, ''); - const metadata = keyword.startsWith('@') ? keyword.replace(/^@/, '') : ''; - return ( - <div className="keyword" onClick={this._props.setToEditing} onPointerDown={this.handleDragStart} ref={this._ref} key={Utils.GenerateGuid()}> - {metadata ? ( - <span> - <b style={{ fontSize: 'smaller' }}>{keyword} </b> - {this._props.doc[metadata] as string} - </span> - ) : ( - keyword - )} - {this.props.isEditing && ( - <IconButton - tooltip="Remove label" - onPointerDown={undoable(() => KeywordItem.RemoveLabel(this._props.doc, this._props.keyword, this._props.keywordDoc), `remove label ${this._props.keyword}`)} - icon={<FontAwesomeIcon icon="times" size="sm" />} - style={{ width: '8px', height: '8px', marginLeft: '10px' }} - /> - )} - </div> - ); - } -} - -interface KeywordBoxProps { - View: DocumentView; -} - -/** - * A component that handles the keyword display for documents. - */ -@observer -export class KeywordBox extends ObservableReactComponent<KeywordBoxProps> { - private _ref: React.RefObject<HTMLDivElement>; - - constructor(props: any) { - super(props); - makeObservable(this); - this._ref = React.createRef(); - } - - @observable _currentInput = ''; - @observable _isEditing = !StrListCast(this._props.View.dataDoc.tags).length; - - @computed get currentScale() { - return NumCast((this._props.View.Document.embedContainer as Doc)?._freeform_scale, 1); - } - @computed get isEditing() { - return this._isEditing && DocumentView.SelectedDocs().includes(this._props.View.Document); - } - - @action - setToEditing = (editing = true) => { - this._isEditing = editing; - editing && this._props.View.select(false); - }; - - /** - * Adds the keyword to the document. - * @param keyword - */ - submitLabel = undoable((keyword: string) => { - const submittedLabel = keyword.trim(); - submittedLabel && KeywordItem.addLabelToDoc(this._props.View.Document, '#' + submittedLabel.replace(/^#/, '')); - this._currentInput = ''; // Clear the input box - }, 'added doc label'); - - render() { - const keywordsList = StrListCast(this._props.View.dataDoc.tags); - - return !this._props.View.Document.showLabels ? null : ( - <div - className="keywords-container" - ref={r => r && new ResizeObserver(action(() => (this._props.View.Document[KeywordsHeight] = r?.getBoundingClientRect().height ?? 0))).observe(r)} - style={{ - transformOrigin: 'top left', - maxWidth: `${100 * this.currentScale}%`, - width: 'max-content', - transform: `scale(${1 / this.currentScale})`, - backgroundColor: this.isEditing ? Colors.LIGHT_GRAY : Colors.TRANSPARENT, - borderColor: this.isEditing ? Colors.BLACK : Colors.TRANSPARENT, - }}> - <div className="keywords-content" style={{ width: '100%' }}> - <div className="keywords-list"> - {!keywordsList.length ? null : ( // - <IconButton style={{ width: '8px' }} tooltip="Close Menu" onPointerDown={() => this.setToEditing(!this._isEditing)} icon={<FontAwesomeIcon icon={this._isEditing ? 'chevron-up' : 'chevron-down'} size="sm" />} /> - )} - {keywordsList.map(keyword => ( - <KeywordItem key={keyword} doc={this._props.View.Document} keyword={keyword} keywordDoc={KeywordItem.findKeywordCollectionDoc(keyword)} setToEditing={this.setToEditing} isEditing={this.isEditing} /> - ))} - </div> - {this.isEditing ? ( - <div className="keyword-editing-box"> - <div className="keyword-input-box"> - <input - value={this._currentInput} - autoComplete="off" - onChange={action(e => (this._currentInput = e.target.value))} - 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> - <div className="keyword-suggestions-box"> - {KeywordItem.AllKeywordCollections.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> - ) : null} - </div> - </div> - ); - } -} diff --git a/src/client/views/StyleProvider.scss b/src/client/views/StyleProvider.scss index 6f6939d54..ce00f6101 100644 --- a/src/client/views/StyleProvider.scss +++ b/src/client/views/StyleProvider.scss @@ -53,67 +53,3 @@ .styleProvider-treeView-icon { opacity: 0; } - -.keywords-container { - display: flex; - flex-wrap: wrap; - flex-direction: column; - border: 1px solid; - border-radius: 4px; -} - -.keywords-list { - display: flex; - flex-wrap: wrap; - .iconButton-container { - min-height: unset !important; - } -} - -.keyword { - padding: 1px 5px; - background-color: lightblue; - border: 1px solid black; - border-radius: 5px; - white-space: nowrap; - display: flex; - align-items: center; -} - -.keyword-suggestions-box { - display: flex; - flex-wrap: wrap; - margin: auto; - align-self: center; - width: 90%; - border: 1px solid black; - border-radius: 2px; - margin-top: 8px; -} - -.keyword-suggestion { - cursor: pointer; - padding: 1px 1px; - margin: 2px 2px; - background-color: lightblue; - border: 1px solid black; - border-radius: 5px; - white-space: nowrap; - display: flex; - align-items: center; -} - -.keyword-editing-box { - margin-top: 8px; -} - -.keyword-input-box { - margin: auto; - align-self: center; - width: 90%; -} - -.keyword-buttons { - margin-left: auto; - width: 10%; -} diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index a841ec63a..e48994586 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -19,7 +19,7 @@ import { SnappingManager } from '../util/SnappingManager'; import { undoable, UndoManager } from '../util/UndoManager'; import { TreeSort } from './collections/TreeSort'; import { Colors } from './global/globalEnums'; -import { KeywordBox } from './KeywordBox'; +import { TagsView } from './TagsView'; import { CollectionFreeFormDocumentView } from './nodes/CollectionFreeFormDocumentView'; import { DocumentView, DocumentViewProps } from './nodes/DocumentView'; import { FieldViewProps } from './nodes/FieldView'; @@ -363,14 +363,14 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & </Tooltip> ); }; - const keywords = () => props?.DocumentView?.() && CollectionFreeFormDocumentView.from(props.DocumentView()) ? <KeywordBox View={props.DocumentView()}/> : null; + const tags = () => props?.DocumentView?.() && CollectionFreeFormDocumentView.from(props.DocumentView()) ? <TagsView View={props.DocumentView()}/> : null; return ( <> {paint()} {lock()} {filter()} {audio()} - {keywords()} + {tags()} </> ); } diff --git a/src/client/views/TagsView.scss b/src/client/views/TagsView.scss new file mode 100644 index 000000000..f7365a51b --- /dev/null +++ b/src/client/views/TagsView.scss @@ -0,0 +1,63 @@ +.tagsView-container { + display: flex; + flex-wrap: wrap; + flex-direction: column; + border: 1px solid; + border-radius: 4px; +} + +.tagsView-list { + display: flex; + flex-wrap: wrap; + .iconButton-container { + min-height: unset !important; + } +} + +.tagItem { + padding: 1px 5px; + background-color: lightblue; + border: 1px solid black; + border-radius: 5px; + white-space: nowrap; + display: flex; + align-items: center; +} + +.tagsView-suggestions-box { + display: flex; + flex-wrap: wrap; + margin: auto; + align-self: center; + width: 90%; + border: 1px solid black; + border-radius: 2px; + margin-top: 8px; +} + +.tagsView-suggestion { + cursor: pointer; + padding: 1px 1px; + margin: 2px 2px; + background-color: lightblue; + border: 1px solid black; + border-radius: 5px; + white-space: nowrap; + display: flex; + align-items: center; +} + +.tagsView-editing-box { + margin-top: 8px; +} + +.tagsView-input-box { + margin: auto; + align-self: center; + width: 90%; +} + +.tagsView-buttons { + margin-left: auto; + width: 10%; +} diff --git a/src/client/views/TagsView.tsx b/src/client/views/TagsView.tsx new file mode 100644 index 000000000..dffd1e096 --- /dev/null +++ b/src/client/views/TagsView.tsx @@ -0,0 +1,344 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Button, Colors, IconButton } from 'browndash-components'; +import { action, computed, makeObservable, observable } 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, Opt, StrListCast } from '../../fields/Doc'; +import { DocData } from '../../fields/DocSymbols'; +import { List } from '../../fields/List'; +import { NumCast, 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'; + +/** + * 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 { + doc: Doc; + tag: string; + tagDoc: Opt<Doc>; + 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<TagItemProps> { + /** + * 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<Doc>(); + newTagCol[DocData].docs = new List<Doc>(); + // If the active Dashboard does not have a tag Doc collection, create it. + if (Doc.ActiveDashboard) { + if (!Doc.ActiveDashboard.myTagCollections) Doc.ActiveDashboard.myTagCollections = new List<Doc>(); + 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)?.[DocData].docs); + + /** + * 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.AddDocToList(tagCollection[DocData], 'collections', doc); + + // Iterate through the tag Doc collections and add a copy of the document to each collection + for (const cdoc of DocListCast(tagCollection[DocData].docs)) { + if (!DocListCast(doc[DocData].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[DocData].data).find(d => Doc.AreProtosEqual(d, doc))) { + const newEmbedding = Doc.MakeEmbedding(doc); + Doc.AddDocToList(collection[DocData], 'data', newEmbedding); + Doc.SetContainer(newEmbedding, collection); + } + } + } + + if (!doc[DocData].tags) doc[DocData].tags = new List<string>(); + const tagList = doc[DocData].tags as List<string>; + 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[DocData].tags) { + if (doc.type === DocumentType.COL) { + tagDoc && Doc.RemoveDocFromList(tagDoc[DocData], 'collections', doc); + + for (const cur_doc of TagItem.allDocsWithTag(tag)) { + doc[DocData].data = new List<Doc>(DocListCast(doc[DocData].data).filter(d => !Doc.AreProtosEqual(cur_doc, d))); + } + } else { + tagDoc && Doc.RemoveDocFromList(tagDoc[DocData], 'docs', doc); + + for (const collection of DocListCast(tagDoc?.collections)) { + collection[DocData].data = new List<Doc>(DocListCast(collection[DocData].data).filter(d => !Doc.AreProtosEqual(doc, d))); + } + } + } + doc[DocData].tags = new List<string>((doc[DocData].tags as List<string>).filter(label => label !== tag)); + }; + + private _ref: React.RefObject<HTMLDivElement>; + + constructor(props: any) { + super(props); + makeObservable(this); + this._ref = React.createRef(); + } + + /** + * Creates a smart collection. + * @returns + */ + createTagCollection = () => { + // 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 newCollection = ((doc: Doc) => { + const docData = doc[DocData]; + docData.data = new List<Doc>(newEmbeddings); + docData.title = this._props.tag; + docData.tags = new List<string>([this._props.tag]); + docData.showTags = true; + docData.freeform_fitContentsToBox = true; + doc._freeform_panX = doc._freeform_panY = 0; + doc._width = 900; + doc._height = 900; + doc.layout_fitWidth = true; + return doc; + })(Doc.MakeCopy(Doc.UserDoc().emptyCollection as Doc, true)); + 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 dragData = new DragManager.DocumentDragData([this.createTagCollection()]); + DragManager.StartDocumentDrag([this._ref.current!], dragData, e.clientX, e.clientY, {}); + return true; + }, + returnFalse, + emptyFunction + ); + e.preventDefault(); + }; + + render() { + setTimeout(() => TagItem.addTagToDoc(this._props.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 tag = this._props.tag.replace(/^#/, ''); + const metadata = tag.startsWith('@') ? tag.replace(/^@/, '') : ''; + return ( + <div className="tagItem" onClick={this._props.setToEditing} onPointerDown={this.handleDragStart} ref={this._ref}> + {metadata ? ( + <span> + <b style={{ fontSize: 'smaller' }}>{tag} </b> + {this._props.doc[metadata] as string} + </span> + ) : ( + tag + )} + {this.props.showRemoveUI && ( + <IconButton + tooltip="Remove tag" + onPointerDown={undoable(() => TagItem.removeTagFromDoc(this._props.doc, this._props.tag, this._props.tagDoc), `remove tag ${this._props.tag}`)} + icon={<FontAwesomeIcon icon="times" size="sm" />} + style={{ width: '8px', height: '8px', marginLeft: '10px' }} + /> + )} + </div> + ); + } +} + +interface TagViewProps { + View: DocumentView; +} + +/** + * 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<TagViewProps> { + private _ref: React.RefObject<HTMLDivElement>; + + constructor(props: any) { + super(props); + makeObservable(this); + this._ref = React.createRef(); + } + + @observable _currentInput = ''; + @observable _isEditing = !StrListCast(this._props.View.dataDoc.tags).length; + + @computed get currentScale() { + return NumCast((this._props.View.Document.embedContainer as Doc)?._freeform_scale, 1); + } + @computed get isEditing() { + return this._isEditing && DocumentView.SelectedDocs().includes(this._props.View.Document); + } + + @action + setToEditing = (editing = true) => { + this._isEditing = editing; + editing && this._props.View.select(false); + }; + + /** + * Adds the specified tag to the Doc. If the tag is not prefixed with '#', then a '#' prefix is added. + * Whne the tag (after the '#') begins with '@', then a metadata key/value pair is displayed instead of + * just the tag. + * @param tag tag string to add + */ + submitTag = undoable((tag: string) => { + const submittedLabel = tag.trim(); + submittedLabel && TagItem.addTagToDoc(this._props.View.Document, '#' + submittedLabel.replace(/^#/, '')); + this._currentInput = ''; // Clear the input box + }, 'added doc label'); + + /** + * When '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 = StrListCast(this._props.View.dataDoc.tags); + + return !this._props.View.Document.showTags ? null : ( + <div + className="tagsView-container" + ref={r => r && new ResizeObserver(action(() => (this._props.View.TagPanelHeight = r?.getBoundingClientRect().height ?? 0))).observe(r)} + style={{ + transformOrigin: 'top left', + maxWidth: `${100 * this.currentScale}%`, + width: 'max-content', + transform: `scale(${1 / this.currentScale})`, + backgroundColor: this.isEditing ? Colors.LIGHT_GRAY : Colors.TRANSPARENT, + borderColor: this.isEditing ? Colors.BLACK : Colors.TRANSPARENT, + }}> + <div className="tagsView-content" style={{ width: '100%' }}> + <div className="tagsView-list"> + {!tagsList.length ? null : ( // + <IconButton style={{ width: '8px' }} tooltip="Close Menu" onPointerDown={() => this.setToEditing(!this._isEditing)} icon={<FontAwesomeIcon icon={this._isEditing ? 'chevron-up' : 'chevron-down'} size="sm" />} /> + )} + {tagsList.map(tag => ( + <TagItem key={tag} doc={this._props.View.Document} tag={tag} tagDoc={TagItem.findTagCollectionDoc(tag)} setToEditing={this.setToEditing} showRemoveUI={this.isEditing} /> + ))} + </div> + {this.isEditing ? ( + <div className="tagsView-editing-box"> + <div className="tagsView-input-box"> + <input + value={this._currentInput} + autoComplete="off" + onChange={action(e => (this._currentInput = e.target.value))} + onKeyDown={e => { + e.key === 'Enter' ? this.submitTag(this._currentInput) : null; + e.stopPropagation(); + }} + type="text" + placeholder="Input tags for document..." + aria-label="tagsView-input" + className="tagsView-input" + style={{ width: '100%', borderRadius: '5px' }} + /> + </div> + <div className="tagsView-suggestions-box"> + {TagItem.AllTagCollectionDocs.map((doc, i) => { + const tag = StrCast(doc.title); + return ( + <Button + style={{ margin: '2px 2px', border: '1px solid black', backgroundColor: 'lightblue', color: 'black' }} + text={tag} + color={SnappingManager.userVariantColor} + tooltip="Add existing tag" + onClick={() => this.submitTag(tag)} + key={i} + /> + ); + })} + </div> + </div> + ) : null} + </div> + </div> + ); + } +} diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx index 421b5d0a6..6eb3eb784 100644 --- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx +++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx @@ -1,30 +1,30 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Colors, IconButton } from 'browndash-components'; +import similarity from 'compute-cosine-similarity'; +import { ring } from 'ldrs'; +import 'ldrs/ring'; import { action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import React from 'react'; +import { Utils, numberRange } from '../../../../Utils'; import { Doc, NumListCast, Opt } from '../../../../fields/Doc'; -import { Docs } from '../../../documents/Documents'; -import { DocumentType } from '../../../documents/DocumentTypes'; -import { ViewBoxBaseComponent } from '../../DocComponent'; -import { FieldView, FieldViewProps } from '../../nodes/FieldView'; -import { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; -import './ImageLabelBox.scss'; -import { MainView } from '../../MainView'; -import 'ldrs/ring'; -import { ring } from 'ldrs'; -import { SnappingManager } from '../../../util/SnappingManager'; -import { ImageCast } from '../../../../fields/Types'; import { DocData } from '../../../../fields/DocSymbols'; -import { SettingsManager } from '../../../util/SettingsManager'; -import { CollectionCardView } from '../CollectionCardDeckView'; -import { gptGetEmbedding, gptImageLabel } from '../../../apis/gpt/GPT'; -import { numberRange, Utils } from '../../../../Utils'; import { List } from '../../../../fields/List'; +import { ImageCast } from '../../../../fields/Types'; +import { gptGetEmbedding, gptImageLabel } from '../../../apis/gpt/GPT'; +import { DocumentType } from '../../../documents/DocumentTypes'; +import { Docs } from '../../../documents/Documents'; import { DragManager } from '../../../util/DragManager'; -import { OpenWhere } from '../../nodes/OpenWhere'; -import similarity from 'compute-cosine-similarity'; +import { SettingsManager } from '../../../util/SettingsManager'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { ViewBoxBaseComponent } from '../../DocComponent'; +import { MainView } from '../../MainView'; import { DocumentView } from '../../nodes/DocumentView'; +import { FieldView, FieldViewProps } from '../../nodes/FieldView'; +import { OpenWhere } from '../../nodes/OpenWhere'; +import { CollectionCardView } from '../CollectionCardDeckView'; +import './ImageLabelBox.scss'; +import { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; export class ImageInformationItem {} @@ -139,9 +139,9 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { toggleDisplayInformation = () => { this._displayImageInformation = !this._displayImageInformation; if (this._displayImageInformation) { - this._selectedImages.forEach(doc => (doc[DocData].showLabels = true)); + this._selectedImages.forEach(doc => (doc[DocData].showTags = true)); } else { - this._selectedImages.forEach(doc => (doc[DocData].showLabels = false)); + this._selectedImages.forEach(doc => (doc[DocData].showTags = false)); } }; @@ -163,7 +163,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { // Converts the images into a Base64 format, afterwhich the information is sent to GPT to label them. const imageInfos = this._selectedImages.map(async doc => { - if (!doc[DocData].data_labels) { + if (!doc[DocData].tags) { const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.'); return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 => !hrefBase64 ? undefined : @@ -174,14 +174,14 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { (await Promise.all(imageInfos)).forEach(imageInfo => { if (imageInfo) { - imageInfo.doc[DocData].data_labels = new List<string>(); + imageInfo.doc[DocData].tags = (imageInfo.doc[DocData].tags as List<string>) ?? new List<string>(); const labels = imageInfo.labels.split('\n'); labels.forEach(label => { - label = label.replace(/^\d+\.\s*|-|\*/, '').trim(); + label = label.replace(/^\d+\.\s*|-|f\*/, '').trim(); console.log(label); - imageInfo.doc[DocData][`${label}`] = true; - (imageInfo.doc[DocData].data_labels as List<string>).push(label); + imageInfo.doc[DocData][label] = true; + (imageInfo.doc[DocData].tags as List<string>).push(label); }); } }); @@ -196,10 +196,10 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { this.startLoading(); for (const doc of this._selectedImages) { - for (let index = 0; index < (doc[DocData].data_labels as List<string>).length; index++) { - const label = (doc[DocData].data_labels as List<string>)[index]; + for (let index = 0; index < (doc[DocData].tags as List<string>).length; index++) { + const label = (doc[DocData].tags as List<string>)[index]; const embedding = await gptGetEmbedding(label); - doc[DocData][`data_labels_embedding_${index + 1}`] = new List<number>(embedding); + doc[DocData][`tags_embedding_${index + 1}`] = new List<number>(embedding); } } @@ -210,7 +210,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { // For each image, loop through the labels, and calculate similarity. Associate it with the // most similar one. this._selectedImages.forEach(doc => { - const embedLists = numberRange((doc[DocData].data_labels as List<string>).length).map(n => Array.from(NumListCast(doc[DocData][`data_labels_embedding_${n + 1}`]))); + const embedLists = numberRange((doc[DocData].tags as List<string>).length).map(n => Array.from(NumListCast(doc[DocData][`tags_embedding_${n + 1}`]))); const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map((l, index) => (embedding && similarity(Array.from(embedding), l)!) || 0)); const {label: mostSimilarLabelCollect} = this._labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) })) @@ -317,7 +317,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { await DocumentView.showDocument(doc, { willZoomCentered: true }); }}></img> <div className="image-information-labels" onClick={() => this._props.addDocTab(doc, OpenWhere.addRightKeyvalue)}> - {(doc[DocData].data_labels as List<string>).map(label => { + {(doc[DocData].tags as List<string>).map(label => { return ( <div key={Utils.GenerateGuid()} className="image-label" style={{ backgroundColor: SettingsManager.userVariantColor, borderColor: SettingsManager.userColor }}> {label} diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 5efdb3df4..4c357cf45 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -1124,6 +1124,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { @observable private _isHovering = false; @observable private _selected = false; @observable public static CurrentlyPlaying: DocumentView[] = []; // audio or video media views that are currently playing + @observable public TagPanelHeight = 0; @computed private get shouldNotScale() { return (this.layout_fitWidth && !this.nativeWidth) || this.ComponentView?.isUnstyledView?.(); diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts index 79c118490..e0d6c7c05 100644 --- a/src/client/views/nodes/formattedText/RichTextRules.ts +++ b/src/client/views/nodes/formattedText/RichTextRules.ts @@ -404,7 +404,7 @@ export class RichTextRules { if (!tags.includes(tag)) { tags.push(tag); this.Document[DocData].tags = new List<string>(tags); - this.Document[DocData].showLabels = true; + this.Document[DocData].showTags = true; } const fieldView = state.schema.nodes.dashField.create({ fieldKey: '#' + tag }); return state.tr diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index f246f49e5..ffb5aab79 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -11,7 +11,7 @@ import { ClientUtils, incrementTitleCopy } from '../ClientUtils'; import { AclAdmin, AclAugment, AclEdit, AclPrivate, AclReadonly, Animation, AudioPlay, Brushed, CachedUpdates, DirectLinks, DocAcl, DocCss, DocData, DocLayout, DocViews, FieldKeys, FieldTuples, ForceServerWrite, Height, Highlight, - Initializing, KeywordsHeight, Self, SelfProxy, TransitionTimer, UpdatingFromServer, Width + Initializing, Self, SelfProxy, TransitionTimer, UpdatingFromServer, Width } from './DocSymbols'; // prettier-ignore import { Copy, FieldChanged, HandleUpdate, Id, Parent, ToJavascriptString, ToScriptString, ToString } from './FieldSymbols'; import { InkTool } from './InkField'; @@ -303,7 +303,6 @@ export class Doc extends RefField { Height, Highlight, Initializing, - KeywordsHeight, Self, SelfProxy, UpdatingFromServer, @@ -369,7 +368,6 @@ export class Doc extends RefField { @observable public [Highlight]: boolean = false; @observable public [Brushed]: boolean = false; @observable public [DocViews] = new ObservableSet<unknown /* DocumentView */>(); - @observable public [KeywordsHeight]: number = 0; private [Self] = this; private [SelfProxy]: Doc; diff --git a/src/fields/DocSymbols.ts b/src/fields/DocSymbols.ts index 9e091ab29..dc18d8638 100644 --- a/src/fields/DocSymbols.ts +++ b/src/fields/DocSymbols.ts @@ -32,6 +32,5 @@ export const DocViews = Symbol('DocViews'); export const Brushed = Symbol('DocBrushed'); export const DocCss = Symbol('DocCss'); export const TransitionTimer = Symbol('DocTransitionTimer'); -export const KeywordsHeight = Symbol('DocKeywordsHeight'); export const DashVersion = 'v0.8.0'; |