From b440901843a930c6c87ec23c59f90f1349c25b50 Mon Sep 17 00:00:00 2001
From: IEatChili <nanunguyen99@gmail.com>
Date: Thu, 6 Jun 2024 15:23:12 -0400
Subject: feat: updated ui

---
 .../collectionFreeForm/ImageLabelBox.tsx           | 150 +++++++++++++++++++++
 1 file changed, 150 insertions(+)
 create mode 100644 src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx

(limited to 'src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx')

diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
new file mode 100644
index 000000000..1c0035f0d
--- /dev/null
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -0,0 +1,150 @@
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { Colors, IconButton } from 'browndash-components';
+import { action, makeObservable, observable } from 'mobx';
+import { observer } from 'mobx-react';
+import React from 'react';
+import { Doc } 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 { MarqueeView } from './MarqueeView';
+
+@observer
+export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
+    public static LayoutString(fieldKey: string) {
+        return FieldView.LayoutString(ImageLabelBox, fieldKey);
+    }
+
+    public static Instance: ImageLabelBox | null = null;
+    private _inputRef = React.createRef<HTMLInputElement>();
+    @observable _loading: boolean = true;
+    @observable _currentLabel: string = '';
+    @observable _labelGroups: string[] = [];
+
+    constructor(props: any) {
+        super(props);
+        makeObservable(this);
+        ImageLabelBox.Instance = this;
+
+        console.log('Image Box Has Been Initialized');
+    }
+
+    /**
+     * This method is called when the SearchBox component is first mounted. When the user opens
+     * the search panel, the search input box is automatically selected. This allows the user to
+     * type in the search input box immediately, without needing clicking on it first.
+     */
+    componentDidMount() {
+        // if (this._inputRef.current) {
+        //     this._inputRef.current.focus();
+        // }
+    }
+
+    @action
+    addLabel = (label: string) => {
+        label = label.toUpperCase().trim();
+        if (label.length > 0) {
+            if (!this._labelGroups.includes(label)) {
+                this._labelGroups = [...this._labelGroups, label];
+            }
+        }
+    };
+
+    @action
+    removeLabel = (label: string) => {
+        const labelUp = label.toUpperCase();
+        this._labelGroups = this._labelGroups.filter(group => group !== labelUp);
+    };
+
+    @action
+    groupImages = () => {
+        MarqueeOptionsMenu.Instance.groupImages();
+        this._labelGroups = [];
+        MainView.Instance.closeFlyout();
+    };
+
+    @action
+    startLoading = () => {
+        this._loading = true;
+    };
+
+    @action
+    endLoading = () => {
+        this._loading = false;
+    };
+
+    render() {
+        if (this._loading) {
+            return <div className="searchBox-container">Loading...</div>;
+        }
+
+        return (
+            <div className="searchBox-container">
+                <div className="searchBox-bar">
+                    <input
+                        defaultValue=""
+                        autoComplete="off"
+                        // onKeyDown={e => {
+                        //     e.key === 'Enter' ? this.submitSearch() : null;
+                        //     e.stopPropagation();
+                        // }}
+                        type="text"
+                        placeholder="Input a group to put images into..."
+                        aria-label="label-input"
+                        id="new-label"
+                        className="searchBox-input"
+                        style={{ width: '100%', borderRadius: '5px' }}
+                        ref={this._inputRef}
+                    />
+                    <IconButton
+                        tooltip={'Add group'}
+                        onPointerDown={() => {
+                            const input = document.getElementById('new-label') as HTMLInputElement;
+                            const newLabel = input.value;
+                            this.addLabel(newLabel);
+                            this._currentLabel = '';
+                            input.value = '';
+                        }}
+                        icon={<FontAwesomeIcon icon="plus" />}
+                        color={MarqueeOptionsMenu.Instance.userColor}
+                        style={{ width: '19px' }}
+                    />
+                    {this._labelGroups.length > 0 ? (
+                        <IconButton tooltip={'Group Images'} onPointerDown={this.groupImages} icon={<FontAwesomeIcon icon="object-group" />} color={MarqueeOptionsMenu.Instance.userColor} style={{ width: '19px' }} />
+                    ) : (
+                        <div></div>
+                    )}
+                </div>
+                <div>
+                    <div className="image-label-list">
+                        {this._labelGroups.map(group => {
+                            return (
+                                <div>
+                                    <p style={{ color: MarqueeOptionsMenu.Instance.userColor }}>{group}</p>
+                                    <IconButton
+                                        tooltip={'Remove Label'}
+                                        onPointerDown={() => {
+                                            this.removeLabel(group);
+                                        }}
+                                        icon={'x'}
+                                        color={MarqueeOptionsMenu.Instance.userColor}
+                                        style={{ width: '8px' }}
+                                    />
+                                </div>
+                            );
+                        })}
+                    </div>
+                </div>
+            </div>
+        );
+    }
+}
+
+Docs.Prototypes.TemplateMap.set(DocumentType.IMAGEGROUPER, {
+    layout: { view: ImageLabelBox, dataField: 'data' },
+    options: { acl: '', _width: 400 },
+});
-- 
cgit v1.2.3-70-g09d2


From 56568bbb0db34c0129b6a3c8770ce3f889dcbd31 Mon Sep 17 00:00:00 2001
From: IEatChili <nanunguyen99@gmail.com>
Date: Mon, 10 Jun 2024 11:06:02 -0400
Subject: feat: ui changes

---
 package-lock.json                                  |  6 +++
 package.json                                       |  1 +
 .../collectionFreeForm/ImageLabelBox.scss          | 23 +++++++++
 .../collectionFreeForm/ImageLabelBox.tsx           | 60 ++++++++++++++++++----
 .../collectionFreeForm/MarqueeOptionsMenu.tsx      |  2 +-
 .../collections/collectionFreeForm/MarqueeView.tsx |  9 ++--
 6 files changed, 86 insertions(+), 15 deletions(-)

(limited to 'src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx')

diff --git a/package-lock.json b/package-lock.json
index 9b3904ff7..6cd2ab605 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -136,6 +136,7 @@
                 "js-datepicker": "^5.18.2",
                 "jsonschema": "^1.4.1",
                 "jszip": "^3.10.1",
+                "ldrs": "^1.0.2",
                 "lodash": "^4.17.21",
                 "mapbox-gl": "^3.0.1",
                 "markdown-it": "^14.1.0",
@@ -23613,6 +23614,11 @@
                 "safe-buffer": "~5.1.0"
             }
         },
+        "node_modules/ldrs": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/ldrs/-/ldrs-1.0.2.tgz",
+            "integrity": "sha512-sYJmivdkIiHrUEqTrEWccBoLdaENpzbzkABI5rk8rRxTXrg9i2xVuDvUUuhOhJY3RmQyaoxs046pM1DCRdcIpg=="
+        },
         "node_modules/leac": {
             "version": "0.6.0",
             "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
diff --git a/package.json b/package.json
index ed05c4f45..bbd2a4c46 100644
--- a/package.json
+++ b/package.json
@@ -221,6 +221,7 @@
         "js-datepicker": "^5.18.2",
         "jsonschema": "^1.4.1",
         "jszip": "^3.10.1",
+        "ldrs": "^1.0.2",
         "lodash": "^4.17.21",
         "mapbox-gl": "^3.0.1",
         "markdown-it": "^14.1.0",
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss b/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss
index d0c12814c..5f2ce4e14 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss
@@ -1,3 +1,18 @@
+.image-box-container {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+    font-size: 10px;
+    line-height: 1;
+    background: none;
+    z-index: 1000;
+    padding: 0px;
+    overflow: auto;
+    cursor: default;
+}
+
 .image-label-list {
     display: flex;
     flex-direction: column;
@@ -16,6 +31,7 @@
             text-align: center; // Centers the text of the paragraph
             font-size: large;
             vertical-align: middle;
+            margin-left: 10px;
         }
 
         .IconButton {
@@ -24,3 +40,10 @@
         }
     }
 }
+
+.image-information {
+    display: flex;
+    flex-direction: column;
+    //align-items: center;
+    width: 100%;
+}
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
index 1c0035f0d..83ae69cfb 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -12,6 +12,11 @@ import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
 import './ImageLabelBox.scss';
 import { MainView } from '../../MainView';
 import { MarqueeView } from './MarqueeView';
+import 'ldrs/ring';
+import { ring } from 'ldrs';
+import { SnappingManager } from '../../../util/SnappingManager';
+import { ImageCast } from '../../../../fields/Types';
+import { DocData } from '../../../../fields/DocSymbols';
 
 @observer
 export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
@@ -24,12 +29,14 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
     @observable _loading: boolean = true;
     @observable _currentLabel: string = '';
     @observable _labelGroups: string[] = [];
+    @observable _selectedImages: Doc[] = [];
+    @observable _displayImageInformation: boolean = false;
 
     constructor(props: any) {
         super(props);
         makeObservable(this);
         ImageLabelBox.Instance = this;
-
+        ring.register();
         console.log('Image Box Has Been Initialized');
     }
 
@@ -77,14 +84,32 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
         this._loading = false;
     };
 
+    @action
+    setSelectedImages = (selected: Doc[]) => {
+        this._selectedImages = selected;
+    };
+
     render() {
         if (this._loading) {
-            return <div className="searchBox-container">Loading...</div>;
+            return (
+                <div className="image-box-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
+                    <l-ring size="60" color="white" />
+                </div>
+            );
         }
 
         return (
-            <div className="searchBox-container">
-                <div className="searchBox-bar">
+            <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
+                <div className="searchBox-bar" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
+                    <IconButton
+                        tooltip={'See image information'}
+                        onPointerDown={() => {
+                            this._displayImageInformation = !this._displayImageInformation;
+                        }}
+                        icon={this._displayImageInformation ? <FontAwesomeIcon icon="caret-up" /> : <FontAwesomeIcon icon="caret-down" />}
+                        color={MarqueeOptionsMenu.Instance.userColor}
+                        style={{ width: '19px' }}
+                    />
                     <input
                         defaultValue=""
                         autoComplete="off"
@@ -93,7 +118,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                         //     e.stopPropagation();
                         // }}
                         type="text"
-                        placeholder="Input a group to put images into..."
+                        placeholder="Input labels for image groupings..."
                         aria-label="label-input"
                         id="new-label"
                         className="searchBox-input"
@@ -101,7 +126,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                         ref={this._inputRef}
                     />
                     <IconButton
-                        tooltip={'Add group'}
+                        tooltip={'Add a label'}
                         onPointerDown={() => {
                             const input = document.getElementById('new-label') as HTMLInputElement;
                             const newLabel = input.value;
@@ -113,11 +138,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                         color={MarqueeOptionsMenu.Instance.userColor}
                         style={{ width: '19px' }}
                     />
-                    {this._labelGroups.length > 0 ? (
-                        <IconButton tooltip={'Group Images'} onPointerDown={this.groupImages} icon={<FontAwesomeIcon icon="object-group" />} color={MarqueeOptionsMenu.Instance.userColor} style={{ width: '19px' }} />
-                    ) : (
-                        <div></div>
-                    )}
+                    {this._labelGroups.length > 0 ? <IconButton tooltip={'Group Images'} onPointerDown={this.groupImages} icon={<FontAwesomeIcon icon="object-group" />} color={Colors.MEDIUM_BLUE} style={{ width: '19px' }} /> : <div></div>}
                 </div>
                 <div>
                     <div className="image-label-list">
@@ -139,6 +160,23 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                         })}
                     </div>
                 </div>
+                {this._displayImageInformation ? (
+                    <div className="image-information">
+                        {this._selectedImages.map(doc => {
+                            const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
+                            return (
+                                <div>
+                                    <img src={`${name}_o.${type}`}></img>
+                                    {(doc[DocData].data_labels as string).split('\n').map(label => {
+                                        return <div className="image-label">{label}</div>;
+                                    })}
+                                </div>
+                            );
+                        })}
+                    </div>
+                ) : (
+                    <div></div>
+                )}
             </div>
         );
     }
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx
index f02cd9d45..b94a22d04 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx
@@ -39,7 +39,7 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> {
                 <IconButton tooltip="Summarize Documents" onPointerDown={this.summarize} icon={<FontAwesomeIcon icon="compress-arrows-alt" />} color={this.userColor} />
                 <IconButton tooltip="Delete Documents" onPointerDown={this.delete} icon={<FontAwesomeIcon icon="trash-alt" />} color={this.userColor} />
                 <IconButton tooltip="Pin selected region" onPointerDown={this.pinWithView} icon={<FontAwesomeIcon icon="map-pin" />} color={this.userColor} />
-                <IconButton tooltip="Classify Images" onPointerDown={this.classifyImages} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} />
+                <IconButton tooltip="Classify and Sort Images" onPointerDown={this.classifyImages} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} />
             </>
         );
         return this.getElement(buttons);
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index bb5a2a66e..f98883bff 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -456,6 +456,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
         }
 
         this._selectedDocs = this.marqueeSelect(false, DocumentType.IMG); // Get the selected documents from the marquee select.
+        ImageLabelBox.Instance!.setSelectedImages(this._selectedDocs);
 
         const imageInfos = this._selectedDocs.map(async doc => {
             if (!doc[DocData].data_labels) {
@@ -478,7 +479,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
         }); // Add the labels as fields to each image.
 
         ImageLabelBox.Instance!.endLoading();
-        console.log('Complete!');
+        console.log(this._selectedDocs);
     });
 
     /**
@@ -502,8 +503,10 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
                                    { label: '', similarityScore: 0, }); // prettier-ignore
             doc[DocData].data_label = mostSimilarLabelCollect; // The label most similar to the image's contents.
         });
-        this._props.Document._type_collection = CollectionViewType.Time; // Change the collection view to a Time view.
-        this._props.Document.pivotField = 'data_label'; // Sets the pivot to be the 'data_label'.
+        if (this._selectedDocs) {
+            this._props.Document._type_collection = CollectionViewType.Time; // Change the collection view to a Time view.
+            this._props.Document.pivotField = 'data_label'; // Sets the pivot to be the 'data_label'.
+        }
     });
 
     @undoBatch
-- 
cgit v1.2.3-70-g09d2


From 05e634842f7d798b8f8c201c7ff7821b32de376f Mon Sep 17 00:00:00 2001
From: IEatChili <nanunguyen99@gmail.com>
Date: Wed, 12 Jun 2024 12:32:19 -0400
Subject: feat: updated and fixed ui

---
 src/client/util/CurrentUserUtils.ts                |   2 +-
 src/client/views/MainView.tsx                      |   2 +-
 .../collectionFreeForm/ImageLabelBox.scss          |  40 +++++-
 .../collectionFreeForm/ImageLabelBox.tsx           | 159 +++++++++++++++------
 .../collections/collectionFreeForm/MarqueeView.tsx |  46 +-----
 5 files changed, 164 insertions(+), 85 deletions(-)

(limited to 'src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx')

diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index 486b6815a..cb3d9df62 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -458,7 +458,7 @@ pie title Minerals in my tap water
             { title: "Shared",          toolTip: "Shared Docs",     target: Doc.MySharedDocs,                                  ignoreClick: true, icon: "users",       funcs: {badgeValue: badgeValue}},
             { title: "Trails",          toolTip: "Trails ⌘R",       target: Doc.UserDoc(),                                     ignoreClick: true, icon: "pres-trail",  funcs: {target: getActiveDashTrails}},
             { title: "User Doc",        toolTip: "User Doc",        target: this.setupUserDocView(doc, "myUserDocView"),       ignoreClick: true, icon: "address-card",funcs: {hidden: "IsNoviceMode()"} },
-            { title: "Image Grouper",   toolTip: "Image Grouper",   target: this.setupImageGrouper(doc, "myImageGrouper"),     ignoreClick: true, icon: "folder-open", hidden: true }
+            { title: "Image Grouper",   toolTip: "Image Grouper",   target: this.setupImageGrouper(doc, "myImageGrouper"),     ignoreClick: true, icon: "folder-open", hidden: false }
         ].map(tuple => ({...tuple, scripts:{onClick: 'selectMainMenu(this)'}}));
     }
 
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index 5b4c2b5ba..716edc22d 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -76,7 +76,7 @@ import { PresBox } from './nodes/trails';
 import { AnchorMenu } from './pdf/AnchorMenu';
 import { GPTPopup } from './pdf/GPTPopup/GPTPopup';
 import { TopBar } from './topbar/TopBar';
-import { ImageLabelBox } from './collections/collectionFreeForm/ImageLabelBox';
+import { ImageLabelBox, ImageLabelBoxData } from './collections/collectionFreeForm/ImageLabelBox';
 
 const { LEFT_MENU_WIDTH, TOPBAR_HEIGHT } = require('./global/globalCssVariables.module.scss'); // prettier-ignore
 const _global = (window /* browser */ || global) /* node */ as any;
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss b/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss
index 5f2ce4e14..819c72760 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss
@@ -41,9 +41,45 @@
     }
 }
 
-.image-information {
+.image-information-list {
     display: flex;
     flex-direction: column;
-    //align-items: center;
+    align-items: center;
+    width: 100%;
+    margin-top: 10px;
+}
+
+.image-information {
+    border: 1px solid;
     width: 100%;
+    display: inline-flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    overflow: hidden;
+    padding: 2px;
+    overflow-x: auto;
+    overflow-y: auto;
+
+    img {
+        max-width: 200px;
+        max-height: 200px;
+        width: auto;
+        height: auto;
+    }
+}
+
+.image-information-labels {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+
+    .image-label {
+        margin-top: 5px;
+        margin-bottom: 5px;
+        padding: 3px;
+        border-radius: 2px;
+        border: solid 1px;
+    }
 }
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
index 83ae69cfb..f5530ccc4 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -1,6 +1,6 @@
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { Colors, IconButton } from 'browndash-components';
-import { action, makeObservable, observable } from 'mobx';
+import { action, computed, makeObservable, observable, reaction } from 'mobx';
 import { observer } from 'mobx-react';
 import React from 'react';
 import { Doc } from '../../../../fields/Doc';
@@ -11,12 +11,51 @@ import { FieldView, FieldViewProps } from '../../nodes/FieldView';
 import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
 import './ImageLabelBox.scss';
 import { MainView } from '../../MainView';
-import { MarqueeView } from './MarqueeView';
 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';
+
+export class ImageLabelBoxData {
+    static _instance: ImageLabelBoxData;
+    @observable _docs: Doc[] = [];
+    @observable _labelGroups: string[] = [];
+
+    constructor() {
+        makeObservable(this);
+        ImageLabelBoxData._instance = this;
+    }
+    public static get Instance() {
+        return ImageLabelBoxData._instance ?? new ImageLabelBoxData();
+    }
+
+    @action
+    public setData = (docs: Doc[]) => {
+        this._docs = docs;
+    };
+
+    @action
+    addLabel = (label: string) => {
+        label = label.toUpperCase().trim();
+        if (label.length > 0) {
+            if (!this._labelGroups.includes(label)) {
+                this._labelGroups = [...this._labelGroups, label];
+            }
+        }
+    };
+
+    @action
+    removeLabel = (label: string) => {
+        const labelUp = label.toUpperCase();
+        this._labelGroups = this._labelGroups.filter(group => group !== labelUp);
+    };
+}
 
 @observer
 export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
@@ -24,59 +63,53 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
         return FieldView.LayoutString(ImageLabelBox, fieldKey);
     }
 
-    public static Instance: ImageLabelBox | null = null;
+    public static Instance: ImageLabelBox;
     private _inputRef = React.createRef<HTMLInputElement>();
-    @observable _loading: boolean = true;
-    @observable _currentLabel: string = '';
-    @observable _labelGroups: string[] = [];
-    @observable _selectedImages: Doc[] = [];
+    @observable _loading: boolean = false;
+    private _currentLabel: string = '';
+
+    @computed get _labelGroups() {
+        return ImageLabelBoxData.Instance._labelGroups;
+    }
+
+    @computed get _selectedImages() {
+        // return DocListCast(this.dataDoc.data);
+        return ImageLabelBoxData.Instance._docs;
+    }
     @observable _displayImageInformation: boolean = false;
 
     constructor(props: any) {
         super(props);
         makeObservable(this);
-        ImageLabelBox.Instance = this;
         ring.register();
+        ImageLabelBox.Instance = this;
         console.log('Image Box Has Been Initialized');
     }
 
+    // ImageLabelBox.Instance.setData()
     /**
      * This method is called when the SearchBox component is first mounted. When the user opens
      * the search panel, the search input box is automatically selected. This allows the user to
      * type in the search input box immediately, without needing clicking on it first.
      */
     componentDidMount() {
-        // if (this._inputRef.current) {
-        //     this._inputRef.current.focus();
-        // }
+        this.classifyImagesInBox();
+        reaction(
+            () => this._selectedImages,
+            () => this.classifyImagesInBox()
+        );
     }
 
-    @action
-    addLabel = (label: string) => {
-        label = label.toUpperCase().trim();
-        if (label.length > 0) {
-            if (!this._labelGroups.includes(label)) {
-                this._labelGroups = [...this._labelGroups, label];
-            }
-        }
-    };
-
-    @action
-    removeLabel = (label: string) => {
-        const labelUp = label.toUpperCase();
-        this._labelGroups = this._labelGroups.filter(group => group !== labelUp);
-    };
-
     @action
     groupImages = () => {
         MarqueeOptionsMenu.Instance.groupImages();
-        this._labelGroups = [];
         MainView.Instance.closeFlyout();
     };
 
     @action
     startLoading = () => {
         this._loading = true;
+        console.log('Start loading has been called!');
     };
 
     @action
@@ -85,8 +118,38 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
     };
 
     @action
-    setSelectedImages = (selected: Doc[]) => {
-        this._selectedImages = selected;
+    toggleDisplayInformation = () => {
+        this._displayImageInformation = !this._displayImageInformation;
+    };
+
+    onInputChange = action((e: React.ChangeEvent<HTMLInputElement>) => {
+        this._currentLabel = e.target.value;
+    });
+
+    classifyImagesInBox = async () => {
+        this.startLoading();
+
+        const imageInfos = this._selectedImages.map(async doc => {
+            if (!doc[DocData].data_labels) {
+                const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
+                return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 =>
+                !hrefBase64 ? undefined : 
+                    gptImageLabel(hrefBase64).then(labels => 
+                        Promise.all(labels.split('\n').map(label => gptGetEmbedding(label))).then(embeddings => 
+                            ({ doc, embeddings, labels }))) ); // prettier-ignore
+            }
+        }); // Converts the images into a Base64 format, afterwhich the information is sent to GPT to label them.
+
+        (await Promise.all(imageInfos)).forEach(imageInfo => {
+            if (imageInfo && imageInfo.embeddings && Array.isArray(imageInfo.embeddings)) {
+                imageInfo.doc[DocData].data_labels = imageInfo.labels;
+                numberRange(3).forEach(n => {
+                    imageInfo.doc[`data_labels_embedding_${n + 1}`] = new List<number>(imageInfo.embeddings[n]);
+                });
+            }
+        }); // Add the labels as fields to each image.
+
+        this.endLoading();
     };
 
     render() {
@@ -98,14 +161,20 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
             );
         }
 
+        if (this._selectedImages.length === 0) {
+            return (
+                <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
+                    <p style={{ fontSize: 'large' }}>In order to classify and sort images, marquee select the desired images and press the 'Classify and Sort Images' button. Then, add the desired groups for the images to be put in.</p>
+                </div>
+            );
+        }
+
         return (
             <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
                 <div className="searchBox-bar" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
                     <IconButton
                         tooltip={'See image information'}
-                        onPointerDown={() => {
-                            this._displayImageInformation = !this._displayImageInformation;
-                        }}
+                        onPointerDown={this.toggleDisplayInformation}
                         icon={this._displayImageInformation ? <FontAwesomeIcon icon="caret-up" /> : <FontAwesomeIcon icon="caret-down" />}
                         color={MarqueeOptionsMenu.Instance.userColor}
                         style={{ width: '19px' }}
@@ -113,6 +182,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                     <input
                         defaultValue=""
                         autoComplete="off"
+                        onChange={this.onInputChange}
                         // onKeyDown={e => {
                         //     e.key === 'Enter' ? this.submitSearch() : null;
                         //     e.stopPropagation();
@@ -129,8 +199,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                         tooltip={'Add a label'}
                         onPointerDown={() => {
                             const input = document.getElementById('new-label') as HTMLInputElement;
-                            const newLabel = input.value;
-                            this.addLabel(newLabel);
+                            ImageLabelBoxData.Instance.addLabel(this._currentLabel);
                             this._currentLabel = '';
                             input.value = '';
                         }}
@@ -144,12 +213,12 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                     <div className="image-label-list">
                         {this._labelGroups.map(group => {
                             return (
-                                <div>
+                                <div key={Utils.GenerateGuid()}>
                                     <p style={{ color: MarqueeOptionsMenu.Instance.userColor }}>{group}</p>
                                     <IconButton
                                         tooltip={'Remove Label'}
                                         onPointerDown={() => {
-                                            this.removeLabel(group);
+                                            ImageLabelBoxData.Instance.removeLabel(group);
                                         }}
                                         icon={'x'}
                                         color={MarqueeOptionsMenu.Instance.userColor}
@@ -161,15 +230,21 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                     </div>
                 </div>
                 {this._displayImageInformation ? (
-                    <div className="image-information">
+                    <div className="image-information-list">
                         {this._selectedImages.map(doc => {
                             const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
                             return (
-                                <div>
+                                <div className="image-information" style={{ borderColor: SettingsManager.userColor }} key={Utils.GenerateGuid()}>
                                     <img src={`${name}_o.${type}`}></img>
-                                    {(doc[DocData].data_labels as string).split('\n').map(label => {
-                                        return <div className="image-label">{label}</div>;
-                                    })}
+                                    <div className="image-information-labels">
+                                        {(doc[DocData].data_labels as string).split('\n').map(label => {
+                                            return (
+                                                <div key={Utils.GenerateGuid()} className="image-label" style={{ backgroundColor: SettingsManager.userVariantColor, borderColor: SettingsManager.userColor }}>
+                                                    {label}
+                                                </div>
+                                            );
+                                        })}
+                                    </div>
                                 </div>
                             );
                         })}
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index f98883bff..81f2a94c1 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -37,7 +37,7 @@ import { ImageLabelHandler } from './ImageLabelHandler';
 import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
 import './MarqueeView.scss';
 import { MainView } from '../../MainView';
-import { ImageLabelBox } from './ImageLabelBox';
+import { ImageLabelBox, ImageLabelBoxData } from './ImageLabelBox';
 import { SearchBox } from '../../search/SearchBox';
 
 interface MarqueeViewProps {
@@ -442,44 +442,12 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
      */
     @undoBatch
     classifyImages = action(async (e: React.MouseEvent | undefined) => {
-        if (e) {
-            const groupButton = DocListCast(Doc.MyLeftSidebarMenu.data).find(d => d.target === Doc.MyImageGrouper);
-            if (groupButton) {
-                MainView.Instance.expandFlyout(groupButton);
-                while (!ImageLabelBox.Instance) {
-                    await new Promise(resolve => setTimeout(resolve, 1000)).then(() => {
-                        console.log('Waiting for Image Label Box');
-                    });
-                }
-                ImageLabelBox.Instance.startLoading();
-            }
+        const groupButton = DocListCast(Doc.MyLeftSidebarMenu.data).find(d => d.target === Doc.MyImageGrouper);
+        if (groupButton) {
+            this._selectedDocs = this.marqueeSelect(false, DocumentType.IMG);
+            ImageLabelBoxData.Instance.setData(this._selectedDocs);
+            MainView.Instance.expandFlyout(groupButton);
         }
-
-        this._selectedDocs = this.marqueeSelect(false, DocumentType.IMG); // Get the selected documents from the marquee select.
-        ImageLabelBox.Instance!.setSelectedImages(this._selectedDocs);
-
-        const imageInfos = this._selectedDocs.map(async doc => {
-            if (!doc[DocData].data_labels) {
-                const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
-                return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 =>
-                !hrefBase64 ? undefined : 
-                    gptImageLabel(hrefBase64).then(labels => 
-                        Promise.all(labels.split('\n').map(label => gptGetEmbedding(label))).then(embeddings => 
-                            ({ doc, embeddings, labels }))) ); // prettier-ignore
-            }
-        }); // Converts the images into a Base64 format, afterwhich the information is sent to GPT to label them.
-
-        (await Promise.all(imageInfos)).forEach(imageInfo => {
-            if (imageInfo && imageInfo.embeddings && Array.isArray(imageInfo.embeddings)) {
-                imageInfo.doc[DocData].data_labels = imageInfo.labels;
-                numberRange(3).forEach(n => {
-                    imageInfo.doc[`data_labels_embedding_${n + 1}`] = new List<number>(imageInfo.embeddings[n]);
-                });
-            }
-        }); // Add the labels as fields to each image.
-
-        ImageLabelBox.Instance!.endLoading();
-        console.log(this._selectedDocs);
     });
 
     /**
@@ -496,7 +464,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
         // most similar one.
         this._selectedDocs.forEach(doc => {
             const embedLists = numberRange(3).map(n => Array.from(NumListCast(doc[`data_labels_embedding_${n + 1}`])));
-            const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map(l => (embedding && similarity(Array.from(embedding), l)) || 0));
+            const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map((l, index) => (embedding && (1 - index * 0.1) * similarity(Array.from(embedding), l)!) || 0));
             const {label: mostSimilarLabelCollect} = 
                 labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) }))
                            .reduce((prev, cur) => cur.similarityScore < 0.3 || cur.similarityScore <= prev.similarityScore ? prev: cur,
-- 
cgit v1.2.3-70-g09d2


From 8aee62b8623e23f6478960291857ee47f50f9aaf Mon Sep 17 00:00:00 2001
From: IEatChili <nanunguyen99@gmail.com>
Date: Thu, 13 Jun 2024 16:28:24 -0400
Subject: feat: more ui updates

---
 .../collectionFreeForm/ImageLabelBox.tsx           | 81 +++++++++++++++++++---
 .../collections/collectionFreeForm/MarqueeView.tsx | 22 +-----
 src/client/views/nodes/ImageBox.tsx                |  4 ++
 3 files changed, 76 insertions(+), 31 deletions(-)

(limited to 'src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx')

diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
index f5530ccc4..571a4504f 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -3,9 +3,9 @@ import { Colors, IconButton } from 'browndash-components';
 import { action, computed, makeObservable, observable, reaction } from 'mobx';
 import { observer } from 'mobx-react';
 import React from 'react';
-import { Doc } from '../../../../fields/Doc';
+import { Doc, NumListCast, Opt } from '../../../../fields/Doc';
 import { Docs } from '../../../documents/Documents';
-import { DocumentType } from '../../../documents/DocumentTypes';
+import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes';
 import { ViewBoxBaseComponent } from '../../DocComponent';
 import { FieldView, FieldViewProps } from '../../nodes/FieldView';
 import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
@@ -21,6 +21,9 @@ import { CollectionCardView } from '../CollectionCardDeckView';
 import { gptGetEmbedding, gptImageLabel } from '../../../apis/gpt/GPT';
 import { numberRange, Utils } from '../../../../Utils';
 import { List } from '../../../../fields/List';
+import { DragManager } from '../../../util/DragManager';
+import { OpenWhere } from '../../nodes/OpenWhere';
+import similarity from 'compute-cosine-similarity';
 
 export class ImageLabelBoxData {
     static _instance: ImageLabelBoxData;
@@ -63,11 +66,26 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
         return FieldView.LayoutString(ImageLabelBox, fieldKey);
     }
 
+    private _dropDisposer?: DragManager.DragDropDisposer;
     public static Instance: ImageLabelBox;
     private _inputRef = React.createRef<HTMLInputElement>();
     @observable _loading: boolean = false;
     private _currentLabel: string = '';
 
+    protected createDropTarget = (ele: HTMLDivElement) => {
+        this._dropDisposer?.();
+        ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc));
+    };
+
+    protected onInternalDrop(e: Event, de: DragManager.DropEvent): boolean {
+        const { docDragData } = de.complete;
+        if (docDragData) {
+            ImageLabelBoxData.Instance.setData(ImageLabelBoxData.Instance._docs.concat(docDragData.droppedDocuments));
+            return false;
+        }
+        return false;
+    }
+
     @computed get _labelGroups() {
         return ImageLabelBoxData.Instance._labelGroups;
     }
@@ -102,7 +120,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
 
     @action
     groupImages = () => {
-        MarqueeOptionsMenu.Instance.groupImages();
+        this.groupImagesInBox();
         MainView.Instance.closeFlyout();
     };
 
@@ -122,6 +140,14 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
         this._displayImageInformation = !this._displayImageInformation;
     };
 
+    @action
+    submitLabel = () => {
+        const input = document.getElementById('new-label') as HTMLInputElement;
+        ImageLabelBoxData.Instance.addLabel(this._currentLabel);
+        this._currentLabel = '';
+        input.value = '';
+    };
+
     onInputChange = action((e: React.ChangeEvent<HTMLInputElement>) => {
         this._currentLabel = e.target.value;
     });
@@ -143,6 +169,13 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
         (await Promise.all(imageInfos)).forEach(imageInfo => {
             if (imageInfo && imageInfo.embeddings && Array.isArray(imageInfo.embeddings)) {
                 imageInfo.doc[DocData].data_labels = imageInfo.labels;
+
+                const labels = imageInfo.labels.split('\n');
+                labels.forEach(label => {
+                    label = label.replace(/^\d+\.\s*/, '').trim();
+                    imageInfo.doc[DocData][`${label}`] = true;
+                });
+
                 numberRange(3).forEach(n => {
                     imageInfo.doc[`data_labels_embedding_${n + 1}`] = new List<number>(imageInfo.embeddings[n]);
                 });
@@ -152,6 +185,32 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
         this.endLoading();
     };
 
+    /**
+     * Groups images to most similar labels.
+     */
+    groupImagesInBox = action(async () => {
+        console.log('Calling!');
+        const labelToEmbedding = new Map<string, number[]>();
+        // Create embeddings for the labels.
+        await Promise.all(this._labelGroups.map(async label => gptGetEmbedding(label).then(labelEmbedding => labelToEmbedding.set(label, labelEmbedding))));
+
+        // For each image, loop through the labels, and calculate similarity. Associate it with the
+        // most similar one.
+        this._selectedImages.forEach(doc => {
+            const embedLists = numberRange(3).map(n => Array.from(NumListCast(doc[`data_labels_embedding_${n + 1}`])));
+            const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map((l, index) => (embedding && (1 - index * 0.1) * similarity(Array.from(embedding), l)!) || 0));
+            const {label: mostSimilarLabelCollect} = 
+                this._labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) }))
+                           .reduce((prev, cur) => cur.similarityScore < 0.3 || cur.similarityScore <= prev.similarityScore ? prev: cur,
+                                   { label: '', similarityScore: 0, }); // prettier-ignore
+            doc[DocData].data_label = mostSimilarLabelCollect; // The label most similar to the image's contents.
+        });
+
+        if (this._selectedImages) {
+            MarqueeOptionsMenu.Instance.groupImages();
+        }
+    });
+
     render() {
         if (this._loading) {
             return (
@@ -163,14 +222,14 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
 
         if (this._selectedImages.length === 0) {
             return (
-                <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
+                <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }} ref={ele => this.createDropTarget(ele)}>
                     <p style={{ fontSize: 'large' }}>In order to classify and sort images, marquee select the desired images and press the 'Classify and Sort Images' button. Then, add the desired groups for the images to be put in.</p>
                 </div>
             );
         }
 
         return (
-            <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
+            <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }} ref={ele => this.createDropTarget(ele)}>
                 <div className="searchBox-bar" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
                     <IconButton
                         tooltip={'See image information'}
@@ -183,12 +242,12 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                         defaultValue=""
                         autoComplete="off"
                         onChange={this.onInputChange}
-                        // onKeyDown={e => {
-                        //     e.key === 'Enter' ? this.submitSearch() : null;
-                        //     e.stopPropagation();
-                        // }}
+                        onKeyDown={e => {
+                            e.key === 'Enter' ? this.submitLabel() : null;
+                            e.stopPropagation();
+                        }}
                         type="text"
-                        placeholder="Input labels for image groupings..."
+                        placeholder="Input groups for images to be put into..."
                         aria-label="label-input"
                         id="new-label"
                         className="searchBox-input"
@@ -234,7 +293,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                         {this._selectedImages.map(doc => {
                             const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
                             return (
-                                <div className="image-information" style={{ borderColor: SettingsManager.userColor }} key={Utils.GenerateGuid()}>
+                                <div className="image-information" style={{ borderColor: SettingsManager.userColor }} key={Utils.GenerateGuid()} onClick={() => this._props.addDocTab(doc, OpenWhere.addRightKeyvalue)}>
                                     <img src={`${name}_o.${type}`}></img>
                                     <div className="image-information-labels">
                                         {(doc[DocData].data_labels as string).split('\n').map(label => {
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index 81f2a94c1..f03a9d62d 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -455,26 +455,8 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
      */
     @undoBatch
     groupImages = action(async () => {
-        const labelGroups = ImageLabelBox.Instance!._labelGroups;
-        const labelToEmbedding = new Map<string, number[]>();
-        // Create embeddings for the labels.
-        await Promise.all(labelGroups.map(async label => gptGetEmbedding(label).then(labelEmbedding => labelToEmbedding.set(label, labelEmbedding))));
-
-        // For each image, loop through the labels, and calculate similarity. Associate it with the
-        // most similar one.
-        this._selectedDocs.forEach(doc => {
-            const embedLists = numberRange(3).map(n => Array.from(NumListCast(doc[`data_labels_embedding_${n + 1}`])));
-            const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map((l, index) => (embedding && (1 - index * 0.1) * similarity(Array.from(embedding), l)!) || 0));
-            const {label: mostSimilarLabelCollect} = 
-                labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) }))
-                           .reduce((prev, cur) => cur.similarityScore < 0.3 || cur.similarityScore <= prev.similarityScore ? prev: cur,
-                                   { label: '', similarityScore: 0, }); // prettier-ignore
-            doc[DocData].data_label = mostSimilarLabelCollect; // The label most similar to the image's contents.
-        });
-        if (this._selectedDocs) {
-            this._props.Document._type_collection = CollectionViewType.Time; // Change the collection view to a Time view.
-            this._props.Document.pivotField = 'data_label'; // Sets the pivot to be the 'data_label'.
-        }
+        this._props.Document._type_collection = CollectionViewType.Time; // Change the collection view to a Time view.
+        this._props.Document.pivotField = 'data_label'; // Sets the pivot to be the 'data_label'.
     });
 
     @undoBatch
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index e4b3a1b9b..3da878a4f 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -173,6 +173,10 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
                     Doc.SetNativeHeight(this.dataDoc, Doc.NativeHeight(targetDoc), this.fieldKey);
                 }
             }
+            const layoutDoc = de.complete.docDragData?.draggedDocuments[0];
+            const targetField = Doc.LayoutFieldKey(layoutDoc);
+            const targetDoc = layoutDoc[DocData];
+            console.log(targetDoc[targetField]);
             added === false && e.preventDefault();
             added !== undefined && e.stopPropagation();
             return added;
-- 
cgit v1.2.3-70-g09d2


From 376ff1626b24cbac12b27ad072690424549f05c7 Mon Sep 17 00:00:00 2001
From: IEatChili <nanunguyen99@gmail.com>
Date: Tue, 18 Jun 2024 14:33:47 -0400
Subject: feat: added view of labels on docs in freeform

---
 src/client/views/KeywordBox.tsx                    | 69 +++++++++++++++++++
 src/client/views/StyleProvider.scss                | 13 ++++
 src/client/views/StyleProvider.tsx                 |  9 +++
 .../collectionFreeForm/ImageLabelBox.tsx           | 79 ++++++++++++++++------
 src/client/views/nodes/DocumentView.tsx            |  2 +-
 src/client/views/nodes/ImageBox.tsx                |  9 +++
 6 files changed, 161 insertions(+), 20 deletions(-)
 create mode 100644 src/client/views/KeywordBox.tsx

(limited to 'src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx')

diff --git a/src/client/views/KeywordBox.tsx b/src/client/views/KeywordBox.tsx
new file mode 100644
index 000000000..3faddeb64
--- /dev/null
+++ b/src/client/views/KeywordBox.tsx
@@ -0,0 +1,69 @@
+import { action, makeObservable } from 'mobx';
+import { observer } from 'mobx-react';
+import React from 'react';
+import { Doc } from '../../fields/Doc';
+import { DocData } from '../../fields/DocSymbols';
+import { List } from '../../fields/List';
+import { ObservableReactComponent } from './ObservableReactComponent';
+
+interface KeywordBoxProps {
+    _doc: Doc;
+    _isEditing: boolean;
+}
+
+@observer
+export class KeywordBox extends ObservableReactComponent<KeywordBoxProps> {
+    constructor(props: any) {
+        super(props);
+        makeObservable(this);
+    }
+
+    @action
+    setToEditing = () => {
+        this._props._isEditing = true;
+    };
+
+    @action
+    setToView = () => {
+        this._props._isEditing = false;
+    };
+
+    submitLabel = () => {};
+
+    onInputChange = () => {};
+
+    render() {
+        const keywordsList = this._props._doc![DocData].data_labels;
+        return (
+            <div className="keywords-container">
+                {(keywordsList as List<string>).map(label => {
+                    return (
+                        <div className="keyword" onClick={this.setToEditing}>
+                            {label}
+                        </div>
+                    );
+                })}
+                {this._props._isEditing ? (
+                    <div>
+                        <input
+                            defaultValue=""
+                            autoComplete="off"
+                            onChange={this.onInputChange}
+                            onKeyDown={e => {
+                                e.key === 'Enter' ? this.submitLabel() : null;
+                                e.stopPropagation();
+                            }}
+                            type="text"
+                            placeholder="Input keywords for document..."
+                            aria-label="keyword-input"
+                            className="keyword-input"
+                            style={{ width: '100%', borderRadius: '5px' }}
+                        />
+                    </div>
+                ) : (
+                    <div></div>
+                )}
+            </div>
+        );
+    }
+}
diff --git a/src/client/views/StyleProvider.scss b/src/client/views/StyleProvider.scss
index ce00f6101..1e2af9a3a 100644
--- a/src/client/views/StyleProvider.scss
+++ b/src/client/views/StyleProvider.scss
@@ -53,3 +53,16 @@
 .styleProvider-treeView-icon {
     opacity: 0;
 }
+
+.keywords-container {
+    display: flex;
+    flex-wrap: wrap;
+}
+
+.keyword {
+    padding: 5px 10px;
+    background-color: lightblue;
+    border: 1px solid black;
+    border-radius: 5px;
+    white-space: nowrap;
+}
diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx
index b7f8a3170..fb509516a 100644
--- a/src/client/views/StyleProvider.tsx
+++ b/src/client/views/StyleProvider.tsx
@@ -12,7 +12,9 @@ import { BsArrowDown, BsArrowDownUp, BsArrowUp } from 'react-icons/bs';
 import { FaFilter } from 'react-icons/fa';
 import { ClientUtils, DashColor, lightOrDark } from '../../ClientUtils';
 import { Doc, Opt, StrListCast } from '../../fields/Doc';
+import { DocData } from '../../fields/DocSymbols';
 import { Id } from '../../fields/FieldSymbols';
+import { List } from '../../fields/List';
 import { ScriptField } from '../../fields/ScriptField';
 import { BoolCast, Cast, DocCast, ImageCast, NumCast, ScriptCast, StrCast } from '../../fields/Types';
 import { AudioAnnoState } from '../../server/SharedMediaTypes';
@@ -23,6 +25,7 @@ import { SnappingManager } from '../util/SnappingManager';
 import { undoBatch, UndoManager } from '../util/UndoManager';
 import { TreeSort } from './collections/TreeSort';
 import { Colors } from './global/globalEnums';
+import { KeywordBox } from './KeywordBox';
 import { DocumentView, DocumentViewProps } from './nodes/DocumentView';
 import { FieldViewProps } from './nodes/FieldView';
 import { StyleProp } from './StyleProp';
@@ -367,12 +370,18 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps &
                     </Tooltip>
                 );
             };
+            const keywords = () => {
+                if (doc && doc![DocData].data_labels && doc![DocData].showLabels) {
+                    return (<KeywordBox _isEditing={false} _doc={doc}></KeywordBox>)
+                }
+            }
             return (
                 <>
                     {paint()}
                     {lock()}
                     {filter()}
                     {audio()}
+                    {keywords()}
                 </>
             );
         }
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
index 571a4504f..cfb81e1a0 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -5,7 +5,7 @@ import { observer } from 'mobx-react';
 import React from 'react';
 import { Doc, NumListCast, Opt } from '../../../../fields/Doc';
 import { Docs } from '../../../documents/Documents';
-import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes';
+import { DocumentType } from '../../../documents/DocumentTypes';
 import { ViewBoxBaseComponent } from '../../DocComponent';
 import { FieldView, FieldViewProps } from '../../nodes/FieldView';
 import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
@@ -24,6 +24,9 @@ import { List } from '../../../../fields/List';
 import { DragManager } from '../../../util/DragManager';
 import { OpenWhere } from '../../nodes/OpenWhere';
 import similarity from 'compute-cosine-similarity';
+import { DocumentView } from '../../nodes/DocumentView';
+
+export class ImageInformationItem {}
 
 export class ImageLabelBoxData {
     static _instance: ImageLabelBoxData;
@@ -101,7 +104,6 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
         makeObservable(this);
         ring.register();
         ImageLabelBox.Instance = this;
-        console.log('Image Box Has Been Initialized');
     }
 
     // ImageLabelBox.Instance.setData()
@@ -127,7 +129,6 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
     @action
     startLoading = () => {
         this._loading = true;
-        console.log('Start loading has been called!');
     };
 
     @action
@@ -138,6 +139,11 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
     @action
     toggleDisplayInformation = () => {
         this._displayImageInformation = !this._displayImageInformation;
+        if (this._displayImageInformation) {
+            this._selectedImages.forEach(doc => (doc[DocData].showLabels = true));
+        } else {
+            this._selectedImages.forEach(doc => (doc[DocData].showLabels = false));
+        }
     };
 
     @action
@@ -155,33 +161,58 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
     classifyImagesInBox = async () => {
         this.startLoading();
 
+        // const imageInfos = this._selectedImages.map(async doc => {
+        //     if (!doc[DocData].data_labels) {
+        //         const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
+        //         return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 =>
+        //         !hrefBase64 ? undefined :
+        //             gptImageLabel(hrefBase64).then(labels =>
+        //                 Promise.all(labels.split('\n').map(label => gptGetEmbedding(label))).then(embeddings =>
+        //                     ({ doc, embeddings, labels }))) ); // prettier-ignore
+        //     }
+        // }); // 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) {
                 const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
                 return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 =>
                 !hrefBase64 ? undefined : 
                     gptImageLabel(hrefBase64).then(labels => 
-                        Promise.all(labels.split('\n').map(label => gptGetEmbedding(label))).then(embeddings => 
-                            ({ doc, embeddings, labels }))) ); // prettier-ignore
+                
+                            ({ doc, labels }))) ; // prettier-ignore
             }
-        }); // Converts the images into a Base64 format, afterwhich the information is sent to GPT to label them.
+        });
 
         (await Promise.all(imageInfos)).forEach(imageInfo => {
-            if (imageInfo && imageInfo.embeddings && Array.isArray(imageInfo.embeddings)) {
-                imageInfo.doc[DocData].data_labels = imageInfo.labels;
+            if (imageInfo) {
+                imageInfo.doc[DocData].data_labels = new List<string>();
 
                 const labels = imageInfo.labels.split('\n');
                 labels.forEach(label => {
-                    label = label.replace(/^\d+\.\s*/, '').trim();
+                    label = label.replace(/^\d+\.\s*|-|\*/, '').trim();
                     imageInfo.doc[DocData][`${label}`] = true;
-                });
-
-                numberRange(3).forEach(n => {
-                    imageInfo.doc[`data_labels_embedding_${n + 1}`] = new List<number>(imageInfo.embeddings[n]);
+                    (imageInfo.doc[DocData].data_labels as List<string>).push(label);
                 });
             }
         }); // Add the labels as fields to each image.
 
+        // (await Promise.all(imageInfos)).forEach(imageInfo => {
+        //     if (imageInfo && imageInfo.embeddings && Array.isArray(imageInfo.embeddings)) {
+        //         imageInfo.doc[DocData].data_labels = new List<string>();
+
+        //         const labels = imageInfo.labels.split('\n');
+        //         labels.forEach(label => {
+        //             label = label.replace(/^\d+\.\s*|-|\*/, '').trim();
+        //             imageInfo.doc[DocData][`${label}`] = true;
+        //             (imageInfo.doc[DocData].data_labels as List<string>).push(label);
+        //         });
+
+        //         numberRange(5).forEach(n => {
+        //             imageInfo.doc[`data_labels_embedding_${n + 1}`] = new List<number>(imageInfo.embeddings[n]);
+        //         });
+        //     }
+        // }); // Add the labels as fields to each image.
+
         this.endLoading();
     };
 
@@ -189,7 +220,13 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
      * Groups images to most similar labels.
      */
     groupImagesInBox = action(async () => {
-        console.log('Calling!');
+        this._selectedImages.forEach(doc => {
+            (doc[DocData].data_labels as List<string>).forEach(async (label, index) => {
+                const embedding = await gptGetEmbedding(label);
+                doc[`data_labels_embedding_${index + 1}`] = new List<number>(embedding);
+            });
+        });
+
         const labelToEmbedding = new Map<string, number[]>();
         // Create embeddings for the labels.
         await Promise.all(this._labelGroups.map(async label => gptGetEmbedding(label).then(labelEmbedding => labelToEmbedding.set(label, labelEmbedding))));
@@ -197,7 +234,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(3).map(n => Array.from(NumListCast(doc[`data_labels_embedding_${n + 1}`])));
+            const embedLists = numberRange(5).map(n => Array.from(NumListCast(doc[`data_labels_embedding_${n + 1}`])));
             const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map((l, index) => (embedding && (1 - index * 0.1) * similarity(Array.from(embedding), l)!) || 0));
             const {label: mostSimilarLabelCollect} = 
                 this._labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) }))
@@ -293,10 +330,14 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                         {this._selectedImages.map(doc => {
                             const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
                             return (
-                                <div className="image-information" style={{ borderColor: SettingsManager.userColor }} key={Utils.GenerateGuid()} onClick={() => this._props.addDocTab(doc, OpenWhere.addRightKeyvalue)}>
-                                    <img src={`${name}_o.${type}`}></img>
-                                    <div className="image-information-labels">
-                                        {(doc[DocData].data_labels as string).split('\n').map(label => {
+                                <div className="image-information" style={{ borderColor: SettingsManager.userColor }} key={Utils.GenerateGuid()}>
+                                    <img
+                                        src={`${name}_o.${type}`}
+                                        onClick={async () => {
+                                            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 => {
                                             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 7a1f94948..9ff96c692 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -573,7 +573,7 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
             if (this._props.renderDepth === 0) {
                 appearanceItems.splice(0, 0, { description: 'Open in Lightbox', event: () => DocumentView.SetLightboxDoc(this.Document), icon: 'external-link-alt' });
             }
-            appearanceItems.push({ description: 'Pin', event: () => this._props.pinToPres(this.Document, {}), icon: 'eye' });
+            appearanceItems.push({ description: 'Pin', event: () => this._props.pinToPres(this.Document, {}), icon: 'map-pin' });
             if (this.Document._layout_isFlashcard) {
                 appearanceItems.push({ description: 'Create ChatCard', event: () => this.askGPT(), icon: 'id-card' });
             }
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index 3da878a4f..fb90f907f 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -1,5 +1,6 @@
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { Tooltip } from '@mui/material';
+import zIndex from '@mui/material/styles/zIndex';
 import { Colors } from 'browndash-components';
 import { action, computed, IReactionDisposer, makeObservable, observable, ObservableMap, reaction } from 'mobx';
 import { observer } from 'mobx-react';
@@ -10,6 +11,7 @@ import { Doc, DocListCast, Opt } from '../../../fields/Doc';
 import { DocData } from '../../../fields/DocSymbols';
 import { Id } from '../../../fields/FieldSymbols';
 import { InkTool } from '../../../fields/InkField';
+import { List } from '../../../fields/List';
 import { ObjectField } from '../../../fields/ObjectField';
 import { Cast, ImageCast, NumCast, StrCast } from '../../../fields/Types';
 import { ImageField } from '../../../fields/URLField';
@@ -281,6 +283,13 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
                 }),
                 icon: 'pencil-alt',
             });
+            funcs.push({
+                description: 'Toggle Keywords',
+                event: () => {
+                    this.Document[DocData].showLabels = !this.Document[DocData].showLabels;
+                },
+                icon: 'eye',
+            });
             ContextMenu.Instance?.addItem({ description: 'Options...', subitems: funcs, icon: 'asterisk' });
         }
     };
-- 
cgit v1.2.3-70-g09d2


From 3190f1eb07a47a5e1ccdd20e346b47094118292d Mon Sep 17 00:00:00 2001
From: IEatChili <nanunguyen99@gmail.com>
Date: Wed, 26 Jun 2024 13:58:20 -0400
Subject: feat: worked more on keyword input for docs

---
 src/client/views/DocumentButtonBar.tsx             |  19 +++
 src/client/views/DocumentDecorations.tsx           |   1 +
 src/client/views/KeywordBox.tsx                    | 168 +++++++++++++++++----
 src/client/views/StyleProvider.scss                |  31 +++-
 src/client/views/StyleProvider.tsx                 |   6 +-
 .../collectionFreeForm/ImageLabelBox.tsx           |  53 ++-----
 .../collections/collectionFreeForm/MarqueeView.tsx |  28 +++-
 src/client/views/nodes/ImageBox.tsx                |   7 -
 8 files changed, 233 insertions(+), 80 deletions(-)

(limited to 'src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx')

diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx
index 487868169..a75c7098c 100644
--- a/src/client/views/DocumentButtonBar.tsx
+++ b/src/client/views/DocumentButtonBar.tsx
@@ -31,6 +31,7 @@ import { DocumentLinksButton } from './nodes/DocumentLinksButton';
 import { DocumentView } from './nodes/DocumentView';
 import { OpenWhere } from './nodes/OpenWhere';
 import { DashFieldView } from './nodes/formattedText/DashFieldView';
+import { DocData } from '../../fields/DocSymbols';
 
 @observer
 export class DocumentButtonBar extends ObservableReactComponent<{ views: () => (DocumentView | undefined)[]; stack?: any }> {
@@ -282,6 +283,23 @@ export class DocumentButtonBar extends ObservableReactComponent<{ views: () => (
         );
     }
 
+    @computed
+    get keywordButton() {
+        const targetDoc = this.view0?.Document;
+        return !targetDoc ? null : (
+            <Tooltip title={<div className="dash-keyword-button">Open keyword menu</div>}>
+                <div
+                    className="documentButtonBar-icon"
+                    style={{ color: 'white' }}
+                    onClick={() => {
+                        targetDoc[DocData].showLabels = !targetDoc[DocData].showLabels;
+                    }}>
+                    <FontAwesomeIcon className="documentdecorations-icon" icon="tag" />
+                </div>
+            </Tooltip>
+        );
+    }
+
     @observable _isRecording = false;
     _stopFunc: () => void = emptyFunction;
     @computed
@@ -452,6 +470,7 @@ export class DocumentButtonBar extends ObservableReactComponent<{ views: () => (
                 <div className="documentButtonBar-button">{this.pinButton}</div>
                 <div className="documentButtonBar-button">{this.recordButton}</div>
                 <div className="documentButtonBar-button">{this.calendarButton}</div>
+                <div className="documentButtonBar-button">{this.keywordButton}</div>
                 {!Doc.UserDoc().documentLinksButton_fullMenu ? null : <div className="documentButtonBar-button">{this.shareButton}</div>}
                 <div className="documentButtonBar-button">{this.menuButton}</div>
             </div>
diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx
index 93c3e3338..20bf8fd9f 100644
--- a/src/client/views/DocumentDecorations.tsx
+++ b/src/client/views/DocumentDecorations.tsx
@@ -88,6 +88,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora
                 (this._showNothing = !inputting && !DocumentButtonBar.Instance?._tooltipOpen && !(this.Bounds.x !== Number.MAX_VALUE && //
                     (this.Bounds.x > center.x+x || this.Bounds.r < center.x+x || 
                      this.Bounds.y > center.y+y || this.Bounds.b < center.y+y )));
+                
             })); // prettier-ignore
     }
 
diff --git a/src/client/views/KeywordBox.tsx b/src/client/views/KeywordBox.tsx
index 3faddeb64..8c69f446d 100644
--- a/src/client/views/KeywordBox.tsx
+++ b/src/client/views/KeywordBox.tsx
@@ -1,64 +1,170 @@
-import { action, makeObservable } from 'mobx';
+import { Colors, IconButton } from 'browndash-components';
+import { action, computed, makeObservable, observable, reaction } from 'mobx';
 import { observer } from 'mobx-react';
 import React from 'react';
 import { Doc } from '../../fields/Doc';
 import { DocData } from '../../fields/DocSymbols';
 import { List } from '../../fields/List';
+import { DragManager, SetupDrag } from '../util/DragManager';
+import { SnappingManager } from '../util/SnappingManager';
+import { DocumentView } from './nodes/DocumentView';
 import { ObservableReactComponent } from './ObservableReactComponent';
 
+interface KeywordItemProps {
+    doc: Doc;
+    label: string;
+    setToEditing: () => void;
+    isEditing: boolean;
+}
+
+@observer
+export class KeywordItem extends ObservableReactComponent<KeywordItemProps> {
+    constructor(props: any) {
+        super(props);
+        makeObservable(this);
+        this.ref = React.createRef();
+    }
+
+    private _dropDisposer?: DragManager.DragDropDisposer;
+    private ref: React.RefObject<HTMLDivElement>;
+
+    protected createDropTarget = (ele: HTMLDivElement) => {
+        this._dropDisposer?.();
+        SetupDrag(this.ref, () => undefined);
+        //ele && (this._dropDisposer = DragManager. (ele, this.onInternalDrop.bind(this), this.layoutDoc));
+        //ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc));
+    };
+
+    @action
+    removeLabel = () => {
+        if (this._props.doc[DocData].data_labels) {
+            this._props.doc[DocData].data_labels = (this._props.doc[DocData].data_labels as List<string>).filter(label => label !== this._props.label) as List<string>;
+        }
+    };
+
+    render() {
+        return (
+            <div className="keyword" onClick={this._props.setToEditing} onPointerDown={() => {}} ref={this.ref}>
+                {this._props.label}
+                {this.props.isEditing && <IconButton tooltip={'Remove label'} onPointerDown={this.removeLabel} icon={'X'} style={{ width: '8px', height: '8px', marginLeft: '10px' }} />}
+            </div>
+        );
+    }
+}
+
 interface KeywordBoxProps {
-    _doc: Doc;
-    _isEditing: boolean;
+    doc: Doc;
+    isEditing: boolean;
 }
 
 @observer
 export class KeywordBox extends ObservableReactComponent<KeywordBoxProps> {
+    @observable _currentInput: string = '';
+    //private disposer: () => void;
+
     constructor(props: any) {
         super(props);
         makeObservable(this);
     }
 
+    // componentDidMount(): void {
+    //     reaction(
+    //         () => ({
+    //             isDragging: SnappingManager.IsDragging,
+    //             selectedDoc: DocumentView.SelectedDocs().lastElement(),
+    //             isEditing: this._props.isEditing,
+    //         }),
+    //         ({ isDragging, selectedDoc, isEditing }) => {
+    //             if (isDragging || selectedDoc !== this._props.doc || !isEditing) {
+    //                 this.setToView();
+    //             }
+    //         }
+    //     );
+    // }
+
+    // componentWillUnmount() {
+    //     this.disposer();
+    // }
+
     @action
     setToEditing = () => {
-        this._props._isEditing = true;
+        this._props.isEditing = true;
     };
 
     @action
     setToView = () => {
-        this._props._isEditing = false;
+        this._props.isEditing = false;
     };
 
-    submitLabel = () => {};
+    submitLabel = () => {
+        if (this._currentInput.trim()) {
+            if (!this._props.doc[DocData].data_labels) {
+                this._props.doc[DocData].data_labels = new List<string>();
+            }
 
-    onInputChange = () => {};
+            (this._props.doc![DocData].data_labels! as List<string>).push(this._currentInput.trim());
+            this._currentInput = ''; // Clear the input box
+        }
+    };
+
+    @action
+    onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+        this._currentInput = e.target.value;
+    };
 
     render() {
-        const keywordsList = this._props._doc![DocData].data_labels;
+        const keywordsList = this._props.doc[DocData].data_labels ? this._props.doc[DocData].data_labels : new List<string>();
+        const seldoc = DocumentView.SelectedDocs().lastElement();
+        if (SnappingManager.IsDragging || !(seldoc === this._props.doc) || !this._props.isEditing) {
+            setTimeout(
+                action(() => {
+                    if ((keywordsList as List<string>).length === 0) {
+                        this._props.doc[DocData].showLabels = false;
+                    }
+                    this.setToView();
+                })
+            );
+        }
+
         return (
-            <div className="keywords-container">
-                {(keywordsList as List<string>).map(label => {
-                    return (
-                        <div className="keyword" onClick={this.setToEditing}>
-                            {label}
+            <div className="keywords-container" style={{ backgroundColor: this._props.isEditing ? Colors.LIGHT_GRAY : Colors.TRANSPARENT, borderColor: this._props.isEditing ? Colors.BLACK : Colors.TRANSPARENT }}>
+                <div className="keywords-list">
+                    {(keywordsList as List<string>).map(label => {
+                        return <KeywordItem doc={this._props.doc} label={label} setToEditing={this.setToEditing} isEditing={this._props.isEditing}></KeywordItem>;
+                    })}
+                </div>
+                {this._props.isEditing ? (
+                    <div className="keyword-editing-box">
+                        <div className="keyword-input-box">
+                            <input
+                                value={this._currentInput}
+                                autoComplete="off"
+                                onChange={this.onInputChange}
+                                onKeyDown={e => {
+                                    e.key === 'Enter' ? this.submitLabel() : 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-buttons">
+                            <IconButton
+                                tooltip={'Close Menu'}
+                                onPointerDown={() => {
+                                    if ((keywordsList as List<string>).length === 0) {
+                                        this._props.doc[DocData].showLabels = false;
+                                    } else {
+                                        this.setToView();
+                                    }
+                                }}
+                                icon={'x'}
+                                style={{ width: '4px' }}
+                            />
                         </div>
-                    );
-                })}
-                {this._props._isEditing ? (
-                    <div>
-                        <input
-                            defaultValue=""
-                            autoComplete="off"
-                            onChange={this.onInputChange}
-                            onKeyDown={e => {
-                                e.key === 'Enter' ? this.submitLabel() : null;
-                                e.stopPropagation();
-                            }}
-                            type="text"
-                            placeholder="Input keywords for document..."
-                            aria-label="keyword-input"
-                            className="keyword-input"
-                            style={{ width: '100%', borderRadius: '5px' }}
-                        />
                     </div>
                 ) : (
                     <div></div>
diff --git a/src/client/views/StyleProvider.scss b/src/client/views/StyleProvider.scss
index 1e2af9a3a..7cc06f922 100644
--- a/src/client/views/StyleProvider.scss
+++ b/src/client/views/StyleProvider.scss
@@ -57,12 +57,41 @@
 .keywords-container {
     display: flex;
     flex-wrap: wrap;
+    flex-direction: column;
+    padding-bottom: 4px;
+    border: 1px solid;
+    border-radius: 4px;
+}
+
+.keywords-list {
+    display: flex;
+    flex-wrap: wrap;
 }
 
 .keyword {
-    padding: 5px 10px;
+    padding: 5px 5px;
     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 {
+    // display: flex;
+    // align-items: center;
+    // align-content: center;
+    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 fb509516a..f4d73cd1d 100644
--- a/src/client/views/StyleProvider.tsx
+++ b/src/client/views/StyleProvider.tsx
@@ -371,8 +371,10 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps &
                 );
             };
             const keywords = () => {
-                if (doc && doc![DocData].data_labels && doc![DocData].showLabels) {
-                    return (<KeywordBox _isEditing={false} _doc={doc}></KeywordBox>)
+                if (doc && doc![DocData].showLabels && (!doc[DocData].data_labels || (doc[DocData].data_labels as List<string>).length === 0)){
+                    return (<KeywordBox isEditing={true} doc={doc}></KeywordBox>)
+                } else if (doc && doc![DocData].data_labels && doc![DocData].showLabels) {
+                    return (<KeywordBox isEditing={false} doc={doc}></KeywordBox>)
                 }
             }
             return (
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
index cfb81e1a0..fec4d3e12 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -123,7 +123,6 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
     @action
     groupImages = () => {
         this.groupImagesInBox();
-        MainView.Instance.closeFlyout();
     };
 
     @action
@@ -161,16 +160,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
     classifyImagesInBox = async () => {
         this.startLoading();
 
-        // const imageInfos = this._selectedImages.map(async doc => {
-        //     if (!doc[DocData].data_labels) {
-        //         const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
-        //         return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 =>
-        //         !hrefBase64 ? undefined :
-        //             gptImageLabel(hrefBase64).then(labels =>
-        //                 Promise.all(labels.split('\n').map(label => gptGetEmbedding(label))).then(embeddings =>
-        //                     ({ doc, embeddings, labels }))) ); // prettier-ignore
-        //     }
-        // }); // Converts the images into a Base64 format, afterwhich the information is sent to GPT to label them.
+        // 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) {
@@ -178,8 +168,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                 return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 =>
                 !hrefBase64 ? undefined : 
                     gptImageLabel(hrefBase64).then(labels => 
-                
-                            ({ doc, labels }))) ; // prettier-ignore
+                        ({ doc, labels }))) ; // prettier-ignore
             }
         });
 
@@ -194,24 +183,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                     (imageInfo.doc[DocData].data_labels as List<string>).push(label);
                 });
             }
-        }); // Add the labels as fields to each image.
-
-        // (await Promise.all(imageInfos)).forEach(imageInfo => {
-        //     if (imageInfo && imageInfo.embeddings && Array.isArray(imageInfo.embeddings)) {
-        //         imageInfo.doc[DocData].data_labels = new List<string>();
-
-        //         const labels = imageInfo.labels.split('\n');
-        //         labels.forEach(label => {
-        //             label = label.replace(/^\d+\.\s*|-|\*/, '').trim();
-        //             imageInfo.doc[DocData][`${label}`] = true;
-        //             (imageInfo.doc[DocData].data_labels as List<string>).push(label);
-        //         });
-
-        //         numberRange(5).forEach(n => {
-        //             imageInfo.doc[`data_labels_embedding_${n + 1}`] = new List<number>(imageInfo.embeddings[n]);
-        //         });
-        //     }
-        // }); // Add the labels as fields to each image.
+        });
 
         this.endLoading();
     };
@@ -220,12 +192,15 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
      * Groups images to most similar labels.
      */
     groupImagesInBox = action(async () => {
-        this._selectedImages.forEach(doc => {
-            (doc[DocData].data_labels as List<string>).forEach(async (label, index) => {
+        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];
                 const embedding = await gptGetEmbedding(label);
                 doc[`data_labels_embedding_${index + 1}`] = new List<number>(embedding);
-            });
-        });
+            }
+        }
 
         const labelToEmbedding = new Map<string, number[]>();
         // Create embeddings for the labels.
@@ -234,8 +209,8 @@ 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(5).map(n => Array.from(NumListCast(doc[`data_labels_embedding_${n + 1}`])));
-            const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map((l, index) => (embedding && (1 - index * 0.1) * similarity(Array.from(embedding), l)!) || 0));
+            const embedLists = numberRange((doc[DocData].data_labels as List<string>).length).map(n => Array.from(NumListCast(doc[`data_labels_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)) }))
                            .reduce((prev, cur) => cur.similarityScore < 0.3 || cur.similarityScore <= prev.similarityScore ? prev: cur,
@@ -243,9 +218,13 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
             doc[DocData].data_label = mostSimilarLabelCollect; // The label most similar to the image's contents.
         });
 
+        this.endLoading();
+
         if (this._selectedImages) {
             MarqueeOptionsMenu.Instance.groupImages();
         }
+
+        MainView.Instance.closeFlyout();
     });
 
     render() {
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index f03a9d62d..197681f62 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -455,8 +455,32 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
      */
     @undoBatch
     groupImages = action(async () => {
-        this._props.Document._type_collection = CollectionViewType.Time; // Change the collection view to a Time view.
-        this._props.Document.pivotField = 'data_label'; // Sets the pivot to be the 'data_label'.
+        const labelGroups: string[] = ImageLabelBoxData.Instance._labelGroups;
+        const labelToCollection: Map<string, Doc> = new Map();
+        const selectedImages = ImageLabelBoxData.Instance._docs;
+
+        // Create new collections associated with each label and get the embeddings for the labels.
+        let x_offset = 0;
+        for (const label of labelGroups) {
+            const newCollection = this.getCollection([], undefined, false);
+            newCollection._freeform_panX = this.Bounds.left + this.Bounds.width / 2;
+            newCollection._freeform_panY = this.Bounds.top + this.Bounds.height / 2;
+            console.log(newCollection._x);
+            labelToCollection.set(label, newCollection);
+            this._props.addDocument?.(newCollection);
+            //newCollection._x = (newCollection._x as number) + x_offset;
+            //x_offset += newCollection._width as number;
+        }
+
+        for (const doc of selectedImages) {
+            if (doc[DocData].data_label) {
+                Doc.AddDocToList(labelToCollection.get(doc[DocData].data_label as string)!, undefined, doc);
+                this._props.removeDocument?.(doc);
+            }
+        }
+
+        //this._props.Document._type_collection = CollectionViewType.Time; // Change the collection view to a Time view.
+        //this._props.Document.pivotField = 'data_label'; // Sets the pivot to be the 'data_label'.
     });
 
     @undoBatch
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index fb90f907f..1c90fae9e 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -283,13 +283,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
                 }),
                 icon: 'pencil-alt',
             });
-            funcs.push({
-                description: 'Toggle Keywords',
-                event: () => {
-                    this.Document[DocData].showLabels = !this.Document[DocData].showLabels;
-                },
-                icon: 'eye',
-            });
             ContextMenu.Instance?.addItem({ description: 'Options...', subitems: funcs, icon: 'asterisk' });
         }
     };
-- 
cgit v1.2.3-70-g09d2


From 732a00ddba502e3692fde374554c2ed394d275e4 Mon Sep 17 00:00:00 2001
From: IEatChili <nanunguyen99@gmail.com>
Date: Wed, 17 Jul 2024 15:13:11 -0400
Subject: feat: created smart collections

---
 src/client/documents/Documents.ts                  |   9 +-
 src/client/views/DocumentDecorations.scss          |   2 +-
 src/client/views/DocumentDecorations.tsx           |   2 +
 src/client/views/KeywordBox.tsx                    | 197 +++++++++++++++++----
 src/client/views/StyleProvider.scss                |   1 -
 src/client/views/collections/CollectionSubView.tsx |   4 +-
 src/client/views/collections/CollectionView.tsx    |   1 +
 .../collectionFreeForm/ImageLabelBox.tsx           |   4 +-
 .../collections/collectionFreeForm/MarqueeView.tsx |   3 -
 9 files changed, 174 insertions(+), 49 deletions(-)

(limited to 'src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx')

diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 449347403..3737aa0b5 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -37,12 +37,13 @@ export enum FInfoFieldType {
     date = 'date',
     list = 'list',
     rtf = 'rich text',
+    map = 'map',
 }
 export class FInfo {
     description: string = '';
     readOnly: boolean = false;
     fieldType?: FInfoFieldType;
-    values?: FieldType[];
+    values?: FieldType[] | Map<any, any>;
 
     filterable?: boolean = true; // can be used as a Filter in FilterPanel
     // format?: string; // format to display values (e.g, decimal places, $, etc)
@@ -143,6 +144,10 @@ class ListInfo extends FInfo {
     fieldType? = FInfoFieldType.list;
     values?: List<any>[] = [];
 }
+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;
@@ -155,6 +160,7 @@ 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);
@@ -481,6 +487,7 @@ 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/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss
index 239c0a977..67e1054c3 100644
--- a/src/client/views/DocumentDecorations.scss
+++ b/src/client/views/DocumentDecorations.scss
@@ -512,7 +512,7 @@ $resizeHandler: 8px;
     justify-content: center;
     align-items: center;
     gap: 5px;
-    top: 4px;
+    //top: 4px;
     background: $light-gray;
     opacity: 0.2;
     pointer-events: all;
diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx
index 20bf8fd9f..dc40562e8 100644
--- a/src/client/views/DocumentDecorations.tsx
+++ b/src/client/views/DocumentDecorations.tsx
@@ -640,6 +640,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora
     render() {
         const { b, r, x, y } = this.Bounds;
         const seldocview = DocumentView.Selected().lastElement();
+        const doc = DocumentView.SelectedDocs().lastElement();
         if (SnappingManager.IsDragging || r - x < 1 || x === Number.MAX_VALUE || !seldocview || this._hidden || isNaN(r) || isNaN(b) || isNaN(x) || isNaN(y)) {
             setTimeout(
                 action(() => {
@@ -831,6 +832,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora
                                 <div
                                     className="link-button-container"
                                     style={{
+                                        top: `${doc[DocData].showLabels ? 4 + (doc._keywordHeight as number) : 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
index d94f011f4..321362299 100644
--- a/src/client/views/KeywordBox.tsx
+++ b/src/client/views/KeywordBox.tsx
@@ -2,17 +2,26 @@ import { Colors, IconButton } from 'browndash-components';
 import { action, computed, makeObservable, observable, reaction } from 'mobx';
 import { observer } from 'mobx-react';
 import React from 'react';
-import { Doc } from '../../fields/Doc';
+import { returnFalse, setupMoveUpEvents } from '../../ClientUtils';
+import { Doc, DocListCast } from '../../fields/Doc';
 import { DocData } from '../../fields/DocSymbols';
 import { List } from '../../fields/List';
+import { DocCast, NumCast } from '../../fields/Types';
+import { emptyFunction } from '../../Utils';
+import { Docs } from '../documents/Documents';
+import { DocUtils } from '../documents/DocUtils';
 import { DragManager, SetupDrag } from '../util/DragManager';
 import { SnappingManager } from '../util/SnappingManager';
+import { CollectionFreeFormView } from './collections/collectionFreeForm';
+import { MainView } from './MainView';
 import { DocumentView } from './nodes/DocumentView';
 import { ObservableReactComponent } from './ObservableReactComponent';
 
 interface KeywordItemProps {
     doc: Doc;
-    label: string;
+    keyword: string;
+    keywordDoc: Doc;
+    keywordCollection: Doc[];
     setToEditing: () => void;
     isEditing: boolean;
 }
@@ -25,28 +34,77 @@ export class KeywordItem extends ObservableReactComponent<KeywordItemProps> {
         this.ref = React.createRef();
     }
 
-    private _dropDisposer?: DragManager.DragDropDisposer;
     private ref: React.RefObject<HTMLDivElement>;
 
-    protected createDropTarget = (ele: HTMLDivElement) => {
-        this._dropDisposer?.();
-        SetupDrag(this.ref, () => undefined);
-        //ele && (this._dropDisposer = DragManager. (ele, this.onInternalDrop.bind(this), this.layoutDoc));
-        //ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc));
+    getKeywordCollectionDocs = () => {
+        for (const doc of DocListCast(Doc.UserDoc().myKeywordCollections)) {
+            if (doc.title === this._props.keyword) {
+                return doc[DocData].docs;
+            }
+        }
+        return null;
+    };
+
+    createCollection = () => {
+        const selected = DocListCast(this.getKeywordCollectionDocs()!);
+        const newEmbeddings = selected.map(doc => Doc.MakeEmbedding(doc));
+        const newCollection = ((doc: Doc) => {
+            const docData = doc[DocData];
+            docData.data = new List<Doc>(newEmbeddings);
+            docData.title = this._props.keyword;
+            doc._freeform_panX = doc._freeform_panY = 0;
+            return doc;
+        })(Doc.MakeCopy(Doc.UserDoc().emptyCollection as Doc, true));
+        newEmbeddings.forEach(embed => (embed.embedContainer = newCollection));
+        newCollection._width = 900;
+        newCollection._height = 900;
+        newCollection.layout_fitWidth = true;
+        //newCollection[DocData].smartCollection = this._props.keywordDoc;
+
+        this._props.keywordDoc.collections = new List<Doc>([...DocListCast(this._props.keywordDoc.collections), newCollection]);
+        return newCollection;
+    };
+
+    @action
+    handleDragStart = (e: React.PointerEvent) => {
+        if (this._props.isEditing) {
+            const clone = this.ref.current?.cloneNode(true) as HTMLElement;
+            if (!clone) return;
+
+            setupMoveUpEvents(
+                this,
+                e,
+                () => {
+                    const dragData = new DragManager.DocumentDragData([this.createCollection()]);
+                    DragManager.StartDocumentDrag([this.ref.current!], dragData, e.clientX, e.clientY, {});
+                    return true;
+                },
+                returnFalse,
+                emptyFunction
+            );
+            e.preventDefault();
+        }
     };
 
     @action
     removeLabel = () => {
         if (this._props.doc[DocData].data_labels) {
-            this._props.doc[DocData].data_labels = (this._props.doc[DocData].data_labels as List<string>).filter(label => label !== this._props.label) as List<string>;
-            this._props.doc![DocData][`${this._props.label}`] = false;
+            const filtered_docs = new List<Doc>(DocListCast(this.getKeywordCollectionDocs()!).filter(doc => doc !== this._props.doc));
+            this._props.keywordDoc[DocData].docs = filtered_docs;
+
+            this._props.doc[DocData].data_labels = (this._props.doc[DocData].data_labels as List<string>).filter(label => label !== this._props.keyword) as List<string>;
+            this._props.doc![DocData][`${this._props.keyword}`] = false;
+
+            for (const collection of DocListCast(this._props.keywordDoc.collections)) {
+                collection[DocData].data = new List<Doc>(DocListCast(collection[DocData].data).filter(doc => !Doc.AreProtosEqual(this._props.doc, doc)));
+            }
         }
     };
 
     render() {
         return (
-            <div className="keyword" onClick={this._props.setToEditing} onPointerDown={() => {}} ref={this.ref}>
-                {this._props.label}
+            <div className="keyword" onClick={this._props.setToEditing} onPointerDown={this.handleDragStart} ref={this.ref}>
+                {this._props.keyword}
                 {this.props.isEditing && <IconButton tooltip={'Remove label'} onPointerDown={this.removeLabel} icon={'X'} style={{ width: '8px', height: '8px', marginLeft: '10px' }} />}
             </div>
         );
@@ -61,31 +119,39 @@ interface KeywordBoxProps {
 @observer
 export class KeywordBox extends ObservableReactComponent<KeywordBoxProps> {
     @observable _currentInput: string = '';
-    //private disposer: () => void;
+    private height: number = 0;
+    private ref: React.RefObject<HTMLDivElement>;
+
+    @computed
+    get currentScale() {
+        return NumCast((this._props.doc.embedContainer as Doc)?._freeform_scale, 1);
+    }
 
     constructor(props: any) {
         super(props);
         makeObservable(this);
+        this.ref = React.createRef();
     }
 
-    // componentDidMount(): void {
-    //     reaction(
-    //         () => ({
-    //             isDragging: SnappingManager.IsDragging,
-    //             selectedDoc: DocumentView.SelectedDocs().lastElement(),
-    //             isEditing: this._props.isEditing,
-    //         }),
-    //         ({ isDragging, selectedDoc, isEditing }) => {
-    //             if (isDragging || selectedDoc !== this._props.doc || !isEditing) {
-    //                 this.setToView();
-    //             }
-    //         }
-    //     );
-    // }
-
-    // componentWillUnmount() {
-    //     this.disposer();
-    // }
+    componentDidMount(): void {
+        this.height = this.ref.current?.getBoundingClientRect().height ? this.ref.current?.getBoundingClientRect().height : 0;
+        this._props.doc._keywordHeight = this.height;
+
+        reaction(
+            () => this.currentScale,
+            () => {
+                if (this.currentScale < 1) {
+                    this.height = this.ref.current?.getBoundingClientRect().height ? this.ref.current?.getBoundingClientRect().height : 0;
+                    this._props.doc._keywordHeight = this.height;
+                }
+            }
+        );
+    }
+
+    componentDidUpdate(prevProps: Readonly<KeywordBoxProps>): void {
+        this.height = this.ref.current?.getBoundingClientRect().height ? this.ref.current?.getBoundingClientRect().height : 0;
+        this._props.doc._keywordHeight = this.height;
+    }
 
     @action
     setToEditing = () => {
@@ -97,14 +163,51 @@ export class KeywordBox extends ObservableReactComponent<KeywordBoxProps> {
         this._props.isEditing = false;
     };
 
+    getKeywordCollection = (keyword: string) => {
+        for (const doc of DocListCast(Doc.UserDoc().myKeywordCollections)) {
+            if (doc.title === keyword) {
+                return doc;
+            }
+        }
+
+        const keywordCollection = new Doc();
+        keywordCollection.title = keyword;
+        keywordCollection[DocData].docs = new List<Doc>();
+        keywordCollection.collections = new List<Doc>();
+        Doc.UserDoc().myKeywordCollections = new List<Doc>([...DocListCast(Doc.UserDoc().myKeywordCollections), keywordCollection]);
+
+        return keywordCollection;
+    };
+
     submitLabel = () => {
-        if (this._currentInput.trim()) {
+        if (!Doc.UserDoc().myKeywordCollections) {
+            Doc.UserDoc().myKeywordCollections = new List<Doc>();
+        }
+
+        const submittedLabel = this._currentInput.trim();
+        if (submittedLabel) {
+            // If the keyword collection is not in the user doc, add it as a new doc, with the keyword as its title.
+            const keywordCollection = this.getKeywordCollection(submittedLabel);
+
+            // If the document has no keywords field, create the field.
             if (!this._props.doc[DocData].data_labels) {
                 this._props.doc[DocData].data_labels = new List<string>();
             }
 
-            (this._props.doc![DocData].data_labels! as List<string>).push(this._currentInput.trim());
+            // Add this document to the keyword's collection of associated documents.
+            keywordCollection[DocData].docs = new List<Doc>([...DocListCast(keywordCollection[DocData].docs), this._props.doc]);
+
+            // Push the keyword to the document's keyword list field.
+            (this._props.doc![DocData].data_labels! as List<string>).push(submittedLabel);
             this._props.doc![DocData][`${this._currentInput}`] = true;
+
+            // Iterate through the keyword document's collections and add a copy of the document to each collection
+            for (const collection of DocListCast(keywordCollection.collections)) {
+                const newEmbedding = Doc.MakeEmbedding(this._props.doc);
+                collection[DocData].data = new List<Doc>([...DocListCast(collection.data), newEmbedding]);
+                newEmbedding.embedContainer = collection;
+            }
+
             this._currentInput = ''; // Clear the input box
         }
     };
@@ -120,19 +223,35 @@ export class KeywordBox extends ObservableReactComponent<KeywordBoxProps> {
         if (SnappingManager.IsDragging || !(seldoc === this._props.doc) || !this._props.isEditing) {
             setTimeout(
                 action(() => {
-                    if ((keywordsList as List<string>).length === 0) {
-                        this._props.doc[DocData].showLabels = false;
-                    }
+                    // if ((keywordsList as List<string>).length === 0) {
+                    //     this._props.doc[DocData].showLabels = false;
+                    // }
                     this.setToView();
                 })
             );
         }
 
         return (
-            <div className="keywords-container" style={{ backgroundColor: this._props.isEditing ? Colors.LIGHT_GRAY : Colors.TRANSPARENT, borderColor: this._props.isEditing ? Colors.BLACK : Colors.TRANSPARENT }}>
+            <div
+                className="keywords-container"
+                ref={this.ref}
+                style={{
+                    transformOrigin: 'top left',
+                    transform: `scale(${1 / this.currentScale})`,
+                    backgroundColor: this._props.isEditing ? Colors.LIGHT_GRAY : Colors.TRANSPARENT,
+                    borderColor: this._props.isEditing ? Colors.BLACK : Colors.TRANSPARENT,
+                }}>
                 <div className="keywords-list">
-                    {(keywordsList as List<string>).map(label => {
-                        return <KeywordItem doc={this._props.doc} label={label} setToEditing={this.setToEditing} isEditing={this._props.isEditing}></KeywordItem>;
+                    {(keywordsList as List<string>).map(keyword => {
+                        return (
+                            <KeywordItem
+                                doc={this._props.doc}
+                                keyword={keyword}
+                                keywordDoc={this.getKeywordCollection(keyword)}
+                                keywordCollection={DocListCast(this.getKeywordCollection(keyword))}
+                                setToEditing={this.setToEditing}
+                                isEditing={this._props.isEditing}></KeywordItem>
+                        );
                     })}
                 </div>
                 {this._props.isEditing ? (
diff --git a/src/client/views/StyleProvider.scss b/src/client/views/StyleProvider.scss
index 7cc06f922..4267762aa 100644
--- a/src/client/views/StyleProvider.scss
+++ b/src/client/views/StyleProvider.scss
@@ -58,7 +58,6 @@
     display: flex;
     flex-wrap: wrap;
     flex-direction: column;
-    padding-bottom: 4px;
     border: 1px solid;
     border-radius: 4px;
 }
diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx
index e250d7a90..26528b2b3 100644
--- a/src/client/views/collections/CollectionSubView.tsx
+++ b/src/client/views/collections/CollectionSubView.tsx
@@ -1,4 +1,4 @@
-import { action, computed, makeObservable, observable } from 'mobx';
+import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx';
 import * as React from 'react';
 import * as rp from 'request-promise';
 import { ClientUtils, returnFalse } from '../../../ClientUtils';
@@ -9,7 +9,7 @@ import { Id } from '../../../fields/FieldSymbols';
 import { List } from '../../../fields/List';
 import { listSpec } from '../../../fields/Schema';
 import { ScriptField } from '../../../fields/ScriptField';
-import { BoolCast, Cast, ScriptCast, StrCast } from '../../../fields/Types';
+import { BoolCast, Cast, DocCast, ScriptCast, StrCast } from '../../../fields/Types';
 import { WebField } from '../../../fields/URLField';
 import { GetEffectiveAcl, TraceMobx } from '../../../fields/util';
 import { GestureUtils } from '../../../pen-gestures/GestureUtils';
diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx
index 5c304b4a9..a750b731a 100644
--- a/src/client/views/collections/CollectionView.tsx
+++ b/src/client/views/collections/CollectionView.tsx
@@ -34,6 +34,7 @@ import { CollectionMulticolumnView } from './collectionMulticolumn/CollectionMul
 import { CollectionMultirowView } from './collectionMulticolumn/CollectionMultirowView';
 import { CollectionSchemaView } from './collectionSchema/CollectionSchemaView';
 import { CollectionCardView } from './CollectionCardDeckView';
+import { DocData } from '../../../fields/DocSymbols';
 
 @observer
 export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewProps>() {
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
index fec4d3e12..af01d6cbc 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -238,14 +238,14 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
 
         if (this._selectedImages.length === 0) {
             return (
-                <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }} ref={ele => this.createDropTarget(ele)}>
+                <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }} ref={ele => this.createDropTarget(ele!)}>
                     <p style={{ fontSize: 'large' }}>In order to classify and sort images, marquee select the desired images and press the 'Classify and Sort Images' button. Then, add the desired groups for the images to be put in.</p>
                 </div>
             );
         }
 
         return (
-            <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }} ref={ele => this.createDropTarget(ele)}>
+            <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }} ref={ele => this.createDropTarget(ele!)}>
                 <div className="searchBox-bar" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
                     <IconButton
                         tooltip={'See image information'}
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index 07e3acb1d..d7a41df64 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -482,11 +482,8 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
             }
             labelToCollection.set(label, newCollection);
             this._props.addDocument?.(newCollection);
-            console.log('added collection!');
         }
 
-        console.log(labelToCollection);
-
         for (const doc of selectedImages) {
             if (doc[DocData].data_label) {
                 Doc.AddDocToList(labelToCollection.get(doc[DocData].data_label as string)!, undefined, doc);
-- 
cgit v1.2.3-70-g09d2


From 0e975569e5686138e52bdc554b3f0391f42aeead Mon Sep 17 00:00:00 2001
From: IEatChili <nanunguyen99@gmail.com>
Date: Thu, 15 Aug 2024 14:13:02 -0400
Subject: feat: added face recogntion box

---
 src/client/apis/gpt/GPT.ts                         |   2 +-
 src/client/documents/DocumentTypes.ts              |   1 +
 src/client/documents/Documents.ts                  |   4 +
 src/client/util/CurrentUserUtils.ts                |  13 +-
 src/client/views/KeywordBox.tsx                    |   7 +-
 src/client/views/Main.tsx                          |   2 +
 .../collectionFreeForm/FaceCollectionBox.scss      |  74 +++++++
 .../collectionFreeForm/FaceCollectionBox.tsx       | 217 +++++++++++++++++++++
 .../collectionFreeForm/ImageLabelBox.tsx           |   5 +-
 src/client/views/search/FaceRecognitionHandler.tsx |  74 +++++--
 src/fields/Doc.ts                                  |   1 +
 11 files changed, 375 insertions(+), 25 deletions(-)
 create mode 100644 src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
 create mode 100644 src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx

(limited to 'src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx')

diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts
index 05007960d..8dd3fd6e2 100644
--- a/src/client/apis/gpt/GPT.ts
+++ b/src/client/apis/gpt/GPT.ts
@@ -128,7 +128,7 @@ const gptImageLabel = async (src: string): Promise<string> => {
                 {
                     role: 'user',
                     content: [
-                        { type: 'text', text: 'Give three to five labels to describe this image.' },
+                        { type: 'text', text: 'Give three labels to describe this image.' },
                         {
                             type: 'image_url',
                             image_url: {
diff --git a/src/client/documents/DocumentTypes.ts b/src/client/documents/DocumentTypes.ts
index a9ea889b3..b66a29ac2 100644
--- a/src/client/documents/DocumentTypes.ts
+++ b/src/client/documents/DocumentTypes.ts
@@ -17,6 +17,7 @@ export enum DocumentType {
     FONTICON = 'fonticonbox',
     SEARCH = 'search', // search query
     IMAGEGROUPER = 'imagegrouper',
+    FACECOLLECTION = 'facecollection',
     LABEL = 'label', // simple text label
     BUTTON = 'button', // onClick button
     WEBCAM = 'webcam', // webcam
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 3737aa0b5..ecea74fab 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -795,6 +795,10 @@ export namespace Docs {
             return InstanceFromProto(Prototypes.get(DocumentType.IMAGEGROUPER), undefined, options);
         }
 
+        export function FaceCollectionDocument(options: DocumentOptions = {}) {
+            return InstanceFromProto(Prototypes.get(DocumentType.FACECOLLECTION), undefined, options);
+        }
+
         export function LoadingDocument(file: File | string, options: DocumentOptions) {
             return InstanceFromProto(Prototypes.get(DocumentType.LOADING), undefined, { _height: 150, _width: 200, title: typeof file === 'string' ? file : file.name, ...options }, undefined, '');
         }
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index cb3d9df62..db0de83b9 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -458,7 +458,8 @@ pie title Minerals in my tap water
             { title: "Shared",          toolTip: "Shared Docs",     target: Doc.MySharedDocs,                                  ignoreClick: true, icon: "users",       funcs: {badgeValue: badgeValue}},
             { title: "Trails",          toolTip: "Trails ⌘R",       target: Doc.UserDoc(),                                     ignoreClick: true, icon: "pres-trail",  funcs: {target: getActiveDashTrails}},
             { title: "User Doc",        toolTip: "User Doc",        target: this.setupUserDocView(doc, "myUserDocView"),       ignoreClick: true, icon: "address-card",funcs: {hidden: "IsNoviceMode()"} },
-            { title: "Image Grouper",   toolTip: "Image Grouper",   target: this.setupImageGrouper(doc, "myImageGrouper"),     ignoreClick: true, icon: "folder-open", hidden: false }
+            { title: "Image Grouper",   toolTip: "Image Grouper",   target: this.setupImageGrouper(doc, "myImageGrouper"),     ignoreClick: true, icon: "folder-open", hidden: false },
+            { title: "Face Collection", toolTip: "Face Collection", target: this.setupFaceCollection(doc, "myFaceCollection"), ignoreClick: true, icon: "face-smile", hidden: false },
         ].map(tuple => ({...tuple, scripts:{onClick: 'selectMainMenu(this)'}}));
     }
 
@@ -500,6 +501,12 @@ pie title Minerals in my tap water
             _lockedPosition: true, _type_collection: CollectionViewType.Schema  });
     }
 
+    static setupFaceCollection(doc: Doc, field: string) {
+        return DocUtils.AssignDocField(doc, field, (opts) => Docs.Create.FaceCollectionDocument(opts), {
+            dontRegisterView: true, backgroundColor: "dimgray", ignoreClick: true, title: "Face Collection", isSystem: true, childDragAction: dropActionType.embed, 
+            _lockedPosition: true, _type_collection: CollectionViewType.Schema  });
+    }
+
     /// Initializes the panel of draggable tools that is opened from the left sidebar.
     static setupToolsBtnPanel(doc: Doc, field:string) {
         const allTools = DocListCast(DocCast(doc[field])?.data);
@@ -702,8 +709,8 @@ pie title Minerals in my tap water
             { title: "Fit All",   icon: "object-group",        toolTip: "Fit Docs to View (double click to make sticky)",btnType: ButtonType.ToggleButton, ignoreClick:true, expertMode: false, toolType:"viewAll",    funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}', onDoubleClick: '{ return showFreeform(this.toolType, _readOnly_, true);}'}}, // Only when floating document is selected in freeform
             { title: "Clusters",  icon: "braille",             toolTip: "Show Doc Clusters",   btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"clusters",   funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform
             { title: "Cards",     icon: "brain",               toolTip: "Flashcards",          btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"flashcards", funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform
-            { title: "Arrange",   icon:"arrow-down-short-wide",toolTip:"Toggle Auto Arrange",  btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"arrange",    funcs: {hidden: 'IsNoviceMode()'}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform
-
+            { title: "Arrange",   icon:"arrow-down-short-wide",toolTip:"Auto Arrange",  btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"arrange",    funcs: {hidden: 'IsNoviceMode()'}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform
+            
         ]
     }
     static textTools():Button[] {
diff --git a/src/client/views/KeywordBox.tsx b/src/client/views/KeywordBox.tsx
index 68584a7fa..fc9c38a11 100644
--- a/src/client/views/KeywordBox.tsx
+++ b/src/client/views/KeywordBox.tsx
@@ -7,7 +7,7 @@ import { Doc, DocListCast } from '../../fields/Doc';
 import { DocData } from '../../fields/DocSymbols';
 import { List } from '../../fields/List';
 import { NumCast, StrCast } from '../../fields/Types';
-import { emptyFunction } from '../../Utils';
+import { emptyFunction, Utils } from '../../Utils';
 import { DocumentType } from '../documents/DocumentTypes';
 import { DragManager } from '../util/DragManager';
 import { SnappingManager } from '../util/SnappingManager';
@@ -124,7 +124,7 @@ export class KeywordItem extends ObservableReactComponent<KeywordItemProps> {
 
     render() {
         return (
-            <div className="keyword" onClick={this._props.setToEditing} onPointerDown={this.handleDragStart} ref={this.ref}>
+            <div className="keyword" onClick={this._props.setToEditing} onPointerDown={this.handleDragStart} ref={this.ref} key={Utils.GenerateGuid()}>
                 {this._props.keyword}
                 {this.props.isEditing && <IconButton tooltip={'Remove label'} onPointerDown={this.removeLabel} icon={'X'} style={{ width: '8px', height: '8px', marginLeft: '10px' }} />}
             </div>
@@ -297,7 +297,7 @@ export class KeywordBox extends ObservableReactComponent<KeywordBoxProps> {
                 <div className="keywords-content">
                     <div className="keywords-list">
                         {(keywordsList as List<string>).map(keyword => {
-                            return <KeywordItem doc={this._props.doc} keyword={keyword} keywordDoc={this.getKeywordCollection(keyword)} setToEditing={this.setToEditing} isEditing={this._props.isEditing}></KeywordItem>;
+                            return <KeywordItem key={Utils.GenerateGuid()} doc={this._props.doc} keyword={keyword} keywordDoc={this.getKeywordCollection(keyword)} setToEditing={this.setToEditing} isEditing={this._props.isEditing}></KeywordItem>;
                         })}
                     </div>
                     {this._props.isEditing ? (
@@ -331,6 +331,7 @@ export class KeywordBox extends ObservableReactComponent<KeywordBoxProps> {
                                                 onClick={() => {
                                                     this.submitLabel(keyword);
                                                 }}
+                                                key={Utils.GenerateGuid()}
                                             />
                                         );
                                     })}
diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx
index ada934aea..85c2b3d47 100644
--- a/src/client/views/Main.tsx
+++ b/src/client/views/Main.tsx
@@ -62,6 +62,7 @@ import { PresBox, PresElementBox } from './nodes/trails';
 import { SearchBox } from './search/SearchBox';
 import { ImageLabelBox } from './collections/collectionFreeForm/ImageLabelBox';
 import { FaceRecognitionHandler } from './search/FaceRecognitionHandler';
+import { FaceCollectionBox } from './collections/collectionFreeForm/FaceCollectionBox';
 
 dotenv.config();
 
@@ -135,6 +136,7 @@ FieldLoader.ServerLoadStatus = { requested: 0, retrieved: 0, message: 'cache' };
             PresElementBox,
             SearchBox,
             ImageLabelBox, //Here!
+            FaceCollectionBox,
             FunctionPlotBox,
             InkingStroke,
             LinkBox,
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
new file mode 100644
index 000000000..480d109c8
--- /dev/null
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
@@ -0,0 +1,74 @@
+.face-document-item {
+    background: #555555;
+    margin-top: 10px;
+    margin-bottom: 10px;
+    padding: 10px;
+    border-radius: 10px;
+    position: relative;
+
+    h1 {
+        color: white;
+        font-size: 24px;
+        text-align: center;
+    }
+
+    .face-collection-buttons {
+        position: absolute;
+        top: 10px;
+        right: 10px;
+    }
+
+    .face-document-image-container {
+        display: flex;
+        justify-content: center;
+        flex-wrap: wrap;
+
+        .image-wrapper {
+            position: relative;
+            width: 70px;
+            height: 70px;
+            margin: 10px;
+            display: flex;
+            align-items: center; // Center vertically
+            justify-content: center; // Center horizontally
+
+            img {
+                width: 100%;
+                height: 100%;
+                object-fit: cover; // This ensures the image covers the container without stretching
+                border-radius: 5px;
+                border: 2px solid white;
+                transition: border-color 0.4s;
+
+                &:hover {
+                    border-color: orange; // Change this to your desired hover border color
+                }
+            }
+
+            .remove-item {
+                position: absolute;
+                bottom: -5;
+                right: -5;
+                background-color: rgba(0, 0, 0, 0.5); // Optional: to add a background behind the icon for better visibility
+                border-radius: 30%;
+                width: 10px; // Adjust size as needed
+                height: 10px; // Adjust size as needed
+                display: flex;
+                align-items: center;
+                justify-content: center;
+            }
+        }
+
+        // img {
+        //     max-width: 60px;
+        //     margin: 10px;
+        //     border-radius: 5px;
+        //     border: 2px solid white;
+        //     transition: 0.4s;
+
+        //     &:hover {
+        //         border-color: orange;
+        //     }
+        // }
+    }
+}
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
new file mode 100644
index 000000000..1d3f88df1
--- /dev/null
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -0,0 +1,217 @@
+import { observer } from 'mobx-react';
+import React from 'react';
+import { Docs } from '../../../documents/Documents';
+import { DocumentType } from '../../../documents/DocumentTypes';
+import { ViewBoxBaseComponent } from '../../DocComponent';
+import { FieldView, FieldViewProps } from '../../nodes/FieldView';
+import 'ldrs/ring';
+import { SnappingManager } from '../../../util/SnappingManager';
+import { action, computed, makeObservable, observable, reaction } from 'mobx';
+import { Doc, DocListCast, NumListCast } from '../../../../fields/Doc';
+import { DocData } from '../../../../fields/DocSymbols';
+import { ImageCast, StrCast } from '../../../../fields/Types';
+import { ObservableReactComponent } from '../../ObservableReactComponent';
+import './FaceCollectionBox.scss';
+import { IconButton, Size } from 'browndash-components';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
+import { List } from '../../../../fields/List';
+import { DocumentView } from '../../nodes/DocumentView';
+import { Utils } from '../../../../Utils';
+import { DragManager } from '../../../util/DragManager';
+import * as faceapi from 'face-api.js';
+import { FaceMatcher } from 'face-api.js';
+
+interface FaceDocumentProps {
+    faceDoc: Doc;
+}
+
+/**
+ * A componenent to visually represent a Face Document.
+ */
+@observer
+export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps> {
+    private ref: React.RefObject<HTMLDivElement>;
+    @observable _displayImages: boolean = true;
+    private _dropDisposer?: DragManager.DragDropDisposer;
+    private _inputRef = React.createRef<HTMLInputElement>();
+
+    constructor(props: any) {
+        super(props);
+        makeObservable(this);
+        this.ref = React.createRef();
+    }
+
+    protected createDropTarget = (ele: HTMLDivElement) => {
+        this._dropDisposer?.();
+        ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this._props.faceDoc));
+    };
+
+    protected onInternalDrop(e: Event, de: DragManager.DropEvent): boolean {
+        const { docDragData } = de.complete;
+        if (docDragData) {
+            const filteredDocs = docDragData.droppedDocuments.filter(doc => doc.type === DocumentType.IMG);
+            filteredDocs.forEach(doc => {
+                // If the current Face Document has no items, and the doc has more than one face descriptor, don't let the user add the document first.
+                if ((this._props.faceDoc[DocData].faceDescriptors as List<List<number>>).length === 0 && (doc[DocData].faces as List<List<number>>).length > 1) {
+                    alert('Cannot add a document with multiple faces as the first item!');
+                } else {
+                    // Loop through the documents' face descriptors.
+                    // Choose the face with the smallest distance to add.
+                    const float32Array = (this._props.faceDoc[DocData].faceDescriptors as List<List<number>>).map(faceDescriptor => new Float32Array(Array.from(faceDescriptor)));
+                    const labeledFaceDescriptor = new faceapi.LabeledFaceDescriptors(StrCast(this._props.faceDoc[DocData].label), float32Array);
+                    const faceDescriptors: faceapi.LabeledFaceDescriptors[] = [labeledFaceDescriptor];
+
+                    const faceMatcher = new FaceMatcher(faceDescriptors, 1);
+                    let cur_lowest_distance = 1;
+                    let cur_matching_face = new List<number>();
+
+                    (doc[DocData].faces as List<List<number>>).forEach(face => {
+                        // If the face has the current lowest distance, mark it as such
+                        // Once that lowest distance is found, add the face descriptor to the faceDoc, and add the associated doc
+                        const convered_32_array: Float32Array = new Float32Array(Array.from(face));
+                        const match = faceMatcher.matchDescriptor(convered_32_array);
+
+                        if (match.distance < cur_lowest_distance) {
+                            cur_lowest_distance = match.distance;
+                            cur_matching_face = face;
+                        }
+                    });
+
+                    if (doc[DocData][`FACE DESCRIPTOR - ${this._props.faceDoc[DocData].label}`]) {
+                        doc[DocData][`FACE DESCRIPTOR - ${this._props.faceDoc[DocData].label}`] = new List<List<number>>([...(doc[DocData][`FACE DESCRIPTOR - ${this._props.faceDoc[DocData].label}`] as List<List<number>>), cur_matching_face]);
+                    } else {
+                        doc[DocData][`FACE DESCRIPTOR - ${this._props.faceDoc[DocData].label}`] = new List<List<number>>([cur_matching_face]);
+                    }
+
+                    this._props.faceDoc[DocData].associatedDocs = new List<Doc>([...DocListCast(this._props.faceDoc[DocData].associatedDocs), doc]);
+                    this._props.faceDoc[DocData].faceDescriptors = new List<List<number>>([...(this._props.faceDoc[DocData].faceDescriptors as List<List<number>>), cur_matching_face]);
+
+                    //const match = faceMatcher.findBestMatch(cur_descriptor);
+                }
+            });
+            return false;
+        }
+        return false;
+    }
+
+    /**
+     * Toggles whether a Face Document displays its associated docs.
+     */
+    @action
+    onDisplayClick() {
+        this._displayImages = !this._displayImages;
+    }
+
+    /**
+     * Deletes a Face Document.
+     */
+    @action
+    deleteFaceDocument = () => {
+        if (Doc.ActiveDashboard) {
+            Doc.ActiveDashboard[DocData].faceDocuments = new List<Doc>(DocListCast(Doc.ActiveDashboard[DocData].faceDocuments).filter(doc => doc !== this._props.faceDoc));
+        }
+    };
+
+    /**
+     * Deletes a document from a Face Document's associated docs list.
+     * @param doc
+     */
+    @action
+    deleteAssociatedDoc = (doc: Doc) => {
+        this._props.faceDoc[DocData].faceDescriptors = new List<List<number>>(
+            (this._props.faceDoc[DocData].faceDescriptors as List<List<number>>).filter(fd => !(doc[DocData][`FACE DESCRIPTOR - ${this._props.faceDoc[DocData].label}`] as List<List<number>>).includes(fd))
+        );
+        doc[DocData][`FACE DESCRIPTOR - ${this._props.faceDoc[DocData].label}`] = new List<List<number>>();
+        this._props.faceDoc[DocData].associatedDocs = new List<Doc>(DocListCast(this._props.faceDoc[DocData].associatedDocs).filter(associatedDoc => associatedDoc !== doc));
+    };
+
+    render() {
+        return (
+            <div className="face-document-item" ref={ele => this.createDropTarget(ele!)}>
+                <div className="face-collection-buttons">
+                    <IconButton tooltip={'Delete Face From Collection'} onPointerDown={this.deleteFaceDocument} icon={'x'} style={{ width: '4px' }} size={Size.XSMALL} />
+                </div>
+                <div className="face-document-top">
+                    <h1>{StrCast(this._props.faceDoc[DocData].label)}</h1>
+                </div>
+                <IconButton
+                    tooltip={'See image information'}
+                    onPointerDown={() => this.onDisplayClick()}
+                    icon={this._displayImages ? <FontAwesomeIcon icon="caret-up" /> : <FontAwesomeIcon icon="caret-down" />}
+                    color={MarqueeOptionsMenu.Instance.userColor}
+                    style={{ width: '19px' }}
+                />
+                {this._displayImages ? (
+                    <div className="face-document-image-container">
+                        {DocListCast(this._props.faceDoc[DocData].associatedDocs).map(doc => {
+                            const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
+                            return (
+                                <div className="image-wrapper" key={Utils.GenerateGuid()}>
+                                    <img
+                                        onClick={async () => {
+                                            await DocumentView.showDocument(doc, { willZoomCentered: true });
+                                        }}
+                                        style={{ maxWidth: '60px', margin: '10px' }}
+                                        src={`${name}_o.${type}`}
+                                    />
+                                    <div className="remove-item">
+                                        <IconButton
+                                            tooltip={'Remove Doc From Face Collection'}
+                                            onPointerDown={() => {
+                                                this.deleteAssociatedDoc(doc);
+                                            }}
+                                            icon={'x'}
+                                            style={{ width: '4px' }}
+                                            size={Size.XSMALL}
+                                        />
+                                    </div>
+                                </div>
+                            );
+                        })}
+                    </div>
+                ) : (
+                    <div></div>
+                )}
+            </div>
+        );
+    }
+}
+
+@observer
+export class FaceCollectionBox extends ViewBoxBaseComponent<FieldViewProps>() {
+    public static LayoutString(fieldKey: string) {
+        return FieldView.LayoutString(FaceCollectionBox, fieldKey);
+    }
+
+    public static Instance: FaceCollectionBox;
+
+    @computed get currentDocs() {
+        if (Doc.ActiveDashboard) {
+            return DocListCast(Doc.ActiveDashboard[DocData].faceDocuments);
+        } else {
+            return [];
+        }
+    }
+
+    constructor(props: any) {
+        super(props);
+        makeObservable(this);
+        FaceCollectionBox.Instance = this;
+    }
+
+    render() {
+        return (
+            <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
+                {this.currentDocs.map(doc => {
+                    return <FaceDocumentItem faceDoc={doc} />;
+                })}
+            </div>
+        );
+    }
+}
+
+Docs.Prototypes.TemplateMap.set(DocumentType.FACECOLLECTION, {
+    layout: { view: FaceCollectionBox, dataField: 'data' },
+    options: { acl: '', _width: 400 },
+});
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
index af01d6cbc..421b5d0a6 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -179,6 +179,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                 const labels = imageInfo.labels.split('\n');
                 labels.forEach(label => {
                     label = label.replace(/^\d+\.\s*|-|\*/, '').trim();
+                    console.log(label);
                     imageInfo.doc[DocData][`${label}`] = true;
                     (imageInfo.doc[DocData].data_labels as List<string>).push(label);
                 });
@@ -198,7 +199,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
             for (let index = 0; index < (doc[DocData].data_labels as List<string>).length; index++) {
                 const label = (doc[DocData].data_labels as List<string>)[index];
                 const embedding = await gptGetEmbedding(label);
-                doc[`data_labels_embedding_${index + 1}`] = new List<number>(embedding);
+                doc[DocData][`data_labels_embedding_${index + 1}`] = new List<number>(embedding);
             }
         }
 
@@ -209,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[`data_labels_embedding_${n + 1}`])));
+            const embedLists = numberRange((doc[DocData].data_labels as List<string>).length).map(n => Array.from(NumListCast(doc[DocData][`data_labels_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)) }))
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index fcd38c42f..ef4622ea2 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -1,14 +1,13 @@
 import * as faceapi from 'face-api.js';
-import { FaceMatcher, TinyFaceDetectorOptions } from 'face-api.js';
-import { Doc, DocListCast, NumListCast } from '../../../fields/Doc';
+import { FaceMatcher } from 'face-api.js';
+import { Doc, DocListCast } from '../../../fields/Doc';
 import { DocData } from '../../../fields/DocSymbols';
 import { List } from '../../../fields/List';
-import { ObjectField } from '../../../fields/ObjectField';
-import { ImageCast, StrCast } from '../../../fields/Types';
-import { DocUtils } from '../../documents/DocUtils';
-import { Deserializable } from '../../util/SerializationHelper';
-import { DocumentView } from '../nodes/DocumentView';
+import { ImageCast, NumCast, StrCast } from '../../../fields/Types';
 
+/**
+ * A class that handles face recognition.
+ */
 export class FaceRecognitionHandler {
     static _instance: FaceRecognitionHandler;
     private loadedModels: boolean = false;
@@ -18,8 +17,12 @@ export class FaceRecognitionHandler {
     constructor() {
         FaceRecognitionHandler._instance = this;
         this.loadModels();
+        this.examinedDocs = new Set(DocListCast(Doc.UserDoc()[DocData].examinedFaceDocs, []));
     }
 
+    /**
+     * Loads the face detection models.
+     */
     async loadModels() {
         const MODEL_URL = `/models`;
         await faceapi.loadFaceDetectionModel(MODEL_URL);
@@ -32,19 +35,31 @@ export class FaceRecognitionHandler {
         return FaceRecognitionHandler._instance ?? new FaceRecognitionHandler();
     }
 
+    /**
+     * When a document is added, look for matching face documents.
+     * @param doc The document being analyzed.
+     */
     public async findMatches(doc: Doc) {
         if (this.loadedModels) {
+            // If the Dashboard doesn't have a list of face documents yet, initialize the list.
             if (!Doc.ActiveDashboard![DocData].faceDocuments) {
                 Doc.ActiveDashboard![DocData].faceDocuments = new List<Doc>();
             }
 
+            // If the doc is currently already been examined, or it is being processed, stop examining the document.
             if (this.examinedDocs.has(doc) || this.processingDocs.has(doc)) {
                 return;
             }
 
+            // Mark the document as being processed.
             this.processingDocs.add(doc);
 
             try {
+                if (!Doc.UserDoc()[DocData].faceDocNum) {
+                    Doc.UserDoc()[DocData].faceDocNum = 0;
+                }
+
+                // Get the image the document contains and analyze for faces.
                 const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
                 const imageURL = `${name}_o.${type}`;
 
@@ -52,30 +67,51 @@ export class FaceRecognitionHandler {
 
                 const fullFaceDescriptions = await faceapi.detectAllFaces(img).withFaceLandmarks().withFaceDescriptors();
 
+                doc[DocData].faces = new List<List<number>>();
+
+                // For each face detected, find a match.
                 for (const fd of fullFaceDescriptions) {
-                    const match = this.findMatch(fd.descriptor);
+                    let match = this.findMatch(fd.descriptor);
+                    let converted_list = new List<number>();
+
                     if (match) {
+                        // If a matching Face Document has been found, add the document to the Face Document's associated docs and append the face
+                        // descriptor to the Face Document's descriptor list.
                         const converted_array = Array.from(fd.descriptor);
-                        const converted_list = new List<number>(converted_array);
+                        converted_list = new List<number>(converted_array);
                         match[DocData].associatedDocs = new List<Doc>([...DocListCast(match[DocData].associatedDocs), doc]);
                         match[DocData].faceDescriptors = new List<List<number>>([...(match[DocData].faceDescriptors as List<List<number>>), converted_list]);
                     } else {
+                        // If a matching Face Document has not been found, create a new Face Document.
                         const newFaceDocument = new Doc();
                         const converted_array = Array.from(fd.descriptor);
-                        const converted_list = new List<number>(converted_array);
+                        converted_list = new List<number>(converted_array);
                         newFaceDocument[DocData].faceDescriptors = new List<List<number>>();
                         (newFaceDocument[DocData].faceDescriptors as List<List<number>>).push(converted_list);
-                        newFaceDocument[DocData].label = `Person ${DocListCast(Doc.ActiveDashboard![DocData].faceDocuments).length + 1}`;
+                        Doc.UserDoc()[DocData].faceDocNum = NumCast(Doc.UserDoc()[DocData].faceDocNum) + 1;
+                        newFaceDocument[DocData].label = `Face ${Doc.UserDoc()[DocData].faceDocNum}`;
                         newFaceDocument[DocData].associatedDocs = new List<Doc>([doc]);
 
                         Doc.ActiveDashboard![DocData].faceDocuments = new List<Doc>([...DocListCast(Doc.ActiveDashboard![DocData].faceDocuments), newFaceDocument]);
+                        match = newFaceDocument;
                     }
+
+                    // Assign a field in the document of the matching Face Document.
+                    if (doc[DocData][`FACE DESCRIPTOR - ${match[DocData].label}`]) {
+                        doc[DocData][`FACE DESCRIPTOR - ${match[DocData].label}`] = new List<List<number>>([...(doc[DocData][`FACE DESCRIPTOR - ${match[DocData].label}`] as List<List<number>>), converted_list]);
+                    } else {
+                        doc[DocData][`FACE DESCRIPTOR - ${match[DocData].label}`] = new List<List<number>>([converted_list]);
+                    }
+
+                    doc[DocData].faces = new List<List<number>>([...(doc[DocData].faces as List<List<number>>), converted_list]);
                 }
 
+                // Updates the examined docs field.
                 this.examinedDocs.add(doc);
-                console.log(this.examinedDocs);
-
-                DocListCast(Doc.ActiveDashboard![DocData].faceDocuments).forEach(doc => console.log(DocListCast(doc[DocData].associatedDocs)));
+                if (!Doc.UserDoc()[DocData].examinedFaceDocs) {
+                    Doc.UserDoc()[DocData].examinedFaceDocs = new List<Doc>();
+                }
+                Doc.UserDoc()[DocData].examinedFaceDocs = new List<Doc>([...DocListCast(Doc.UserDoc()[DocData].examinedFaceDocs), doc]);
             } catch (error) {
                 console.error('Error processing document:', error);
             } finally {
@@ -84,6 +120,11 @@ export class FaceRecognitionHandler {
         }
     }
 
+    /**
+     * Finds a matching Face Document given a descriptor
+     * @param cur_descriptor The current descriptor whose match is being searched for.
+     * @returns The most similar Face Document.
+     */
     private findMatch(cur_descriptor: Float32Array) {
         if (DocListCast(Doc.ActiveDashboard![DocData].faceDocuments).length < 1) {
             return null;
@@ -95,19 +136,20 @@ export class FaceRecognitionHandler {
         });
         const faceMatcher = new FaceMatcher(faceDescriptors, 0.6);
         const match = faceMatcher.findBestMatch(cur_descriptor);
-
         if (match.label == 'unknown') {
             return null;
         } else {
             for (const doc of DocListCast(Doc.ActiveDashboard![DocData].faceDocuments)) {
                 if (doc[DocData].label === match.label) {
-                    console.log(match.label);
                     return doc;
                 }
             }
         }
     }
 
+    /**
+     * Loads an image
+     */
     private loadImage = (src: string): Promise<HTMLImageElement> => {
         return new Promise((resolve, reject) => {
             const img = new Image();
diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts
index 4abb23404..ebd30ceba 100644
--- a/src/fields/Doc.ts
+++ b/src/fields/Doc.ts
@@ -265,6 +265,7 @@ export class Doc extends RefField {
     public static get MyDockedBtns()          { return DocCast(Doc.UserDoc().myDockedBtns); } // prettier-ignore
     public static get MySearcher()            { return DocCast(Doc.UserDoc().mySearcher); } // prettier-ignore
     public static get MyImageGrouper()        { return DocCast(Doc.UserDoc().myImageGrouper); } //prettier-ignore
+    public static get MyFaceCollection()      { return DocCast(Doc.UserDoc().myFaceCollection); } //prettier-ignore
     public static get MyHeaderBar()           { return DocCast(Doc.UserDoc().myHeaderBar); } // prettier-ignore
     public static get MyLeftSidebarMenu()     { return DocCast(Doc.UserDoc().myLeftSidebarMenu); } // prettier-ignore
     public static get MyLeftSidebarPanel()    { return DocCast(Doc.UserDoc().myLeftSidebarPanel); } // prettier-ignore
-- 
cgit v1.2.3-70-g09d2


From a52cda0a098716c24b3c6aafe23d2bd9267c72d9 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 26 Aug 2024 10:43:43 -0400
Subject: moved KeywordsBox to TagsView.  changed imagellable to use tags

---
 src/client/documents/Documents.ts                  |   8 +-
 src/client/views/DocumentButtonBar.tsx             |   2 +-
 src/client/views/DocumentDecorations.tsx           |   6 +-
 src/client/views/KeywordBox.tsx                    | 316 -------------------
 src/client/views/StyleProvider.scss                |  64 ----
 src/client/views/StyleProvider.tsx                 |   6 +-
 src/client/views/TagsView.scss                     |  63 ++++
 src/client/views/TagsView.tsx                      | 344 +++++++++++++++++++++
 .../collectionFreeForm/ImageLabelBox.tsx           |  58 ++--
 src/client/views/nodes/DocumentView.tsx            |   1 +
 .../views/nodes/formattedText/RichTextRules.ts     |   2 +-
 src/fields/Doc.ts                                  |   4 +-
 src/fields/DocSymbols.ts                           |   1 -
 13 files changed, 447 insertions(+), 428 deletions(-)
 delete mode 100644 src/client/views/KeywordBox.tsx
 create mode 100644 src/client/views/TagsView.scss
 create mode 100644 src/client/views/TagsView.tsx

(limited to 'src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx')

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';
-- 
cgit v1.2.3-70-g09d2


From cf10fbb80a022ddb141e41203b9135b6f1ed9527 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Tue, 27 Aug 2024 13:49:07 -0400
Subject: uniying image labels => tags.  made face tags  distinguishable from
 other tags - and drag off to place face collection.

---
 src/client/documents/Documents.ts                  |   1 +
 src/client/views/TagsView.scss                     |   4 +
 src/client/views/TagsView.tsx                      |  61 +++++++----
 .../collectionFreeForm/FaceCollectionBox.tsx       |  33 ++++--
 .../collectionFreeForm/ImageLabelBox.tsx           |  10 +-
 src/client/views/search/FaceRecognitionHandler.tsx | 112 +++++++--------------
 src/fields/Doc.ts                                  |   6 +-
 7 files changed, 117 insertions(+), 110 deletions(-)

(limited to 'src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx')

diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 235f54fe8..af181b031 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -364,6 +364,7 @@ export class DocumentOptions {
     data_useCors?: BOOLt = new BoolInfo('whether CORS protocol should be used for web page');
     _face_showImages?: BOOLt = new BoolInfo('whether to show images in uniqe face Doc');
     face?: DOCt = new DocInfo('face document');
+    faceDescriptor?: List<number>;
     columnHeaders?: List<SchemaHeaderField>; // headers for stacking views
     schemaHeaders?: List<SchemaHeaderField>; // headers for schema view
     dockingConfig?: STRt = new StrInfo('configuration of golden layout windows (applies only if doc is rendered as a CollectionDockingView)', false);
diff --git a/src/client/views/TagsView.scss b/src/client/views/TagsView.scss
index f7365a51b..24f9e86bc 100644
--- a/src/client/views/TagsView.scss
+++ b/src/client/views/TagsView.scss
@@ -24,6 +24,10 @@
     align-items: center;
 }
 
+.faceItem {
+    background-color: lightGreen;
+}
+
 .tagsView-suggestions-box {
     display: flex;
     flex-wrap: wrap;
diff --git a/src/client/views/TagsView.tsx b/src/client/views/TagsView.tsx
index 82c85ddf0..da1add55b 100644
--- a/src/client/views/TagsView.tsx
+++ b/src/client/views/TagsView.tsx
@@ -6,10 +6,10 @@ 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 { Doc, DocListCast, Field, Opt, StrListCast } from '../../fields/Doc';
 import { DocData } from '../../fields/DocSymbols';
 import { List } from '../../fields/List';
-import { NumCast, StrCast } from '../../fields/Types';
+import { DocCast, NumCast, StrCast } from '../../fields/Types';
 import { DocumentType } from '../documents/DocumentTypes';
 import { DragManager } from '../util/DragManager';
 import { SnappingManager } from '../util/SnappingManager';
@@ -17,6 +17,7 @@ import { undoable } from '../util/UndoManager';
 import { ObservableReactComponent } from './ObservableReactComponent';
 import './TagsView.scss';
 import { DocumentView } from './nodes/DocumentView';
+import { FaceRecognitionHandler } from './search/FaceRecognitionHandler';
 
 /**
  * The TagsView is a metadata input/display panel shown at the bottom of a DocumentView in a freeform collection.
@@ -158,6 +159,10 @@ export class TagItem extends ObservableReactComponent<TagItemProps> {
      * @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));
 
@@ -188,9 +193,13 @@ export class TagItem extends ObservableReactComponent<TagItemProps> {
             this,
             e,
             () => {
-                const dragData = new DragManager.DocumentDragData([this.createTagCollection()]);
-                DragManager.StartDocumentDrag([this._ref.current!], dragData, e.clientX, e.clientY, {});
-                return true;
+                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,
             emptyFunction
@@ -199,20 +208,20 @@ export class TagItem extends ObservableReactComponent<TagItemProps> {
     };
 
     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
+        this._props.tagDoc && 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}>
+            <div className={'tagItem' + (!this._props.tagDoc ? ' faceItem' : '')} 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}
+                        {Field.toString(this._props.doc[metadata])}
                     </span>
                 ) : (
                     tag
                 )}
-                {this.props.showRemoveUI && (
+                {this.props.showRemoveUI && this._props.tagDoc && (
                     <IconButton
                         tooltip="Remove tag"
                         onPointerDown={undoable(() => TagItem.removeTagFromDoc(this._props.doc, this._props.tag, this._props.tagDoc), `remove tag ${this._props.tag}`)}
@@ -234,12 +243,9 @@ interface TagViewProps {
  */
 @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 = '';
@@ -268,18 +274,26 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
      * 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');
+    submitTag = undoable(
+        action((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);
+        const tagsList = new Set<string>(StrListCast(this._props.View.dataDoc.tags));
+        const facesList = new Set<string>(
+            DocListCast(this._props.View.dataDoc[Doc.LayoutFieldKey(this._props.View.Document) + '_annotations'])
+                .filter(d => d.face)
+                .map(doc => StrCast(DocCast(doc.face)?.title))
+        );
 
         return !this._props.View.Document.showTags ? null : (
             <div
@@ -295,11 +309,14 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
                 }}>
                 <div className="tagsView-content" style={{ width: '100%' }}>
                     <div className="tagsView-list">
-                        {!tagsList.length ? null : ( //
+                        {!tagsList.size ? 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} />
+                        {Array.from(tagsList).map((tag, i) => (
+                            <TagItem key={i} doc={this._props.View.Document} tag={tag} tagDoc={TagItem.findTagCollectionDoc(tag) ?? TagItem.createTagCollectionDoc(tag)} setToEditing={this.setToEditing} showRemoveUI={this.isEditing} />
+                        ))}
+                        {Array.from(facesList).map((tag, i) => (
+                            <TagItem key={i} doc={this._props.View.Document} tag={tag} tagDoc={undefined} setToEditing={this.setToEditing} showRemoveUI={this.isEditing} />
                         ))}
                     </div>
                     {this.isEditing ? (
@@ -330,7 +347,7 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
                                             color={SnappingManager.userVariantColor}
                                             tooltip="Add existing tag"
                                             onClick={() => this.submitTag(tag)}
-                                            key={i}
+                                            key={tag}
                                         />
                                     );
                                 })}
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index ea1c22962..c62303dc0 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -9,8 +9,9 @@ import React from 'react';
 import { DivHeight, lightOrDark, returnTrue, setupMoveUpEvents } from '../../../../ClientUtils';
 import { emptyFunction } from '../../../../Utils';
 import { Doc, Opt } from '../../../../fields/Doc';
+import { DocData } from '../../../../fields/DocSymbols';
 import { List } from '../../../../fields/List';
-import { ImageCast, NumCast, StrCast } from '../../../../fields/Types';
+import { DocCast, ImageCast, NumCast, StrCast } from '../../../../fields/Types';
 import { DocumentType } from '../../../documents/DocumentTypes';
 import { Docs } from '../../../documents/Documents';
 import { DragManager } from '../../../util/DragManager';
@@ -87,23 +88,27 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
             ?.filter(doc => doc.type === DocumentType.IMG)
             .forEach(imgDoc => {
                 // If the current Face Document has no faces, and the doc has more than one face descriptor, don't let the user add the document first.  Or should we just use the first face ?
-                if (FaceRecognitionHandler.UniqueFaceDescriptors(this.Document).length === 0 && FaceRecognitionHandler.ImageDocFaceDescriptors(imgDoc).length > 1) {
+                if (FaceRecognitionHandler.UniqueFaceDescriptors(this.Document).length === 0 && FaceRecognitionHandler.ImageDocFaceAnnos(imgDoc).length > 1) {
                     alert('Cannot add a document with multiple faces as the first item!');
                 } else {
                     // Loop through the documents' face descriptors and choose the face in the iage with the smallest distance (most similar to the face colleciton)
                     const faceDescriptorsAsFloat32Array = FaceRecognitionHandler.UniqueFaceDescriptors(this.Document).map(fd => new Float32Array(Array.from(fd)));
                     const labeledFaceDescriptor = new faceapi.LabeledFaceDescriptors(FaceRecognitionHandler.UniqueFaceLabel(this.Document), faceDescriptorsAsFloat32Array);
                     const faceMatcher = new FaceMatcher([labeledFaceDescriptor], 1);
-                    const { face_match } = FaceRecognitionHandler.ImageDocFaceDescriptors(imgDoc).reduce(
-                        (prev, face) => {
-                            const match = faceMatcher.matchDescriptor(new Float32Array(Array.from(face)));
-                            return match.distance < prev.dist ? { dist: match.distance, face_match: face } : prev;
+                    const { faceAnno } = FaceRecognitionHandler.ImageDocFaceAnnos(imgDoc).reduce(
+                        (prev, faceAnno) => {
+                            const match = faceMatcher.matchDescriptor(new Float32Array(Array.from(faceAnno.faceDescriptor as List<number>)));
+                            return match.distance < prev.dist ? { dist: match.distance, faceAnno } : prev;
                         },
-                        { dist: 1, face_match: undefined as Opt<List<number>> }
+                        { dist: 1, faceAnno: undefined as Opt<Doc> }
                     );
 
                     // assign the face in the image that's closest to the face collection's face
-                    FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, face_match, this.Document);
+                    if (faceAnno) {
+                        FaceRecognitionHandler.UniqueFaceRemoveFaceImage(faceAnno, DocCast(faceAnno.face));
+                        FaceRecognitionHandler.UniqueFaceAddFaceImage(faceAnno, this.Document);
+                        faceAnno.face = this.Document;
+                    }
                 }
             });
         e.stopPropagation();
@@ -224,7 +229,15 @@ export class FaceCollectionBox extends ViewBoxBaseComponent<FieldViewProps>() {
     }
 
     moveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[]) => boolean): boolean => !!(this._props.removeDocument?.(doc) && addDocument?.(doc));
-
+    addDocument = (doc: Doc | Doc[], annotationKey?: string) => {
+        const uniqueFaceDoc = doc instanceof Doc ? doc : doc[0];
+        const added = uniqueFaceDoc.type === DocumentType.UFACE;
+        if (added) {
+            Doc.SetContainer(uniqueFaceDoc, Doc.MyFaceCollection);
+            Doc.ActiveDashboard && Doc.AddDocToList(Doc.ActiveDashboard[DocData], 'myUniqueFaces', uniqueFaceDoc);
+        }
+        return added;
+    };
     /**
      * this changes style provider requests that target the dashboard to requests that target the face collection box which is what's actually being rendered.
      * This is needed, for instance, to get the default background color from the face collection, not the dashboard.
@@ -233,6 +246,7 @@ export class FaceCollectionBox extends ViewBoxBaseComponent<FieldViewProps>() {
         if (doc === Doc.ActiveDashboard) return this._props.styleProvider?.(this.Document, this._props, property);
         return this._props.styleProvider?.(doc, this._props, property);
     };
+
     render() {
         return !Doc.ActiveDashboard ? null : (
             <div className="faceCollectionBox">
@@ -245,6 +259,7 @@ export class FaceCollectionBox extends ViewBoxBaseComponent<FieldViewProps>() {
                     Document={Doc.ActiveDashboard}
                     fieldKey="myUniqueFaces"
                     moveDocument={this.moveDocument}
+                    addDocument={this.addDocument}
                     isContentActive={returnTrue}
                     isAnyChildContentActive={returnTrue}
                     childHideDecorations={true}
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
index 6eb3eb784..5d3154e3c 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -51,7 +51,7 @@ export class ImageLabelBoxData {
         label = label.toUpperCase().trim();
         if (label.length > 0) {
             if (!this._labelGroups.includes(label)) {
-                this._labelGroups = [...this._labelGroups, label];
+                this._labelGroups = [...this._labelGroups, label.startsWith('#') ? label : '#' + label];
             }
         }
     };
@@ -178,8 +178,12 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
 
                 const labels = imageInfo.labels.split('\n');
                 labels.forEach(label => {
-                    label = label.replace(/^\d+\.\s*|-|f\*/, '').trim();
-                    console.log(label);
+                    label =
+                        '#' +
+                        label
+                            .replace(/^\d+\.\s*|-|f\*/, '')
+                            .replace(/^#/, '')
+                            .trim();
                     imageInfo.doc[DocData][label] = true;
                     (imageInfo.doc[DocData].tags as List<string>).push(label);
                 });
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index 021802061..4c10307e6 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -1,15 +1,14 @@
 import * as faceapi from 'face-api.js';
 import { FaceMatcher } from 'face-api.js';
-import { Doc, DocListCast, Opt } from '../../../fields/Doc';
+import { Doc, DocListCast } from '../../../fields/Doc';
 import { DocData } from '../../../fields/DocSymbols';
 import { List } from '../../../fields/List';
-import { listSpec } from '../../../fields/Schema';
-import { Cast, ImageCast, NumCast, StrCast } from '../../../fields/Types';
+import { ComputedField } from '../../../fields/ScriptField';
+import { DocCast, ImageCast, NumCast, StrCast } from '../../../fields/Types';
 import { ImageField } from '../../../fields/URLField';
 import { DocumentType } from '../../documents/DocumentTypes';
 import { Docs } from '../../documents/Documents';
 import { DocumentManager } from '../../util/DocumentManager';
-import { ComputedField } from '../../../fields/ScriptField';
 
 /**
  * A singleton class that handles face recognition and manages face Doc collections for each face found.
@@ -18,16 +17,17 @@ import { ComputedField } from '../../../fields/ScriptField';
  * as a face collection Doc). If the face matches a face collection Doc, then it will be added to that
  * collection along with the numerical representation of the face, its face descriptor.
  *
- * Image Doc's that are added to one or more face collection Docs will be given these metadata fields:
- *     <image data field>_faceDescriptors - list of all the numerical face representations found in the image.
- *     <image data field>_faces - list of unique face Docs corresponding to recognized faces in the image.
+ * Image Doc's that are added to one or more face collection Docs will be given an annotation rectangle that
+ * highlights where the face is, and the annotation will have these fields:
+ *     faceDescriptor - the numerical face representations found in the image.
+ *     face - the unique face Docs corresponding to recognized face in the image.
+ *     annotationOn - the image where the face was found
  *
  * unique face Doc's are created for each person identified and are stored in the Dashboard's myUniqueFaces field
  *
  * Each unique face Doc represents a unique face and collects all matching face images for that person.  It has these fields:
  *     face - a string label for the person that was recognized                                     (TODO: currently it's just a 'face#')
- *     face_descriptors - a list of all the face descriptors for different images of the person
- *     face_docList - a list of all image Docs that contain a face for the person
+ *     face_annos - a list of face annotations, where each anno has
  */
 export class FaceRecognitionHandler {
     static _instance: FaceRecognitionHandler;
@@ -54,42 +54,12 @@ export class FaceRecognitionHandler {
         });
     };
 
-    /**
-     * return the metadata field name where unique face Docs are stored
-     * @param imgDoc image with faces
-     * @returns name of field
-     */
-    private static ImageDocFaceField = (imgDoc: Doc) => `${Doc.LayoutFieldKey(imgDoc)}_faces`;
-
     /**
      * Returns an array of faceDocs for each face recognized in the image
      * @param imgDoc image with faces
      * @returns faceDoc array
      */
-    private static ImageDocFaces = (imgDoc: Doc) => DocListCast(imgDoc[`${Doc.LayoutFieldKey(imgDoc)}_faces`]);
-
-    /**
-     * initializes an image with an empty list of face descriptors
-     * @param imgDoc image to initialize
-     */
-    private static initImageDocFaceDescriptors = (imgDoc: Doc) => {
-        imgDoc[DocData][`${Doc.LayoutFieldKey(imgDoc)}_faceDescriptors`] = new List<List<number>>();
-    };
-    /**
-     * returns the face descriptors for each face found on an image Doc
-     * @param imgDoc
-     * @returns list of face descriptors
-     */
-    public static ImageDocFaceDescriptors = (imgDoc: Doc) => imgDoc[DocData][`${Doc.LayoutFieldKey(imgDoc)}_faceDescriptors`] as List<List<number>>;
-
-    /**
-     * Adds a face descriptor for a face found in an image
-     * @param imgDoc image Doc with face
-     * @param faceDescriptor descriptor of a face
-     */
-    public static ImageDocAddFaceDescriptor = (imgDoc: Doc, faceDescriptor: List<number>) => {
-        Cast(imgDoc[DocData][`${Doc.LayoutFieldKey(imgDoc)}_faceDescriptors`], listSpec('number'), null).push(faceDescriptor as unknown as number);
-    };
+    public static ImageDocFaceAnnos = (imgDoc: Doc) => DocListCast(imgDoc[`${Doc.LayoutFieldKey(imgDoc)}_annotations`]).filter(doc => doc.face);
 
     /**
      * returns a list of all face collection Docs on the current dashboard
@@ -97,6 +67,13 @@ export class FaceRecognitionHandler {
      */
     public static UniqueFaces = () => DocListCast(Doc.ActiveDashboard?.[DocData].myUniqueFaces);
 
+    /**
+     * Find a unique face from its name
+     * @param name name of unique face
+     * @returns unique face or undefined
+     */
+    public static FindUniqueFaceByName = (name: string) => FaceRecognitionHandler.UniqueFaces().find(faceDoc => faceDoc.title === name);
+
     /**
      * Removes a unique face from the set of recognized unique faces
      * @param faceDoc unique face Doc
@@ -117,28 +94,23 @@ export class FaceRecognitionHandler {
      * @param faceDoc unique face Doc
      * @returns face descriptors
      */
-    public static UniqueFaceDescriptors = (faceDoc: Doc) => faceDoc[DocData].face_descriptors as List<List<number>>;
+    public static UniqueFaceDescriptors = (faceDoc: Doc) => DocListCast(faceDoc[DocData].face_annos).map(face => face.faceDescriptor as List<number>);
 
     /**
      * Returns a list of all face image Docs associated with a unique face Doc
      * @param faceDoc unique face Doc
      * @returns image Docs
      */
-    public static UniqueFaceImages = (faceDoc: Doc) => DocListCast(faceDoc[DocData].face_images);
+    public static UniqueFaceImages = (faceDoc: Doc) => DocListCast(faceDoc[DocData].face_annos).map(face => DocCast(face.annotationOn));
 
     /**
      * Adds a face image to a unique face Doc, adds the unique face Doc to the images list of reognized faces,
      * and updates the unique face's set of face image descriptors
      * @param img - image with faces to add to a face collection Doc
-     * @param faceDescriptor - the face descriptor for the face in the image to add
-     * @param faceDoc - unique face Doc
+     * @param faceAnno - a face annotation
      */
-    public static UniqueFaceAddFaceImage = (img: Doc, faceDescriptor: Opt<List<number>>, faceDoc: Doc) => {
-        Doc.AddDocToList(faceDoc, 'face_images', img);
-        if (faceDescriptor) {
-            Cast(faceDoc.face_descriptors, listSpec('number'), null).push(faceDescriptor as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
-            Doc.AddDocToList(img[DocData], FaceRecognitionHandler.ImageDocFaceField(img), faceDoc);
-        }
+    public static UniqueFaceAddFaceImage = (faceAnno: Doc, faceDoc: Doc) => {
+        Doc.AddDocToList(faceDoc, 'face_annos', faceAnno);
     };
 
     /**
@@ -146,11 +118,9 @@ export class FaceRecognitionHandler {
      * @param img - image with faces to remove
      * @param faceDoc - unique face Doc
      */
-    public static UniqueFaceRemoveFaceImage = (img: Doc, faceDoc: Doc) => {
-        Doc.RemoveDocFromList(faceDoc[DocData], 'face_images', img);
-        const descriptorsEqual = (a: List<number>, b: List<number>) => (a === b ? true : a.length === b.length ? a.every((element, index) => element === b[index]) : false);
-        faceDoc[DocData].face_descriptors = new List<List<number>>(FaceRecognitionHandler.UniqueFaceDescriptors(faceDoc).filter(fd => !FaceRecognitionHandler.ImageDocFaceDescriptors(img).some(desc => descriptorsEqual(fd, desc))));
-        Doc.RemoveDocFromList(img[DocData], FaceRecognitionHandler.ImageDocFaceField(img), faceDoc);
+    public static UniqueFaceRemoveFaceImage = (faceAnno: Doc, faceDoc: Doc) => {
+        Doc.RemoveDocFromList(faceDoc[DocData], 'face_annos', faceAnno);
+        faceAnno.face = undefined;
     };
 
     constructor() {
@@ -192,8 +162,7 @@ export class FaceRecognitionHandler {
         });
         const uface = uniqueFaceDoc[DocData];
         uface.face = `Face${faceDocNum}`;
-        uface.face_images = new List<Doc>();
-        uface.face_descriptors = new List<List<number>>();
+        uface.face_annos = new List<Doc>();
         Doc.SetContainer(uniqueFaceDoc, Doc.MyFaceCollection);
 
         Doc.ActiveDashboard && Doc.AddDocToList(Doc.ActiveDashboard[DocData], 'myUniqueFaces', uniqueFaceDoc);
@@ -243,7 +212,6 @@ export class FaceRecognitionHandler {
             if (imgUrl && !DocListCast(Doc.MyFaceCollection.examinedFaceDocs).includes(imgDoc[DocData])) {
                 // only examine Docs that have an image and that haven't already been examined.
                 Doc.AddDocToList(Doc.MyFaceCollection, 'examinedFaceDocs', imgDoc[DocData]);
-                FaceRecognitionHandler.initImageDocFaceDescriptors(imgDoc);
                 FaceRecognitionHandler.loadImage(imgUrl).then(
                     // load image and analyze faces
                     img => faceapi
@@ -255,22 +223,20 @@ export class FaceRecognitionHandler {
                                 const scale = NumCast(imgDoc.data_nativeWidth) / img.width;
                                 imgDocFaceDescriptions.forEach((fd, i) => {
                                     const faceDescriptor = new List<number>(Array.from(fd.descriptor));
-                                    FaceRecognitionHandler.ImageDocAddFaceDescriptor(imgDoc, faceDescriptor); // add face descriptor to image's list of descriptors
                                     const matchedUniqueFace = this.findMatchingFaceDoc(fd.descriptor) ?? this.createUniqueFaceDoc(activeDashboard);
-                                    FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, faceDescriptor, matchedUniqueFace); // add image/faceDescriptor to matched unique face
-                                    annos.push(
-                                        Docs.Create.FreeformDocument([], {
-                                            title: ComputedField.MakeFunction(`this.face.face`, undefined, undefined, 'this.face.face = value') as unknown as string, //
-                                            annotationOn: imgDoc,
-                                            face: matchedUniqueFace[DocData],
-                                            backgroundColor: '#55555555',
-                                            x: fd.alignedRect.box.left * scale,
-                                            y: fd.alignedRect.box.top * scale,
-                                            _width: fd.alignedRect.box.width * scale,
-                                            _height: fd.alignedRect.box.height * scale,
-                                        })
-                                    );
-                                    Doc.SetContainer(annos.lastElement(), imgDoc[DocData]);
+                                    const faceAnno = Docs.Create.FreeformDocument([], {
+                                        title: ComputedField.MakeFunction(`this.face.face`, undefined, undefined, 'this.face.face = value') as unknown as string, //
+                                        annotationOn: imgDoc,
+                                        face: matchedUniqueFace[DocData],
+                                        faceDescriptor: faceDescriptor,
+                                        backgroundColor: 'transparent',
+                                        x: fd.alignedRect.box.left * scale,
+                                        y: fd.alignedRect.box.top * scale,
+                                        _width: fd.alignedRect.box.width * scale,
+                                        _height: fd.alignedRect.box.height * scale,
+                                    })
+                                    FaceRecognitionHandler.UniqueFaceAddFaceImage(faceAnno, matchedUniqueFace); // add image/faceDescriptor to matched unique face
+                                    annos.push(faceAnno);
                                 });
 
                                 imgDoc[DocData].data_annotations = new List<Doc>(annos);
diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts
index e3178f20c..7abba7679 100644
--- a/src/fields/Doc.ts
+++ b/src/fields/Doc.ts
@@ -791,9 +791,9 @@ export namespace Doc {
                 linkMap.set(link[Id], await Doc.makeClone(link, cloneMap, linkMap, rtfs, exclusions, pruneDocs, cloneLinks, cloneTemplates));
             }
         });
-        if (Doc.Get(copy, 'title', true)) copy.title = '>:' + doc.title;
-        //  Doc.SetInPlace(copy, 'title', '>:' + doc.title, true);
         copy.cloneOf = doc;
+        const cfield = ComputedField.WithoutComputed(() => FieldValue(doc.title));
+        if (Doc.Get(copy, 'title', true) && !(cfield instanceof ComputedField)) copy.title = '>:' + doc.title;
         cloneMap.set(doc[Id], copy);
 
         return copy;
@@ -1424,7 +1424,7 @@ export namespace Doc {
 
     export function Paste(docids: string[], clone: boolean, addDocument: (doc: Doc | Doc[]) => boolean, ptx?: number, pty?: number, newPoint?: number[]) {
         DocServer.GetRefFields(docids).then(async fieldlist => {
-            const list = Array.from(Object.values(fieldlist))
+            const list = Array.from(fieldlist.values())
                 .map(d => DocCast(d))
                 .filter(d => d);
             const docs = clone ? (await Promise.all(Doc.MakeClones(list, false, false))).map(res => res.clone) : list;
-- 
cgit v1.2.3-70-g09d2


From a566129971f1a29b1d42679befa27c63b73a7167 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Tue, 27 Aug 2024 17:43:27 -0400
Subject: move chat tag labels to tags_chat and updated tagsView

---
 src/client/views/TagsView.tsx                      | 32 ++++++++++++++++++++--
 .../collectionFreeForm/ImageLabelBox.tsx           | 15 +++++-----
 2 files changed, 37 insertions(+), 10 deletions(-)

(limited to 'src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx')

diff --git a/src/client/views/TagsView.tsx b/src/client/views/TagsView.tsx
index d2ca77446..89025d668 100644
--- a/src/client/views/TagsView.tsx
+++ b/src/client/views/TagsView.tsx
@@ -1,6 +1,6 @@
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { Button, Colors, IconButton } from 'browndash-components';
-import { action, computed, makeObservable, observable } from 'mobx';
+import { IReactionDisposer, action, computed, makeObservable, observable, reaction } from 'mobx';
 import { observer } from 'mobx-react';
 import React from 'react';
 import ResizeObserver from 'resize-observer-polyfill';
@@ -248,11 +248,25 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
         makeObservable(this);
     }
 
+    @observable _panelHeightDirty = 0;
     @observable _currentInput = '';
     @observable _isEditing = !StrListCast(this._props.View.dataDoc.tags).length;
+    _heightDisposer: IReactionDisposer | undefined;
+
+    componentDidMount() {
+        this._heightDisposer = reaction(
+            () => this._props.View.screenToContentsTransform(),
+            xf => {
+                this._panelHeightDirty = this._panelHeightDirty + 1;
+            }
+        );
+    }
+    componentWillUnmount() {
+        this._heightDisposer?.();
+    }
 
     @computed get currentScale() {
-        return NumCast((this._props.View.Document.embedContainer as Doc)?._freeform_scale, 1);
+        return NumCast(DocCast(this._props.View.Document.embedContainer)?._freeform_scale, 1);
     }
     @computed get isEditing() {
         return this._isEditing && DocumentView.SelectedDocs().includes(this._props.View.Document);
@@ -289,11 +303,13 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
      */
     render() {
         const tagsList = new Set<string>(StrListCast(this._props.View.dataDoc.tags));
+        const chatTagsList = new Set<string>(StrListCast(this._props.View.dataDoc.tags_chat));
         const facesList = new Set<string>(
             DocListCast(this._props.View.dataDoc[Doc.LayoutFieldKey(this._props.View.Document) + '_annotations'])
                 .filter(d => d.face)
                 .map(doc => StrCast(DocCast(doc.face)?.title))
         );
+        this._panelHeightDirty;
 
         return !this._props.View.Document.showTags ? null : (
             <div
@@ -351,6 +367,18 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
                                         />
                                     );
                                 })}
+                                {Array.from(chatTagsList).map(tag => {
+                                    return (
+                                        <Button
+                                            style={{ margin: '2px 2px', border: '1px solid black', backgroundColor: 'lightpink', color: 'black' }}
+                                            text={tag}
+                                            color={SnappingManager.userVariantColor}
+                                            tooltip="Add existing tag"
+                                            onClick={() => this.submitTag(tag)}
+                                            key={tag}
+                                        />
+                                    );
+                                })}
                             </div>
                         </div>
                     ) : null}
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
index 5d3154e3c..e419e522c 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -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].tags) {
+            if (!doc[DocData].tags_chat) {
                 const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
                 return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 =>
                 !hrefBase64 ? undefined : 
@@ -174,7 +174,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
 
         (await Promise.all(imageInfos)).forEach(imageInfo => {
             if (imageInfo) {
-                imageInfo.doc[DocData].tags = (imageInfo.doc[DocData].tags as List<string>) ?? new List<string>();
+                imageInfo.doc[DocData].tags_chat = (imageInfo.doc[DocData].tags_chat as List<string>) ?? new List<string>();
 
                 const labels = imageInfo.labels.split('\n');
                 labels.forEach(label => {
@@ -184,8 +184,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                             .replace(/^\d+\.\s*|-|f\*/, '')
                             .replace(/^#/, '')
                             .trim();
-                    imageInfo.doc[DocData][label] = true;
-                    (imageInfo.doc[DocData].tags as List<string>).push(label);
+                    (imageInfo.doc[DocData].tags_chat as List<string>).push(label);
                 });
             }
         });
@@ -200,8 +199,8 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
         this.startLoading();
 
         for (const doc of this._selectedImages) {
-            for (let index = 0; index < (doc[DocData].tags as List<string>).length; index++) {
-                const label = (doc[DocData].tags as List<string>)[index];
+            for (let index = 0; index < (doc[DocData].tags_chat as List<string>).length; index++) {
+                const label = (doc[DocData].tags_chat as List<string>)[index];
                 const embedding = await gptGetEmbedding(label);
                 doc[DocData][`tags_embedding_${index + 1}`] = new List<number>(embedding);
             }
@@ -214,7 +213,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].tags as List<string>).length).map(n => Array.from(NumListCast(doc[DocData][`tags_embedding_${n + 1}`])));
+            const embedLists = numberRange((doc[DocData].tags_chat 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)) }))
@@ -321,7 +320,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].tags as List<string>).map(label => {
+                                        {(doc[DocData].tags_chat as List<string>).map(label => {
                                             return (
                                                 <div key={Utils.GenerateGuid()} className="image-label" style={{ backgroundColor: SettingsManager.userVariantColor, borderColor: SettingsManager.userColor }}>
                                                     {label}
-- 
cgit v1.2.3-70-g09d2


From bb1f4c62071987cc59461a8ce53522fa7a9036cc Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Thu, 12 Sep 2024 14:42:10 -0400
Subject: changed closing of face rectangles to hide them.  added Shift+click
 of tags button to show face rectangles.

---
 src/client/documents/Documents.ts                          |  1 +
 src/client/views/DocumentButtonBar.tsx                     | 14 +++++++++-----
 src/client/views/DocumentDecorations.tsx                   |  7 ++++---
 src/client/views/TagsView.tsx                              |  6 +++---
 .../views/collections/collectionFreeForm/ImageLabelBox.tsx |  4 ++--
 src/client/views/nodes/formattedText/RichTextRules.ts      |  2 +-
 src/client/views/search/FaceRecognitionHandler.tsx         |  3 +++
 7 files changed, 23 insertions(+), 14 deletions(-)

(limited to 'src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx')

diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index fbdf361dd..89356072a 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -273,6 +273,7 @@ export class DocumentOptions {
     _layout_showTitle?: string; // field name to display in header (:hover is an optional suffix)
     _layout_showSidebar?: BOOLt = new BoolInfo('whether an annotationsidebar should be displayed for text docuemnts');
     _layout_showCaption?: string; // which field to display in the caption area.  leave empty to have no caption
+    _layout_showTags?: BOOLt = new BoolInfo('whether to show the list of document tags at the bottom of a DocView');
 
     _chromeHidden?: BOOLt = new BoolInfo('whether the editing chrome for a document is hidden');
     hideClickBehaviors?: BOOLt = new BoolInfo('whether to hide click behaviors in context menu');
diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx
index 785e69f84..f14fd033b 100644
--- a/src/client/views/DocumentButtonBar.tsx
+++ b/src/client/views/DocumentButtonBar.tsx
@@ -9,7 +9,7 @@ import * as React from 'react';
 import { FaEdit } from 'react-icons/fa';
 import { returnFalse, returnTrue, setupMoveUpEvents, simulateMouseClick } from '../../ClientUtils';
 import { emptyFunction } from '../../Utils';
-import { Doc } from '../../fields/Doc';
+import { Doc, DocListCast } from '../../fields/Doc';
 import { Cast, DocCast } from '../../fields/Types';
 import { DocUtils, IsFollowLinkScript } from '../documents/DocUtils';
 import { CalendarManager } from '../util/CalendarManager';
@@ -270,12 +270,16 @@ export class DocumentButtonBar extends ObservableReactComponent<{ views: () => (
                 <div
                     className="documentButtonBar-icon"
                     style={{ color: 'white' }}
-                    onClick={() => {
-                        const showing = DocumentView.Selected().some(dv => dv.dataDoc.showTags);
+                    onClick={undoable(e => {
+                        const showing = DocumentView.Selected().some(dv => dv.layoutDoc._layout_showTags);
                         DocumentView.Selected().forEach(dv => {
-                            dv.dataDoc.showTags = !showing;
+                            dv.layoutDoc._layout_showTags = !showing;
+                            if (e.shiftKey)
+                                DocListCast(dv.Document[Doc.LayoutFieldKey(dv.Document) + '_annotations']).forEach(doc => {
+                                    if (doc.face) doc.hidden = showing;
+                                });
                         });
-                    }}>
+                    }, 'show Doc tags')}>
                     <FontAwesomeIcon className="documentdecorations-icon" icon="tag" />
                 </div>
             </Tooltip>
diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx
index 118a5bd3d..907bb2614 100644
--- a/src/client/views/DocumentDecorations.tsx
+++ b/src/client/views/DocumentDecorations.tsx
@@ -232,7 +232,8 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora
                         if (iconViewDoc.activeFrame) {
                             iconViewDoc.opacity = 0; // bcz: hacky ... allows inkMasks and other documents to be "turned off" without removing them from the animated collection which allows them to function properly in a presenation.
                         } else {
-                            iconView._props.removeDocument?.(iconView.Document);
+                            if (iconView.Document.annotationOn && iconView.Document.face) iconView.Document.hidden = true;
+                            else iconView._props.removeDocument?.(iconView.Document);
                         }
                     });
                     views.forEach(DocumentView.DeselectView);
@@ -836,7 +837,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora
                                 <div
                                     className="link-button-container"
                                     style={{
-                                        top: DocumentView.Selected().length > 1 ? 0 : `${doc[DocData].showTags ? 4 + seldocview.TagPanelHeight : 4}px`,
+                                        top: DocumentView.Selected().length > 1 ? 0 : `${doc._layout_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()} />
@@ -845,7 +846,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora
                             <div
                                 className="documentDecorations-tagsView"
                                 style={{
-                                    top: `${doc[DocData].showTags ? 4 + seldocview.TagPanelHeight : 4}px`,
+                                    top: `${doc._layout_showTags ? 4 + seldocview.TagPanelHeight : 4}px`,
                                     transform: `translate(${-this._resizeBorderWidth / 2 + 10}px, ${this._resizeBorderWidth + bounds.b - bounds.y + this._titleHeight}px) `,
                                 }}>
                                 {DocumentView.Selected().length > 1 ? <TagsView Views={DocumentView.Selected()} /> : null}
diff --git a/src/client/views/TagsView.tsx b/src/client/views/TagsView.tsx
index ae9d42749..0b303f6f5 100644
--- a/src/client/views/TagsView.tsx
+++ b/src/client/views/TagsView.tsx
@@ -172,12 +172,12 @@ export class TagItem extends ObservableReactComponent<TagItemProps> {
             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;
+            doc._layout_showTags = true;
             return doc;
         })(Doc.MakeCopy(Doc.UserDoc().emptyCollection as Doc, true));
         newEmbeddings.forEach(embed => Doc.SetContainer(embed, newCollection));
@@ -322,7 +322,7 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
     );
 
     /**
-     * When 'showTags' is set on a Doc, this displays a wrapping panel of tagItemViews corresponding to all the tags set on the Doc).
+     * 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() {
@@ -336,7 +336,7 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
         );
         this._panelHeightDirty;
 
-        return !this.View.Document.showTags && this._props.Views.length === 1 ? null : (
+        return !this.View.Document._layout_showTags && this._props.Views.length === 1 ? null : (
             <div
                 className="tagsView-container"
                 ref={r => r && new ResizeObserver(action(() => this._props.Views.length === 1 && (this.View.TagPanelHeight = Math.max(0, (r?.getBoundingClientRect().height ?? 0) - this.InsetDist)))).observe(r)}
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
index e419e522c..033d1590d 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -139,9 +139,9 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
     toggleDisplayInformation = () => {
         this._displayImageInformation = !this._displayImageInformation;
         if (this._displayImageInformation) {
-            this._selectedImages.forEach(doc => (doc[DocData].showTags = true));
+            this._selectedImages.forEach(doc => (doc._layout_showTags = true));
         } else {
-            this._selectedImages.forEach(doc => (doc[DocData].showTags = false));
+            this._selectedImages.forEach(doc => (doc._layout_showTags = false));
         }
     };
 
diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts
index e0d6c7c05..0ef67b4be 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].showTags = true;
+                    this.Document._layout_showTags = true;
                 }
                 const fieldView = state.schema.nodes.dashField.create({ fieldKey: '#' + tag });
                 return state.tr
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index 636a44984..c507e54b6 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -220,6 +220,7 @@ export class FaceRecognitionHandler {
                             .then(imgDocFaceDescriptions => {  // For each face detected, find a match.
                                 const annos = [] as Doc[];
                                 const scale = NumCast(imgDoc.data_nativeWidth) / img.width;
+                                const showTags= imgDocFaceDescriptions.length > 1;
                                 imgDocFaceDescriptions.forEach((fd, i) => {
                                     const faceDescriptor = new List<number>(Array.from(fd.descriptor));
                                     const matchedUniqueFace = this.findMatchingFaceDoc(fd.descriptor) ?? this.createUniqueFaceDoc(activeDashboard);
@@ -233,12 +234,14 @@ export class FaceRecognitionHandler {
                                         y: fd.alignedRect.box.top * scale,
                                         _width: fd.alignedRect.box.width * scale,
                                         _height: fd.alignedRect.box.height * scale,
+                                        _layout_showTags: showTags
                                     })
                                     FaceRecognitionHandler.UniqueFaceAddFaceImage(faceAnno, matchedUniqueFace); // add image/faceDescriptor to matched unique face
                                     annos.push(faceAnno);
                                 });
 
                                 imgDoc[DocData].data_annotations = new List<Doc>(annos);
+                                imgDoc._layout_showTags = annos.length > 0;
                                 return imgDocFaceDescriptions;
                             })
                 ); // prettier-ignore
-- 
cgit v1.2.3-70-g09d2

