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

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

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

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 },
+});
-- 
cgit v1.2.3-70-g09d2


From cdb21e036ff65d63991a53798133407be1d5755f Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Tue, 20 Aug 2024 21:30:32 -0400
Subject: fixed error handling of images too big to load.  cleaned up
 facecollectionbox.  changed metadata field naming to match conventions.

---
 src/client/Network.ts                              | 11 +--
 src/client/documents/DocUtils.ts                   |  2 +-
 .../collectionFreeForm/FaceCollectionBox.tsx       | 88 ++++++++------------
 .../collections/collectionFreeForm/MarqueeView.tsx | 97 +++-------------------
 src/client/views/nodes/FaceRectangles.tsx          |  3 +-
 src/client/views/search/FaceRecognitionHandler.tsx | 64 +++++++-------
 6 files changed, 87 insertions(+), 178 deletions(-)

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

diff --git a/src/client/Network.ts b/src/client/Network.ts
index 8876d8190..17f8a6534 100644
--- a/src/client/Network.ts
+++ b/src/client/Network.ts
@@ -1,3 +1,4 @@
+import formidable from 'formidable';
 import * as requestPromise from 'request-promise';
 import { ClientUtils } from '../ClientUtils';
 import { Utils } from '../Utils';
@@ -49,15 +50,9 @@ export namespace Networking {
             if (!fileguidpairs.length) {
                 return [];
             }
-            const maxFileSize = 5000000;
+            const maxFileSize = 6000000;
             if (fileguidpairs.some(f => f.file.size > maxFileSize)) {
-                return new Promise<Upload.FileResponse<T>[]>(res => {
-                    res([{
-                            source:        { size: 0, filepath: '', mimetype: '', originalFilename: '', newFilename: '',hashAlgorithm: false, 
-                            toJSON: () => ({ size: 0, filepath: '', mimetype: '', originalFilename: '', newFilename: '',name: '', length: 0,  mtime: new Date(), type: '' }) },
-                            result: { name: '', message: `max file size (${maxFileSize / 1000000}MB) exceeded` }
-                        }]) // prettier-ignore
-                });
+                return new Promise<Upload.FileResponse<T>[]>(res => res([{ source: { newFilename: '', mimetype: '' } as formidable.File, result: new Error(`max file size (${maxFileSize / 1000000}MB) exceeded`) }]));
             }
             formData.set('fileguids', fileguidpairs.map(pair => pair.guid).join(';'));
             formData.set('filesize', fileguidpairs.reduce((sum, pair) => sum + pair.file.size, 0).toString());
diff --git a/src/client/documents/DocUtils.ts b/src/client/documents/DocUtils.ts
index a503d732b..35d835f1f 100644
--- a/src/client/documents/DocUtils.ts
+++ b/src/client/documents/DocUtils.ts
@@ -739,7 +739,7 @@ export namespace DocUtils {
             const {
                 source: { newFilename, mimetype },
                 result,
-            } = upfiles.lastElement() ?? { source: { newFilename: '', mimetype: '' }, result: { message: 'upload failed' } };
+            } = upfiles.lastElement() ?? { source: { newFilename: '', mimetype: '' }, result: new Error('upload failed') };
             if (result instanceof Error) {
                 if (overwriteDoc) {
                     overwriteDoc.loadingError = result.message;
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index 50b91e8fe..d5a2809dc 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -11,17 +11,20 @@ import { Doc, DocListCast } from '../../../../fields/Doc';
 import { DocData } from '../../../../fields/DocSymbols';
 import { Id } from '../../../../fields/FieldSymbols';
 import { List } from '../../../../fields/List';
-import { ImageCast, StrCast } from '../../../../fields/Types';
+import { listSpec } from '../../../../fields/Schema';
+import { Cast, ImageCast, StrCast } from '../../../../fields/Types';
 import { DocumentType } from '../../../documents/DocumentTypes';
 import { Docs } from '../../../documents/Documents';
 import { DragManager } from '../../../util/DragManager';
 import { SnappingManager } from '../../../util/SnappingManager';
+import { undoable } from '../../../util/UndoManager';
 import { ViewBoxBaseComponent } from '../../DocComponent';
 import { ObservableReactComponent } from '../../ObservableReactComponent';
 import { DocumentView } from '../../nodes/DocumentView';
 import { FieldView, FieldViewProps } from '../../nodes/FieldView';
 import './FaceCollectionBox.scss';
 import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
+import { FaceRecognitionHandler } from '../../search/FaceRecognitionHandler';
 
 interface FaceDocumentProps {
     faceDoc: Doc;
@@ -32,17 +35,15 @@ interface FaceDocumentProps {
  */
 @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: FaceDocumentProps) {
         super(props);
         makeObservable(this);
-        this.ref = React.createRef();
     }
 
+    @observable _displayImages: boolean = true;
+
     protected createDropTarget = (ele: HTMLDivElement) => {
         this._dropDisposer?.();
         ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this._props.faceDoc));
@@ -54,20 +55,20 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
             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) {
+                if ((this._props.faceDoc[DocData].face_descriptors as List<List<number>>).length === 0 && (doc[DocData][FaceRecognitionHandler.FacesField(doc)] 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 float32Array = (this._props.faceDoc[DocData].face_descriptors as List<List<number>>).map(faceDescriptor => new Float32Array(Array.from(faceDescriptor)));
+                    const labeledFaceDescriptor = new faceapi.LabeledFaceDescriptors(StrCast(this._props.faceDoc[DocData].face_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 => {
+                    (doc[DocData][FaceRecognitionHandler.FacesField(doc)] 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));
@@ -79,16 +80,15 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
                         }
                     });
 
-                    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]);
+                    const faceFieldKey = FaceRecognitionHandler.FaceField(doc, this._props.faceDoc);
+                    if (doc[DocData][faceFieldKey]) {
+                        Cast(doc[DocData][faceFieldKey], listSpec('number'), null).push(cur_matching_face as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
                     } else {
-                        doc[DocData][`FACE DESCRIPTOR - ${this._props.faceDoc[DocData].label}`] = new List<List<number>>([cur_matching_face]);
+                        doc[DocData][faceFieldKey] = 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);
+                    Doc.AddDocToList(this._props.faceDoc[DocData], 'face_docList', doc);
+                    Cast(this._props.faceDoc[DocData].face_descriptors, listSpec('number'), null).push(cur_matching_face as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
                 }
             });
             return false;
@@ -107,12 +107,11 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
     /**
      * Deletes a Face Document.
      */
-    @action
-    deleteFaceDocument = () => {
+    deleteFaceDocument = undoable(() => {
         if (Doc.ActiveDashboard) {
-            Doc.ActiveDashboard[DocData].faceDocuments = new List<Doc>(DocListCast(Doc.ActiveDashboard[DocData].faceDocuments).filter(doc => doc !== this._props.faceDoc));
+            Doc.RemoveDocFromList(Doc.ActiveDashboard[DocData], 'faceDocuments', this._props.faceDoc);
         }
-    };
+    }, 'remove face');
 
     /**
      * Deletes a document from a Face Document's associated docs list.
@@ -120,60 +119,44 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
      */
     @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))
+        this._props.faceDoc[DocData].face_descriptors = new List<List<number>>(
+            (this._props.faceDoc[DocData].face_descriptors as List<List<number>>).filter(fd => !(doc[DocData][FaceRecognitionHandler.FaceField(doc, this._props.faceDoc)] 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));
+        doc[DocData][FaceRecognitionHandler.FaceField(doc, this._props.faceDoc)] = new List<List<number>>();
+        Doc.RemoveDocFromList(this._props.faceDoc[DocData], 'face_docList', 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} />
+                    <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>
+                    <h1>{StrCast(this._props.faceDoc[DocData].face_label)}</h1>
                 </div>
                 <IconButton
-                    tooltip={'See image information'}
+                    tooltip="See image information"
                     onPointerDown={() => this.onDisplayClick()}
-                    icon={this._displayImages ? <FontAwesomeIcon icon="caret-up" /> : <FontAwesomeIcon icon="caret-down" />}
+                    icon={<FontAwesomeIcon icon={this._displayImages ? 'caret-up' : '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 => {
+                        {DocListCast(this._props.faceDoc[DocData].face_docList).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}`}
-                                    />
+                                    <img onClick={() => 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}
-                                        />
+                                        <IconButton tooltip={'Remove Doc From Face Collection'} onPointerDown={() => this.deleteAssociatedDoc(doc)} icon={'x'} style={{ width: '4px' }} size={Size.XSMALL} />
                                     </div>
                                 </div>
                             );
                         })}
                     </div>
-                ) : (
-                    <div></div>
-                )}
+                ) : null}
             </div>
         );
     }
@@ -190,9 +173,8 @@ export class FaceCollectionBox extends ViewBoxBaseComponent<FieldViewProps>() {
     @computed get currentDocs() {
         if (Doc.ActiveDashboard) {
             return DocListCast(Doc.ActiveDashboard[DocData].faceDocuments);
-        } else {
-            return [];
         }
+        return [];
     }
 
     constructor(props: FieldViewProps) {
@@ -204,9 +186,9 @@ export class FaceCollectionBox extends ViewBoxBaseComponent<FieldViewProps>() {
     render() {
         return (
             <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
-                {this.currentDocs.map(doc => {
-                    return <FaceDocumentItem key={doc[Id]} faceDoc={doc} />;
-                })}
+                {this.currentDocs.map(doc => (
+                    <FaceDocumentItem key={doc[Id]} faceDoc={doc} />
+                ))}
             </div>
         );
     }
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index d7a41df64..6fee076ee 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -1,28 +1,24 @@
-/* eslint-disable jsx-a11y/no-static-element-interactions */
-import similarity from 'compute-cosine-similarity';
 import { action, computed, makeObservable, observable } from 'mobx';
 import { observer } from 'mobx-react';
 import * as React from 'react';
 import { ClientUtils, lightOrDark, returnFalse } from '../../../../ClientUtils';
-import { intersectRect, numberRange } from '../../../../Utils';
-import { Doc, DocListCast, NumListCast, Opt } from '../../../../fields/Doc';
+import { intersectRect } from '../../../../Utils';
+import { Doc, DocListCast, Opt } from '../../../../fields/Doc';
 import { AclAdmin, AclAugment, AclEdit, DocData } from '../../../../fields/DocSymbols';
 import { Id } from '../../../../fields/FieldSymbols';
-import { InkData, InkField, InkTool } from '../../../../fields/InkField';
+import { InkTool } from '../../../../fields/InkField';
 import { List } from '../../../../fields/List';
-import { RichTextField } from '../../../../fields/RichTextField';
-import { Cast, FieldValue, ImageCast, NumCast, StrCast } from '../../../../fields/Types';
+import { Cast, NumCast, StrCast } from '../../../../fields/Types';
 import { ImageField } from '../../../../fields/URLField';
 import { GetEffectiveAcl } from '../../../../fields/util';
-import { gptGetEmbedding, gptImageLabel } from '../../../apis/gpt/GPT';
-import { CognitiveServices } from '../../../cognitive_services/CognitiveServices';
 import { DocUtils } from '../../../documents/DocUtils';
-import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes';
+import { DocumentType } from '../../../documents/DocumentTypes';
 import { Docs, DocumentOptions } from '../../../documents/Documents';
 import { SnappingManager, freeformScrollMode } from '../../../util/SnappingManager';
 import { Transform } from '../../../util/Transform';
 import { UndoManager, undoBatch } from '../../../util/UndoManager';
 import { ContextMenu } from '../../ContextMenu';
+import { MainView } from '../../MainView';
 import { ObservableReactComponent } from '../../ObservableReactComponent';
 import { MarqueeViewBounds } from '../../PinFuncs';
 import { PreviewCursor } from '../../PreviewCursor';
@@ -30,15 +26,10 @@ import { DocumentView } from '../../nodes/DocumentView';
 import { OpenWhere } from '../../nodes/OpenWhere';
 import { pasteImageBitmap } from '../../nodes/WebBoxRenderer';
 import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox';
-import { CollectionCardView } from '../CollectionCardDeckView';
 import { SubCollectionViewProps } from '../CollectionSubView';
-import { CollectionFreeFormView } from './CollectionFreeFormView';
-import { ImageLabelHandler } from './ImageLabelHandler';
+import { ImageLabelBoxData } from './ImageLabelBox';
 import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
 import './MarqueeView.scss';
-import { MainView } from '../../MainView';
-import { ImageLabelBox, ImageLabelBoxData } from './ImageLabelBox';
-import { SearchBox } from '../../search/SearchBox';
 
 interface MarqueeViewProps {
     getContainerTransform: () => Transform;
@@ -68,7 +59,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
 
     static Instance: MarqueeView;
 
-    constructor(props: any) {
+    constructor(props: SubCollectionViewProps & MarqueeViewProps) {
         super(props);
         makeObservable(this);
         MarqueeView.Instance = this;
@@ -495,71 +486,6 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
         //this._props.Document.pivotField = 'data_label'; // Sets the pivot to be the 'data_label'.
     });
 
-    @undoBatch
-    syntaxHighlight = action((e: KeyboardEvent | React.PointerEvent | undefined) => {
-        const selected = this.marqueeSelect(false);
-        if (e instanceof KeyboardEvent ? e.key === 'i' : true) {
-            const inks = selected.filter(s => s.type === DocumentType.INK);
-            const setDocs = selected.filter(s => s.type === DocumentType.RTF && s.color);
-            const sets = setDocs.map(sd => Cast(sd.data, RichTextField)?.Text as string);
-            const colors = setDocs.map(sd => FieldValue(sd.color) as string);
-            const wordToColor = new Map<string, string>();
-            sets.forEach((st: string, i: number) => st.split(',').forEach(word => wordToColor.set(word, colors[i])));
-            const strokes: InkData[] = [];
-            inks.filter(i => Cast(i.data, InkField)).forEach(i => {
-                const d = Cast(i.data, InkField, null);
-                const left = Math.min(...(d?.inkData.map(pd => pd.X) ?? [0]));
-                const top = Math.min(...(d?.inkData.map(pd => pd.Y) ?? [0]));
-                strokes.push(d.inkData.map(pd => ({ X: pd.X + NumCast(i.x) - left, Y: pd.Y + NumCast(i.y) - top })));
-            });
-            CognitiveServices.Inking.Appliers.InterpretStrokes(strokes).then(results => {
-                // const wordResults = results.filter((r: any) => r.category === "inkWord");
-                // for (const word of wordResults) {
-                //     const indices: number[] = word.strokeIds;
-                //     indices.forEach(i => {
-                //         if (wordToColor.has(word.recognizedText.toLowerCase())) {
-                //             inks[i].color = wordToColor.get(word.recognizedText.toLowerCase());
-                //         }
-                //         else {
-                //             for (const alt of word.alternates) {
-                //                 if (wordToColor.has(alt.recognizedString.toLowerCase())) {
-                //                     inks[i].color = wordToColor.get(alt.recognizedString.toLowerCase());
-                //                     break;
-                //                 }
-                //             }
-                //         }
-                //     })
-                // }
-                // const wordResults = results.filter((r: any) => r.category === "inkWord");
-                // for (const word of wordResults) {
-                //     const indices: number[] = word.strokeIds;
-                //     indices.forEach(i => {
-                //         const otherInks: Doc[] = [];
-                //         indices.forEach(i2 => i2 !== i && otherInks.push(inks[i2]));
-                //         inks[i].relatedInks = new List<Doc>(otherInks);
-                //         const uniqueColors: string[] = [];
-                //         Array.from(wordToColor.values()).forEach(c => uniqueColors.indexOf(c) === -1 && uniqueColors.push(c));
-                //         inks[i].alternativeColors = new List<string>(uniqueColors);
-                //         if (wordToColor.has(word.recognizedText.toLowerCase())) {
-                //             inks[i].color = wordToColor.get(word.recognizedText.toLowerCase());
-                //         }
-                //         else if (word.alternates) {
-                //             for (const alt of word.alternates) {
-                //                 if (wordToColor.has(alt.recognizedString.toLowerCase())) {
-                //                     inks[i].color = wordToColor.get(alt.recognizedString.toLowerCase());
-                //                     break;
-                //                 }
-                //             }
-                //         }
-                //     });
-                // }
-                const lines = results.filter((r: any) => r.category === 'line');
-                const text = lines.map((l: any) => l.recognizedText).join('\r\n');
-                this._props.addDocument?.(Docs.Create.TextDocument(text, { _width: this.Bounds.width, _height: this.Bounds.height, x: this.Bounds.left + this.Bounds.width, y: this.Bounds.top, title: text }));
-            });
-        }
-    });
-
     @undoBatch
     summary = action(() => {
         const selected = this.marqueeSelect(false).map(d => {
@@ -704,8 +630,8 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
                     transform: `translate(${p[0]}px, ${p[1]}px)`,
                     width: Math.abs(v[0]),
                     height: Math.abs(v[1]),
-                    color: lightOrDark(this._props.Document?.backgroundColor ?? 'white'),
-                    borderColor: lightOrDark(this._props.Document?.backgroundColor ?? 'white'),
+                    color: lightOrDark((this._props.Document?.backgroundColor as string) ?? 'white'),
+                    borderColor: lightOrDark((this._props.Document?.backgroundColor as string) ?? 'white'),
                     zIndex: 2000,
                 }}>
                 {' '}
@@ -714,7 +640,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
                         <polyline //
                             points={this._lassoPts.reduce((s, pt) => s + pt[0] + ',' + pt[1] + ' ', '')}
                             fill="none"
-                            stroke={lightOrDark(this._props.Document?.backgroundColor ?? 'white')}
+                            stroke={lightOrDark((this._props.Document?.backgroundColor as string) ?? 'white')}
                             strokeWidth="1"
                             strokeDasharray="3"
                         />
@@ -753,7 +679,6 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
     };
     render() {
         return (
-            // eslint-disable-next-line jsx-a11y/click-events-have-key-events
             <div
                 className="marqueeView"
                 ref={r => {
diff --git a/src/client/views/nodes/FaceRectangles.tsx b/src/client/views/nodes/FaceRectangles.tsx
index ade4225d9..19aa90a8b 100644
--- a/src/client/views/nodes/FaceRectangles.tsx
+++ b/src/client/views/nodes/FaceRectangles.tsx
@@ -4,6 +4,7 @@ import { Doc, DocListCast } from '../../../fields/Doc';
 import { Id } from '../../../fields/FieldSymbols';
 import { Cast, NumCast } from '../../../fields/Types';
 import FaceRectangle from './FaceRectangle';
+import { FaceRecognitionHandler } from '../search/FaceRecognitionHandler';
 
 interface FaceRectanglesProps {
     document: Doc;
@@ -19,7 +20,7 @@ export interface RectangleTemplate {
 @observer
 export class FaceRectangles extends React.Component<FaceRectanglesProps> {
     render() {
-        const faces = DocListCast(this.props.document.faces);
+        const faces = DocListCast(this.props.document[FaceRecognitionHandler.FacesField(this.props.document)]);
         const templates: RectangleTemplate[] = faces.map(faceDoc => {
             const rectangle = Cast(faceDoc.faceRectangle, Doc) as Doc;
             const style = {
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index 29ca6e797..dc271fe73 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -1,42 +1,46 @@
 import * as faceapi from 'face-api.js';
 import { FaceMatcher } from 'face-api.js';
+import { computed } from 'mobx';
 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 { DocumentManager } from '../../util/DocumentManager';
-import { computed } from 'mobx';
 import { DocumentType } from '../../documents/DocumentTypes';
-import { listSpec } from '../../../fields/Schema';
+import { DocumentManager } from '../../util/DocumentManager';
 
 /**
  * A class that handles face recognition.
  */
 export class FaceRecognitionHandler {
     static _instance: FaceRecognitionHandler;
-    private loadedModels: boolean = false;
-    @computed get examinedFaceDocs() {
-        return DocListCast(Doc.UserDoc().examinedFaceDocs);
-    }
-    processingDocs: Set<Doc> = new Set();
-    pendingLoadDocs: Doc[] = [];
+    private _loadedModels: boolean = false;
+    private _processingDocs: Set<Doc> = new Set();
+    private _pendingLoadDocs: Doc[] = [];
+
+    public static FaceField = (target: Doc, doc: Doc) => `${Doc.LayoutFieldKey(target)}_${doc.face_label}`;
+    public static FacesField = (target: Doc) => `${Doc.LayoutFieldKey(target)}_Faces`;
 
     constructor() {
         FaceRecognitionHandler._instance = this;
-        this.loadModels().then(() => this.pendingLoadDocs.forEach(this.findMatches));
+        this.loadModels().then(() => this._pendingLoadDocs.forEach(this.findMatches));
         DocumentManager.Instance.AddAnyViewRenderedCB(dv => FaceRecognitionHandler.Instance.findMatches(dv.Document));
     }
 
+    @computed get examinedFaceDocs() {
+        return DocListCast(Doc.UserDoc().examinedFaceDocs);
+    }
+
     /**
      * Loads the face detection models.
      */
-    async loadModels() {
+    loadModels = async () => {
         const MODEL_URL = `/models`;
         await faceapi.loadFaceDetectionModel(MODEL_URL);
         await faceapi.loadFaceLandmarkModel(MODEL_URL);
         await faceapi.loadFaceRecognitionModel(MODEL_URL);
-        this.loadedModels = true;
-    }
+        this._loadedModels = true;
+    };
 
     public static get Instance() {
         return FaceRecognitionHandler._instance ?? new FaceRecognitionHandler();
@@ -47,8 +51,8 @@ export class FaceRecognitionHandler {
      * @param doc The document being analyzed.
      */
     public findMatches = async (doc: Doc) => {
-        if (!this.loadedModels || !Doc.ActiveDashboard) {
-            this.pendingLoadDocs.push(doc);
+        if (!this._loadedModels || !Doc.ActiveDashboard) {
+            this._pendingLoadDocs.push(doc);
             return;
         }
 
@@ -59,12 +63,12 @@ export class FaceRecognitionHandler {
 
         const imgUrl = ImageCast(doc[Doc.LayoutFieldKey(doc)]);
         // If the doc isn't an image or currently already been examined or is being processed, stop examining the document.
-        if (!imgUrl || this.examinedFaceDocs.includes(doc) || this.processingDocs.has(doc)) {
+        if (!imgUrl || this.examinedFaceDocs.includes(doc) || this._processingDocs.has(doc)) {
             return;
         }
 
         // Mark the document as being processed.
-        this.processingDocs.add(doc);
+        this._processingDocs.add(doc);
 
         // Get the image the document contains and analyze for faces.
         const [name, type] = imgUrl.url.href.split('.');
@@ -74,7 +78,7 @@ export class FaceRecognitionHandler {
 
         const fullFaceDescriptions = await faceapi.detectAllFaces(img).withFaceLandmarks().withFaceDescriptors();
 
-        doc[DocData].faces = new List<List<number>>();
+        doc[DocData][FaceRecognitionHandler.FacesField(doc)] = new List<List<number>>();
 
         // For each face detected, find a match.
         for (const fd of fullFaceDescriptions) {
@@ -84,34 +88,36 @@ export class FaceRecognitionHandler {
             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.
-                Doc.AddDocToList(match, 'associatedDocs', doc);
-                Cast(match.faceDescriptors, listSpec('number'), null).push(converted_list as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
+                Doc.AddDocToList(match, 'face_docList', doc);
+                Cast(match.face_descriptors, listSpec('number'), null).push(converted_list as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
             } else {
                 // If a matching Face Document has not been found, create a new Face Document.
                 Doc.UserDoc().faceDocNum = NumCast(Doc.UserDoc().faceDocNum) + 1;
 
                 const newFaceDocument = new Doc();
-                newFaceDocument.label = `Face ${Doc.UserDoc().faceDocNum}`;
-                newFaceDocument.associatedDocs = new List<Doc>([doc]);
-                newFaceDocument.faceDescriptors = new List<List<number>>([converted_list]);
+                newFaceDocument.title = `Face ${Doc.UserDoc().faceDocNum}`;
+                newFaceDocument.face = ''; // just to make prettyprinting look better
+                newFaceDocument.face_label = `Face${Doc.UserDoc().faceDocNum}`;
+                newFaceDocument.face_docList = new List<Doc>([doc]);
+                newFaceDocument.face_descriptors = new List<List<number>>([converted_list]);
 
                 Doc.AddDocToList(Doc.ActiveDashboard[DocData], 'faceDocuments', newFaceDocument);
                 match = newFaceDocument;
             }
 
             // Assign a field in the document of the matching Face Document.
-            const faceDescripField = `FACE DESCRIPTOR - ${match[DocData].label}`;
+            const faceDescripField = FaceRecognitionHandler.FaceField(doc, match);
             if (doc[DocData][faceDescripField]) {
                 Cast(doc[DocData][faceDescripField], listSpec('number'), null).push(converted_list as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
             } else {
                 doc[DocData][faceDescripField] = new List<List<number>>([converted_list]);
             }
 
-            Cast(doc[DocData].faces, listSpec('number'), null).push(converted_list as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
+            Cast(doc[DocData][FaceRecognitionHandler.FacesField(doc)], listSpec('number'), null).push(converted_list as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
 
             Doc.AddDocToList(Doc.UserDoc(), 'examinedFaceDocs', doc);
         }
-        this.processingDocs.delete(doc);
+        this._processingDocs.delete(doc);
     };
 
     /**
@@ -125,14 +131,14 @@ export class FaceRecognitionHandler {
         }
 
         const faceDescriptors: faceapi.LabeledFaceDescriptors[] = DocListCast(Doc.ActiveDashboard[DocData].faceDocuments).map(faceDocument => {
-            const float32Array = (faceDocument[DocData].faceDescriptors as List<List<number>>).map(faceDescriptor => new Float32Array(Array.from(faceDescriptor)));
-            return new faceapi.LabeledFaceDescriptors(StrCast(faceDocument[DocData].label), float32Array);
+            const float32Array = (faceDocument[DocData].face_descriptors as List<List<number>>).map(faceDescriptor => new Float32Array(Array.from(faceDescriptor)));
+            return new faceapi.LabeledFaceDescriptors(StrCast(faceDocument[DocData].face_label), float32Array);
         });
         const faceMatcher = new FaceMatcher(faceDescriptors, 0.6);
         const match = faceMatcher.findBestMatch(cur_descriptor);
         if (match.label !== 'unknown') {
             for (const doc of DocListCast(Doc.ActiveDashboard[DocData].faceDocuments)) {
-                if (doc[DocData].label === match.label) {
+                if (doc[DocData].face_label === match.label) {
                     return doc;
                 }
             }
-- 
cgit v1.2.3-70-g09d2


From 25ee9e6b3f7da67bcf94eb2affd5793c67777930 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Wed, 21 Aug 2024 17:04:32 -0400
Subject: cleanup of face recognition.  some lint fixes.

---
 eslint.config.mjs                                  |   8 +
 src/.DS_Store                                      | Bin 10244 -> 10244 bytes
 src/client/util/CurrentUserUtils.ts                |   5 -
 src/client/util/DocumentManager.ts                 |   1 -
 src/client/views/MainView.tsx                      |   2 +-
 src/client/views/collections/CollectionSubView.tsx |   4 +-
 .../collectionFreeForm/FaceCollectionBox.tsx       | 121 ++++++-------
 src/client/views/nodes/DocumentView.tsx            |   8 +-
 src/client/views/search/FaceRecognitionHandler.tsx | 190 +++++++++++++++------
 src/fields/Doc.ts                                  |   6 +-
 src/fields/RichTextUtils.ts                        |   4 -
 src/server/database.ts                             |   3 -
 src/server/server_Initialization.ts                |   2 +-
 13 files changed, 198 insertions(+), 156 deletions(-)

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

diff --git a/eslint.config.mjs b/eslint.config.mjs
index 04655ce13..119f2f486 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -29,6 +29,7 @@ export default [
 
             'import/prefer-default-export': 'off',
             'no-unused-expressions': 'off',
+            '@typescript-eslint/no-unused-expressions': 'off',
             'prefer-template': 'off',
             'no-inner-declarations': 'off',
             'no-plusplus': 'off',
@@ -42,6 +43,13 @@ export default [
             '@typescript-eslint/no-unused-vars': 'error',
             '@typescript-eslint/no-namespace': 'off',
             'react/destructuring-assignment': 0,
+            'prefer-arrow-callback': 'error',
+            'no-return-assign': 'error',
+            'no-await-in-loop': 'error',
+            'no-loop-func': 'error',
+            'no-conditional-assign': 'error',
+            'no-use-before-define': 'error',
+            'no-explicit-any': 'error',
             'no-restricted-globals': ['error', 'event'],
         },
     },
diff --git a/src/.DS_Store b/src/.DS_Store
index 7a0b53ce0..426a2ee90 100644
Binary files a/src/.DS_Store and b/src/.DS_Store differ
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index 2962682c2..0681a21cb 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -95,7 +95,6 @@ export class CurrentUserUtils {
         });
 
         const reqdOpts:DocumentOptions = { title: "child click editors", _height:75, isSystem: true};
-        // eslint-disable-next-line no-return-assign
         return DocUtils.AssignOpts(tempClicks, reqdOpts, reqdClickList) ?? (doc[field] = Docs.Create.TreeDocument(reqdClickList, reqdOpts));
     }
 
@@ -120,7 +119,6 @@ export class CurrentUserUtils {
         });
 
         const reqdOpts:DocumentOptions = {title: "click editor templates", _height:75, isSystem: true};
-        // eslint-disable-next-line no-return-assign
         return DocUtils.AssignOpts(tempClicks, reqdOpts, reqdClickList) ?? (doc[field] = Docs.Create.TreeDocument(reqdClickList, reqdOpts));
     }
 
@@ -138,7 +136,6 @@ export class CurrentUserUtils {
         }), ... DocListCast(tempNotes?.data).filter(note => !reqdTempOpts.find(reqd => reqd.title === note.title))];
 
         const reqdOpts:DocumentOptions = { title: "Note Layouts", _height: 75, isSystem: true };
-        // eslint-disable-next-line no-return-assign
         return DocUtils.AssignOpts(tempNotes, reqdOpts, reqdNoteList) ?? (doc[field] = Docs.Create.TreeDocument(reqdNoteList, reqdOpts));
     }
     static setupUserTemplates(doc: Doc, field="template_user") {
@@ -146,7 +143,6 @@ export class CurrentUserUtils {
         const reqdUserList = DocListCast(tempUsers?.data);
 
         const reqdOpts:DocumentOptions = { title: "User Layouts", _height: 75, isSystem: true };
-        // eslint-disable-next-line no-return-assign
         return DocUtils.AssignOpts(tempUsers, reqdOpts, reqdUserList) ?? (doc[field] = Docs.Create.TreeDocument(reqdUserList, reqdOpts));
     }
 
@@ -183,7 +179,6 @@ export class CurrentUserUtils {
                                         default: return labelBox;
                                     }})();
             const allopts = {isSystem: true, onClickScriptDisable: "never",  ...opts, title};
-            // eslint-disable-next-line no-return-assign
             return DocUtils.AssignScripts(  (curIcon?.iconTemplate === opts.iconTemplate ? 
                       DocUtils.AssignOpts(curIcon, allopts):undefined) ?? ((templateIconsDoc[title] = MakeTemplate(creator(allopts, templateField)))),
                 {onClick:"deiconifyView(documentView)", onDoubleClick: "deiconifyViewToLightbox(documentView)", });
diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts
index a10237fcc..83b83240e 100644
--- a/src/client/util/DocumentManager.ts
+++ b/src/client/util/DocumentManager.ts
@@ -17,7 +17,6 @@ export class DocumentManager {
     // eslint-disable-next-line no-use-before-define
     private static _instance: DocumentManager;
     public static get Instance(): DocumentManager {
-        // eslint-disable-next-line no-return-assign
         return this._instance || (this._instance = new this());
     }
 
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index 62ad0a3fb..9f1c7da3d 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -75,7 +75,7 @@ import { AnchorMenu } from './pdf/AnchorMenu';
 import { GPTPopup } from './pdf/GPTPopup/GPTPopup';
 import { TopBar } from './topbar/TopBar';
 
-// eslint-disable-next-line @typescript-eslint/no-var-requires
+// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
 const { LEFT_MENU_WIDTH, TOPBAR_HEIGHT } = require('./global/globalCssVariables.module.scss'); // prettier-ignore
 
 @observer
diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx
index a6768ab35..5782d407e 100644
--- a/src/client/views/collections/CollectionSubView.tsx
+++ b/src/client/views/collections/CollectionSubView.tsx
@@ -1,4 +1,4 @@
-import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx';
+import { action, computed, makeObservable, observable } 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, DocCast, ScriptCast, StrCast } from '../../../fields/Types';
+import { BoolCast, Cast, 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/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index d5a2809dc..94f9a3c94 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -3,28 +3,28 @@ import { IconButton, Size } from 'browndash-components';
 import * as faceapi from 'face-api.js';
 import { FaceMatcher } from 'face-api.js';
 import 'ldrs/ring';
-import { action, computed, makeObservable, observable } from 'mobx';
+import { action, makeObservable, observable } from 'mobx';
 import { observer } from 'mobx-react';
 import React from 'react';
-import { Utils } from '../../../../Utils';
-import { Doc, DocListCast } from '../../../../fields/Doc';
-import { DocData } from '../../../../fields/DocSymbols';
+import { setupMoveUpEvents } from '../../../../ClientUtils';
+import { Utils, emptyFunction } from '../../../../Utils';
+import { Doc, Opt } from '../../../../fields/Doc';
 import { Id } from '../../../../fields/FieldSymbols';
 import { List } from '../../../../fields/List';
-import { listSpec } from '../../../../fields/Schema';
-import { Cast, ImageCast, StrCast } from '../../../../fields/Types';
+import { ImageCast } from '../../../../fields/Types';
 import { DocumentType } from '../../../documents/DocumentTypes';
 import { Docs } from '../../../documents/Documents';
 import { DragManager } from '../../../util/DragManager';
+import { dropActionType } from '../../../util/DropActionTypes';
 import { SnappingManager } from '../../../util/SnappingManager';
 import { undoable } from '../../../util/UndoManager';
 import { ViewBoxBaseComponent } from '../../DocComponent';
 import { ObservableReactComponent } from '../../ObservableReactComponent';
 import { DocumentView } from '../../nodes/DocumentView';
 import { FieldView, FieldViewProps } from '../../nodes/FieldView';
+import { FaceRecognitionHandler } from '../../search/FaceRecognitionHandler';
 import './FaceCollectionBox.scss';
 import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
-import { FaceRecognitionHandler } from '../../search/FaceRecognitionHandler';
 
 interface FaceDocumentProps {
     faceDoc: Doc;
@@ -50,49 +50,29 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
     };
 
     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].face_descriptors as List<List<number>>).length === 0 && (doc[DocData][FaceRecognitionHandler.FacesField(doc)] as List<List<number>>).length > 1) {
+        de.complete.docDragData?.droppedDocuments
+            ?.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.FaceDocDescriptors(this._props.faceDoc).length === 0 && FaceRecognitionHandler.ImageDocFaceDescriptors(imgDoc).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].face_descriptors as List<List<number>>).map(faceDescriptor => new Float32Array(Array.from(faceDescriptor)));
-                    const labeledFaceDescriptor = new faceapi.LabeledFaceDescriptors(StrCast(this._props.faceDoc[DocData].face_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][FaceRecognitionHandler.FacesField(doc)] 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;
-                        }
-                    });
-
-                    const faceFieldKey = FaceRecognitionHandler.FaceField(doc, this._props.faceDoc);
-                    if (doc[DocData][faceFieldKey]) {
-                        Cast(doc[DocData][faceFieldKey], listSpec('number'), null).push(cur_matching_face as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
-                    } else {
-                        doc[DocData][faceFieldKey] = new List<List<number>>([cur_matching_face]);
-                    }
-
-                    Doc.AddDocToList(this._props.faceDoc[DocData], 'face_docList', doc);
-                    Cast(this._props.faceDoc[DocData].face_descriptors, listSpec('number'), null).push(cur_matching_face as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
+                    // 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.FaceDocDescriptors(this._props.faceDoc).map(fd => new Float32Array(Array.from(fd)));
+                    const labeledFaceDescriptor = new faceapi.LabeledFaceDescriptors(FaceRecognitionHandler.FaceDocLabel(this._props.faceDoc), 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;
+                        },
+                        { dist: 1, face_match: new List<number>() as Opt<List<number>> }
+                    );
+
+                    // assign the face in the image that's closest to the face collection to be the face that's assigned to the collection
+                    face_match && FaceRecognitionHandler.ImageDocAddFace(imgDoc, face_match, this._props.faceDoc);
                 }
             });
-            return false;
-        }
         return false;
     }
 
@@ -108,23 +88,16 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
      * Deletes a Face Document.
      */
     deleteFaceDocument = undoable(() => {
-        if (Doc.ActiveDashboard) {
-            Doc.RemoveDocFromList(Doc.ActiveDashboard[DocData], 'faceDocuments', this._props.faceDoc);
-        }
-    }, 'remove face');
+        FaceRecognitionHandler.DeleteFaceDoc(this._props.faceDoc);
+    }, 'delete face');
 
     /**
      * Deletes a document from a Face Document's associated docs list.
      * @param doc
      */
-    @action
-    deleteAssociatedDoc = (doc: Doc) => {
-        this._props.faceDoc[DocData].face_descriptors = new List<List<number>>(
-            (this._props.faceDoc[DocData].face_descriptors as List<List<number>>).filter(fd => !(doc[DocData][FaceRecognitionHandler.FaceField(doc, this._props.faceDoc)] as List<List<number>>).includes(fd))
-        );
-        doc[DocData][FaceRecognitionHandler.FaceField(doc, this._props.faceDoc)] = new List<List<number>>();
-        Doc.RemoveDocFromList(this._props.faceDoc[DocData], 'face_docList', doc);
-    };
+    deleteAssociatedDoc = undoable((imgDoc: Doc) => {
+        FaceRecognitionHandler.FaceDocRemoveImageDocFace(imgDoc, this._props.faceDoc);
+    }, 'remove doc from face');
 
     render() {
         return (
@@ -133,7 +106,7 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
                     <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].face_label)}</h1>
+                    <h1>{FaceRecognitionHandler.FaceDocLabel(this._props.faceDoc)}</h1>
                 </div>
                 <IconButton
                     tooltip="See image information"
@@ -144,10 +117,24 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
                 />
                 {this._displayImages ? (
                     <div className="face-document-image-container">
-                        {DocListCast(this._props.faceDoc[DocData].face_docList).map(doc => {
+                        {FaceRecognitionHandler.FaceDocFaces(this._props.faceDoc).map(doc => {
                             const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
                             return (
-                                <div className="image-wrapper" key={Utils.GenerateGuid()}>
+                                <div
+                                    className="image-wrapper"
+                                    key={Utils.GenerateGuid()}
+                                    onPointerDown={e =>
+                                        setupMoveUpEvents(
+                                            this,
+                                            e,
+                                            () => {
+                                                DragManager.StartDocumentDrag([e.target as HTMLElement], new DragManager.DocumentDragData([doc], dropActionType.embed), e.clientX, e.clientY);
+                                                return true;
+                                            },
+                                            emptyFunction,
+                                            emptyFunction
+                                        )
+                                    }>
                                     <img onClick={() => 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} />
@@ -168,25 +155,15 @@ export class FaceCollectionBox extends ViewBoxBaseComponent<FieldViewProps>() {
         return FieldView.LayoutString(FaceCollectionBox, fieldKey);
     }
 
-    public static Instance: FaceCollectionBox;
-
-    @computed get currentDocs() {
-        if (Doc.ActiveDashboard) {
-            return DocListCast(Doc.ActiveDashboard[DocData].faceDocuments);
-        }
-        return [];
-    }
-
     constructor(props: FieldViewProps) {
         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 => (
+                {FaceRecognitionHandler.FaceDocuments().map(doc => (
                     <FaceDocumentItem key={doc[Id]} faceDoc={doc} />
                 ))}
             </div>
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index c807d99ac..5efdb3df4 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -1,5 +1,4 @@
 /* eslint-disable no-use-before-define */
-/* eslint-disable react/jsx-props-no-spreading */
 import { IconProp } from '@fortawesome/fontawesome-svg-core';
 import { Property } from 'csstype';
 import { Howl } from 'howler';
@@ -341,7 +340,6 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
                 if ((clickFunc && waitForDblClick !== 'never') || waitForDblClick === 'always') {
                     this._doubleClickTimeout && clearTimeout(this._doubleClickTimeout);
                     this._doubleClickTimeout = setTimeout(this._singleClickFunc, 300);
-                    // eslint-disable-next-line no-use-before-define
                 } else if (!SnappingManager.LongPress) {
                     this._singleClickFunc();
                     this._singleClickFunc = undefined;
@@ -354,7 +352,6 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
 
     onPointerDown = (e: React.PointerEvent): void => {
         if (this._props.isGroupActive?.() === GroupActive.child && !this._props.isDocumentActive?.()) return;
-        // eslint-disable-next-line no-use-before-define
         this._longPressSelector = setTimeout(() => SnappingManager.LongPress && this._props.select(false), 1000);
         if (!DocumentView.DownDocView) DocumentView.DownDocView = this._docView;
 
@@ -405,7 +402,6 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
             this._doubleTap = (this.onDoubleClickHdlr?.script || this.Document.defaultDoubleClick !== 'ignore') && Date.now() - this._lastTap < ClientUtils.CLICK_TIME;
             if (!this.isContentActive()) this._lastTap = Date.now(); // don't want to process the start of a double tap if the doucment is selected
         }
-        // eslint-disable-next-line no-use-before-define
         if (SnappingManager.LongPress) e.preventDefault();
     };
 
@@ -504,7 +500,7 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
             DocCast(this.dataDoc[this.props.fieldKey + '_0'])[DocData].text = res;
             console.log(res);
         } catch (err) {
-            console.error('GPT call failed');
+            console.error('GPT call failed', err);
         }
     };
 
@@ -1539,7 +1535,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() {
             )
         );
     }
-    // eslint-disable-next-line default-param-last
+
     public static FocusOrOpen(docIn: Doc, optionsIn: FocusViewOptions = { willZoomCentered: true, zoomScale: 0, openLocation: OpenWhere.toggleRight }, containingDoc?: Doc) {
         let doc = docIn;
         const options = optionsIn;
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index dc271fe73..3ef6f9674 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -10,7 +10,22 @@ import { DocumentType } from '../../documents/DocumentTypes';
 import { DocumentManager } from '../../util/DocumentManager';
 
 /**
- * A class that handles face recognition.
+ * A singleton class that handles face recognition and manages face Doc collections for each face found.
+ * Displaying an image doc anywhere will trigger this class to test if the image contains any faces.
+ * If it does, each recognized face will be compared to a global set of faces (each is a face collection Doc
+ * that have already been found. 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>_Face<N> - a nunerical representation of the Nth face found in the image
+ *     <image data field>_Faces  - a list of all the numerical face representations found in the image (why is this needed?)
+ *
+ * Face collection Doc's are created for each person identified and are stored in the Dashboard's faceDocument's list
+ *
+ * Each Face collection Doc represents all the images found for that person.  It has these fields:
+ *     face_label - a string label for the person that was recognized (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
  */
 export class FaceRecognitionHandler {
     static _instance: FaceRecognitionHandler;
@@ -18,13 +33,90 @@ export class FaceRecognitionHandler {
     private _processingDocs: Set<Doc> = new Set();
     private _pendingLoadDocs: Doc[] = [];
 
-    public static FaceField = (target: Doc, doc: Doc) => `${Doc.LayoutFieldKey(target)}_${doc.face_label}`;
-    public static FacesField = (target: Doc) => `${Doc.LayoutFieldKey(target)}_Faces`;
+    private static imgDocFaceField = (imgDoc: Doc, faceDoc: Doc) => `${Doc.LayoutFieldKey(imgDoc)}_${FaceRecognitionHandler.FaceDocLabel(faceDoc)}`;
+    /**
+     * 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)}_Faces`] = 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)}_Faces`] as List<List<number>>;
+
+    /**
+     * Adds metadata to an image Doc describing a face found in the image
+     * @param imgDoc image Doc containing faces
+     * @param faceDescriptor descriptor for the face found
+     * @param faceDoc face collection Doc containing the same face
+     */
+    public static ImageDocAddFace = (imgDoc: Doc, faceDescriptor: List<number>, faceDoc: Doc) => {
+        const faceFieldKey = FaceRecognitionHandler.imgDocFaceField(imgDoc, faceDoc);
+        if (imgDoc[DocData][faceFieldKey]) {
+            Cast(imgDoc[DocData][faceFieldKey], listSpec('number'), null).push(faceDescriptor as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
+        } else {
+            imgDoc[DocData][faceFieldKey] = new List<List<number>>([faceDescriptor]);
+        }
+    };
+
+    /**
+     * returns a list of all face collection Docs on the current dashboard
+     * @returns face collection Doc list
+     */
+    public static FaceDocuments = () => DocListCast(Doc.ActiveDashboard?.[DocData].faceDocuments);
+
+    public static DeleteFaceDoc = (faceDoc: Doc) => Doc.ActiveDashboard && Doc.RemoveDocFromList(Doc.ActiveDashboard[DocData], 'faceDocuments', faceDoc);
+
+    /**
+     * returns the labels associated with a face collection Doc
+     * @param faceDoc the face collection Doc
+     * @returns label string
+     */
+    public static FaceDocLabel = (faceDoc: Doc) => StrCast(faceDoc[DocData].face_label);
+    /**
+     * Returns all the face descriptors associated with a face collection Doc
+     * @param faceDoc a face collection Doc
+     * @returns face descriptors
+     */
+    public static FaceDocDescriptors = (faceDoc: Doc) => faceDoc[DocData].face_descriptors as List<List<number>>;
+
+    /**
+     * Returns a list of all face image Docs associated with the face collection
+     * @param faceDoc a face collection Doc
+     * @returns image Docs
+     */
+    public static FaceDocFaces = (faceDoc: Doc) => DocListCast(faceDoc[DocData].face_docList);
+
+    /**
+     * Adds a face image to the list of faces in a face collection Doc, and updates the face collection's list of 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 - the face collection Doc
+     */
+    public static FaceDocAddImageDocFace = (img: Doc, faceDescriptor: List<number>, faceDoc: Doc) => {
+        Doc.AddDocToList(faceDoc, 'face_docList', img);
+        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
+    };
+
+    /**
+     * Removes a face from a face Doc collection, and updates the face collection's list of image descriptors
+     * @param imgDoc - image with faces to remove from the face Doc collectoin
+     * @param faceDoc - the face Doc collection
+     */
+    public static FaceDocRemoveImageDocFace = (imgDoc: Doc, faceDoc: Doc) => {
+        imgDoc[DocData][FaceRecognitionHandler.imgDocFaceField(imgDoc, faceDoc)] = new List<List<number>>();
+        Doc.RemoveDocFromList(faceDoc[DocData], 'face_docList', imgDoc);
+        faceDoc[DocData].face_descriptors = new List<List<number>>(FaceRecognitionHandler.FaceDocDescriptors(faceDoc).filter(fd => !(imgDoc[DocData][FaceRecognitionHandler.imgDocFaceField(imgDoc, faceDoc)] as List<List<number>>).includes(fd)));
+    };
 
     constructor() {
         FaceRecognitionHandler._instance = this;
-        this.loadModels().then(() => this._pendingLoadDocs.forEach(this.findMatches));
-        DocumentManager.Instance.AddAnyViewRenderedCB(dv => FaceRecognitionHandler.Instance.findMatches(dv.Document));
+        this.loadModels().then(() => this._pendingLoadDocs.forEach(this.classifyFacesInImage));
+        DocumentManager.Instance.AddAnyViewRenderedCB(dv => FaceRecognitionHandler.Instance.classifyFacesInImage(dv.Document));
     }
 
     @computed get examinedFaceDocs() {
@@ -48,48 +140,42 @@ export class FaceRecognitionHandler {
 
     /**
      * When a document is added, look for matching face documents.
-     * @param doc The document being analyzed.
+     * @param imgDoc The document being analyzed.
      */
-    public findMatches = async (doc: Doc) => {
+    public classifyFacesInImage = async (imgDoc: Doc) => {
         if (!this._loadedModels || !Doc.ActiveDashboard) {
-            this._pendingLoadDocs.push(doc);
+            this._pendingLoadDocs.push(imgDoc);
             return;
         }
 
-        if (doc.type === DocumentType.LOADING && !doc.loadingError) {
-            setTimeout(() => this.findMatches(doc), 1000);
+        if (imgDoc.type === DocumentType.LOADING && !imgDoc.loadingError) {
+            setTimeout(() => this.classifyFacesInImage(imgDoc), 1000);
             return;
         }
 
-        const imgUrl = ImageCast(doc[Doc.LayoutFieldKey(doc)]);
+        const imgUrl = ImageCast(imgDoc[Doc.LayoutFieldKey(imgDoc)]);
         // If the doc isn't an image or currently already been examined or is being processed, stop examining the document.
-        if (!imgUrl || this.examinedFaceDocs.includes(doc) || this._processingDocs.has(doc)) {
+        if (!imgUrl || this.examinedFaceDocs.includes(imgDoc) || this._processingDocs.has(imgDoc)) {
             return;
         }
 
         // Mark the document as being processed.
-        this._processingDocs.add(doc);
+        this._processingDocs.add(imgDoc);
+        FaceRecognitionHandler.initImageDocFaceDescriptors(imgDoc);
 
         // Get the image the document contains and analyze for faces.
         const [name, type] = imgUrl.url.href.split('.');
         const imageURL = `${name}_o.${type}`;
-
         const img = await this.loadImage(imageURL);
-
-        const fullFaceDescriptions = await faceapi.detectAllFaces(img).withFaceLandmarks().withFaceDescriptors();
-
-        doc[DocData][FaceRecognitionHandler.FacesField(doc)] = new List<List<number>>();
+        const imgDocFaceDescriptions = await faceapi.detectAllFaces(img).withFaceLandmarks().withFaceDescriptors();
 
         // For each face detected, find a match.
-        for (const fd of fullFaceDescriptions) {
-            let match = this.findMatch(fd.descriptor);
-            const converted_list = new List<number>(Array.from(fd.descriptor));
-
-            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.
-                Doc.AddDocToList(match, 'face_docList', doc);
-                Cast(match.face_descriptors, listSpec('number'), null).push(converted_list as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
+        for (const fd of imgDocFaceDescriptions) {
+            let faceDocMatch = this.findMatchingFaceDoc(fd.descriptor);
+            const faceDescriptor = new List<number>(Array.from(fd.descriptor));
+
+            if (faceDocMatch) {
+                FaceRecognitionHandler.FaceDocAddImageDocFace(imgDoc, faceDescriptor, faceDocMatch);
             } else {
                 // If a matching Face Document has not been found, create a new Face Document.
                 Doc.UserDoc().faceDocNum = NumCast(Doc.UserDoc().faceDocNum) + 1;
@@ -98,53 +184,45 @@ export class FaceRecognitionHandler {
                 newFaceDocument.title = `Face ${Doc.UserDoc().faceDocNum}`;
                 newFaceDocument.face = ''; // just to make prettyprinting look better
                 newFaceDocument.face_label = `Face${Doc.UserDoc().faceDocNum}`;
-                newFaceDocument.face_docList = new List<Doc>([doc]);
-                newFaceDocument.face_descriptors = new List<List<number>>([converted_list]);
+                newFaceDocument.face_docList = new List<Doc>([imgDoc]);
+                newFaceDocument.face_descriptors = new List<List<number>>([faceDescriptor]);
 
                 Doc.AddDocToList(Doc.ActiveDashboard[DocData], 'faceDocuments', newFaceDocument);
-                match = newFaceDocument;
+                faceDocMatch = newFaceDocument;
             }
 
             // Assign a field in the document of the matching Face Document.
-            const faceDescripField = FaceRecognitionHandler.FaceField(doc, match);
-            if (doc[DocData][faceDescripField]) {
-                Cast(doc[DocData][faceDescripField], listSpec('number'), null).push(converted_list as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
-            } else {
-                doc[DocData][faceDescripField] = new List<List<number>>([converted_list]);
-            }
-
-            Cast(doc[DocData][FaceRecognitionHandler.FacesField(doc)], listSpec('number'), null).push(converted_list as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
-
-            Doc.AddDocToList(Doc.UserDoc(), 'examinedFaceDocs', doc);
+            FaceRecognitionHandler.ImageDocAddFace(imgDoc, faceDescriptor, faceDocMatch);
+            Doc.AddDocToList(Doc.UserDoc(), 'examinedFaceDocs', imgDoc);
         }
-        this._processingDocs.delete(doc);
+        this._processingDocs.delete(imgDoc);
     };
 
     /**
-     * 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.
+     * Finds the most similar matching Face Document to a face descriptor
+     * @param faceDescriptor face descriptor number list
+     * @returns face Doc
      */
-    private findMatch(cur_descriptor: Float32Array) {
-        if (!Doc.ActiveDashboard || DocListCast(Doc.ActiveDashboard[DocData].faceDocuments).length < 1) {
-            return null;
+    private findMatchingFaceDoc = (faceDescriptor: Float32Array) => {
+        if (!Doc.ActiveDashboard || FaceRecognitionHandler.FaceDocuments().length < 1) {
+            return undefined;
         }
 
-        const faceDescriptors: faceapi.LabeledFaceDescriptors[] = DocListCast(Doc.ActiveDashboard[DocData].faceDocuments).map(faceDocument => {
-            const float32Array = (faceDocument[DocData].face_descriptors as List<List<number>>).map(faceDescriptor => new Float32Array(Array.from(faceDescriptor)));
-            return new faceapi.LabeledFaceDescriptors(StrCast(faceDocument[DocData].face_label), float32Array);
+        const faceDescriptors = FaceRecognitionHandler.FaceDocuments().map(faceDoc => {
+            const float32Array = FaceRecognitionHandler.FaceDocDescriptors(faceDoc).map(fd => new Float32Array(Array.from(fd)));
+            return new faceapi.LabeledFaceDescriptors(FaceRecognitionHandler.FaceDocLabel(faceDoc), float32Array);
         });
         const faceMatcher = new FaceMatcher(faceDescriptors, 0.6);
-        const match = faceMatcher.findBestMatch(cur_descriptor);
+        const match = faceMatcher.findBestMatch(faceDescriptor);
         if (match.label !== 'unknown') {
-            for (const doc of DocListCast(Doc.ActiveDashboard[DocData].faceDocuments)) {
-                if (doc[DocData].face_label === match.label) {
-                    return doc;
+            for (const faceDoc of FaceRecognitionHandler.FaceDocuments()) {
+                if (FaceRecognitionHandler.FaceDocLabel(faceDoc) === match.label) {
+                    return faceDoc;
                 }
             }
         }
-        return null;
-    }
+        return undefined;
+    };
 
     /**
      * Loads an image
diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts
index eb6ed9757..ffb5aab79 100644
--- a/src/fields/Doc.ts
+++ b/src/fields/Doc.ts
@@ -1,5 +1,3 @@
-/* eslint-disable @typescript-eslint/no-namespace */
-/* eslint-disable default-param-last */
 /* eslint-disable no-use-before-define */
 import { action, computed, makeObservable, observable, ObservableMap, ObservableSet, runInAction } from 'mobx';
 import { computedFn } from 'mobx-utils';
@@ -127,9 +125,7 @@ export type FieldResult<T extends FieldType = FieldType> = Opt<T> | FieldWaiting
  * If no default value is given, and the returned value is not undefined, it can be safely modified.
  */
 export function DocListCastAsync(field: FieldResult): Promise<Doc[] | undefined>;
-// eslint-disable-next-line no-redeclare
 export function DocListCastAsync(field: FieldResult, defaultValue: Doc[]): Promise<Doc[]>;
-// eslint-disable-next-line no-redeclare
 export function DocListCastAsync(field: FieldResult, defaultValue?: Doc[]) {
     const list = Cast(field, listSpec(Doc));
     return list ? Promise.all(list).then(() => list) : Promise.resolve(defaultValue);
@@ -437,7 +433,6 @@ export class Doc extends RefField {
                 const writeMode = DocServer.getFieldWriteMode(fKey);
                 if (fKey.startsWith('acl_') || writeMode !== DocServer.WriteMode.Playground) {
                     delete this[CachedUpdates][fKey];
-                    // eslint-disable-next-line no-await-in-loop
                     await fn();
                 } else {
                     this[CachedUpdates][fKey] = fn;
@@ -1576,6 +1571,7 @@ export namespace Doc {
             try {
                 resolved = JSON.parse(typeof data === 'string' ? data : JSON.stringify(data));
             } catch (e) {
+                console.error(e);
                 return undefined;
             }
             let output: Opt<Doc>;
diff --git a/src/fields/RichTextUtils.ts b/src/fields/RichTextUtils.ts
index d1316d256..b3534dde7 100644
--- a/src/fields/RichTextUtils.ts
+++ b/src/fields/RichTextUtils.ts
@@ -394,13 +394,11 @@ export namespace RichTextUtils {
                 for (const markName of Object.keys(schema.marks)) {
                     // eslint-disable-next-line no-cond-assign
                     if (ignored.includes(markName) || !(mark = markMap[markName])) {
-                        // eslint-disable-next-line no-continue
                         continue;
                     }
                     let converted = MarkToStyle.get(markName) || (markName as keyof docsV1.Schema$TextStyle);
                     let value: unknown = true;
                     if (!converted) {
-                        // eslint-disable-next-line no-continue
                         continue;
                     }
                     // eslint-disable-next-line @typescript-eslint/no-shadow
@@ -412,10 +410,8 @@ export namespace RichTextUtils {
                                 const docDelimeter = '/doc/';
                                 const alreadyShared = '?sharing=true';
                                 if (new RegExp(window.location.origin + docDelimeter).test(url) && !url.endsWith(alreadyShared)) {
-                                    // eslint-disable-next-line no-await-in-loop
                                     const linkDoc = await DocServer.GetRefField(url.split(docDelimeter)[1]);
                                     if (linkDoc instanceof Doc) {
-                                        // eslint-disable-next-line no-await-in-loop
                                         let exported = (await Cast(linkDoc.link_anchor_2, Doc))!;
                                         if (!exported.customLayout) {
                                             exported = Doc.MakeEmbedding(exported);
diff --git a/src/server/database.ts b/src/server/database.ts
index 975b9eb80..10dc540c3 100644
--- a/src/server/database.ts
+++ b/src/server/database.ts
@@ -1,4 +1,3 @@
-/* eslint-disable @typescript-eslint/no-namespace */
 import * as mongodb from 'mongodb';
 import * as mongoose from 'mongoose';
 import { Opt } from '../fields/Doc';
@@ -148,9 +147,7 @@ export namespace Database {
         }
 
         public delete(query: any, collectionName?: string): Promise<mongodb.DeleteResult>;
-        // eslint-disable-next-line no-dupe-class-members
         public delete(id: string, collectionName?: string): Promise<mongodb.DeleteResult>;
-        // eslint-disable-next-line no-dupe-class-members
         public delete(idIn: any, collectionName = DocumentsCollection) {
             let id = idIn;
             if (typeof id === 'string') {
diff --git a/src/server/server_Initialization.ts b/src/server/server_Initialization.ts
index 97c63a93e..0cf9a6e58 100644
--- a/src/server/server_Initialization.ts
+++ b/src/server/server_Initialization.ts
@@ -115,7 +115,7 @@ function registerEmbeddedBrowseRelativePathHandler(server: express.Express) {
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 function proxyServe(req: any, requrl: string, response: any) {
-    // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports
+    // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
     const htmlBodyMemoryStream = new (require('memorystream'))();
     let wasinBrFormat = false;
     const sendModifiedBody = () => {
-- 
cgit v1.2.3-70-g09d2


From 4091d1681437527bfb55cb79570e8b652e9b190f Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Wed, 21 Aug 2024 17:12:35 -0400
Subject: from last

---
 .../views/collections/collectionFreeForm/FaceCollectionBox.tsx       | 5 ++++-
 src/client/views/search/FaceRecognitionHandler.tsx                   | 1 +
 2 files changed, 5 insertions(+), 1 deletion(-)

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

diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index 94f9a3c94..dd8dea41e 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -70,7 +70,10 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
                     );
 
                     // assign the face in the image that's closest to the face collection to be the face that's assigned to the collection
-                    face_match && FaceRecognitionHandler.ImageDocAddFace(imgDoc, face_match, this._props.faceDoc);
+                    if (face_match) {
+                        FaceRecognitionHandler.ImageDocAddFace(imgDoc, face_match, this._props.faceDoc);
+                        FaceRecognitionHandler.FaceDocAddImageDocFace(imgDoc, face_match, this._props.faceDoc);
+                    }
                 }
             });
         return false;
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index 3ef6f9674..6494a1ed4 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -61,6 +61,7 @@ export class FaceRecognitionHandler {
         } else {
             imgDoc[DocData][faceFieldKey] = new List<List<number>>([faceDescriptor]);
         }
+        FaceRecognitionHandler.FaceDocAddImageDocFace(imgDoc, faceDescriptor, faceDoc);
     };
 
     /**
-- 
cgit v1.2.3-70-g09d2


From 95071fa118ee36d7365250f10756dce335dc76d9 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Thu, 22 Aug 2024 11:00:14 -0400
Subject: more cleanup for face collections

---
 src/client/views/Main.tsx                          |   2 +-
 .../collectionFreeForm/FaceCollectionBox.tsx       |  62 ++++++---
 src/client/views/search/FaceRecognitionHandler.tsx | 151 +++++++++++----------
 3 files changed, 121 insertions(+), 94 deletions(-)

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

diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx
index 5a408f593..023324881 100644
--- a/src/client/views/Main.tsx
+++ b/src/client/views/Main.tsx
@@ -138,7 +138,7 @@ FieldLoader.ServerLoadStatus = { requested: 0, retrieved: 0, message: 'cache' };
             PresBox,
             PresElementBox,
             SearchBox,
-            ImageLabelBox, //Here!
+            ImageLabelBox,
             FaceCollectionBox,
             FunctionPlotBox,
             InkingStroke,
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index dd8dea41e..6005da6dd 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -26,18 +26,27 @@ import { FaceRecognitionHandler } from '../../search/FaceRecognitionHandler';
 import './FaceCollectionBox.scss';
 import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
 
-interface FaceDocumentProps {
+/**
+ * This code is used to render the sidebar collection of unique recognized faces, where each
+ * unique face in turn displays the set of images that correspond to the face.
+ */
+
+interface UniqueFaceProps {
     faceDoc: Doc;
 }
 
 /**
- * A componenent to visually represent a Face Document.
+ * React component for rendering a unique face and its collection of image Docs.
+ *
+ * This both displays a collection of images corresponding tp a unique face, and
+ * allows for editing the face collection by removing an image, or drag-and-dropping
+ * an image that was not recognized.
  */
 @observer
-export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps> {
+export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
     private _dropDisposer?: DragManager.DragDropDisposer;
 
-    constructor(props: FaceDocumentProps) {
+    constructor(props: UniqueFaceProps) {
         super(props);
         makeObservable(this);
     }
@@ -54,12 +63,12 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
             ?.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.FaceDocDescriptors(this._props.faceDoc).length === 0 && FaceRecognitionHandler.ImageDocFaceDescriptors(imgDoc).length > 1) {
+                if (FaceRecognitionHandler.UniqueFaceDescriptors(this._props.faceDoc).length === 0 && FaceRecognitionHandler.ImageDocFaceDescriptors(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.FaceDocDescriptors(this._props.faceDoc).map(fd => new Float32Array(Array.from(fd)));
-                    const labeledFaceDescriptor = new faceapi.LabeledFaceDescriptors(FaceRecognitionHandler.FaceDocLabel(this._props.faceDoc), faceDescriptorsAsFloat32Array);
+                    const faceDescriptorsAsFloat32Array = FaceRecognitionHandler.UniqueFaceDescriptors(this._props.faceDoc).map(fd => new Float32Array(Array.from(fd)));
+                    const labeledFaceDescriptor = new faceapi.LabeledFaceDescriptors(FaceRecognitionHandler.UniqueFaceLabel(this._props.faceDoc), faceDescriptorsAsFloat32Array);
                     const faceMatcher = new FaceMatcher([labeledFaceDescriptor], 1);
                     const { face_match } = FaceRecognitionHandler.ImageDocFaceDescriptors(imgDoc).reduce(
                         (prev, face) => {
@@ -71,8 +80,8 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
 
                     // assign the face in the image that's closest to the face collection to be the face that's assigned to the collection
                     if (face_match) {
-                        FaceRecognitionHandler.ImageDocAddFace(imgDoc, face_match, this._props.faceDoc);
-                        FaceRecognitionHandler.FaceDocAddImageDocFace(imgDoc, face_match, this._props.faceDoc);
+                        FaceRecognitionHandler.ImageDocAssociateUniqueFace(imgDoc, face_match, this._props.faceDoc);
+                        FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, face_match, this._props.faceDoc);
                     }
                 }
             });
@@ -88,28 +97,29 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
     }
 
     /**
-     * Deletes a Face Document.
+     * Removes a unique face Doc from the colelction of unique faces.
      */
-    deleteFaceDocument = undoable(() => {
-        FaceRecognitionHandler.DeleteFaceDoc(this._props.faceDoc);
+    deleteUniqueFace = undoable(() => {
+        FaceRecognitionHandler.DeleteUniqueFace(this._props.faceDoc);
     }, 'delete face');
 
     /**
-     * Deletes a document from a Face Document's associated docs list.
-     * @param doc
+     * Removes a face image Doc from a unique face's list of images.
+     * @param imgDoc - image Doc to remove
      */
-    deleteAssociatedDoc = undoable((imgDoc: Doc) => {
-        FaceRecognitionHandler.FaceDocRemoveImageDocFace(imgDoc, this._props.faceDoc);
+    removeFaceImageFromUniqueFace = undoable((imgDoc: Doc) => {
+        FaceRecognitionHandler.ImageDocDeassociateUniqueFace(imgDoc, this._props.faceDoc);
+        FaceRecognitionHandler.UniqueFaceRemoveFaceImage(imgDoc, this._props.faceDoc);
     }, 'remove doc from face');
 
     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} />
+                    <IconButton tooltip="Delete Face From Collection" onPointerDown={this.deleteUniqueFace} icon={'x'} style={{ width: '4px' }} size={Size.XSMALL} />
                 </div>
                 <div className="face-document-top">
-                    <h1>{FaceRecognitionHandler.FaceDocLabel(this._props.faceDoc)}</h1>
+                    <h1>{FaceRecognitionHandler.UniqueFaceLabel(this._props.faceDoc)}</h1>
                 </div>
                 <IconButton
                     tooltip="See image information"
@@ -120,7 +130,7 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
                 />
                 {this._displayImages ? (
                     <div className="face-document-image-container">
-                        {FaceRecognitionHandler.FaceDocFaces(this._props.faceDoc).map(doc => {
+                        {FaceRecognitionHandler.UniqueFaceImages(this._props.faceDoc).map(doc => {
                             const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
                             return (
                                 <div
@@ -140,7 +150,7 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
                                     }>
                                     <img onClick={() => 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} />
+                                        <IconButton tooltip={'Remove Doc From Face Collection'} onPointerDown={() => this.removeFaceImageFromUniqueFace(doc)} icon={'x'} style={{ width: '4px' }} size={Size.XSMALL} />
                                     </div>
                                 </div>
                             );
@@ -152,6 +162,14 @@ export class FaceDocumentItem extends ObservableReactComponent<FaceDocumentProps
     }
 }
 
+/**
+ * This renders the sidebar collection of the unique faces that have been recognized.
+ *
+ * Since the collection of recognized faces is stored on the active dashboard, this class
+ * does not itself store any Docs, but accesses the uniqueFaces field of the current
+ * dashboard.  Each Face collection Doc is rendered using a FaceCollectionDocView which
+ * is not a CollectionView or even a DocumentView (but probably should be).
+ */
 @observer
 export class FaceCollectionBox extends ViewBoxBaseComponent<FieldViewProps>() {
     public static LayoutString(fieldKey: string) {
@@ -166,8 +184,8 @@ export class FaceCollectionBox extends ViewBoxBaseComponent<FieldViewProps>() {
     render() {
         return (
             <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
-                {FaceRecognitionHandler.FaceDocuments().map(doc => (
-                    <FaceDocumentItem key={doc[Id]} faceDoc={doc} />
+                {FaceRecognitionHandler.UniqueFaces().map(doc => (
+                    <UniqueFaceView key={doc[Id]} faceDoc={doc} />
                 ))}
             </div>
         );
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index c446df6ff..c239c775c 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -1,6 +1,5 @@
 import * as faceapi from 'face-api.js';
 import { FaceMatcher } from 'face-api.js';
-import { computed } from 'mobx';
 import { Doc, DocListCast } from '../../../fields/Doc';
 import { DocData } from '../../../fields/DocSymbols';
 import { List } from '../../../fields/List';
@@ -12,28 +11,27 @@ import { DocumentManager } from '../../util/DocumentManager';
 /**
  * A singleton class that handles face recognition and manages face Doc collections for each face found.
  * Displaying an image doc anywhere will trigger this class to test if the image contains any faces.
- * If it does, each recognized face will be compared to a global set of faces (each is a face collection Doc
- * that have already been found. If the face matches a face collection Doc, then it will be added to that
+ * If it does, each recognized face will be compared to a stored, global set of faces (each face is represented
+ * 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>_Face<N> - a nunerical representation of the Nth face found in the image
- *     <image data field>_Faces  - a list of all the numerical face representations found in the image (why is this needed?)
+ *     <image data field>_Face<N> - a numerical representation of the Nth face found in the image
+ *     <image data field>_Faces  - a list of all the numerical face representations found in the image.   (TODO: this is inelegant as it duplicates each Face<N>)
  *
- * Face collection Doc's are created for each person identified and are stored in the Dashboard's faceDocument's list
+ * unique face Doc's are created for each person identified and are stored in the Dashboard's uniqueFaces field
  *
- * Each Face collection Doc represents all the images found for that person.  It has these fields:
- *     face_label - a string label for the person that was recognized (currently it's just a 'face#')
+ * Each unique face Doc represents a unique face and collects all matching face images for that person.  It has these fields:
+ *     face_label - 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
  */
 export class FaceRecognitionHandler {
     static _instance: FaceRecognitionHandler;
     private _loadedModels: boolean = false;
-    private _processingDocs: Set<Doc> = new Set();
     private _pendingLoadDocs: Doc[] = [];
 
-    private static imgDocFaceField = (imgDoc: Doc, faceDoc: Doc) => `${Doc.LayoutFieldKey(imgDoc)}_${FaceRecognitionHandler.FaceDocLabel(faceDoc)}`;
+    private static imgDocFaceField = (imgDoc: Doc, faceDoc: Doc) => `${Doc.LayoutFieldKey(imgDoc)}_${FaceRecognitionHandler.UniqueFaceLabel(faceDoc)}`;
     /**
      * initializes an image with an empty list of face descriptors
      * @param imgDoc image to initialize
@@ -49,67 +47,82 @@ export class FaceRecognitionHandler {
     public static ImageDocFaceDescriptors = (imgDoc: Doc) => imgDoc[DocData][`${Doc.LayoutFieldKey(imgDoc)}_Faces`] as List<List<number>>;
 
     /**
-     * Adds metadata to an image Doc describing a face found in the image
+     * Adds metadata to an image Doc associating it to a unique face that corresponds to a face found in the image
      * @param imgDoc image Doc containing faces
-     * @param faceDescriptor descriptor for the face found
-     * @param faceDoc face collection Doc containing the same face
+     * @param faceDescriptor descriptor for the face
+     * @param faceDoc unique face
      */
-    public static ImageDocAddFace = (imgDoc: Doc, faceDescriptor: List<number>, faceDoc: Doc) => {
+    public static ImageDocAssociateUniqueFace = (imgDoc: Doc, faceDescriptor: List<number>, faceDoc: Doc) => {
         const faceFieldKey = FaceRecognitionHandler.imgDocFaceField(imgDoc, faceDoc);
         if (imgDoc[DocData][faceFieldKey]) {
             Cast(imgDoc[DocData][faceFieldKey], listSpec('number'), null).push(faceDescriptor as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
         } else {
             imgDoc[DocData][faceFieldKey] = new List<List<number>>([faceDescriptor]);
         }
+        Cast(imgDoc[DocData][`${Doc.LayoutFieldKey(imgDoc)}_Faces`], listSpec('number'), null).push(faceDescriptor as unknown as number);
+    };
+
+    /**
+     * Removes metadata from an image Doc to deassociate it from a unique face
+     * @param imgDoc image Doc containing faces
+     * @param faceDoc unique face
+     */
+    public static ImageDocDeassociateUniqueFace = (imgDoc: Doc, faceDoc: Doc) => {
+        // fill in..
     };
 
     /**
      * returns a list of all face collection Docs on the current dashboard
      * @returns face collection Doc list
      */
-    public static FaceDocuments = () => DocListCast(Doc.ActiveDashboard?.[DocData].faceDocuments);
+    public static UniqueFaces = () => DocListCast(Doc.ActiveDashboard?.[DocData].uniqueFaces);
 
-    public static DeleteFaceDoc = (faceDoc: Doc) => Doc.ActiveDashboard && Doc.RemoveDocFromList(Doc.ActiveDashboard[DocData], 'faceDocuments', faceDoc);
+    /**
+     * Removes a unique face from the set of recognized unique faces
+     * @param faceDoc unique face Doc
+     * @returns
+     */
+    public static DeleteUniqueFace = (faceDoc: Doc) => Doc.ActiveDashboard && Doc.RemoveDocFromList(Doc.ActiveDashboard[DocData], 'uniqueFaces', faceDoc);
 
     /**
      * returns the labels associated with a face collection Doc
-     * @param faceDoc the face collection Doc
+     * @param faceDoc unique face Doc
      * @returns label string
      */
-    public static FaceDocLabel = (faceDoc: Doc) => StrCast(faceDoc[DocData].face_label);
+    public static UniqueFaceLabel = (faceDoc: Doc) => StrCast(faceDoc[DocData].face_label);
     /**
-     * Returns all the face descriptors associated with a face collection Doc
-     * @param faceDoc a face collection Doc
+     * Returns all the face descriptors associated with a unique face Doc
+     * @param faceDoc unique face Doc
      * @returns face descriptors
      */
-    public static FaceDocDescriptors = (faceDoc: Doc) => faceDoc[DocData].face_descriptors as List<List<number>>;
+    public static UniqueFaceDescriptors = (faceDoc: Doc) => faceDoc[DocData].face_descriptors as List<List<number>>;
 
     /**
-     * Returns a list of all face image Docs associated with the face collection
-     * @param faceDoc a face collection Doc
+     * Returns a list of all face image Docs associated with a unique face Doc
+     * @param faceDoc unique face Doc
      * @returns image Docs
      */
-    public static FaceDocFaces = (faceDoc: Doc) => DocListCast(faceDoc[DocData].face_docList);
+    public static UniqueFaceImages = (faceDoc: Doc) => DocListCast(faceDoc[DocData].face_images);
 
     /**
-     * Adds a face image to the list of faces in a face collection Doc, and updates the face collection's list of image descriptors
+     * Adds a face image to a unique face Doc, 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 - the face collection Doc
+     * @param faceDoc - unique face Doc
      */
-    public static FaceDocAddImageDocFace = (img: Doc, faceDescriptor: List<number>, faceDoc: Doc) => {
-        Doc.AddDocToList(faceDoc, 'face_docList', img);
+    public static UniqueFaceAddFaceImage = (img: Doc, faceDescriptor: List<number>, faceDoc: Doc) => {
+        Doc.AddDocToList(faceDoc, 'face_images', img);
         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
     };
 
     /**
-     * Removes a face from a face Doc collection, and updates the face collection's list of image descriptors
-     * @param imgDoc - image with faces to remove from the face Doc collectoin
-     * @param faceDoc - the face Doc collection
+     * Removes a face from a unique Face Doc, and updates the unique face's set of face image descriptors
+     * @param imgDoc - image with faces to remove
+     * @param faceDoc - unique face Doc
      */
-    public static FaceDocRemoveImageDocFace = (imgDoc: Doc, faceDoc: Doc) => {
-        Doc.RemoveDocFromList(faceDoc[DocData], 'face_docList', imgDoc);
-        faceDoc[DocData].face_descriptors = new List<List<number>>(FaceRecognitionHandler.FaceDocDescriptors(faceDoc).filter(fd => !(imgDoc[DocData][FaceRecognitionHandler.imgDocFaceField(imgDoc, faceDoc)] as List<List<number>>).includes(fd)));
+    public static UniqueFaceRemoveFaceImage = (imgDoc: Doc, faceDoc: Doc) => {
+        Doc.RemoveDocFromList(faceDoc[DocData], 'face_images', imgDoc);
+        faceDoc[DocData].face_descriptors = new List<List<number>>(FaceRecognitionHandler.UniqueFaceDescriptors(faceDoc).filter(fd => !(imgDoc[DocData][FaceRecognitionHandler.imgDocFaceField(imgDoc, faceDoc)] as List<List<number>>).includes(fd)));
     };
 
     constructor() {
@@ -118,10 +131,6 @@ export class FaceRecognitionHandler {
         DocumentManager.Instance.AddAnyViewRenderedCB(dv => FaceRecognitionHandler.Instance.classifyFacesInImage(dv.Document));
     }
 
-    @computed get examinedFaceDocs() {
-        return DocListCast(Doc.UserDoc().examinedFaceDocs);
-    }
-
     /**
      * Loads the face detection models.
      */
@@ -138,7 +147,27 @@ export class FaceRecognitionHandler {
     }
 
     /**
-     * When a document is added, look for matching face documents.
+     * Creates a new, empty unique face Doc
+     * @returns a unique face Doc
+     */
+    createUniqueFaceDoc = (dashboard: Doc) => {
+        const faceDocNum = NumCast(dashboard.uniqueFaces_count) + 1;
+        dashboard.uniqueFaces_count = faceDocNum; // TODO: improve to a better name
+
+        const uniqueFaceDoc = new Doc();
+        uniqueFaceDoc.title = `Face ${faceDocNum}`;
+        uniqueFaceDoc.face = ''; // just to make prettyprinting look better
+        uniqueFaceDoc.face_label = `Face${faceDocNum}`;
+        uniqueFaceDoc.face_images = new List<Doc>();
+        uniqueFaceDoc.face_descriptors = new List<List<number>>();
+
+        Doc.ActiveDashboard && Doc.AddDocToList(Doc.ActiveDashboard[DocData], 'uniqueFaces', uniqueFaceDoc);
+        return uniqueFaceDoc;
+    };
+
+    /**
+     * When a document is added, this finds faces in the images and tries to
+     * match them to existing unique faces, otherwise new unique face(s) are created.
      * @param imgDoc The document being analyzed.
      */
     public classifyFacesInImage = async (imgDoc: Doc) => {
@@ -154,12 +183,11 @@ export class FaceRecognitionHandler {
 
         const imgUrl = ImageCast(imgDoc[Doc.LayoutFieldKey(imgDoc)]);
         // If the doc isn't an image or currently already been examined or is being processed, stop examining the document.
-        if (!imgUrl || this.examinedFaceDocs.includes(imgDoc) || this._processingDocs.has(imgDoc)) {
+        if (!imgUrl || DocListCast(Doc.MyFaceCollection.examinedFaceDocs).includes(imgDoc)) {
             return;
         }
+        Doc.AddDocToList(Doc.MyFaceCollection, 'examinedFaceDocs', imgDoc);
 
-        // Mark the document as being processed.
-        this._processingDocs.add(imgDoc);
         FaceRecognitionHandler.initImageDocFaceDescriptors(imgDoc);
 
         // Get the image the document contains and analyze for faces.
@@ -170,31 +198,12 @@ export class FaceRecognitionHandler {
 
         // For each face detected, find a match.
         for (const fd of imgDocFaceDescriptions) {
-            let faceDocMatch = this.findMatchingFaceDoc(fd.descriptor);
             const faceDescriptor = new List<number>(Array.from(fd.descriptor));
-
-            if (faceDocMatch) {
-                FaceRecognitionHandler.FaceDocAddImageDocFace(imgDoc, faceDescriptor, faceDocMatch);
-            } else {
-                // If a matching Face Document has not been found, create a new Face Document.
-                Doc.UserDoc().faceDocNum = NumCast(Doc.UserDoc().faceDocNum) + 1;
-
-                const newFaceDocument = new Doc();
-                newFaceDocument.title = `Face ${Doc.UserDoc().faceDocNum}`;
-                newFaceDocument.face = ''; // just to make prettyprinting look better
-                newFaceDocument.face_label = `Face${Doc.UserDoc().faceDocNum}`;
-                newFaceDocument.face_docList = new List<Doc>([imgDoc]);
-                newFaceDocument.face_descriptors = new List<List<number>>([faceDescriptor]);
-
-                Doc.AddDocToList(Doc.ActiveDashboard[DocData], 'faceDocuments', newFaceDocument);
-                faceDocMatch = newFaceDocument;
-            }
-
-            // Assign a field in the document of the matching Face Document.
-            FaceRecognitionHandler.ImageDocAddFace(imgDoc, faceDescriptor, faceDocMatch);
-            Doc.AddDocToList(Doc.UserDoc(), 'examinedFaceDocs', imgDoc);
+            const matchedUniqueFace = this.findMatchingFaceDoc(fd.descriptor) ?? this.createUniqueFaceDoc(Doc.ActiveDashboard);
+            // Add image to unique face's image collection, and assign image metadata referencing unique face
+            FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, faceDescriptor, matchedUniqueFace);
+            FaceRecognitionHandler.ImageDocAssociateUniqueFace(imgDoc, faceDescriptor, matchedUniqueFace);
         }
-        this._processingDocs.delete(imgDoc);
     };
 
     /**
@@ -203,19 +212,19 @@ export class FaceRecognitionHandler {
      * @returns face Doc
      */
     private findMatchingFaceDoc = (faceDescriptor: Float32Array) => {
-        if (!Doc.ActiveDashboard || FaceRecognitionHandler.FaceDocuments().length < 1) {
+        if (!Doc.ActiveDashboard || FaceRecognitionHandler.UniqueFaces().length < 1) {
             return undefined;
         }
 
-        const faceDescriptors = FaceRecognitionHandler.FaceDocuments().map(faceDoc => {
-            const float32Array = FaceRecognitionHandler.FaceDocDescriptors(faceDoc).map(fd => new Float32Array(Array.from(fd)));
-            return new faceapi.LabeledFaceDescriptors(FaceRecognitionHandler.FaceDocLabel(faceDoc), float32Array);
+        const faceDescriptors = FaceRecognitionHandler.UniqueFaces().map(faceDoc => {
+            const float32Array = FaceRecognitionHandler.UniqueFaceDescriptors(faceDoc).map(fd => new Float32Array(Array.from(fd)));
+            return new faceapi.LabeledFaceDescriptors(FaceRecognitionHandler.UniqueFaceLabel(faceDoc), float32Array);
         });
         const faceMatcher = new FaceMatcher(faceDescriptors, 0.6);
         const match = faceMatcher.findBestMatch(faceDescriptor);
         if (match.label !== 'unknown') {
-            for (const faceDoc of FaceRecognitionHandler.FaceDocuments()) {
-                if (FaceRecognitionHandler.FaceDocLabel(faceDoc) === match.label) {
+            for (const faceDoc of FaceRecognitionHandler.UniqueFaces()) {
+                if (FaceRecognitionHandler.UniqueFaceLabel(faceDoc) === match.label) {
                     return faceDoc;
                 }
             }
-- 
cgit v1.2.3-70-g09d2


From a1f90b7c3a99aff9366a353631ea963c778b4740 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Thu, 22 Aug 2024 11:16:39 -0400
Subject: fixing up removing faces from unique faces

---
 .../collectionFreeForm/FaceCollectionBox.tsx        |  2 +-
 src/client/views/search/FaceRecognitionHandler.tsx  | 21 ++++++++++++++++-----
 2 files changed, 17 insertions(+), 6 deletions(-)

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

diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index 6005da6dd..d1db19e43 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -108,8 +108,8 @@ export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
      * @param imgDoc - image Doc to remove
      */
     removeFaceImageFromUniqueFace = undoable((imgDoc: Doc) => {
-        FaceRecognitionHandler.ImageDocDeassociateUniqueFace(imgDoc, this._props.faceDoc);
         FaceRecognitionHandler.UniqueFaceRemoveFaceImage(imgDoc, this._props.faceDoc);
+        FaceRecognitionHandler.ImageDocDisassociateUniqueFace(imgDoc, this._props.faceDoc);
     }, 'remove doc from face');
 
     render() {
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index c239c775c..487c35d90 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -46,6 +46,15 @@ export class FaceRecognitionHandler {
      */
     public static ImageDocFaceDescriptors = (imgDoc: Doc) => imgDoc[DocData][`${Doc.LayoutFieldKey(imgDoc)}_Faces`] 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)}_Faces`], listSpec('number'), null).push(faceDescriptor as unknown as number);
+    };
+
     /**
      * Adds metadata to an image Doc associating it to a unique face that corresponds to a face found in the image
      * @param imgDoc image Doc containing faces
@@ -59,7 +68,6 @@ export class FaceRecognitionHandler {
         } else {
             imgDoc[DocData][faceFieldKey] = new List<List<number>>([faceDescriptor]);
         }
-        Cast(imgDoc[DocData][`${Doc.LayoutFieldKey(imgDoc)}_Faces`], listSpec('number'), null).push(faceDescriptor as unknown as number);
     };
 
     /**
@@ -67,10 +75,10 @@ export class FaceRecognitionHandler {
      * @param imgDoc image Doc containing faces
      * @param faceDoc unique face
      */
-    public static ImageDocDeassociateUniqueFace = (imgDoc: Doc, faceDoc: Doc) => {
-        // fill in..
+    public static ImageDocDisassociateUniqueFace = (imgDoc: Doc, faceDoc: Doc) => {
+        const faceFieldKey = FaceRecognitionHandler.imgDocFaceField(imgDoc, faceDoc);
+        imgDoc[DocData][faceFieldKey] = undefined;
     };
-
     /**
      * returns a list of all face collection Docs on the current dashboard
      * @returns face collection Doc list
@@ -122,7 +130,8 @@ export class FaceRecognitionHandler {
      */
     public static UniqueFaceRemoveFaceImage = (imgDoc: Doc, faceDoc: Doc) => {
         Doc.RemoveDocFromList(faceDoc[DocData], 'face_images', imgDoc);
-        faceDoc[DocData].face_descriptors = new List<List<number>>(FaceRecognitionHandler.UniqueFaceDescriptors(faceDoc).filter(fd => !(imgDoc[DocData][FaceRecognitionHandler.imgDocFaceField(imgDoc, faceDoc)] as List<List<number>>).includes(fd)));
+        // TODO: remove face descriptor from images' list of face descriptors (below doesn't work)
+        // faceDoc[DocData].face_descriptors = new List<List<number>>(FaceRecognitionHandler.UniqueFaceDescriptors(faceDoc).filter(fd => !(imgDoc[DocData][FaceRecognitionHandler.imgDocFaceField(imgDoc, faceDoc)] as List<List<number>>).includes(fd)));
     };
 
     constructor() {
@@ -203,6 +212,8 @@ export class FaceRecognitionHandler {
             // Add image to unique face's image collection, and assign image metadata referencing unique face
             FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, faceDescriptor, matchedUniqueFace);
             FaceRecognitionHandler.ImageDocAssociateUniqueFace(imgDoc, faceDescriptor, matchedUniqueFace);
+            // save the descriptor for the image's list of faces
+            FaceRecognitionHandler.ImageDocAddFaceDescriptor(imgDoc, faceDescriptor);
         }
     };
 
-- 
cgit v1.2.3-70-g09d2


From abac2baf512a545714379fbef3623b46e3be20a8 Mon Sep 17 00:00:00 2001
From: geireann <geireann.lindfield@gmail.com>
Date: Thu, 22 Aug 2024 13:38:03 -0400
Subject: changes to face recognition to store list of face docs , not
 descriptors on images.

---
 .../collectionFreeForm/FaceCollectionBox.tsx       |  2 -
 src/client/views/search/FaceRecognitionHandler.tsx | 61 +++++++++-------------
 2 files changed, 26 insertions(+), 37 deletions(-)

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

diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index d1db19e43..46d90db86 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -80,7 +80,6 @@ export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
 
                     // assign the face in the image that's closest to the face collection to be the face that's assigned to the collection
                     if (face_match) {
-                        FaceRecognitionHandler.ImageDocAssociateUniqueFace(imgDoc, face_match, this._props.faceDoc);
                         FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, face_match, this._props.faceDoc);
                     }
                 }
@@ -109,7 +108,6 @@ export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
      */
     removeFaceImageFromUniqueFace = undoable((imgDoc: Doc) => {
         FaceRecognitionHandler.UniqueFaceRemoveFaceImage(imgDoc, this._props.faceDoc);
-        FaceRecognitionHandler.ImageDocDisassociateUniqueFace(imgDoc, this._props.faceDoc);
     }, 'remove doc from face');
 
     render() {
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index 487c35d90..f00c3fdf1 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -16,8 +16,7 @@ import { DocumentManager } from '../../util/DocumentManager';
  * 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>_Face<N> - a numerical representation of the Nth face found in the image
- *     <image data field>_Faces  - a list of all the numerical face representations found in the image.   (TODO: this is inelegant as it duplicates each Face<N>)
+ *     <image data field>_faceDescriptors - a list of all the numerical face representations found in the image.
  *
  * unique face Doc's are created for each person identified and are stored in the Dashboard's uniqueFaces field
  *
@@ -31,20 +30,34 @@ export class FaceRecognitionHandler {
     private _loadedModels: boolean = false;
     private _pendingLoadDocs: Doc[] = [];
 
-    private static imgDocFaceField = (imgDoc: Doc, faceDoc: Doc) => `${Doc.LayoutFieldKey(imgDoc)}_${FaceRecognitionHandler.UniqueFaceLabel(faceDoc)}`;
+
+    /**
+     * 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)}_Faces`] = new List<List<number>>();
+        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)}_Faces`] as List<List<number>>;
+    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
@@ -52,33 +65,10 @@ export class FaceRecognitionHandler {
      * @param faceDescriptor descriptor of a face
      */
     public static ImageDocAddFaceDescriptor = (imgDoc: Doc, faceDescriptor: List<number>) => {
-        Cast(imgDoc[DocData][`${Doc.LayoutFieldKey(imgDoc)}_Faces`], listSpec('number'), null).push(faceDescriptor as unknown as number);
+        Cast(imgDoc[DocData][`${Doc.LayoutFieldKey(imgDoc)}_faceDescriptors`], listSpec('number'), null).push(faceDescriptor as unknown as number);
     };
 
-    /**
-     * Adds metadata to an image Doc associating it to a unique face that corresponds to a face found in the image
-     * @param imgDoc image Doc containing faces
-     * @param faceDescriptor descriptor for the face
-     * @param faceDoc unique face
-     */
-    public static ImageDocAssociateUniqueFace = (imgDoc: Doc, faceDescriptor: List<number>, faceDoc: Doc) => {
-        const faceFieldKey = FaceRecognitionHandler.imgDocFaceField(imgDoc, faceDoc);
-        if (imgDoc[DocData][faceFieldKey]) {
-            Cast(imgDoc[DocData][faceFieldKey], listSpec('number'), null).push(faceDescriptor as unknown as number); // items are lists of numbers, not numbers, but type system can't handle that
-        } else {
-            imgDoc[DocData][faceFieldKey] = new List<List<number>>([faceDescriptor]);
-        }
-    };
 
-    /**
-     * Removes metadata from an image Doc to deassociate it from a unique face
-     * @param imgDoc image Doc containing faces
-     * @param faceDoc unique face
-     */
-    public static ImageDocDisassociateUniqueFace = (imgDoc: Doc, faceDoc: Doc) => {
-        const faceFieldKey = FaceRecognitionHandler.imgDocFaceField(imgDoc, faceDoc);
-        imgDoc[DocData][faceFieldKey] = undefined;
-    };
     /**
      * returns a list of all face collection Docs on the current dashboard
      * @returns face collection Doc list
@@ -121,17 +111,19 @@ export class FaceRecognitionHandler {
     public static UniqueFaceAddFaceImage = (img: Doc, faceDescriptor: List<number>, faceDoc: Doc) => {
         Doc.AddDocToList(faceDoc, 'face_images', img);
         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);
     };
 
     /**
      * Removes a face from a unique Face Doc, and updates the unique face's set of face image descriptors
-     * @param imgDoc - image with faces to remove
+     * @param img - image with faces to remove
      * @param faceDoc - unique face Doc
      */
-    public static UniqueFaceRemoveFaceImage = (imgDoc: Doc, faceDoc: Doc) => {
-        Doc.RemoveDocFromList(faceDoc[DocData], 'face_images', imgDoc);
-        // TODO: remove face descriptor from images' list of face descriptors (below doesn't work)
-        // faceDoc[DocData].face_descriptors = new List<List<number>>(FaceRecognitionHandler.UniqueFaceDescriptors(faceDoc).filter(fd => !(imgDoc[DocData][FaceRecognitionHandler.imgDocFaceField(imgDoc, faceDoc)] as List<List<number>>).includes(fd)));
+    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);
     };
 
     constructor() {
@@ -211,7 +203,6 @@ export class FaceRecognitionHandler {
             const matchedUniqueFace = this.findMatchingFaceDoc(fd.descriptor) ?? this.createUniqueFaceDoc(Doc.ActiveDashboard);
             // Add image to unique face's image collection, and assign image metadata referencing unique face
             FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, faceDescriptor, matchedUniqueFace);
-            FaceRecognitionHandler.ImageDocAssociateUniqueFace(imgDoc, faceDescriptor, matchedUniqueFace);
             // save the descriptor for the image's list of faces
             FaceRecognitionHandler.ImageDocAddFaceDescriptor(imgDoc, faceDescriptor);
         }
-- 
cgit v1.2.3-70-g09d2


From c9c5514c0607dcacaf8b84ef4a7730a815451d98 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Thu, 22 Aug 2024 15:36:41 -0400
Subject: from last

---
 .../collectionFreeForm/FaceCollectionBox.tsx       |  2 +-
 src/client/views/search/FaceRecognitionHandler.tsx | 52 +++++++++++-----------
 2 files changed, 27 insertions(+), 27 deletions(-)

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

diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index 46d90db86..de7c2c027 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -75,7 +75,7 @@ export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
                             const match = faceMatcher.matchDescriptor(new Float32Array(Array.from(face)));
                             return match.distance < prev.dist ? { dist: match.distance, face_match: face } : prev;
                         },
-                        { dist: 1, face_match: new List<number>() as Opt<List<number>> }
+                        { dist: 1, face_match: undefined as Opt<List<number>> }
                     );
 
                     // assign the face in the image that's closest to the face collection to be the face that's assigned to the collection
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index 9b97fdfbd..f5fc12a8d 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -183,6 +183,32 @@ export class FaceRecognitionHandler {
         return uniqueFaceDoc;
     };
 
+    /**
+     * Finds the most similar matching Face Document to a face descriptor
+     * @param faceDescriptor face descriptor number list
+     * @returns face Doc
+     */
+    private findMatchingFaceDoc = (faceDescriptor: Float32Array) => {
+        if (!Doc.ActiveDashboard || FaceRecognitionHandler.UniqueFaces().length < 1) {
+            return undefined;
+        }
+
+        const faceDescriptors = FaceRecognitionHandler.UniqueFaces().map(faceDoc => {
+            const float32Array = FaceRecognitionHandler.UniqueFaceDescriptors(faceDoc).map(fd => new Float32Array(Array.from(fd)));
+            return new faceapi.LabeledFaceDescriptors(FaceRecognitionHandler.UniqueFaceLabel(faceDoc), float32Array);
+        });
+        const faceMatcher = new FaceMatcher(faceDescriptors, 0.6);
+        const match = faceMatcher.findBestMatch(faceDescriptor);
+        if (match.label !== 'unknown') {
+            for (const faceDoc of FaceRecognitionHandler.UniqueFaces()) {
+                if (FaceRecognitionHandler.UniqueFaceLabel(faceDoc) === match.label) {
+                    return faceDoc;
+                }
+            }
+        }
+        return undefined;
+    };
+
     /**
      * When a document is added, this finds faces in the images and tries to
      * match them to existing unique faces, otherwise new unique face(s) are created.
@@ -214,30 +240,4 @@ export class FaceRecognitionHandler {
             } // prettier-ignore
         }
     };
-
-    /**
-     * Finds the most similar matching Face Document to a face descriptor
-     * @param faceDescriptor face descriptor number list
-     * @returns face Doc
-     */
-    private findMatchingFaceDoc = (faceDescriptor: Float32Array) => {
-        if (!Doc.ActiveDashboard || FaceRecognitionHandler.UniqueFaces().length < 1) {
-            return undefined;
-        }
-
-        const faceDescriptors = FaceRecognitionHandler.UniqueFaces().map(faceDoc => {
-            const float32Array = FaceRecognitionHandler.UniqueFaceDescriptors(faceDoc).map(fd => new Float32Array(Array.from(fd)));
-            return new faceapi.LabeledFaceDescriptors(FaceRecognitionHandler.UniqueFaceLabel(faceDoc), float32Array);
-        });
-        const faceMatcher = new FaceMatcher(faceDescriptors, 0.6);
-        const match = faceMatcher.findBestMatch(faceDescriptor);
-        if (match.label !== 'unknown') {
-            for (const faceDoc of FaceRecognitionHandler.UniqueFaces()) {
-                if (FaceRecognitionHandler.UniqueFaceLabel(faceDoc) === match.label) {
-                    return faceDoc;
-                }
-            }
-        }
-        return undefined;
-    };
 }
-- 
cgit v1.2.3-70-g09d2


From cc3e1bc2c317cf34aba04e4935ae842b16ad4cae Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 26 Aug 2024 11:22:38 -0400
Subject: cleanup of setting myXXstuff on dashboards

---
 src/client/views/DashboardView.tsx                           | 12 +++++-------
 .../collections/collectionFreeForm/FaceCollectionBox.tsx     |  2 +-
 src/client/views/search/FaceRecognitionHandler.tsx           | 12 ++++++------
 3 files changed, 12 insertions(+), 14 deletions(-)

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

diff --git a/src/client/views/DashboardView.tsx b/src/client/views/DashboardView.tsx
index f4fc8ee9f..4616e15e5 100644
--- a/src/client/views/DashboardView.tsx
+++ b/src/client/views/DashboardView.tsx
@@ -426,16 +426,14 @@ export class DashboardView extends ObservableReactComponent<object> {
         const dashboardDoc = DashboardView.StandardCollectionDockingDocument([{ doc: freeformDoc, initialWidth: 600 }], { title: title }, id, 'row');
 
         Doc.AddDocToList(Doc.MyHeaderBar, 'data', freeformDoc, undefined, undefined, true);
+        Doc.AddDocToList(Doc.MyDashboards, 'data', dashboardDoc);
         dashboardDoc.pane_count = 1;
         freeformDoc.embedContainer = dashboardDoc;
         dashboardDoc.myOverlayDocs = new List<Doc>();
         dashboardDoc[DocData].myPublishedDocs = new List<Doc>();
         dashboardDoc[DocData].myTagCollections = new List<Doc>();
-
-        Doc.AddDocToList(Doc.MyDashboards, 'data', dashboardDoc);
-
-        DashboardView.SetupDashboardTrails(dashboardDoc);
-        DashboardView.SetupDashboardCalendars(dashboardDoc);
+        dashboardDoc[DocData].myTrails = DashboardView.SetupDashboardTrails(dashboardDoc);
+        dashboardDoc[DocData].myCalendars = DashboardView.SetupDashboardCalendars(dashboardDoc);
         // open this new dashboard
         Doc.ActiveDashboard = dashboardDoc;
         Doc.ActivePage = 'dashboard';
@@ -468,7 +466,7 @@ export class DashboardView extends ObservableReactComponent<object> {
         };
         const myCalendars = DocUtils.AssignScripts(Docs.Create.CalendarCollectionDocument([], reqdOpts));
         // { treeView_ChildDoubleClick: 'openPresentation(documentView.rootDoc)' }
-        dashboardDoc[DocData].myCalendars = new PrefetchProxy(myCalendars);
+        return new PrefetchProxy(myCalendars);
     }
 
     public static SetupDashboardTrails(dashboardDoc: Doc) {
@@ -514,12 +512,12 @@ export class DashboardView extends ObservableReactComponent<object> {
             layout_explainer: 'All of the trails that you have created will appear here.',
         };
         const myTrails = DocUtils.AssignScripts(Docs.Create.TreeDocument([], reqdOpts), { treeView_ChildDoubleClick: 'openPresentation(documentView.Document)' });
-        dashboardDoc.myTrails = new PrefetchProxy(myTrails);
 
         const contextMenuScripts = [reqdBtnScript.onClick];
         if (Cast(myTrails.contextMenuScripts, listSpec(ScriptField), null)?.length !== contextMenuScripts.length) {
             myTrails.contextMenuScripts = new List<ScriptField>(contextMenuScripts.map(script => ScriptField.MakeFunction(script)!));
         }
+        return new PrefetchProxy(myTrails);
     }
 }
 
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index de7c2c027..1cc1f59dc 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -164,7 +164,7 @@ export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
  * This renders the sidebar collection of the unique faces that have been recognized.
  *
  * Since the collection of recognized faces is stored on the active dashboard, this class
- * does not itself store any Docs, but accesses the uniqueFaces field of the current
+ * does not itself store any Docs, but accesses the myUniqueFaces field of the current
  * dashboard.  Each Face collection Doc is rendered using a FaceCollectionDocView which
  * is not a CollectionView or even a DocumentView (but probably should be).
  */
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index f5fc12a8d..a17e4c54a 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -20,7 +20,7 @@ import { ImageField } from '../../../fields/URLField';
  *     <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.
  *
- * unique face Doc's are created for each person identified and are stored in the Dashboard's uniqueFaces field
+ * 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_label - a string label for the person that was recognized                                     (TODO: currently it's just a 'face#')
@@ -93,14 +93,14 @@ export class FaceRecognitionHandler {
      * returns a list of all face collection Docs on the current dashboard
      * @returns face collection Doc list
      */
-    public static UniqueFaces = () => DocListCast(Doc.ActiveDashboard?.[DocData].uniqueFaces);
+    public static UniqueFaces = () => DocListCast(Doc.ActiveDashboard?.[DocData].myUniqueFaces);
 
     /**
      * Removes a unique face from the set of recognized unique faces
      * @param faceDoc unique face Doc
      * @returns
      */
-    public static DeleteUniqueFace = (faceDoc: Doc) => Doc.ActiveDashboard && Doc.RemoveDocFromList(Doc.ActiveDashboard[DocData], 'uniqueFaces', faceDoc);
+    public static DeleteUniqueFace = (faceDoc: Doc) => Doc.ActiveDashboard && Doc.RemoveDocFromList(Doc.ActiveDashboard[DocData], 'myUniqueFaces', faceDoc);
 
     /**
      * returns the labels associated with a face collection Doc
@@ -169,8 +169,8 @@ export class FaceRecognitionHandler {
      * @returns a unique face Doc
      */
     private createUniqueFaceDoc = (dashboard: Doc) => {
-        const faceDocNum = NumCast(dashboard.uniqueFaces_count) + 1;
-        dashboard.uniqueFaces_count = faceDocNum; // TODO: improve to a better name
+        const faceDocNum = NumCast(dashboard.myUniqueFaces_count) + 1;
+        dashboard.myUniqueFaces_count = faceDocNum; // TODO: improve to a better name
 
         const uniqueFaceDoc = new Doc();
         uniqueFaceDoc.title = `Face ${faceDocNum}`;
@@ -179,7 +179,7 @@ export class FaceRecognitionHandler {
         uniqueFaceDoc.face_images = new List<Doc>();
         uniqueFaceDoc.face_descriptors = new List<List<number>>();
 
-        Doc.ActiveDashboard && Doc.AddDocToList(Doc.ActiveDashboard[DocData], 'uniqueFaces', uniqueFaceDoc);
+        Doc.ActiveDashboard && Doc.AddDocToList(Doc.ActiveDashboard[DocData], 'myUniqueFaces', uniqueFaceDoc);
         return uniqueFaceDoc;
     };
 
-- 
cgit v1.2.3-70-g09d2


From 2e345c1ebd498e3f7e088e6fc3e1ca17082b23c1 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 26 Aug 2024 13:53:26 -0400
Subject: converted unique faces to be a Doc type similar to a collection.

---
 src/client/documents/DocumentTypes.ts              |   1 +
 src/client/documents/Documents.ts                  |   4 +
 src/client/views/DashboardView.tsx                 |   1 +
 src/client/views/Main.tsx                          |   3 +-
 .../views/collections/CollectionStackingView.tsx   |   3 +-
 src/client/views/collections/CollectionSubView.tsx |   1 +
 .../collectionFreeForm/FaceCollectionBox.scss      |  24 ++++-
 .../collectionFreeForm/FaceCollectionBox.tsx       | 113 +++++++++++++--------
 src/client/views/search/FaceRecognitionHandler.tsx |  34 +++++--
 9 files changed, 123 insertions(+), 61 deletions(-)

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

diff --git a/src/client/documents/DocumentTypes.ts b/src/client/documents/DocumentTypes.ts
index 49df943d8..b055546fc 100644
--- a/src/client/documents/DocumentTypes.ts
+++ b/src/client/documents/DocumentTypes.ts
@@ -18,6 +18,7 @@ export enum DocumentType {
     SEARCH = 'search', // search query
     IMAGEGROUPER = 'imagegrouper',
     FACECOLLECTION = 'facecollection',
+    UFACE = 'uniqueface', // unique face collection doc
     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 c0e4e961c..c2211fb80 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.FACECOLLECTION), undefined, options);
         }
 
+        export function UniqeFaceDocument(options: DocumentOptions = {}) {
+            return InstanceFromProto(Prototypes.get(DocumentType.UFACE), 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/views/DashboardView.tsx b/src/client/views/DashboardView.tsx
index 4616e15e5..33e905a54 100644
--- a/src/client/views/DashboardView.tsx
+++ b/src/client/views/DashboardView.tsx
@@ -432,6 +432,7 @@ export class DashboardView extends ObservableReactComponent<object> {
         dashboardDoc.myOverlayDocs = new List<Doc>();
         dashboardDoc[DocData].myPublishedDocs = new List<Doc>();
         dashboardDoc[DocData].myTagCollections = new List<Doc>();
+        dashboardDoc[DocData].myUniqueFaces = new List<Doc>();
         dashboardDoc[DocData].myTrails = DashboardView.SetupDashboardTrails(dashboardDoc);
         dashboardDoc[DocData].myCalendars = DashboardView.SetupDashboardCalendars(dashboardDoc);
         // open this new dashboard
diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx
index 023324881..f7cd0e925 100644
--- a/src/client/views/Main.tsx
+++ b/src/client/views/Main.tsx
@@ -63,7 +63,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';
+import { FaceCollectionBox, UniqueFaceBox } from './collections/collectionFreeForm/FaceCollectionBox';
 import { Node } from 'prosemirror-model';
 import { EditorView } from 'prosemirror-view';
 
@@ -140,6 +140,7 @@ FieldLoader.ServerLoadStatus = { requested: 0, retrieved: 0, message: 'cache' };
             SearchBox,
             ImageLabelBox,
             FaceCollectionBox,
+            UniqueFaceBox,
             FunctionPlotBox,
             InkingStroke,
             LinkBox,
diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx
index 3f8aee792..6402ef16c 100644
--- a/src/client/views/collections/CollectionStackingView.tsx
+++ b/src/client/views/collections/CollectionStackingView.tsx
@@ -98,7 +98,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection
     }
     // are we stacking or masonry?
     @computed get isStackingView() {
-        return (this._props.type_collection ?? this.layoutDoc._type_collection) === CollectionViewType.Stacking;
+        return (this._props.type_collection ?? this.layoutDoc._type_collection) !== CollectionViewType.Masonry;
     }
     // this is the number of StackingViewFieldColumns that we have
     @computed get numGroupColumns() {
@@ -355,6 +355,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection
                 childFilters={this.childDocFilters}
                 hideDecorationTitle={this._props.childHideDecorationTitle}
                 hideResizeHandles={this._props.childHideResizeHandles}
+                hideDecorations={this._props.childHideDecorations}
                 childFiltersByRanges={this.childDocRangeFilters}
                 searchFilterDocs={this.searchFilterDocs}
                 xPadding={NumCast(this.layoutDoc._childXPadding, this._props.childXPadding)}
diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx
index 5782d407e..6aca8f2ca 100644
--- a/src/client/views/collections/CollectionSubView.tsx
+++ b/src/client/views/collections/CollectionSubView.tsx
@@ -45,6 +45,7 @@ export interface CollectionViewProps extends React.PropsWithChildren<FieldViewPr
     childLayoutTemplate?: () => Doc | undefined; // specify a layout Doc template to use for children of the collection
     childHideDecorationTitle?: boolean;
     childHideResizeHandles?: boolean;
+    childHideDecorations?: boolean;
     childDragAction?: dropActionType;
     childXPadding?: number;
     childYPadding?: number;
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
index 480d109c8..86120f966 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
@@ -1,10 +1,11 @@
 .face-document-item {
-    background: #555555;
     margin-top: 10px;
     margin-bottom: 10px;
     padding: 10px;
-    border-radius: 10px;
+    border-radius: inherit;
     position: relative;
+    width: 100%;
+    height: 100%;
 
     h1 {
         color: white;
@@ -14,14 +15,31 @@
 
     .face-collection-buttons {
         position: absolute;
-        top: 10px;
+        top: 0px;
         right: 10px;
     }
+    .face-collection-toggle {
+        position: absolute;
+        top: 0px;
+        left: 10px;
+    }
+    .face-document-top {
+        position: absolute;
+        top: 0;
+        margin: auto;
+        width: 100%;
+        left: 0;
+    }
 
     .face-document-image-container {
         display: flex;
         justify-content: center;
         flex-wrap: wrap;
+        overflow-x: hidden;
+        overflow-y: auto;
+        position: absolute;
+        top: 75px;
+        height: calc(100% - 75px);
 
         .image-wrapper {
             position: relative;
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index 1cc1f59dc..6a0d51e32 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -6,23 +6,21 @@ import 'ldrs/ring';
 import { action, makeObservable, observable } from 'mobx';
 import { observer } from 'mobx-react';
 import React from 'react';
-import { setupMoveUpEvents } from '../../../../ClientUtils';
-import { Utils, emptyFunction } from '../../../../Utils';
+import { lightOrDark, returnTrue, setupMoveUpEvents } from '../../../../ClientUtils';
+import { emptyFunction } from '../../../../Utils';
 import { Doc, Opt } from '../../../../fields/Doc';
-import { Id } from '../../../../fields/FieldSymbols';
 import { List } from '../../../../fields/List';
-import { ImageCast } from '../../../../fields/Types';
+import { ImageCast, StrCast } from '../../../../fields/Types';
 import { DocumentType } from '../../../documents/DocumentTypes';
 import { Docs } from '../../../documents/Documents';
 import { DragManager } from '../../../util/DragManager';
 import { dropActionType } from '../../../util/DropActionTypes';
-import { SnappingManager } from '../../../util/SnappingManager';
 import { undoable } from '../../../util/UndoManager';
 import { ViewBoxBaseComponent } from '../../DocComponent';
-import { ObservableReactComponent } from '../../ObservableReactComponent';
 import { DocumentView } from '../../nodes/DocumentView';
 import { FieldView, FieldViewProps } from '../../nodes/FieldView';
 import { FaceRecognitionHandler } from '../../search/FaceRecognitionHandler';
+import { CollectionStackingView } from '../CollectionStackingView';
 import './FaceCollectionBox.scss';
 import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
 
@@ -31,31 +29,29 @@ import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
  * unique face in turn displays the set of images that correspond to the face.
  */
 
-interface UniqueFaceProps {
-    faceDoc: Doc;
-}
-
 /**
- * React component for rendering a unique face and its collection of image Docs.
+ * Viewer for unique face Doc collections.
  *
  * This both displays a collection of images corresponding tp a unique face, and
  * allows for editing the face collection by removing an image, or drag-and-dropping
  * an image that was not recognized.
  */
 @observer
-export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
+export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
+    public static LayoutString(fieldKey: string) {
+        return FieldView.LayoutString(UniqueFaceBox, fieldKey);
+    }
     private _dropDisposer?: DragManager.DragDropDisposer;
+    private _oldWheel: HTMLElement | null = null;
 
-    constructor(props: UniqueFaceProps) {
+    constructor(props: FieldViewProps) {
         super(props);
         makeObservable(this);
     }
 
-    @observable _displayImages: boolean = true;
-
     protected createDropTarget = (ele: HTMLDivElement) => {
         this._dropDisposer?.();
-        ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this._props.faceDoc));
+        ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this._props.Document));
     };
 
     protected onInternalDrop(e: Event, de: DragManager.DropEvent): boolean {
@@ -63,12 +59,12 @@ export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
             ?.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._props.faceDoc).length === 0 && FaceRecognitionHandler.ImageDocFaceDescriptors(imgDoc).length > 1) {
+                if (FaceRecognitionHandler.UniqueFaceDescriptors(this._props.Document).length === 0 && FaceRecognitionHandler.ImageDocFaceDescriptors(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._props.faceDoc).map(fd => new Float32Array(Array.from(fd)));
-                    const labeledFaceDescriptor = new faceapi.LabeledFaceDescriptors(FaceRecognitionHandler.UniqueFaceLabel(this._props.faceDoc), faceDescriptorsAsFloat32Array);
+                    const faceDescriptorsAsFloat32Array = FaceRecognitionHandler.UniqueFaceDescriptors(this._props.Document).map(fd => new Float32Array(Array.from(fd)));
+                    const labeledFaceDescriptor = new faceapi.LabeledFaceDescriptors(FaceRecognitionHandler.UniqueFaceLabel(this._props.Document), faceDescriptorsAsFloat32Array);
                     const faceMatcher = new FaceMatcher([labeledFaceDescriptor], 1);
                     const { face_match } = FaceRecognitionHandler.ImageDocFaceDescriptors(imgDoc).reduce(
                         (prev, face) => {
@@ -80,11 +76,12 @@ export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
 
                     // assign the face in the image that's closest to the face collection to be the face that's assigned to the collection
                     if (face_match) {
-                        FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, face_match, this._props.faceDoc);
+                        FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, face_match, this._props.Document);
                     }
                 }
             });
-        return false;
+        e.stopPropagation();
+        return true;
     }
 
     /**
@@ -92,14 +89,15 @@ export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
      */
     @action
     onDisplayClick() {
-        this._displayImages = !this._displayImages;
+        this._props.Document._displayImages = !this.props.Document._displayImages;
+        this._props.Document.height = this._props.Document._displayImages ? 400 : 100;
     }
 
     /**
      * Removes a unique face Doc from the colelction of unique faces.
      */
     deleteUniqueFace = undoable(() => {
-        FaceRecognitionHandler.DeleteUniqueFace(this._props.faceDoc);
+        FaceRecognitionHandler.DeleteUniqueFace(this._props.Document);
     }, 'delete face');
 
     /**
@@ -107,9 +105,11 @@ export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
      * @param imgDoc - image Doc to remove
      */
     removeFaceImageFromUniqueFace = undoable((imgDoc: Doc) => {
-        FaceRecognitionHandler.UniqueFaceRemoveFaceImage(imgDoc, this._props.faceDoc);
+        FaceRecognitionHandler.UniqueFaceRemoveFaceImage(imgDoc, this._props.Document);
     }, 'remove doc from face');
 
+    onPassiveWheel = (e: WheelEvent) => e.stopPropagation();
+
     render() {
         return (
             <div className="face-document-item" ref={ele => this.createDropTarget(ele!)}>
@@ -117,23 +117,35 @@ export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
                     <IconButton tooltip="Delete Face From Collection" onPointerDown={this.deleteUniqueFace} icon={'x'} style={{ width: '4px' }} size={Size.XSMALL} />
                 </div>
                 <div className="face-document-top">
-                    <h1>{FaceRecognitionHandler.UniqueFaceLabel(this._props.faceDoc)}</h1>
+                    <h1 style={{ color: lightOrDark(StrCast(this._props.Document.backgroundColor)) }}>{FaceRecognitionHandler.UniqueFaceLabel(this._props.Document)}</h1>
                 </div>
-                <IconButton
-                    tooltip="See image information"
-                    onPointerDown={() => this.onDisplayClick()}
-                    icon={<FontAwesomeIcon icon={this._displayImages ? 'caret-up' : 'caret-down'} />}
-                    color={MarqueeOptionsMenu.Instance.userColor}
-                    style={{ width: '19px' }}
-                />
-                {this._displayImages ? (
-                    <div className="face-document-image-container">
-                        {FaceRecognitionHandler.UniqueFaceImages(this._props.faceDoc).map(doc => {
+                <div className="face-collection-toggle">
+                    <IconButton
+                        tooltip="See image information"
+                        onPointerDown={() => this.onDisplayClick()}
+                        icon={<FontAwesomeIcon icon={this._props.Document._displayImages ? 'caret-up' : 'caret-down'} />}
+                        color={MarqueeOptionsMenu.Instance.userColor}
+                        style={{ width: '19px' }}
+                    />
+                </div>
+                {this.props.Document._displayImages ? (
+                    <div
+                        className="face-document-image-container"
+                        style={{
+                            pointerEvents: this._props.isContentActive() ? undefined : 'none',
+                        }}
+                        ref={ele => {
+                            this._oldWheel?.removeEventListener('wheel', this.onPassiveWheel);
+                            this._oldWheel = ele;
+                            // prevent wheel events from passively propagating up through containers and prevents containers from preventDefault which would block scrolling
+                            ele?.addEventListener('wheel', this.onPassiveWheel, { passive: false });
+                        }}>
+                        {FaceRecognitionHandler.UniqueFaceImages(this._props.Document).map((doc, i) => {
                             const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
                             return (
                                 <div
                                     className="image-wrapper"
-                                    key={Utils.GenerateGuid()}
+                                    key={i}
                                     onPointerDown={e =>
                                         setupMoveUpEvents(
                                             this,
@@ -165,8 +177,8 @@ export class UniqueFaceView extends ObservableReactComponent<UniqueFaceProps> {
  *
  * Since the collection of recognized faces is stored on the active dashboard, this class
  * does not itself store any Docs, but accesses the myUniqueFaces field of the current
- * dashboard.  Each Face collection Doc is rendered using a FaceCollectionDocView which
- * is not a CollectionView or even a DocumentView (but probably should be).
+ * dashboard.  (This should probably go away as Doc type in favor of it just being a
+ * stacking collection of uniqueFace docs)
  */
 @observer
 export class FaceCollectionBox extends ViewBoxBaseComponent<FieldViewProps>() {
@@ -179,18 +191,29 @@ export class FaceCollectionBox extends ViewBoxBaseComponent<FieldViewProps>() {
         makeObservable(this);
     }
 
+    moveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[]) => boolean): boolean => !!(this._props.removeDocument?.(doc) && addDocument?.(doc));
+
     render() {
-        return (
-            <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
-                {FaceRecognitionHandler.UniqueFaces().map(doc => (
-                    <UniqueFaceView key={doc[Id]} faceDoc={doc} />
-                ))}
-            </div>
+        return !Doc.ActiveDashboard ? null : (
+            <CollectionStackingView
+                {...this._props} //
+                Document={Doc.ActiveDashboard}
+                fieldKey="myUniqueFaces"
+                moveDocument={this.moveDocument}
+                isContentActive={returnTrue}
+                isAnyChildContentActive={returnTrue}
+                childHideDecorations={true}
+            />
         );
     }
 }
 
 Docs.Prototypes.TemplateMap.set(DocumentType.FACECOLLECTION, {
     layout: { view: FaceCollectionBox, dataField: 'data' },
-    options: { acl: '', _width: 400 },
+    options: { acl: '', _width: 400, dropAction: dropActionType.embed },
+});
+
+Docs.Prototypes.TemplateMap.set(DocumentType.UFACE, {
+    layout: { view: UniqueFaceBox, dataField: 'face_images' },
+    options: { acl: '', _width: 400, _height: 400 },
 });
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index a17e4c54a..1ab084eaa 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -8,6 +8,8 @@ import { Cast, ImageCast, NumCast, StrCast } from '../../../fields/Types';
 import { DocumentType } from '../../documents/DocumentTypes';
 import { DocumentManager } from '../../util/DocumentManager';
 import { ImageField } from '../../../fields/URLField';
+import { UniqueFaceBox } from '../collections/collectionFreeForm/FaceCollectionBox';
+import { Docs } from '../../documents/Documents';
 
 /**
  * A singleton class that handles face recognition and manages face Doc collections for each face found.
@@ -169,15 +171,25 @@ export class FaceRecognitionHandler {
      * @returns a unique face Doc
      */
     private createUniqueFaceDoc = (dashboard: Doc) => {
-        const faceDocNum = NumCast(dashboard.myUniqueFaces_count) + 1;
-        dashboard.myUniqueFaces_count = faceDocNum; // TODO: improve to a better name
-
-        const uniqueFaceDoc = new Doc();
-        uniqueFaceDoc.title = `Face ${faceDocNum}`;
-        uniqueFaceDoc.face = ''; // just to make prettyprinting look better
-        uniqueFaceDoc.face_label = `Face${faceDocNum}`;
-        uniqueFaceDoc.face_images = new List<Doc>();
-        uniqueFaceDoc.face_descriptors = new List<List<number>>();
+        const faceDocNum = NumCast(dashboard[DocData].myUniqueFaces_count) + 1;
+        dashboard[DocData].myUniqueFaces_count = faceDocNum; // TODO: improve to a better name
+
+        const uniqueFaceDoc = Docs.Create.UniqeFaceDocument({
+            title: `Face ${faceDocNum}`,
+            _layout_reflowHorizontal: true,
+            _layout_reflowVertical: true,
+            _layout_nativeDimEditable: true,
+            _layout_borderRounding: '20px',
+            backgroundColor: '#555555',
+            _width: 400,
+            _height: 400,
+        });
+        const uface = uniqueFaceDoc[DocData];
+        uface.face = ''; // just to make prettyprinting look better
+        uface.face_label = `Face${faceDocNum}`;
+        uface.face_images = new List<Doc>();
+        uface.face_descriptors = new List<List<number>>();
+        Doc.SetContainer(uniqueFaceDoc, Doc.MyFaceCollection);
 
         Doc.ActiveDashboard && Doc.AddDocToList(Doc.ActiveDashboard[DocData], 'myUniqueFaces', uniqueFaceDoc);
         return uniqueFaceDoc;
@@ -222,8 +234,8 @@ export class FaceRecognitionHandler {
             setTimeout(() => this.classifyFacesInImage(imgDoc), 1000);
         } else {
             const imgUrl = ImageCast(imgDoc[Doc.LayoutFieldKey(imgDoc)]);
-            if (imgUrl && !DocListCast(Doc.MyFaceCollection.examinedFaceDocs).includes(imgDoc)) { // only examine Docs that have an image and that haven't already been examined.
-                Doc.AddDocToList(Doc.MyFaceCollection, 'examinedFaceDocs', imgDoc);
+            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.detectAllFaces(img).withFaceLandmarks().withFaceDescriptors()
-- 
cgit v1.2.3-70-g09d2


From d12b43191e01e837d5a144fb19d9d3cd8eadd777 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 26 Aug 2024 15:17:14 -0400
Subject: from last

---
 src/client/documents/Documents.ts                  |  1 +
 .../collectionFreeForm/FaceCollectionBox.scss      | 19 ++++---
 .../collectionFreeForm/FaceCollectionBox.tsx       | 60 ++++++++++++++--------
 src/client/views/search/FaceRecognitionHandler.tsx |  5 +-
 4 files changed, 54 insertions(+), 31 deletions(-)

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

diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index c2211fb80..ac271526f 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -362,6 +362,7 @@ export class DocumentOptions {
 
     data?: FieldType;
     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');
     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/collections/collectionFreeForm/FaceCollectionBox.scss b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
index 86120f966..00297a97d 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
@@ -1,11 +1,10 @@
 .face-document-item {
-    margin-top: 10px;
-    margin-bottom: 10px;
-    padding: 10px;
-    border-radius: inherit;
-    position: relative;
+    display: flex;
+    height: max-content;
+    flex-direction: column;
+    top: 0;
+    position: absolute;
     width: 100%;
-    height: 100%;
 
     h1 {
         color: white;
@@ -24,11 +23,12 @@
         left: 10px;
     }
     .face-document-top {
-        position: absolute;
+        position: relative;
         top: 0;
         margin: auto;
         width: 100%;
         left: 0;
+        height: 60px;
     }
 
     .face-document-image-container {
@@ -37,9 +37,8 @@
         flex-wrap: wrap;
         overflow-x: hidden;
         overflow-y: auto;
-        position: absolute;
-        top: 75px;
-        height: calc(100% - 75px);
+        position: relative;
+        padding: 10px;
 
         .image-wrapper {
             position: relative;
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index 6a0d51e32..33fc3c210 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -3,14 +3,14 @@ import { IconButton, Size } from 'browndash-components';
 import * as faceapi from 'face-api.js';
 import { FaceMatcher } from 'face-api.js';
 import 'ldrs/ring';
-import { action, makeObservable, observable } from 'mobx';
+import { IReactionDisposer, action, makeObservable, observable, reaction } from 'mobx';
 import { observer } from 'mobx-react';
 import React from 'react';
-import { lightOrDark, returnTrue, setupMoveUpEvents } from '../../../../ClientUtils';
+import { DivHeight, lightOrDark, returnTrue, setupMoveUpEvents } from '../../../../ClientUtils';
 import { emptyFunction } from '../../../../Utils';
 import { Doc, Opt } from '../../../../fields/Doc';
 import { List } from '../../../../fields/List';
-import { ImageCast, StrCast } from '../../../../fields/Types';
+import { ImageCast, NumCast, StrCast } from '../../../../fields/Types';
 import { DocumentType } from '../../../documents/DocumentTypes';
 import { Docs } from '../../../documents/Documents';
 import { DragManager } from '../../../util/DragManager';
@@ -48,10 +48,29 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
         super(props);
         makeObservable(this);
     }
+    @observable _headerRef: HTMLDivElement | null = null;
+    @observable _listRef: HTMLDivElement | null = null;
+    _disposers: { [key: string]: IReactionDisposer } = {};
+    _lastHeight = 0;
+    observer = new ResizeObserver(() => this._props.setHeight?.((this.props.Document._face_showImages ? 20 : 0) + (!this._headerRef ? 0 : DivHeight(this._headerRef)) + (!this._listRef ? 0 : DivHeight(this._listRef))));
+
+    componentDidMount(): void {
+        this._disposers.refList = reaction(
+            () => ({ refList: [this._headerRef, this._listRef], autoHeight: this.layoutDoc._layout_autoHeight && !DocumentView.LightboxContains(this.DocumentView?.()) }),
+            ({ refList, autoHeight }) => {
+                this.observer.disconnect();
+                if (autoHeight) refList.filter(r => r).forEach(r => this.observer.observe(r!));
+            },
+            { fireImmediately: true }
+        );
+    }
 
+    componentWillUnmount(): void {
+        //this._disposers?.();
+    }
     protected createDropTarget = (ele: HTMLDivElement) => {
         this._dropDisposer?.();
-        ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this._props.Document));
+        ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.Document));
     };
 
     protected onInternalDrop(e: Event, de: DragManager.DropEvent): boolean {
@@ -59,12 +78,12 @@ 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._props.Document).length === 0 && FaceRecognitionHandler.ImageDocFaceDescriptors(imgDoc).length > 1) {
+                if (FaceRecognitionHandler.UniqueFaceDescriptors(this.Document).length === 0 && FaceRecognitionHandler.ImageDocFaceDescriptors(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._props.Document).map(fd => new Float32Array(Array.from(fd)));
-                    const labeledFaceDescriptor = new faceapi.LabeledFaceDescriptors(FaceRecognitionHandler.UniqueFaceLabel(this._props.Document), faceDescriptorsAsFloat32Array);
+                    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) => {
@@ -76,7 +95,7 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
 
                     // assign the face in the image that's closest to the face collection to be the face that's assigned to the collection
                     if (face_match) {
-                        FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, face_match, this._props.Document);
+                        FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, face_match, this.Document);
                     }
                 }
             });
@@ -87,17 +106,17 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
     /**
      * Toggles whether a Face Document displays its associated docs.
      */
-    @action
     onDisplayClick() {
-        this._props.Document._displayImages = !this.props.Document._displayImages;
-        this._props.Document.height = this._props.Document._displayImages ? 400 : 100;
+        this.Document._face_showImages && (this._lastHeight = NumCast(this.Document.height));
+        this.Document._face_showImages = !this.Document._face_showImages;
+        setTimeout(action(() => (!this.Document.layout_autoHeight || !this.Document._face_showImages) && (this.Document.height = this.Document._face_showImages ? this._lastHeight : 60)));
     }
 
     /**
      * Removes a unique face Doc from the colelction of unique faces.
      */
     deleteUniqueFace = undoable(() => {
-        FaceRecognitionHandler.DeleteUniqueFace(this._props.Document);
+        FaceRecognitionHandler.DeleteUniqueFace(this.Document);
     }, 'delete face');
 
     /**
@@ -105,7 +124,7 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
      * @param imgDoc - image Doc to remove
      */
     removeFaceImageFromUniqueFace = undoable((imgDoc: Doc) => {
-        FaceRecognitionHandler.UniqueFaceRemoveFaceImage(imgDoc, this._props.Document);
+        FaceRecognitionHandler.UniqueFaceRemoveFaceImage(imgDoc, this.Document);
     }, 'remove doc from face');
 
     onPassiveWheel = (e: WheelEvent) => e.stopPropagation();
@@ -116,31 +135,32 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
                 <div className="face-collection-buttons">
                     <IconButton tooltip="Delete Face From Collection" onPointerDown={this.deleteUniqueFace} icon={'x'} style={{ width: '4px' }} size={Size.XSMALL} />
                 </div>
-                <div className="face-document-top">
-                    <h1 style={{ color: lightOrDark(StrCast(this._props.Document.backgroundColor)) }}>{FaceRecognitionHandler.UniqueFaceLabel(this._props.Document)}</h1>
+                <div className="face-document-top" ref={action((r: HTMLDivElement | null) => (this._headerRef = r))}>
+                    <h1 style={{ color: lightOrDark(StrCast(this.Document.backgroundColor)) }}>{FaceRecognitionHandler.UniqueFaceLabel(this.Document)}</h1>
                 </div>
                 <div className="face-collection-toggle">
                     <IconButton
                         tooltip="See image information"
                         onPointerDown={() => this.onDisplayClick()}
-                        icon={<FontAwesomeIcon icon={this._props.Document._displayImages ? 'caret-up' : 'caret-down'} />}
+                        icon={<FontAwesomeIcon icon={this.Document._face_showImages ? 'caret-up' : 'caret-down'} />}
                         color={MarqueeOptionsMenu.Instance.userColor}
                         style={{ width: '19px' }}
                     />
                 </div>
-                {this.props.Document._displayImages ? (
+                {this.props.Document._face_showImages ? (
                     <div
                         className="face-document-image-container"
                         style={{
                             pointerEvents: this._props.isContentActive() ? undefined : 'none',
                         }}
-                        ref={ele => {
+                        ref={action((ele: HTMLDivElement | null) => {
+                            this._listRef = ele;
                             this._oldWheel?.removeEventListener('wheel', this.onPassiveWheel);
                             this._oldWheel = ele;
                             // prevent wheel events from passively propagating up through containers and prevents containers from preventDefault which would block scrolling
                             ele?.addEventListener('wheel', this.onPassiveWheel, { passive: false });
-                        }}>
-                        {FaceRecognitionHandler.UniqueFaceImages(this._props.Document).map((doc, i) => {
+                        })}>
+                        {FaceRecognitionHandler.UniqueFaceImages(this.Document).map((doc, i) => {
                             const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
                             return (
                                 <div
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index 1ab084eaa..f613f78ce 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -180,9 +180,12 @@ export class FaceRecognitionHandler {
             _layout_reflowVertical: true,
             _layout_nativeDimEditable: true,
             _layout_borderRounding: '20px',
+            _layout_fitWidth: true,
+            _layout_autoHeight: true,
+            _face_showImages: true,
             backgroundColor: '#555555',
             _width: 400,
-            _height: 400,
+            _height: 100,
         });
         const uface = uniqueFaceDoc[DocData];
         uface.face = ''; // just to make prettyprinting look better
-- 
cgit v1.2.3-70-g09d2


From 3f31aaa6b667e7b5f31b2f2b4e13a0879348602c Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 26 Aug 2024 16:46:13 -0400
Subject: added recognizeFaces for face images

---
 src/client/util/CurrentUserUtils.ts                |  2 +-
 src/client/util/SettingsManager.tsx                | 11 ++++++
 src/client/views/StyleProvider.tsx                 |  2 +
 .../collectionFreeForm/FaceCollectionBox.scss      | 11 +++++-
 .../collectionFreeForm/FaceCollectionBox.tsx       | 45 +++++++++++++++-------
 src/client/views/search/FaceRecognitionHandler.tsx |  1 +
 6 files changed, 55 insertions(+), 17 deletions(-)

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

diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index 0681a21cb..dc0c95121 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -497,7 +497,7 @@ pie title Minerals in my tap water
 
     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, 
+            dontRegisterView: true, ignoreClick: true, title: "Face Collection", isSystem: true, childDragAction: dropActionType.embed, 
             _lockedPosition: true, _type_collection: CollectionViewType.Schema  });
     }
 
diff --git a/src/client/util/SettingsManager.tsx b/src/client/util/SettingsManager.tsx
index 2d8763b63..fde8869e3 100644
--- a/src/client/util/SettingsManager.tsx
+++ b/src/client/util/SettingsManager.tsx
@@ -231,6 +231,17 @@ export class SettingsManager extends React.Component<object> {
                     size={Size.XSMALL}
                     color={SettingsManager.userColor}
                 />
+                <Toggle
+                    formLabel="Recognize Face Images"
+                    formLabelPlacement="right"
+                    toggleType={ToggleType.SWITCH}
+                    onClick={() => {
+                        Doc.UserDoc().recognizeFaceImages = !Doc.UserDoc().recognizeFaceImages;
+                    }}
+                    toggleStatus={BoolCast(Doc.UserDoc().recognizeFaceImages)}
+                    size={Size.XSMALL}
+                    color={SettingsManager.userColor}
+                />
                 <Toggle
                     formLabel="Show Full Toolbar"
                     formLabelPlacement="right"
diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx
index e48994586..76cb119ab 100644
--- a/src/client/views/StyleProvider.tsx
+++ b/src/client/views/StyleProvider.tsx
@@ -236,6 +236,8 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps &
                 case DocumentType.MAP:
                 case DocumentType.SCREENSHOT:
                 case DocumentType.VID:         docColor = docColor || (Colors.LIGHT_GRAY); break;
+                case DocumentType.UFACE:       docColor = docColor || "dimgray";break;
+                case DocumentType.FACECOLLECTION: docColor = docColor || Colors.DARK_GRAY;break;
                 case DocumentType.COL:
                     docColor = docColor || (doc && Doc.IsSystem(doc)
                             ? SnappingManager.userBackgroundColor
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
index 00297a97d..a72efc948 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
@@ -5,6 +5,7 @@
     top: 0;
     position: absolute;
     width: 100%;
+    height: 100%;
 
     h1 {
         color: white;
@@ -25,10 +26,8 @@
     .face-document-top {
         position: relative;
         top: 0;
-        margin: auto;
         width: 100%;
         left: 0;
-        height: 60px;
     }
 
     .face-document-image-container {
@@ -89,3 +88,11 @@
         // }
     }
 }
+
+.faceCollectionBox {
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+    position: absolute;
+}
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index 33fc3c210..7820af8aa 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -43,6 +43,8 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
     }
     private _dropDisposer?: DragManager.DragDropDisposer;
     private _oldWheel: HTMLElement | null = null;
+    private _disposers: { [key: string]: IReactionDisposer } = {};
+    private _lastHeight = 0;
 
     constructor(props: FieldViewProps) {
         super(props);
@@ -50,13 +52,17 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
     }
     @observable _headerRef: HTMLDivElement | null = null;
     @observable _listRef: HTMLDivElement | null = null;
-    _disposers: { [key: string]: IReactionDisposer } = {};
-    _lastHeight = 0;
-    observer = new ResizeObserver(() => this._props.setHeight?.((this.props.Document._face_showImages ? 20 : 0) + (!this._headerRef ? 0 : DivHeight(this._headerRef)) + (!this._listRef ? 0 : DivHeight(this._listRef))));
+    observer = new ResizeObserver(a => {
+        this._props.setHeight?.(
+            (this.props.Document._face_showImages ? 20 : 0) + //
+                (!this._headerRef ? 0 : DivHeight(this._headerRef)) +
+                (!this._listRef ? 0 : DivHeight(this._listRef))
+        );
+    });
 
     componentDidMount(): void {
         this._disposers.refList = reaction(
-            () => ({ refList: [this._headerRef, this._listRef], autoHeight: this.layoutDoc._layout_autoHeight && !DocumentView.LightboxContains(this.DocumentView?.()) }),
+            () => ({ refList: [this._headerRef, this._listRef], autoHeight: this.layoutDoc._layout_autoHeight }),
             ({ refList, autoHeight }) => {
                 this.observer.disconnect();
                 if (autoHeight) refList.filter(r => r).forEach(r => this.observer.observe(r!));
@@ -66,7 +72,8 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
     }
 
     componentWillUnmount(): void {
-        //this._disposers?.();
+        this.observer.disconnect();
+        Object.keys(this._disposers).forEach(key => this._disposers[key]());
     }
     protected createDropTarget = (ele: HTMLDivElement) => {
         this._dropDisposer?.();
@@ -213,17 +220,27 @@ 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));
 
+    stackingStyleProvider = (doc: Doc | undefined, props: Opt<FieldViewProps>, property: string) => {
+        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 : (
-            <CollectionStackingView
-                {...this._props} //
-                Document={Doc.ActiveDashboard}
-                fieldKey="myUniqueFaces"
-                moveDocument={this.moveDocument}
-                isContentActive={returnTrue}
-                isAnyChildContentActive={returnTrue}
-                childHideDecorations={true}
-            />
+            <div className="faceCollectionBox">
+                <div className="documentButtonMenu">
+                    <div className="documentExplanation" onClick={action(() => (Doc.UserDoc().recognizeFaceImages = !Doc.UserDoc().recognizeFaceImages))}>{`Face Recgognition is ${Doc.UserDoc().recognizeFaceImages ? 'on' : 'off'}`}</div>
+                </div>
+                <CollectionStackingView
+                    {...this._props} //
+                    styleProvider={this.stackingStyleProvider}
+                    Document={Doc.ActiveDashboard}
+                    fieldKey="myUniqueFaces"
+                    moveDocument={this.moveDocument}
+                    isContentActive={returnTrue}
+                    isAnyChildContentActive={returnTrue}
+                    childHideDecorations={true}
+                />
+            </div>
         );
     }
 }
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index f613f78ce..7c8043219 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -230,6 +230,7 @@ export class FaceRecognitionHandler {
      * @param imgDoc The document being analyzed.
      */
     private classifyFacesInImage = async (imgDoc: Doc) => {
+        if (!Doc.UserDoc().recognizeFaceImages) return;
         const activeDashboard = Doc.ActiveDashboard;
         if (!this._apiModelReady || !activeDashboard) {
             this._pendingAPIModelReadyDocs.push(imgDoc);
-- 
cgit v1.2.3-70-g09d2


From 9a92fa403a5e2a094763d6f92ac55549aecf359c Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 26 Aug 2024 20:38:01 -0400
Subject: cleanup of faces and tags

---
 src/client/views/TagsView.tsx                           |  4 ++++
 .../collectionFreeForm/FaceCollectionBox.tsx            | 17 +++++++++++++----
 src/client/views/search/FaceRecognitionHandler.tsx      |  5 ++---
 3 files changed, 19 insertions(+), 7 deletions(-)

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

diff --git a/src/client/views/TagsView.tsx b/src/client/views/TagsView.tsx
index 394162f46..82c85ddf0 100644
--- a/src/client/views/TagsView.tsx
+++ b/src/client/views/TagsView.tsx
@@ -252,6 +252,10 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
         return this._isEditing && DocumentView.SelectedDocs().includes(this._props.View.Document);
     }
 
+    /**
+     * Shows or hides the editing UI for adding/removing Doc tags
+     * @param editing
+     */
     @action
     setToEditing = (editing = true) => {
         this._isEditing = editing;
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index 7820af8aa..662436ddc 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -42,7 +42,6 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
         return FieldView.LayoutString(UniqueFaceBox, fieldKey);
     }
     private _dropDisposer?: DragManager.DragDropDisposer;
-    private _oldWheel: HTMLElement | null = null;
     private _disposers: { [key: string]: IReactionDisposer } = {};
     private _lastHeight = 0;
 
@@ -50,8 +49,10 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
         super(props);
         makeObservable(this);
     }
+
     @observable _headerRef: HTMLDivElement | null = null;
     @observable _listRef: HTMLDivElement | null = null;
+
     observer = new ResizeObserver(a => {
         this._props.setHeight?.(
             (this.props.Document._face_showImages ? 20 : 0) + //
@@ -75,6 +76,7 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
         this.observer.disconnect();
         Object.keys(this._disposers).forEach(key => this._disposers[key]());
     }
+
     protected createDropTarget = (ele: HTMLDivElement) => {
         this._dropDisposer?.();
         ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.Document));
@@ -111,7 +113,8 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
     }
 
     /**
-     * Toggles whether a Face Document displays its associated docs.
+     * Toggles whether a Face Document displays its associated docs.  This saves and restores the last height of the Doc since
+     * toggling the associated Documentss overwrites the Doc height.
      */
     onDisplayClick() {
         this.Document._face_showImages && (this._lastHeight = NumCast(this.Document.height));
@@ -134,6 +137,9 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
         FaceRecognitionHandler.UniqueFaceRemoveFaceImage(imgDoc, this.Document);
     }, 'remove doc from face');
 
+    /**
+     * This stops scroll wheel events when they are used to scroll the face collection.
+     */
     onPassiveWheel = (e: WheelEvent) => e.stopPropagation();
 
     render() {
@@ -161,9 +167,8 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
                             pointerEvents: this._props.isContentActive() ? undefined : 'none',
                         }}
                         ref={action((ele: HTMLDivElement | null) => {
+                            this._listRef?.removeEventListener('wheel', this.onPassiveWheel);
                             this._listRef = ele;
-                            this._oldWheel?.removeEventListener('wheel', this.onPassiveWheel);
-                            this._oldWheel = ele;
                             // prevent wheel events from passively propagating up through containers and prevents containers from preventDefault which would block scrolling
                             ele?.addEventListener('wheel', this.onPassiveWheel, { passive: false });
                         })}>
@@ -220,6 +225,10 @@ 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));
 
+    /**
+     * 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.
+     */
     stackingStyleProvider = (doc: Doc | undefined, props: Opt<FieldViewProps>, property: string) => {
         if (doc === Doc.ActiveDashboard) return this._props.styleProvider?.(this.Document, this._props, property);
         return this._props.styleProvider?.(doc, this._props, property);
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index e35f860ab..8513ec94d 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -5,11 +5,10 @@ import { DocData } from '../../../fields/DocSymbols';
 import { List } from '../../../fields/List';
 import { listSpec } from '../../../fields/Schema';
 import { Cast, ImageCast, NumCast, StrCast } from '../../../fields/Types';
-import { DocumentType } from '../../documents/DocumentTypes';
-import { DocumentManager } from '../../util/DocumentManager';
 import { ImageField } from '../../../fields/URLField';
-import { UniqueFaceBox } from '../collections/collectionFreeForm/FaceCollectionBox';
+import { DocumentType } from '../../documents/DocumentTypes';
 import { Docs } from '../../documents/Documents';
+import { DocumentManager } from '../../util/DocumentManager';
 
 /**
  * A singleton class that handles face recognition and manages face Doc collections for each face found.
-- 
cgit v1.2.3-70-g09d2


From f0d9731e1c43ee13114d42db8c4e27c94bbbf3c0 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 26 Aug 2024 22:25:58 -0400
Subject: made face doc labels editable

---
 .../views/collections/collectionFreeForm/FaceCollectionBox.scss  | 6 ++++++
 .../views/collections/collectionFreeForm/FaceCollectionBox.tsx   | 4 +++-
 src/client/views/search/FaceRecognitionHandler.tsx               | 9 +++++----
 3 files changed, 14 insertions(+), 5 deletions(-)

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

diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
index a72efc948..0a001d84c 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
@@ -11,6 +11,12 @@
         color: white;
         font-size: 24px;
         text-align: center;
+        .face-document-name {
+            text-align: center;
+            background: transparent;
+            width: 80%;
+            border: transparent;
+        }
     }
 
     .face-collection-buttons {
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index 662436ddc..7e47292e5 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -149,7 +149,9 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
                     <IconButton tooltip="Delete Face From Collection" onPointerDown={this.deleteUniqueFace} icon={'x'} style={{ width: '4px' }} size={Size.XSMALL} />
                 </div>
                 <div className="face-document-top" ref={action((r: HTMLDivElement | null) => (this._headerRef = r))}>
-                    <h1 style={{ color: lightOrDark(StrCast(this.Document.backgroundColor)) }}>{FaceRecognitionHandler.UniqueFaceLabel(this.Document)}</h1>
+                    <h1 style={{ color: lightOrDark(StrCast(this.Document.backgroundColor)) }}>
+                        <input className="face-document-name" type="text" onChange={e => FaceRecognitionHandler.SetUniqueFaceLabel(this.Document, e.currentTarget.value)} value={FaceRecognitionHandler.UniqueFaceLabel(this.Document)} />
+                    </h1>
                 </div>
                 <div className="face-collection-toggle">
                     <IconButton
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index 8513ec94d..dee0ab99b 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -24,7 +24,7 @@ import { DocumentManager } from '../../util/DocumentManager';
  * 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_label - a string label for the person that was recognized                                     (TODO: currently it's just a 'face#')
+ *     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
  */
@@ -108,7 +108,9 @@ export class FaceRecognitionHandler {
      * @param faceDoc unique face Doc
      * @returns label string
      */
-    public static UniqueFaceLabel = (faceDoc: Doc) => StrCast(faceDoc[DocData].face_label);
+    public static UniqueFaceLabel = (faceDoc: Doc) => StrCast(faceDoc[DocData].face);
+
+    public static SetUniqueFaceLabel = (faceDoc: Doc, value: string) => (faceDoc[DocData].face = value);
     /**
      * Returns all the face descriptors associated with a unique face Doc
      * @param faceDoc unique face Doc
@@ -186,8 +188,7 @@ export class FaceRecognitionHandler {
             _height: 100,
         });
         const uface = uniqueFaceDoc[DocData];
-        uface.face = ''; // just to make prettyprinting look better
-        uface.face_label = `Face${faceDocNum}`;
+        uface.face = `Face${faceDocNum}`;
         uface.face_images = new List<Doc>();
         uface.face_descriptors = new List<List<number>>();
         Doc.SetContainer(uniqueFaceDoc, Doc.MyFaceCollection);
-- 
cgit v1.2.3-70-g09d2


From 248f9fdced99a36c28fb34f39b78e1267ec02486 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Tue, 27 Aug 2024 08:23:04 -0400
Subject: allow non-faces to be added to face collections

---
 .../views/collections/collectionFreeForm/FaceCollectionBox.tsx |  6 ++----
 src/client/views/search/FaceRecognitionHandler.tsx             | 10 ++++++----
 2 files changed, 8 insertions(+), 8 deletions(-)

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

diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index 7e47292e5..ea1c22962 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -102,10 +102,8 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
                         { dist: 1, face_match: undefined as Opt<List<number>> }
                     );
 
-                    // assign the face in the image that's closest to the face collection to be the face that's assigned to the collection
-                    if (face_match) {
-                        FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, face_match, this.Document);
-                    }
+                    // assign the face in the image that's closest to the face collection's face
+                    FaceRecognitionHandler.UniqueFaceAddFaceImage(imgDoc, face_match, this.Document);
                 }
             });
         e.stopPropagation();
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index 0cee7184c..021802061 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -1,6 +1,6 @@
 import * as faceapi from 'face-api.js';
 import { FaceMatcher } from 'face-api.js';
-import { Doc, DocListCast } from '../../../fields/Doc';
+import { Doc, DocListCast, Opt } from '../../../fields/Doc';
 import { DocData } from '../../../fields/DocSymbols';
 import { List } from '../../../fields/List';
 import { listSpec } from '../../../fields/Schema';
@@ -133,10 +133,12 @@ export class FaceRecognitionHandler {
      * @param faceDescriptor - the face descriptor for the face in the image to add
      * @param faceDoc - unique face Doc
      */
-    public static UniqueFaceAddFaceImage = (img: Doc, faceDescriptor: List<number>, faceDoc: Doc) => {
+    public static UniqueFaceAddFaceImage = (img: Doc, faceDescriptor: Opt<List<number>>, faceDoc: Doc) => {
         Doc.AddDocToList(faceDoc, 'face_images', img);
-        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);
+        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);
+        }
     };
 
     /**
-- 
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/FaceCollectionBox.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 e2a1db72c103b7ed878fe876c5fb9eacc7a8c893 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Tue, 27 Aug 2024 19:09:31 -0400
Subject: enabled any image Doc to be added to a face Doc

---
 src/client/views/TagsView.tsx                           |  1 +
 .../collectionFreeForm/FaceCollectionBox.tsx            | 17 +++++++++--------
 src/client/views/search/FaceRecognitionHandler.tsx      |  2 +-
 3 files changed, 11 insertions(+), 9 deletions(-)

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

diff --git a/src/client/views/TagsView.tsx b/src/client/views/TagsView.tsx
index 89025d668..0ac015b36 100644
--- a/src/client/views/TagsView.tsx
+++ b/src/client/views/TagsView.tsx
@@ -306,6 +306,7 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
         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'])
+                .concat(this._props.View.Document)
                 .filter(d => d.face)
                 .map(doc => StrCast(DocCast(doc.face)?.title))
         );
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index c62303dc0..717081666 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -95,17 +95,18 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
                     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 { 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, faceAnno: undefined as Opt<Doc> }
-                    );
+                    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, faceAnno: undefined as Opt<Doc> }
+                        ).faceAnno ?? imgDoc;
 
                     // assign the face in the image that's closest to the face collection's face
                     if (faceAnno) {
-                        FaceRecognitionHandler.UniqueFaceRemoveFaceImage(faceAnno, DocCast(faceAnno.face));
+                        faceAnno.face && FaceRecognitionHandler.UniqueFaceRemoveFaceImage(faceAnno, DocCast(faceAnno.face));
                         FaceRecognitionHandler.UniqueFaceAddFaceImage(faceAnno, this.Document);
                         faceAnno.face = this.Document;
                     }
diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx
index 4c10307e6..4f6f5d314 100644
--- a/src/client/views/search/FaceRecognitionHandler.tsx
+++ b/src/client/views/search/FaceRecognitionHandler.tsx
@@ -101,7 +101,7 @@ export class FaceRecognitionHandler {
      * @param faceDoc unique face Doc
      * @returns image Docs
      */
-    public static UniqueFaceImages = (faceDoc: Doc) => DocListCast(faceDoc[DocData].face_annos).map(face => DocCast(face.annotationOn));
+    public static UniqueFaceImages = (faceDoc: Doc) => DocListCast(faceDoc[DocData].face_annos).map(face => DocCast(face.annotationOn, face));
 
     /**
      * Adds a face image to a unique face Doc, adds the unique face Doc to the images list of reognized faces,
-- 
cgit v1.2.3-70-g09d2


From b9f15c10e4cfa1288e176cbd1d312c628c5998ad Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Tue, 10 Sep 2024 14:54:44 -0400
Subject: moved TagsView up into DocumentView so that one row of tags will be
 visible. fixed setting pixel size of images to resize annotations so they
 don't appear to change. added vert/horiz centering for stacking views.  fixed
 pres box to not have scroll bars.  fixed resizing properties panel.

---
 src/client/documents/Documents.ts                  |  3 ++-
 src/client/util/CurrentUserUtils.ts                | 12 ++++++-----
 src/client/views/MainView.tsx                      |  8 +++++---
 src/client/views/SidebarAnnos.tsx                  |  1 +
 src/client/views/StyleProvider.tsx                 |  2 +-
 src/client/views/TagsView.tsx                      |  7 +++++--
 .../views/collections/CollectionCalendarView.tsx   |  1 +
 .../views/collections/CollectionStackingView.scss  |  7 ++-----
 .../views/collections/CollectionStackingView.tsx   | 13 +++++++++---
 .../CollectionStackingViewFieldColumn.tsx          | 24 ++++++++++------------
 src/client/views/collections/TreeView.tsx          |  2 +-
 .../collectionFreeForm/FaceCollectionBox.tsx       |  1 +
 src/client/views/global/globalScripts.ts           | 14 ++++++++-----
 src/client/views/nodes/DocumentView.scss           |  2 +-
 src/client/views/nodes/FieldView.tsx               |  1 +
 src/client/views/nodes/ImageBox.tsx                | 14 +++++++++++--
 16 files changed, 70 insertions(+), 42 deletions(-)

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

diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index d5a7b0465..fbdf361dd 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -260,6 +260,7 @@ export class DocumentOptions {
     _layout_noSidebar?: BOOLt = new BoolInfo('whether to display the sidebar toggle button');
     layout_boxShadow?: string; // box-shadow css string OR "standard" to use dash standard box shadow
     layout_maxShown?: NUMt = new NumInfo('maximum number of children to display at one time (see multicolumnview)');
+    _layout_dontCenter?: STRt = new StrInfo("whether collections will center their content - values of 'x', 'xy', or 'y'");
     _layout_autoHeight?: BOOLt = new BoolInfo('whether document automatically resizes vertically to display contents');
     _layout_autoHeightMargins?: NUMt = new NumInfo('Margin heights to be added to the computed auto height of a Doc');
     _layout_curPage?: NUMt = new NumInfo('current page of a PDF or other? paginated document', false);
@@ -979,7 +980,7 @@ export namespace Docs {
         }
 
         export function StackingDocument(documents: Array<Doc>, options: DocumentOptions, id?: string, protoId?: string) {
-            return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { ...options, _type_collection: CollectionViewType.Stacking }, id, undefined, protoId);
+            return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { _layout_dontCenter: 'y', ...options, _type_collection: CollectionViewType.Stacking }, id, undefined, protoId);
         }
 
         export function NoteTakingDocument(documents: Array<Doc>, options: DocumentOptions, id?: string, protoId?: string) {
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index f042f33ce..2014c48e1 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -368,7 +368,7 @@ pie title Minerals in my tap water
             {key: "Chat",       creator: Docs.Create.ChatDocument,          opts: { _width: 300, _height: 300, }},
             {key: "Header",     creator: headerTemplate,                                    opts: { _width: 300, _height: 120, _header_pointerEvents: "all", _header_height: 50, _header_fontSize: 9,_layout_autoHeightMargins: 50, _layout_autoHeight: true, treeView_HideUnrendered: true}},
             {key: "ViewSlide",  creator: slideView,                                         opts: { _width: 400, _height: 300, _xMargin: 3, _yMargin: 3,}},
-            {key: "Trail",      creator: Docs.Create.PresDocument,                          opts: { _width: 400, _height: 30, _type_collection: CollectionViewType.Stacking, dropAction: dropActionType.embed, treeView_HideTitle: true, _layout_fitWidth:true, layout_boxShadow: "0 0" }},
+            {key: "Trail",      creator: Docs.Create.PresDocument,                          opts: { _width: 400, _height: 30, _type_collection: CollectionViewType.Stacking, _layout_dontCenter:'xy', dropAction: dropActionType.embed, treeView_HideTitle: true, _layout_fitWidth:true, layout_boxShadow: "0 0" }},
             {key: "Tab",        creator: opts => Docs.Create.FreeformDocument([], opts),    opts: { _width: 500, _height: 800, _layout_fitWidth: true, _freeform_backgroundGrid: true, }},
             {key: "Slide",      creator: opts => Docs.Create.TreeDocument([], opts),        opts: { _width: 300, _height: 200, _type_collection: CollectionViewType.Tree, 
                                                                                                 treeView_HasOverlay: true, _text_fontSize: "20px", _layout_autoHeight: true, 
@@ -477,6 +477,7 @@ pie title Minerals in my tap water
 
         const reqdStackOpts:DocumentOptions ={
             title: "menuItemPanel", childDragAction: dropActionType.same, layout_boxShadow: "rgba(0,0,0,0)",  dontRegisterView: true, ignoreClick: true,
+            _layout_dontCenter: 'y',
             _chromeHidden: true, _gridGap: 0, _yMargin: 0, _xMargin: 0, _layout_autoHeight: false, _width: 60, _columnWidth: 60, _lockedPosition: true, isSystem: true,
         };
         return DocUtils.AssignDocField(doc, field, (opts, items) => Docs.Create.StackingDocument(items??[], opts), reqdStackOpts, menuBtns, { dropConverter: "convertToButtons(dragData)" });
@@ -510,7 +511,7 @@ pie title Minerals in my tap water
         // doc.myUserBtns = new PrefetchProxy(userBtns);
         const reqdToolOps:DocumentOptions = {
             title: "My Tools", isSystem: true, ignoreClick: true, layout_boxShadow: "0 0",
-            layout_explainer: "This is a palette of documents that can be created.",
+            layout_explainer: "This is a palette of documents that can be created.", _layout_dontCenter: "y",
             _layout_showTitle: "title", _width: 500, _yMargin: 20, _lockedPosition: true, _forceActive: true, _dragOnlyWithinContainer: true, layout_hideContextMenu: true, _chromeHidden: true, 
         };
         return DocUtils.AssignDocField(doc, field, (opts, items) => Docs.Create.StackingDocument(items??[], opts), reqdToolOps, [creatorBtns, userBtns]);
@@ -663,7 +664,8 @@ pie title Minerals in my tap water
     }
     static stackTools(): Button[] {
         return [
-            { title: "Center",    icon: "align-center",        toolTip: "Center Align Stack",  btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"center",     funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform
+            { title: "V. Align",  icon: "pallet",        toolTip: "Center Align Stack",  btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"vcenter",    funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform
+            { title: "Center",    icon: "align-center",  toolTip: "Center Align Stack",  btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"hcenter",    funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform
         ]
     }
     static cardTools(): Button[] {
@@ -777,8 +779,8 @@ pie title Minerals in my tap water
     }
     static imageTools() {
         return [
-            { title: "Pixels",toolTip: "Set Native Pixel Sizze", btnType: ButtonType.ClickButton,  icon: "portrait",  scripts: { onClick: 'imageSetPixelSize();' }},
-            { title: "Rotate",toolTip: "Rotate 90",              btnType: ButtonType.ClickButton,  icon: "redo-alt",  scripts: { onClick: 'imageRotate90();' }},
+            { title: "Pixels",toolTip: "Set Native Pixel Size", btnType: ButtonType.ClickButton,  icon: "portrait",  scripts: { onClick: 'imageSetPixelSize();' }},
+            { title: "Rotate",toolTip: "Rotate 90",             btnType: ButtonType.ClickButton,  icon: "redo-alt",  scripts: { onClick: 'imageRotate90();' }},
         ];
     }
     static contextMenuTools():Button[] {
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index 9f1c7da3d..393abea53 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -3,7 +3,7 @@ import { faBuffer, faHireAHelper } from '@fortawesome/free-brands-svg-icons';
 import * as far from '@fortawesome/free-regular-svg-icons';
 import * as fa from '@fortawesome/free-solid-svg-icons';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { action, computed, configure, makeObservable, observable, reaction, runInAction } from 'mobx';
+import { action, computed, configure, makeObservable, observable, reaction, runInAction, trace } from 'mobx';
 import { observer } from 'mobx-react';
 import * as React from 'react';
 import ResizeObserver from 'resize-observer-polyfill';
@@ -707,8 +707,8 @@ export class MainView extends ObservableReactComponent<object> {
         setupMoveUpEvents(
             this,
             e,
-            action(() => {
-                SnappingManager.SetPropertiesWidth(Math.max(0, this._dashUIWidth - e.clientX));
+            action(moveEv => {
+                SnappingManager.SetPropertiesWidth(Math.max(0, this._dashUIWidth - moveEv.clientX));
                 return !SnappingManager.PropertiesWidth;
             }),
             action(() => {
@@ -807,6 +807,7 @@ export class MainView extends ObservableReactComponent<object> {
                     childFilters={returnEmptyFilter}
                     childFiltersByRanges={returnEmptyFilter}
                     searchFilterDocs={returnEmptyDoclist}
+                    dontCenter="y"
                 />
             </div>
         );
@@ -832,6 +833,7 @@ export class MainView extends ObservableReactComponent<object> {
     };
 
     @computed get mainInnerContent() {
+        trace();
         const leftMenuFlyoutWidth = this._leftMenuFlyoutWidth + this.leftMenuWidth();
         const width = this.propertiesWidth() + leftMenuFlyoutWidth;
         return (
diff --git a/src/client/views/SidebarAnnos.tsx b/src/client/views/SidebarAnnos.tsx
index 8f0a35df0..dd60bfa65 100644
--- a/src/client/views/SidebarAnnos.tsx
+++ b/src/client/views/SidebarAnnos.tsx
@@ -246,6 +246,7 @@ export class SidebarAnnos extends ObservableReactComponent<FieldViewProps & Extr
                         isAnnotationOverlay={false}
                         select={emptyFunction}
                         NativeDimScaling={returnOne}
+                        dontCenter="y"
                         // childlayout_showTitle={this.layout_showTitle}
                         isAnyChildContentActive={returnFalse}
                         childDocumentsActive={this._props.isContentActive}
diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx
index 76cb119ab..513953d17 100644
--- a/src/client/views/StyleProvider.tsx
+++ b/src/client/views/StyleProvider.tsx
@@ -365,7 +365,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps &
                     </Tooltip>
                 );
             };
-            const tags = () => props?.DocumentView?.() && CollectionFreeFormDocumentView.from(props.DocumentView()) ? <TagsView View={props.DocumentView()}/> :  null;
+            const tags = () => props?.DocumentView?.() ? <TagsView View={props.DocumentView()}/> :  null;
             return (
                 <>
                     {paint()}
diff --git a/src/client/views/TagsView.tsx b/src/client/views/TagsView.tsx
index 65e70e8fc..8047363d9 100644
--- a/src/client/views/TagsView.tsx
+++ b/src/client/views/TagsView.tsx
@@ -91,7 +91,7 @@ export class TagItem extends ObservableReactComponent<TagItemProps> {
         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) {
+        if (doc.type === DocumentType.COL && !doc.annotationOn) {
             Doc.AddDocToList(tagCollection[DocData], 'collections', doc);
 
             // Iterate through the tag Doc collections and add a copy of the document to each collection
@@ -257,6 +257,7 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
         super(props);
         makeObservable(this);
     }
+    InsetDist = 25; // how far tag panel is moved up to overlap DocumentView.
 
     @observable _panelHeightDirty = 0;
     @observable _currentInput = '';
@@ -328,7 +329,7 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
         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)}
+                ref={r => r && new ResizeObserver(action(() => (this._props.View.TagPanelHeight = Math.max(0, (r?.getBoundingClientRect().height ?? 0) - this.InsetDist)))).observe(r)}
                 style={{
                     transformOrigin: 'top left',
                     maxWidth: `${100 * this.currentScale}%`,
@@ -336,6 +337,8 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
                     transform: `scale(${1 / this.currentScale})`,
                     backgroundColor: this.isEditing ? Colors.LIGHT_GRAY : Colors.TRANSPARENT,
                     borderColor: this.isEditing ? Colors.BLACK : Colors.TRANSPARENT,
+                    position: 'relative',
+                    top: `calc(-${this.InsetDist} * ${1 / this.currentScale}px)`,
                 }}>
                 <div className="tagsView-content" style={{ width: '100%' }}>
                     <div className="tagsView-list">
diff --git a/src/client/views/collections/CollectionCalendarView.tsx b/src/client/views/collections/CollectionCalendarView.tsx
index 30fd9fc2b..0ea9f8ebc 100644
--- a/src/client/views/collections/CollectionCalendarView.tsx
+++ b/src/client/views/collections/CollectionCalendarView.tsx
@@ -82,6 +82,7 @@ export class CollectionCalendarView extends CollectionSubView() {
                     isAnnotationOverlay={false}
                     // select={emptyFunction} What does this mean?
                     isAnyChildContentActive={returnTrue} // ??
+                    dontCenter="y"
                     // childDocumentsActive={}
                     // whenChildContentsActiveChanged={}
                     childHideDecorationTitle={false}
diff --git a/src/client/views/collections/CollectionStackingView.scss b/src/client/views/collections/CollectionStackingView.scss
index 6225cc52a..6400a0a8e 100644
--- a/src/client/views/collections/CollectionStackingView.scss
+++ b/src/client/views/collections/CollectionStackingView.scss
@@ -54,11 +54,8 @@
     }
 
     .collectionStackingViewFieldColumn {
-        height: max-content;
-    }
-
-    .collectionStackingViewFieldColumnDragging {
-        height: 100%;
+        display: flex;
+        flex-direction: column;
     }
 
     .collectionSchemaView-previewDoc {
diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx
index 6402ef16c..2cfe9329a 100644
--- a/src/client/views/collections/CollectionStackingView.tsx
+++ b/src/client/views/collections/CollectionStackingView.tsx
@@ -146,7 +146,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection
             // assuming we need to get rowSpan because we might be dealing with many columns. Grid gap makes sense if multiple columns
             const rowSpan = Math.ceil((height() + this.gridGap) / this.gridGap);
             // just getting the style
-            const style = this.isStackingView ? { margin: this.Document._stacking_alignCenter ? 'auto' : undefined, transition: trans(), width: width(), marginTop: i ? this.gridGap : 0, height: height() } : { gridRowEnd: `span ${rowSpan}` };
+            const style = this.isStackingView ? { margin: this.dontCenter.includes('x') ? undefined : 'auto', transition: trans(), width: width(), marginTop: i ? this.gridGap : 0, height: height() } : { gridRowEnd: `span ${rowSpan}` };
             // So we're choosing whether we're going to render a column or a masonry doc
             return (
                 <div className={`collectionStackingView-${this.isStackingView ? 'columnDoc' : 'masonryDoc'}`} key={d[Id]} style={style}>
@@ -343,7 +343,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection
                 LayoutTemplateString={this._props.childLayoutString}
                 NativeWidth={this._props.childIgnoreNativeSize ? returnZero : this._props.childLayoutFitWidth?.(doc) || (this.childFitWidth(doc) && !Doc.NativeWidth(doc)) ? width : undefined} // explicitly ignore nativeWidth/height if childIgnoreNativeSize is set- used by PresBox
                 NativeHeight={this._props.childIgnoreNativeSize ? returnZero : this._props.childLayoutFitWidth?.(doc) || (this.childFitWidth(doc) && !Doc.NativeHeight(doc)) ? height : undefined}
-                dontCenter={this._props.childIgnoreNativeSize ? 'xy' : (StrCast(this.layoutDoc.layout_dontCenter) as 'x' | 'y' | 'xy')}
+                dontCenter={this.dontCenter}
                 dontRegisterView={BoolCast(this.layoutDoc.childDontRegisterViews, this._props.dontRegisterView)} // used to be true if DataDoc existed, but template textboxes won't layout_autoHeight resize if dontRegisterView is set, but they need to.
                 rootSelected={this.rootSelected}
                 showTitle={this._props.childlayout_showTitle}
@@ -372,8 +372,11 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection
     }
 
     getDocTransform(doc: Doc) {
+        // these must be referenced for document decorations to update when the text box container is scrolled
+        this._scroll;
+        this._props.ScreenToLocalTransform();
+
         const dref = this.docRefs.get(doc);
-        this._scroll; // must be referenced for document decorations to update when the text box container is scrolled
         const { translateX, translateY, scale } = ClientUtils.GetScreenTransform(dref?.ContentDiv);
         return new Transform(-translateX + (dref?.centeringX || 0) * scale, 
                              -translateY + (dref?.centeringY || 0) * scale, 1)
@@ -531,6 +534,9 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection
             }
         });
     };
+    @computed get dontCenter() {
+        return this._props.dontCenter ?? (this._props.childIgnoreNativeSize ? 'xy' : (StrCast(this.layoutDoc.layout_dontCenter) as 'x' | 'y' | 'xy'));
+    }
     headings = () => Array.from(this.Sections);
     // what a section looks like if we're in stacking view
     sectionStacking = (heading: SchemaHeaderField | undefined, docList: Doc[]) => {
@@ -565,6 +571,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection
                 type={type}
                 createDropTarget={this.createDashEventsTarget}
                 screenToLocalTransform={this.ScreenToLocalBoxXf}
+                dontCenter={this.dontCenter}
             />
         );
     };
diff --git a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx
index 5ae08e535..aba6b51d4 100644
--- a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx
+++ b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx
@@ -41,6 +41,7 @@ interface CSVFieldColumnProps {
     columnWidth: number;
     numGroupColumns: number;
     gridGap: number;
+    dontCenter: 'x' | 'xy' | 'y';
     type: 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | undefined;
     headings: () => object[];
     // I think that stacking view actually has a single column and then supposedly you can add more columns? Unsure
@@ -345,15 +346,6 @@ export class CollectionStackingViewFieldColumn extends ObservableReactComponent<
                     <button type="button" className="collectionStackingView-sectionDelete" onClick={this.deleteColumn}>
                         <FontAwesomeIcon icon="trash" size="lg" />
                     </button>
-                    {/* {evContents === noValueHeader ? null : (
-                        <div className="collectionStackingView-sectionOptions">
-                            <Flyout anchorPoint={anchorPoints.TOP_RIGHT} content={this.renderMenu()}>
-                                <button className="collectionStackingView-sectionOptionButton">
-                                    <FontAwesomeIcon icon="ellipsis-v" size="lg"></FontAwesomeIcon>
-                                </button>
-                            </Flyout>
-                        </div>
-                    )} */}
                 </div>
                 <div
                     className={'collectionStackingView-collapseBar' + (this._props.headingObject.collapsed === true ? ' active' : '')}
@@ -368,13 +360,19 @@ export class CollectionStackingViewFieldColumn extends ObservableReactComponent<
             <>
                 {this._props.Document._columnsHideIfEmpty ? null : headingView}
                 {this.collapsed ? null : (
-                    <div>
+                    <div
+                        style={{
+                            margin: 'auto',
+                            marginTop: this._props.dontCenter.includes('y') ? undefined : 'auto',
+                            marginBottom: this._props.dontCenter.includes('y') ? undefined : 'auto',
+                            width: this._props.columnWidth / (uniqueHeadings.length + (this._props.chromeHidden ? 0 : 1) || 1),
+                        }}>
                         <div
                             key={`${heading}-stack`}
                             className="collectionStackingView-masonrySingle"
                             style={{
                                 padding: `${columnYMargin}px ${0}px ${this._props.yMargin}px ${0}px`,
-                                margin: 'auto',
+                                margin: this._props.dontCenter.includes('x') ? undefined : 'auto',
                                 width: 'max-content', // singleColumn ? undefined : `${cols * (style.columnWidth + style.gridGap) + 2 * style.xMargin - style.gridGap}px`,
                                 height: 'max-content',
                                 position: 'relative',
@@ -419,11 +417,11 @@ export class CollectionStackingViewFieldColumn extends ObservableReactComponent<
         const uniqueHeadings = headings.map((i, idx) => headings.indexOf(i) === idx);
         return (
             <div
-                className={'collectionStackingViewFieldColumn' + (SnappingManager.IsDragging ? 'Dragging' : '')}
+                className="collectionStackingViewFieldColumn"
                 key={heading}
                 style={{
                     width: `${100 / (uniqueHeadings.length + (this._props.chromeHidden ? 0 : 1) || 1)}%`,
-                    height: undefined, // DraggingManager.GetIsDragging() ? "100%" : undefined,
+                    height: undefined,
                     background: this._background,
                 }}
                 ref={this.createColumnDropRef}
diff --git a/src/client/views/collections/TreeView.tsx b/src/client/views/collections/TreeView.tsx
index b10a521ca..d2514dfd1 100644
--- a/src/client/views/collections/TreeView.tsx
+++ b/src/client/views/collections/TreeView.tsx
@@ -474,7 +474,7 @@ export class TreeView extends ObservableReactComponent<TreeViewProps> {
     };
     docTransform = () => this.refTransform(this._dref?.ContentDiv);
     getTransform = () => this.refTransform(this._tref.current);
-    embeddedPanelWidth = () => this._props.panelWidth() / (this.treeView._props.NativeDimScaling?.() || 1);
+    embeddedPanelWidth = () => this._props.panelWidth() / (this.treeView._props.NativeDimScaling?.() || 1) - 3 /* paddingRight for bullet */;
     embeddedPanelHeight = () => {
         const layoutDoc = (temp => temp && Doc.expandTemplateLayout(temp, this.Document))(this.treeView._props.childLayoutTemplate?.()) || this.layoutDoc;
         return Math.min(
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index 717081666..f9f6c81ab 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -264,6 +264,7 @@ export class FaceCollectionBox extends ViewBoxBaseComponent<FieldViewProps>() {
                     isContentActive={returnTrue}
                     isAnyChildContentActive={returnTrue}
                     childHideDecorations={true}
+                    dontCenter="y"
                 />
             </div>
         );
diff --git a/src/client/views/global/globalScripts.ts b/src/client/views/global/globalScripts.ts
index 2c7920bdd..934ed6c3e 100644
--- a/src/client/views/global/globalScripts.ts
+++ b/src/client/views/global/globalScripts.ts
@@ -133,10 +133,10 @@ ScriptingGlobals.add(function toggleOverlay(checkResult?: boolean) {
 });
 
 // eslint-disable-next-line prefer-arrow-callback
-ScriptingGlobals.add(function showFreeform(attr: 'center' | 'grid' | 'snaplines' | 'clusters' | 'viewAll' | 'fitOnce', checkResult?: boolean, persist?: boolean) {
+ScriptingGlobals.add(function showFreeform(attr: 'hcenter' | 'vcenter' | 'grid' | 'snaplines' | 'clusters' | 'viewAll' | 'fitOnce', checkResult?: boolean, persist?: boolean) {
     const selected = DocumentView.SelectedDocs().lastElement();
     // prettier-ignore
-    const map: Map<'flashcards' | 'center' | 'grid' | 'snaplines' | 'clusters' | 'arrange' | 'viewAll' | 'fitOnce' | 'time' | 'docType' | 'color' | 'links' | 'like' | 'star' | 'idea' | 'chat' | '1' | '2' | '3' | '4', 
+    const map: Map<'flashcards' | 'hcenter' | 'vcenter' | 'grid' | 'snaplines' | 'clusters' | 'arrange' | 'viewAll' | 'fitOnce' | 'time' | 'docType' | 'color' | 'links' | 'like' | 'star' | 'idea' | 'chat' | '1' | '2' | '3' | '4', 
     { 
         waitForRender?: boolean; 
         checkResult: (doc: Doc) => boolean; 
@@ -158,9 +158,13 @@ ScriptingGlobals.add(function showFreeform(attr: 'center' | 'grid' | 'snaplines'
                 else (dv.ComponentView as CollectionFreeFormView)?.fitContentOnce();
             },
         }],
-        ['center', {
-            checkResult: (doc:Doc) => BoolCast(doc?._stacking_alignCenter, false),
-            setDoc: (doc:Doc) => { doc._stacking_alignCenter = !doc._stacking_alignCenter; },
+        ['vcenter', {
+            checkResult: (doc:Doc) => !StrCast(doc?._layout_dontCenter).includes('y'),
+            setDoc: (doc:Doc) => { doc._layout_dontCenter = StrCast(doc.layout_dontCenter).includes('y') ? StrCast(doc.layout_dontCenter).replace(/y/,"") : StrCast(doc.layout_dontCenter) + 'y'; },
+        }],
+        ['hcenter', {
+            checkResult: (doc:Doc) => !StrCast(doc?._layout_dontCenter).includes('x'),
+            setDoc: (doc:Doc) => { doc._layout_dontCenter = StrCast(doc.layout_dontCenter).includes('x') ? StrCast(doc.layout_dontCenter).replace(/x/,"") : 'x'+ StrCast(doc.layout_dontCenter); },
         }],
         ['clusters', {
             waitForRender: true, // flags that undo batch should terminate after a re-render giving the script the chance to fire
diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss
index 23dada260..2873b4bba 100644
--- a/src/client/views/nodes/DocumentView.scss
+++ b/src/client/views/nodes/DocumentView.scss
@@ -242,7 +242,7 @@
 
 .contentFittingDocumentView {
     position: relative;
-    display: flex;
+    display: block;
     width: 100%;
     height: 100%;
     transition: inherit;
diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx
index dd71fd946..c269c7bcb 100644
--- a/src/client/views/nodes/FieldView.tsx
+++ b/src/client/views/nodes/FieldView.tsx
@@ -82,6 +82,7 @@ export interface FieldViewSharedProps {
     // eslint-disable-next-line no-use-before-define
     onKey?: (e: React.KeyboardEvent, fieldProps: FieldViewProps) => boolean | undefined;
     fitWidth?: (doc: Doc) => boolean | undefined;
+    dontCenter?: 'x' | 'y' | 'xy' | undefined;
     searchFilterDocs: () => Doc[];
     showTitle?: () => string;
     whenChildContentsActiveChanged: (isActive: boolean) => void;
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index d0a7fc6ac..be3525544 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -11,7 +11,7 @@ import { DocData } from '../../../fields/DocSymbols';
 import { Id } from '../../../fields/FieldSymbols';
 import { InkTool } from '../../../fields/InkField';
 import { ObjectField } from '../../../fields/ObjectField';
-import { Cast, ImageCast, NumCast, StrCast } from '../../../fields/Types';
+import { Cast, ImageCast, NumCast, RTFCast, StrCast } from '../../../fields/Types';
 import { ImageField } from '../../../fields/URLField';
 import { TraceMobx } from '../../../fields/util';
 import { emptyFunction } from '../../../Utils';
@@ -188,8 +188,9 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
 
     @undoBatch
     setNativeSize = action(() => {
+        const oldnativeWidth = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']);
         const nscale = NumCast(this._props.PanelWidth()) * NumCast(this.layoutDoc._freeform_scale, 1);
-        const nw = nscale / NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']);
+        const nw = nscale / oldnativeWidth;
         this.dataDoc[this.fieldKey + '_nativeHeight'] = NumCast(this.dataDoc[this.fieldKey + '_nativeHeight']) * nw;
         this.dataDoc[this.fieldKey + '_nativeWidth'] = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']) * nw;
         this.dataDoc._freeform_panX = nw * NumCast(this.dataDoc._freeform_panX);
@@ -198,6 +199,15 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
         this.dataDoc._freeform_panX_min = this.dataDoc._freeform_panX_min ? nw * NumCast(this.dataDoc._freeform_panX_min) : undefined;
         this.dataDoc._freeform_panY_max = this.dataDoc._freeform_panY_max ? nw * NumCast(this.dataDoc._freeform_panY_max) : undefined;
         this.dataDoc._freeform_panY_min = this.dataDoc._freeform_panY_min ? nw * NumCast(this.dataDoc._freeform_panY_min) : undefined;
+        const newnativeWidth = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']);
+        DocListCast(this.dataDoc[this.annotationKey]).forEach(doc => {
+            doc.x = (NumCast(doc.x) / oldnativeWidth) * newnativeWidth;
+            doc.y = (NumCast(doc.y) / oldnativeWidth) * newnativeWidth;
+            if (!RTFCast(doc[Doc.LayoutFieldKey(doc)])) {
+                doc.width = (NumCast(doc.width) / oldnativeWidth) * newnativeWidth;
+                doc.height = (NumCast(doc.height) / oldnativeWidth) * newnativeWidth;
+            }
+        });
     });
     @undoBatch
     rotate = action(() => {
-- 
cgit v1.2.3-70-g09d2


From 623da0b4eec34fbd238cc26a5e0c105426a6711e Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Thu, 12 Sep 2024 09:50:31 -0400
Subject: added tags for multi-selections.

---
 src/client/views/DocumentButtonBar.tsx             | 10 +++-
 src/client/views/DocumentDecorations.scss          |  7 +++
 src/client/views/DocumentDecorations.tsx           | 11 +++-
 src/client/views/StyleProvider.tsx                 |  2 +-
 src/client/views/TagsView.tsx                      | 65 ++++++++++++++--------
 .../collectionFreeForm/FaceCollectionBox.tsx       |  2 +-
 6 files changed, 69 insertions(+), 28 deletions(-)

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

diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx
index 437ef045f..785e69f84 100644
--- a/src/client/views/DocumentButtonBar.tsx
+++ b/src/client/views/DocumentButtonBar.tsx
@@ -267,7 +267,15 @@ 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.showTags = !dv.dataDoc.showTags))}>
+                <div
+                    className="documentButtonBar-icon"
+                    style={{ color: 'white' }}
+                    onClick={() => {
+                        const showing = DocumentView.Selected().some(dv => dv.dataDoc.showTags);
+                        DocumentView.Selected().forEach(dv => {
+                            dv.dataDoc.showTags = !showing;
+                        });
+                    }}>
                     <FontAwesomeIcon className="documentdecorations-icon" icon="tag" />
                 </div>
             </Tooltip>
diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss
index 67e1054c3..346df10d5 100644
--- a/src/client/views/DocumentDecorations.scss
+++ b/src/client/views/DocumentDecorations.scss
@@ -38,6 +38,13 @@ $resizeHandler: 8px;
         background: green;
         border-radius: 50%;
     }
+    .documentDecorations-tagsView {
+        position: absolute;
+        height: 100%;
+        pointer-events: all;
+        border-radius: 50%;
+        color: black;
+    }
 }
 
 .documentDecorations-container {
diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx
index da35459bb..118a5bd3d 100644
--- a/src/client/views/DocumentDecorations.tsx
+++ b/src/client/views/DocumentDecorations.tsx
@@ -35,6 +35,7 @@ import { DocumentView } from './nodes/DocumentView';
 import { ImageBox } from './nodes/ImageBox';
 import { OpenWhere, OpenWhereMod } from './nodes/OpenWhere';
 import { FormattedTextBox } from './nodes/formattedText/FormattedTextBox';
+import { TagsView } from './TagsView';
 
 interface DocumentDecorationsProps {
     PanelWidth: number;
@@ -835,12 +836,20 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora
                                 <div
                                     className="link-button-container"
                                     style={{
-                                        top: `${doc[DocData].showTags ? 4 + seldocview.TagPanelHeight : 4}px`,
+                                        top: DocumentView.Selected().length > 1 ? 0 : `${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()} />
                                 </div>
                             )}
+                            <div
+                                className="documentDecorations-tagsView"
+                                style={{
+                                    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) `,
+                                }}>
+                                {DocumentView.Selected().length > 1 ? <TagsView Views={DocumentView.Selected()} /> : null}
+                            </div>
                         </div>
 
                         {useRotation && (
diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx
index 513953d17..262f888fb 100644
--- a/src/client/views/StyleProvider.tsx
+++ b/src/client/views/StyleProvider.tsx
@@ -365,7 +365,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps &
                     </Tooltip>
                 );
             };
-            const tags = () => props?.DocumentView?.() ? <TagsView View={props.DocumentView()}/> :  null;
+            const tags = () => props?.DocumentView?.() ? <TagsView Views={[props.DocumentView()]}/> :  null;
             return (
                 <>
                     {paint()}
diff --git a/src/client/views/TagsView.tsx b/src/client/views/TagsView.tsx
index 8047363d9..ae9d42749 100644
--- a/src/client/views/TagsView.tsx
+++ b/src/client/views/TagsView.tsx
@@ -33,7 +33,7 @@ import { FaceRecognitionHandler } from './search/FaceRecognitionHandler';
  */
 
 interface TagItemProps {
-    doc: Doc;
+    docs: Doc[];
     tag: string;
     tagDoc: Opt<Doc>;
     showRemoveUI: boolean;
@@ -207,8 +207,12 @@ export class TagItem extends ObservableReactComponent<TagItemProps> {
         e.preventDefault();
     };
 
+    @computed get doc() {
+        return this._props.docs.lastElement();
+    }
+
     render() {
-        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
+        this._props.tagDoc && setTimeout(() => this._props.docs.forEach(doc => TagItem.addTagToDoc(doc, this._props.tag))); // bcz: hack to make sure that Docs are added to their tag Doc collection since metadata can get set anywhere without a guard triggering an add to the collection
         const tag = this._props.tag.replace(/^#/, '');
         const metadata = tag.startsWith('@') ? tag.replace(/^@/, '') : '';
         return (
@@ -216,16 +220,16 @@ export class TagItem extends ObservableReactComponent<TagItemProps> {
                 {metadata ? (
                     <span>
                         <b style={{ fontSize: 'smaller' }}>{tag}&nbsp;</b>
-                        {typeof this._props.doc[metadata] === 'boolean' ? (
+                        {typeof this.doc[metadata] === 'boolean' ? (
                             <input
                                 type="checkbox"
                                 onClick={e => e.stopPropagation()}
                                 onPointerDown={e => e.stopPropagation()}
-                                onChange={undoable(e => (this._props.doc[metadata] = !this._props.doc[metadata]), 'metadata toggle')}
-                                checked={this._props.doc[metadata] as boolean}
+                                onChange={undoable(e => (this.doc[metadata] = !this.doc[metadata]), 'metadata toggle')}
+                                checked={this.doc[metadata] as boolean}
                             />
                         ) : (
-                            Field.toString(this._props.doc[metadata])
+                            Field.toString(this.doc[metadata])
                         )}
                     </span>
                 ) : (
@@ -234,7 +238,7 @@ export class TagItem extends ObservableReactComponent<TagItemProps> {
                 {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}`)}
+                        onPointerDown={undoable(() => this._props.docs.forEach(doc => TagItem.removeTagFromDoc(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' }}
                     />
@@ -245,7 +249,7 @@ export class TagItem extends ObservableReactComponent<TagItemProps> {
 }
 
 interface TagViewProps {
-    View: DocumentView;
+    Views: DocumentView[];
 }
 
 /**
@@ -261,12 +265,12 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
 
     @observable _panelHeightDirty = 0;
     @observable _currentInput = '';
-    @observable _isEditing = !StrListCast(this._props.View.dataDoc.tags).length;
+    @observable _isEditing = !StrListCast(this.View.dataDoc.tags).length;
     _heightDisposer: IReactionDisposer | undefined;
 
     componentDidMount() {
         this._heightDisposer = reaction(
-            () => this._props.View.screenToContentsTransform(),
+            () => this.View.screenToContentsTransform(),
             xf => {
                 this._panelHeightDirty = this._panelHeightDirty + 1;
             }
@@ -276,11 +280,15 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
         this._heightDisposer?.();
     }
 
+    @computed get View() {
+        return this._props.Views.lastElement();
+    }
+
     @computed get currentScale() {
-        return Math.max(1, 1 / this._props.View.screenToLocalScale());
+        return this._props.Views.length > 1 ? 1 : Math.max(1, 1 / this.View.screenToLocalScale());
     }
     @computed get isEditing() {
-        return this._isEditing && DocumentView.SelectedDocs().includes(this._props.View.Document);
+        return this._isEditing && (this._props.Views.length > 1 || DocumentView.SelectedDocs().includes(this.View.Document));
     }
 
     /**
@@ -290,7 +298,7 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
     @action
     setToEditing = (editing = true) => {
         this._isEditing = editing;
-        editing && this._props.View.select(false);
+        editing && this._props.Views.length === 1 && this.View.select(false);
     };
 
     /**
@@ -303,8 +311,10 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
         action((tag: string) => {
             const submittedLabel = tag.trim().replace(/^#/, '').split(':');
             if (submittedLabel[0]) {
-                TagItem.addTagToDoc(this._props.View.Document, '#' + submittedLabel[0]);
-                if (submittedLabel.length > 1) Doc.SetField(this._props.View.Document, submittedLabel[0].replace(/^@/, ''), ':' + submittedLabel[1]);
+                this._props.Views.forEach(view => {
+                    TagItem.addTagToDoc(view.Document, '#' + submittedLabel[0]);
+                    if (submittedLabel.length > 1) Doc.SetField(view.Document, submittedLabel[0].replace(/^@/, ''), ':' + submittedLabel[1]);
+                });
             }
             this._currentInput = ''; // Clear the input box
         }),
@@ -316,20 +326,20 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
      * When the dropdown is clicked, this will toggle an extended UI that allows additional tags to be added/removed.
      */
     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 tagsList = new Set<string>(StrListCast(this.View.dataDoc.tags));
+        const chatTagsList = new Set<string>(StrListCast(this.View.dataDoc.tags_chat));
         const facesList = new Set<string>(
-            DocListCast(this._props.View.dataDoc[Doc.LayoutFieldKey(this._props.View.Document) + '_annotations'])
-                .concat(this._props.View.Document)
+            DocListCast(this.View.dataDoc[Doc.LayoutFieldKey(this.View.Document) + '_annotations'])
+                .concat(this.View.Document)
                 .filter(d => d.face)
                 .map(doc => StrCast(DocCast(doc.face)?.title))
         );
         this._panelHeightDirty;
 
-        return !this._props.View.Document.showTags ? null : (
+        return !this.View.Document.showTags && this._props.Views.length === 1 ? null : (
             <div
                 className="tagsView-container"
-                ref={r => r && new ResizeObserver(action(() => (this._props.View.TagPanelHeight = Math.max(0, (r?.getBoundingClientRect().height ?? 0) - this.InsetDist)))).observe(r)}
+                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)}
                 style={{
                     transformOrigin: 'top left',
                     maxWidth: `${100 * this.currentScale}%`,
@@ -338,7 +348,7 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
                     backgroundColor: this.isEditing ? Colors.LIGHT_GRAY : Colors.TRANSPARENT,
                     borderColor: this.isEditing ? Colors.BLACK : Colors.TRANSPARENT,
                     position: 'relative',
-                    top: `calc(-${this.InsetDist} * ${1 / this.currentScale}px)`,
+                    top: this._props.Views.length > 1 ? 25 : `calc(-${this.InsetDist} * ${1 / this.currentScale}px)`,
                 }}>
                 <div className="tagsView-content" style={{ width: '100%' }}>
                     <div className="tagsView-list">
@@ -346,10 +356,17 @@ export class TagsView extends ObservableReactComponent<TagViewProps> {
                             <IconButton style={{ width: '8px' }} tooltip="Close Menu" onPointerDown={() => this.setToEditing(!this._isEditing)} icon={<FontAwesomeIcon icon={this._isEditing ? 'chevron-up' : 'chevron-down'} size="sm" />} />
                         )}
                         {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} />
+                            <TagItem
+                                key={i}
+                                docs={this._props.Views.map(view => 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} />
+                            <TagItem key={i} docs={this._props.Views.map(view => view.Document)} tag={tag} tagDoc={undefined} setToEditing={this.setToEditing} showRemoveUI={this.isEditing} />
                         ))}
                     </div>
                     {this.isEditing ? (
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
index f9f6c81ab..534f67927 100644
--- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -179,7 +179,7 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
                             ele?.addEventListener('wheel', this.onPassiveWheel, { passive: false });
                         })}>
                         {FaceRecognitionHandler.UniqueFaceImages(this.Document).map((doc, i) => {
-                            const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
+                            const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)])?.url.href.split('.') ?? ['-missing-', '.png'];
                             return (
                                 <div
                                     className="image-wrapper"
-- 
cgit v1.2.3-70-g09d2

