aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/client/documents/Documents.ts8
-rw-r--r--src/client/views/DocumentButtonBar.tsx2
-rw-r--r--src/client/views/DocumentDecorations.tsx6
-rw-r--r--src/client/views/KeywordBox.tsx316
-rw-r--r--src/client/views/StyleProvider.scss64
-rw-r--r--src/client/views/StyleProvider.tsx6
-rw-r--r--src/client/views/TagsView.scss63
-rw-r--r--src/client/views/TagsView.tsx344
-rw-r--r--src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx58
-rw-r--r--src/client/views/nodes/DocumentView.tsx1
-rw-r--r--src/client/views/nodes/formattedText/RichTextRules.ts2
-rw-r--r--src/fields/Doc.ts4
-rw-r--r--src/fields/DocSymbols.ts1
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}&nbsp;</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}&nbsp;</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';