aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/collections/collectionFreeForm
diff options
context:
space:
mode:
authoreleanor-park <eleanor_park@brown.edu>2024-08-27 16:44:12 -0400
committereleanor-park <eleanor_park@brown.edu>2024-08-27 16:44:12 -0400
commit39d2bba7bf4b0cc3759931691640083a48cce662 (patch)
tree8bf110760aa926237b6294aec545f48cfc92747d /src/client/views/collections/collectionFreeForm
parent6f73686ec4dc3e01ae3eacc0150aa59eafea0325 (diff)
parentb8a04a0fedf8ef3612395764a0ecd01f6824ebd1 (diff)
Merge branch 'master' into eleanor-gptdraw
Diffstat (limited to 'src/client/views/collections/collectionFreeForm')
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormInfoState.tsx12
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx20
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormPannableContents.tsx4
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx98
-rw-r--r--src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss104
-rw-r--r--src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx280
-rw-r--r--src/client/views/collections/collectionFreeForm/ImageLabelBox.scss85
-rw-r--r--src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx347
-rw-r--r--src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx2
-rw-r--r--src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx6
-rw-r--r--src/client/views/collections/collectionFreeForm/MarqueeView.tsx200
11 files changed, 952 insertions, 206 deletions
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormInfoState.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormInfoState.tsx
index fc39cafaa..c17371151 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormInfoState.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormInfoState.tsx
@@ -12,7 +12,7 @@ import './CollectionFreeFormView.scss';
* returns a truthy value
*/
// eslint-disable-next-line no-use-before-define
-export type infoArc = [() => any, (res?: any) => infoState];
+export type infoArc = [() => unknown, (res?: unknown) => infoState];
export const StateMessage = Symbol('StateMessage');
export const StateMessageGIF = Symbol('StateMessageGIF');
@@ -20,9 +20,9 @@ export const StateEntryFunc = Symbol('StateEntryFunc');
export class infoState {
[StateMessage]: string = '';
[StateMessageGIF]?: string = '';
- [StateEntryFunc]?: () => any;
+ [StateEntryFunc]?: () => unknown;
[key: string]: infoArc;
- constructor(message: string, arcs: { [key: string]: infoArc }, messageGif?: string, entryFunc?: () => any) {
+ constructor(message: string, arcs: { [key: string]: infoArc }, messageGif?: string, entryFunc?: () => unknown) {
this[StateMessage] = message;
Object.assign(this, arcs);
this[StateMessageGIF] = messageGif;
@@ -44,7 +44,7 @@ export function InfoState(
msg: string, //
arcs: { [key: string]: infoArc },
gif?: string,
- entryFunc?: () => any
+ entryFunc?: () => unknown
) {
// eslint-disable-next-line new-cap
return new infoState(msg, arcs, gif, entryFunc);
@@ -52,7 +52,7 @@ export function InfoState(
export interface CollectionFreeFormInfoStateProps {
infoState: infoState;
- next: (state: infoState) => any;
+ next: (state: infoState) => unknown;
close: () => void;
}
@@ -61,7 +61,7 @@ export class CollectionFreeFormInfoState extends ObservableReactComponent<Collec
_disposers: IReactionDisposer[] = [];
@observable _expanded = false;
- constructor(props: any) {
+ constructor(props: CollectionFreeFormInfoStateProps) {
super(props);
makeObservable(this);
}
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx
index de51cc73c..79aad0ef2 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx
@@ -9,7 +9,7 @@ import { aggregateBounds } from '../../../../Utils';
export interface ViewDefBounds {
type: string;
- payload: any;
+ payload: unknown;
x: number;
y: number;
z?: number;
@@ -72,11 +72,15 @@ function toLabel(target: FieldResult<FieldType>) {
*/
function getTextWidth(text: string, font: string): number {
// re-use canvas object for better performance
- const canvas = (getTextWidth as any).canvas || ((getTextWidth as any).canvas = document.createElement('canvas'));
+ const selfStoreHack = getTextWidth as unknown as { canvas: Element };
+ const canvas = (selfStoreHack.canvas = (selfStoreHack.canvas as unknown as HTMLCanvasElement) ?? document.createElement('canvas'));
const context = canvas.getContext('2d');
- context.font = font;
- const metrics = context.measureText(text);
- return metrics.width;
+ if (context) {
+ context.font = font;
+ const metrics = context.measureText(text);
+ return metrics.width;
+ }
+ return 0;
}
interface PivotColumn {
@@ -131,13 +135,13 @@ export function computeStarburstLayout(poolData: Map<string, PoolData>, pivotDoc
return normalizeResults(burstDiam, 12, docMap, poolData, viewDefsToJSX, [], 0, [divider]);
}
-export function computePivotLayout(poolData: Map<string, PoolData>, pivotDoc: Doc, childPairs: { layout: Doc; data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[], engineProps: any) {
+export function computePivotLayout(poolData: Map<string, PoolData>, pivotDoc: Doc, childPairs: { layout: Doc; data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[], engineProps: unknown) {
const docMap = new Map<string, PoolData>();
const fieldKey = 'data';
const pivotColumnGroups = new Map<FieldResult<FieldType>, PivotColumn>();
let nonNumbers = 0;
- const pivotFieldKey = toLabel(engineProps?.pivotField ?? pivotDoc._pivotField) || 'author';
+ const pivotFieldKey = toLabel((engineProps as { pivotField?: string })?.pivotField ?? pivotDoc._pivotField) || 'author';
childPairs.forEach(pair => {
const listValue = Cast(pair.layout[pivotFieldKey], listSpec('string'), null);
@@ -265,7 +269,7 @@ export function computePivotLayout(poolData: Map<string, PoolData>, pivotDoc: Do
y: -maxColHeight + pivotAxisWidth,
width: pivotAxisWidth * numCols * expander,
height: maxColHeight,
- payload: pivotColumnGroups.get(key)!.filters,
+ payload: pivotColumnGroups.get(key)?.filters,
}));
groupNames.push(...dividers);
// eslint-disable-next-line no-use-before-define
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormPannableContents.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormPannableContents.tsx
index e543b4008..bc9dd022c 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormPannableContents.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormPannableContents.tsx
@@ -54,8 +54,8 @@ export class CollectionFreeFormPannableContents extends ObservableReactComponent
<div
className={'collectionfreeformview' + (this._props.viewDefDivClick ? '-viewDef' : '-none')}
onScroll={e => {
- const target = e.target as any;
- if (getComputedStyle(target)?.overflow === 'visible') {
+ const { target } = e;
+ if (target instanceof Element && getComputedStyle(target)?.overflow === 'visible') {
target.scrollTop = target.scrollLeft = 0; // if collection is visible, scrolling messes things up since there are no scroll bars
}
}}
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
index 8dc5f03b0..453d44915 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
@@ -1,16 +1,14 @@
/* eslint-disable react/jsx-props-no-spreading */
-/* eslint-disable jsx-a11y/click-events-have-key-events */
-/* eslint-disable jsx-a11y/no-static-element-interactions */
-import { Bezier, Point } from 'bezier-js';
+import { Bezier } from 'bezier-js';
import { Colors } from 'browndash-components';
+import { Property } from 'csstype';
import { action, computed, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx';
import { observer } from 'mobx-react';
import { computedFn } from 'mobx-utils';
import * as React from 'react';
import { ClientUtils, DashColor, lightOrDark, OmitKeys, returnFalse, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../../ClientUtils';
import { DateField } from '../../../../fields/DateField';
-import { Doc, DocListCast, Field, FieldType, Opt } from '../../../../fields/Doc';
-import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveEraserWidth, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, ActiveIsInkMask, SetActiveInkColor, SetActiveInkWidth } from '../../nodes/DocumentView';
+import { Doc, DocListCast, Field, FieldType, Opt, StrListCast } from '../../../../fields/Doc';
import { DocData, Height, Width } from '../../../../fields/DocSymbols';
import { Id } from '../../../../fields/FieldSymbols';
import { InkData, InkField, InkTool, Segment } from '../../../../fields/InkField';
@@ -34,20 +32,20 @@ import { CompileScript } from '../../../util/Scripting';
import { ScriptingGlobals } from '../../../util/ScriptingGlobals';
import { freeformScrollMode, SnappingManager } from '../../../util/SnappingManager';
import { Transform } from '../../../util/Transform';
-import { undoable, undoBatch, UndoManager } from '../../../util/UndoManager';
+import { undoable, UndoManager } from '../../../util/UndoManager';
import { Timeline } from '../../animationtimeline/Timeline';
import { ContextMenu } from '../../ContextMenu';
import { InkingStroke } from '../../InkingStroke';
import { CollectionFreeFormDocumentView } from '../../nodes/CollectionFreeFormDocumentView';
import { SchemaCSVPopUp } from '../../nodes/DataVizBox/SchemaCSVPopUp';
-import { ActiveFillColor, DocumentView } from '../../nodes/DocumentView';
+import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveEraserWidth, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, ActiveIsInkMask, DocumentView, SetActiveInkColor, SetActiveInkWidth } from '../../nodes/DocumentView';
import { FieldViewProps } from '../../nodes/FieldView';
import { FocusViewOptions } from '../../nodes/FocusViewOptions';
import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox';
import { OpenWhere, OpenWhereMod } from '../../nodes/OpenWhere';
import { PinDocView, PinProps } from '../../PinFuncs';
import { StyleProp } from '../../StyleProp';
-import { CollectionSubView } from '../CollectionSubView';
+import { CollectionSubView, SubCollectionViewProps } from '../CollectionSubView';
import { TreeViewType } from '../CollectionTreeViewType';
import { CollectionFreeFormBackgroundGrid } from './CollectionFreeFormBackgroundGrid';
import { CollectionFreeFormClusters } from './CollectionFreeFormClusters';
@@ -74,7 +72,7 @@ export interface collectionFreeformViewProps {
childPointerEvents?: () => string | undefined;
viewField?: string;
noOverlay?: boolean; // used to suppress docs in the overlay (z) layer (ie, for minimap since overlay doesn't scale)
- engineProps?: any;
+ engineProps?: unknown;
getScrollHeight?: () => number | undefined;
}
@@ -86,7 +84,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
public unprocessedDocs: Doc[] = [];
public static collectionsWithUnprocessedInk = new Set<CollectionFreeFormView>();
public static from(dv?: DocumentView): CollectionFreeFormView | undefined {
- const parent = CollectionFreeFormDocumentView.from(dv)?._props.parent;
+ const parent = CollectionFreeFormDocumentView.from(dv)?._props.reactParent;
return parent instanceof CollectionFreeFormView ? parent : undefined;
}
@@ -127,14 +125,14 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
@observable _marqueeViewRef = React.createRef<MarqueeView>();
@observable _brushedView: { width: number; height: number; panX: number; panY: number } | undefined = undefined; // highlighted region of freeform canvas used by presentations to indicate a region
@observable GroupChildDrag: boolean = false; // child document view being dragged. needed to update drop areas of groups when a group item is dragged.
- @observable _childPointerEvents: 'none' | 'all' | 'visiblepainted' | undefined = undefined;
+ @observable _childPointerEvents: Property.PointerEvents | undefined = undefined;
@observable _lightboxDoc: Opt<Doc> = undefined;
@observable _paintedId = 'id' + Utils.GenerateGuid().replace(/-/g, '');
@observable _keyframeEditing = false;
@observable _eraserX: number = 0;
@observable _eraserY: number = 0;
@observable _showEraserCircle: boolean = false; // to determine whether the radius eraser should show
- constructor(props: any) {
+ constructor(props: SubCollectionViewProps) {
super(props);
makeObservable(this);
}
@@ -189,7 +187,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
.transform(this.panZoomXf);
}
@computed get backgroundColor() {
- return this._props.styleProvider?.(this.Document, this._props, StyleProp.BackgroundColor);
+ return this._props.styleProvider?.(this.Document, this._props, StyleProp.BackgroundColor) as string;
}
@computed get fitWidth() {
return this._props.fitWidth?.(this.Document) ?? this.layoutDoc.layout_fitWidth;
@@ -361,7 +359,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
* @param options
* @returns
*/
- focus = (anchor: Doc, options: FocusViewOptions): any => {
+ focus = (anchor: Doc, options: FocusViewOptions) => {
if (anchor.isGroup && !options.docTransform && options.contextPath?.length) {
// don't focus on group if there's a context path because we're about to focus on a group item
// which will override any group focus. (If we allowed the group to focus, it would mark didMove even if there were no net movement)
@@ -447,8 +445,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
return true;
}
- @undoBatch
- internalAnchorAnnoDrop(e: Event, de: DragManager.DropEvent, annoDragData: DragManager.AnchorAnnoDragData) {
+ internalAnchorAnnoDrop = undoable((e: Event, de: DragManager.DropEvent, annoDragData: DragManager.AnchorAnnoDragData) => {
const dropCreator = annoDragData.dropDocCreator;
const [xp, yp] = this.screenToFreeformContentsXf.transformPoint(de.x, de.y);
annoDragData.dropDocCreator = (annotationOn: Doc | undefined) => {
@@ -461,10 +458,9 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
return dropDoc || this.Document;
};
return true;
- }
+ }, 'anchor drop');
- @undoBatch
- internalLinkDrop(e: Event, de: DragManager.DropEvent, linkDragData: DragManager.LinkDragData) {
+ internalLinkDrop = undoable((e: Event, de: DragManager.DropEvent, linkDragData: DragManager.LinkDragData) => {
if (this.DocumentView?.() && linkDragData.linkDragView.containerViewPath?.().includes(this.DocumentView())) {
const [x, y] = this.screenToFreeformContentsXf.transformPoint(de.x, de.y);
// do nothing if link is dropped into any freeform view parent of dragged document
@@ -480,9 +476,9 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
return added;
}
return false;
- }
+ }, 'link drop');
- onInternalDrop = (e: Event, de: DragManager.DropEvent) => {
+ onInternalDrop = (e: Event, de: DragManager.DropEvent): boolean => {
if (de.complete.annoDragData?.dragDocument && super.onInternalDrop(e, de)) return this.internalAnchorAnnoDrop(e, de, de.complete.annoDragData);
if (de.complete.linkDragData) return this.internalLinkDrop(e, de, de.complete.linkDragData);
if (de.complete.docDragData?.droppedDocuments.length) return this.internalDocDrop(e, de, de.complete.docDragData);
@@ -530,8 +526,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
}
};
- @undoBatch
- onGesture = (e: Event, ge: GestureUtils.GestureEvent) => {
+ onGesture = undoable((e: Event, ge: GestureUtils.GestureEvent) => {
switch (ge.gesture) {
case Gestures.Text:
if (ge.text) {
@@ -557,8 +552,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
e.stopPropagation();
}
}
- };
-
+ }, 'gesture');
@action
onEraserUp = (): void => {
this._deleteList.lastElement()?._props.removeDocument?.(this._deleteList.map(ink => ink.Document));
@@ -1148,6 +1142,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
// for some reason bezier.js doesn't handle the case of intersecting a linear curve, so we wrap the intersection
// call in a test for linearity
bintersects = (curve: Bezier, otherCurve: Bezier) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((curve as any)._linear) {
// bezier.js doesn't intersect properly if the curve is actually a line -- so get intersect other curve against this line, then figure out the t coordinates of the intersection on this line
const intersections = otherCurve.lineIntersects({ p1: curve.points[0], p2: curve.points[3] });
@@ -1157,6 +1152,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
return intT ? [intT] : [];
}
}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((otherCurve as any)._linear) {
return curve.lineIntersects({ p1: otherCurve.points[0], p2: otherCurve.points[3] });
}
@@ -1556,11 +1552,11 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
const childData = entry.pair.data;
return (
<CollectionFreeFormDocumentView
- // eslint-disable-next-line react/jsx-props-no-spreading
- {...OmitKeys(entry, ['replica', 'pair']).omit}
+ // eslint-disable-next-line react/jsx-props-no-spreading, @typescript-eslint/no-explicit-any
+ {...(OmitKeys(entry, ['replica', 'pair']).omit as any)}
key={childLayout[Id] + (entry.replica || '')}
Document={childLayout}
- parent={this}
+ reactParent={this}
containerViewPath={this.DocumentView?.().docViewPath}
styleProvider={this._clusters.styleProvider}
TemplateDataDocument={childData}
@@ -1675,7 +1671,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
};
}
- onViewDefDivClick = (e: React.MouseEvent, payload: any) => {
+ onViewDefDivClick = (e: React.MouseEvent, payload: unknown) => {
(this._props.viewDefDivClick || ScriptCast(this.Document.onViewDefDivClick))?.script.run({ this: this.Document, payload });
e.stopPropagation();
};
@@ -1709,7 +1705,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
ele: (
<div
className="collectionFreeform-customDiv"
- title={viewDef.payload?.join(' ')}
+ title={StrListCast(viewDef.payload as string).join(' ')}
key={'div' + x + y + z + viewDef.payload}
onClick={e => this.onViewDefDivClick(e, viewDef)}
style={{ width, height, backgroundColor: color, transform }}
@@ -1730,7 +1726,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
doEngineLayout(
poolData: Map<string, PoolData>,
- engine: (poolData: Map<string, PoolData>, pivotDoc: Doc, childPairs: { layout: Doc; data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[], engineProps: any) => ViewDefResult[]
+ engine: (poolData: Map<string, PoolData>, pivotDoc: Doc, childPairs: { layout: Doc; data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[], engineProps: unknown) => ViewDefResult[]
) {
return engine(poolData, this.Document, this.childLayoutPairs, [this._props.PanelWidth(), this._props.PanelHeight()], this.viewDefsToJSX, this._props.engineProps);
}
@@ -1760,7 +1756,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
.forEach(entry =>
elements.push({
ele: this.getChildDocView(entry[1]),
- bounds: (entry[1].opacity === 0 ? { ...entry[1], width: 0, height: 0 } : { ...entry[1] }) as any,
+ bounds: entry[1].opacity === 0 ? { payload: undefined, type: '', ...entry[1], width: 0, height: 0 } : { payload: undefined, type: '', ...entry[1] },
inkMask: BoolCast(entry[1].pair.layout.stroke_isInkMask) ? NumCast(entry[1].pair.layout.opacity, 1) : -1,
})
);
@@ -1843,7 +1839,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
this._disposers.pointerevents = reaction(
() => this.childPointerEvents,
pointerevents => {
- this._childPointerEvents = pointerevents as any;
+ this._childPointerEvents = pointerevents as Property.PointerEvents | undefined;
},
{ fireImmediately: true }
);
@@ -1918,8 +1914,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
this._showEraserCircle = true;
};
- @undoBatch
- promoteCollection = () => {
+ promoteCollection = undoable(() => {
const childDocs = this.childDocs.slice();
childDocs.forEach(docIn => {
const doc = docIn;
@@ -1928,10 +1923,9 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
doc.y = scr?.[1];
});
this._props.addDocTab(childDocs, OpenWhere.inParentFromScreen);
- };
+ }, 'promote collection');
- @undoBatch
- layoutDocsInGrid = () => {
+ layoutDocsInGrid = undoable(() => {
const docs = this.childLayoutPairs.map(pair => pair.layout);
const width = Math.max(...docs.map(doc => NumCast(doc._width))) + 20;
const height = Math.max(...docs.map(doc => NumCast(doc._height))) + 20;
@@ -1941,40 +1935,37 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
doc.x = NumCast(this.Document[this.panXFieldKey]) + (i % dim) * width - (width * dim) / 2;
doc.y = NumCast(this.Document[this.panYFieldKey]) + Math.floor(i / dim) * height - (height * dim) / 2;
});
- };
+ }, 'layout docs in grid');
- @undoBatch
- toggleNativeDimensions = () => Doc.toggleNativeDimensions(this.layoutDoc, 1, this.nativeWidth, this.nativeHeight);
+ toggleNativeDimensions = undoable(() => Doc.toggleNativeDimensions(this.layoutDoc, 1, this.nativeWidth, this.nativeHeight), 'toggle native dimensions');
///
/// resetView restores a freeform collection to unit scale and centered at (0,0) UNLESS
/// the view is a group, in which case this does nothing (since Groups calculate their own scale and center)
///
- @undoBatch
- resetView = () => {
+ resetView = undoable(() => {
this.layoutDoc[this.panXFieldKey] = NumCast(this.dataDoc[this.panXFieldKey + '_reset']);
this.layoutDoc[this.panYFieldKey] = NumCast(this.dataDoc[this.panYFieldKey + '_reset']);
this.layoutDoc[this.scaleFieldKey] = NumCast(this.dataDoc[this.scaleFieldKey + '_reset'], 1);
- };
+ }, 'reset view');
///
/// resetView restores a freeform collection to unit scale and centered at (0,0) UNLESS
/// the view is a group, in which case this does nothing (since Groups calculate their own scale and center)
///
- @undoBatch
- toggleResetView = () => {
+ toggleResetView = undoable(() => {
this.dataDoc[this.autoResetFieldKey] = !this.dataDoc[this.autoResetFieldKey];
if (this.dataDoc[this.autoResetFieldKey]) {
this.dataDoc[this.panXFieldKey + '_reset'] = this.layoutDoc[this.panXFieldKey];
this.dataDoc[this.panYFieldKey + '_reset'] = this.layoutDoc[this.panYFieldKey];
this.dataDoc[this.scaleFieldKey + '_reset'] = this.layoutDoc[this.scaleFieldKey];
}
- };
+ }, 'toggle reset view');
onContextMenu = () => {
if (this._props.isAnnotationOverlay || !ContextMenu.Instance) return;
const appearance = ContextMenu.Instance.findByDescription('Appearance...');
- const appearanceItems = appearance && 'subitems' in appearance ? appearance.subitems : [];
+ const appearanceItems = appearance?.subitems ?? [];
!this.Document.isGroup && appearanceItems.push({ description: 'Reset View', event: this.resetView, icon: 'compress-arrows-alt' });
!this.Document.isGroup && appearanceItems.push({ description: 'Toggle Auto Reset View', event: this.toggleResetView, icon: 'compress-arrows-alt' });
if (this._props.setContentViewBox === emptyFunction) {
@@ -2001,7 +1992,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
!appearance && ContextMenu.Instance.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'eye' });
const options = ContextMenu.Instance.findByDescription('Options...');
- const optionItems = options && 'subitems' in options ? options.subitems : [];
+ const optionItems = options?.subitems ?? [];
!this._props.isAnnotationOverlay &&
!Doc.noviceMode &&
optionItems.push({
@@ -2035,12 +2026,11 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
}
!options && ContextMenu.Instance.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' });
const mores = ContextMenu.Instance.findByDescription('More...');
- const moreItems = mores && 'subitems' in mores ? mores.subitems : [];
+ const moreItems = mores?.subitems ?? [];
!mores && ContextMenu.Instance.addItem({ description: 'More...', subitems: moreItems, icon: 'eye' });
};
- @undoBatch
- transcribeStrokes = () => {
+ transcribeStrokes = undoable(() => {
if (this.Document.isGroup && this.Document.transcription) {
const text = StrCast(this.Document.transcription);
const lines = text.split('\n');
@@ -2048,7 +2038,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
this.addDocument(Docs.Create.TextDocument(text, { title: lines[0], x: NumCast(this.layoutDoc.x) + NumCast(this.layoutDoc._width) + 20, y: NumCast(this.layoutDoc.y), _width: 200, _height: height }));
}
- };
+ }, 'transcribe strokes');
@action
dragEnding = () => {
@@ -2214,7 +2204,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
onDragOver={e => e.preventDefault()}
onContextMenu={this.onContextMenu}
style={{
- pointerEvents: this._props.isContentActive() && SnappingManager.IsDragging ? 'all' : (this._props.pointerEvents?.() as any),
+ pointerEvents: this._props.isContentActive() && SnappingManager.IsDragging ? 'all' : this._props.pointerEvents?.(),
textAlign: this.isAnnotationOverlay ? 'initial' : undefined,
transform: `scale(${this.nativeDimScaling})`,
width: `${100 / this.nativeDimScaling}%`,
diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
new file mode 100644
index 000000000..0a001d84c
--- /dev/null
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss
@@ -0,0 +1,104 @@
+.face-document-item {
+ display: flex;
+ height: max-content;
+ flex-direction: column;
+ top: 0;
+ position: absolute;
+ width: 100%;
+ height: 100%;
+
+ h1 {
+ color: white;
+ font-size: 24px;
+ text-align: center;
+ .face-document-name {
+ text-align: center;
+ background: transparent;
+ width: 80%;
+ border: transparent;
+ }
+ }
+
+ .face-collection-buttons {
+ position: absolute;
+ top: 0px;
+ right: 10px;
+ }
+ .face-collection-toggle {
+ position: absolute;
+ top: 0px;
+ left: 10px;
+ }
+ .face-document-top {
+ position: relative;
+ top: 0;
+ width: 100%;
+ left: 0;
+ }
+
+ .face-document-image-container {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ overflow-x: hidden;
+ overflow-y: auto;
+ position: relative;
+ padding: 10px;
+
+ .image-wrapper {
+ position: relative;
+ width: 70px;
+ height: 70px;
+ margin: 10px;
+ display: flex;
+ align-items: center; // Center vertically
+ justify-content: center; // Center horizontally
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover; // This ensures the image covers the container without stretching
+ border-radius: 5px;
+ border: 2px solid white;
+ transition: border-color 0.4s;
+
+ &:hover {
+ border-color: orange; // Change this to your desired hover border color
+ }
+ }
+
+ .remove-item {
+ position: absolute;
+ bottom: -5;
+ right: -5;
+ background-color: rgba(0, 0, 0, 0.5); // Optional: to add a background behind the icon for better visibility
+ border-radius: 30%;
+ width: 10px; // Adjust size as needed
+ height: 10px; // Adjust size as needed
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ }
+
+ // img {
+ // max-width: 60px;
+ // margin: 10px;
+ // border-radius: 5px;
+ // border: 2px solid white;
+ // transition: 0.4s;
+
+ // &:hover {
+ // border-color: orange;
+ // }
+ // }
+ }
+}
+
+.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
new file mode 100644
index 000000000..c62303dc0
--- /dev/null
+++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx
@@ -0,0 +1,280 @@
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { IconButton, Size } from 'browndash-components';
+import * as faceapi from 'face-api.js';
+import { FaceMatcher } from 'face-api.js';
+import 'ldrs/ring';
+import { IReactionDisposer, action, makeObservable, observable, reaction } from 'mobx';
+import { observer } from 'mobx-react';
+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 { DocCast, ImageCast, NumCast, 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 { undoable } from '../../../util/UndoManager';
+import { ViewBoxBaseComponent } from '../../DocComponent';
+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';
+
+/**
+ * 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.
+ */
+
+/**
+ * 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 UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() {
+ public static LayoutString(fieldKey: string) {
+ return FieldView.LayoutString(UniqueFaceBox, fieldKey);
+ }
+ private _dropDisposer?: DragManager.DragDropDisposer;
+ private _disposers: { [key: string]: IReactionDisposer } = {};
+ private _lastHeight = 0;
+
+ constructor(props: 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) + //
+ (!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 }),
+ ({ refList, autoHeight }) => {
+ this.observer.disconnect();
+ if (autoHeight) refList.filter(r => r).forEach(r => this.observer.observe(r!));
+ },
+ { fireImmediately: true }
+ );
+ }
+
+ componentWillUnmount(): void {
+ 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));
+ };
+
+ protected onInternalDrop(e: Event, de: DragManager.DropEvent): boolean {
+ 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.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 { 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> }
+ );
+
+ // assign the face in the image that's closest to the face collection's face
+ if (faceAnno) {
+ FaceRecognitionHandler.UniqueFaceRemoveFaceImage(faceAnno, DocCast(faceAnno.face));
+ FaceRecognitionHandler.UniqueFaceAddFaceImage(faceAnno, this.Document);
+ faceAnno.face = this.Document;
+ }
+ }
+ });
+ e.stopPropagation();
+ return true;
+ }
+
+ /**
+ * 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));
+ 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.Document);
+ }, 'delete face');
+
+ /**
+ * Removes a face image Doc from a unique face's list of images.
+ * @param imgDoc - image Doc to remove
+ */
+ removeFaceImageFromUniqueFace = undoable((imgDoc: Doc) => {
+ 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() {
+ return (
+ <div className="face-document-item" ref={ele => this.createDropTarget(ele!)}>
+ <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" ref={action((r: HTMLDivElement | null) => (this._headerRef = r))}>
+ <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
+ tooltip="See image information"
+ onPointerDown={() => this.onDisplayClick()}
+ icon={<FontAwesomeIcon icon={this.Document._face_showImages ? 'caret-up' : 'caret-down'} />}
+ color={MarqueeOptionsMenu.Instance.userColor}
+ style={{ width: '19px' }}
+ />
+ </div>
+ {this.props.Document._face_showImages ? (
+ <div
+ className="face-document-image-container"
+ style={{
+ pointerEvents: this._props.isContentActive() ? undefined : 'none',
+ }}
+ ref={action((ele: HTMLDivElement | null) => {
+ this._listRef?.removeEventListener('wheel', this.onPassiveWheel);
+ this._listRef = 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.Document).map((doc, i) => {
+ const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
+ return (
+ <div
+ className="image-wrapper"
+ key={i}
+ 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.removeFaceImageFromUniqueFace(doc)} icon={'x'} style={{ width: '4px' }} size={Size.XSMALL} />
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ ) : null}
+ </div>
+ );
+ }
+}
+
+/**
+ * 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 myUniqueFaces field of the current
+ * 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>() {
+ public static LayoutString(fieldKey: string) {
+ return FieldView.LayoutString(FaceCollectionBox, fieldKey);
+ }
+
+ constructor(props: FieldViewProps) {
+ super(props);
+ makeObservable(this);
+ }
+
+ 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.
+ */
+ 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 : (
+ <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}
+ addDocument={this.addDocument}
+ isContentActive={returnTrue}
+ isAnyChildContentActive={returnTrue}
+ childHideDecorations={true}
+ />
+ </div>
+ );
+ }
+}
+
+Docs.Prototypes.TemplateMap.set(DocumentType.FACECOLLECTION, {
+ layout: { view: FaceCollectionBox, dataField: 'data' },
+ 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/collections/collectionFreeForm/ImageLabelBox.scss b/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss
new file mode 100644
index 000000000..819c72760
--- /dev/null
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.scss
@@ -0,0 +1,85 @@
+.image-box-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ font-size: 10px;
+ line-height: 1;
+ background: none;
+ z-index: 1000;
+ padding: 0px;
+ overflow: auto;
+ cursor: default;
+}
+
+.image-label-list {
+ display: flex;
+ flex-direction: column;
+ align-items: center; // Centers the content vertically in the flex container
+ width: 100%;
+
+ > div {
+ display: flex;
+ justify-content: space-between; // Puts the content and delete button on opposite ends
+ align-items: center;
+ width: 100%;
+ margin-top: 8px; // Adds space between label rows
+ background-color: black;
+
+ p {
+ text-align: center; // Centers the text of the paragraph
+ font-size: large;
+ vertical-align: middle;
+ margin-left: 10px;
+ }
+
+ .IconButton {
+ // Styling for the delete button
+ margin-left: auto; // Pushes the button to the far right
+ }
+ }
+}
+
+.image-information-list {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ margin-top: 10px;
+}
+
+.image-information {
+ border: 1px solid;
+ width: 100%;
+ display: inline-flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ overflow: hidden;
+ padding: 2px;
+ overflow-x: auto;
+ overflow-y: auto;
+
+ img {
+ max-width: 200px;
+ max-height: 200px;
+ width: auto;
+ height: auto;
+ }
+}
+
+.image-information-labels {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+
+ .image-label {
+ margin-top: 5px;
+ margin-bottom: 5px;
+ padding: 3px;
+ border-radius: 2px;
+ border: solid 1px;
+ }
+}
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
new file mode 100644
index 000000000..5d3154e3c
--- /dev/null
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -0,0 +1,347 @@
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { Colors, IconButton } from 'browndash-components';
+import similarity from 'compute-cosine-similarity';
+import { ring } from 'ldrs';
+import 'ldrs/ring';
+import { action, computed, makeObservable, observable, reaction } from 'mobx';
+import { observer } from 'mobx-react';
+import React from 'react';
+import { Utils, numberRange } from '../../../../Utils';
+import { Doc, NumListCast, Opt } from '../../../../fields/Doc';
+import { DocData } from '../../../../fields/DocSymbols';
+import { List } from '../../../../fields/List';
+import { ImageCast } from '../../../../fields/Types';
+import { gptGetEmbedding, gptImageLabel } from '../../../apis/gpt/GPT';
+import { DocumentType } from '../../../documents/DocumentTypes';
+import { Docs } from '../../../documents/Documents';
+import { DragManager } from '../../../util/DragManager';
+import { SettingsManager } from '../../../util/SettingsManager';
+import { SnappingManager } from '../../../util/SnappingManager';
+import { ViewBoxBaseComponent } from '../../DocComponent';
+import { MainView } from '../../MainView';
+import { DocumentView } from '../../nodes/DocumentView';
+import { FieldView, FieldViewProps } from '../../nodes/FieldView';
+import { OpenWhere } from '../../nodes/OpenWhere';
+import { CollectionCardView } from '../CollectionCardDeckView';
+import './ImageLabelBox.scss';
+import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
+
+export class ImageInformationItem {}
+
+export class ImageLabelBoxData {
+ static _instance: ImageLabelBoxData;
+ @observable _docs: Doc[] = [];
+ @observable _labelGroups: string[] = [];
+
+ constructor() {
+ makeObservable(this);
+ ImageLabelBoxData._instance = this;
+ }
+ public static get Instance() {
+ return ImageLabelBoxData._instance ?? new ImageLabelBoxData();
+ }
+
+ @action
+ public setData = (docs: Doc[]) => {
+ this._docs = docs;
+ };
+
+ @action
+ addLabel = (label: string) => {
+ label = label.toUpperCase().trim();
+ if (label.length > 0) {
+ if (!this._labelGroups.includes(label)) {
+ this._labelGroups = [...this._labelGroups, label.startsWith('#') ? label : '#' + label];
+ }
+ }
+ };
+
+ @action
+ removeLabel = (label: string) => {
+ const labelUp = label.toUpperCase();
+ this._labelGroups = this._labelGroups.filter(group => group !== labelUp);
+ };
+}
+
+@observer
+export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
+ public static LayoutString(fieldKey: string) {
+ return FieldView.LayoutString(ImageLabelBox, fieldKey);
+ }
+
+ private _dropDisposer?: DragManager.DragDropDisposer;
+ public static Instance: ImageLabelBox;
+ private _inputRef = React.createRef<HTMLInputElement>();
+ @observable _loading: boolean = false;
+ private _currentLabel: string = '';
+
+ protected createDropTarget = (ele: HTMLDivElement) => {
+ this._dropDisposer?.();
+ ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc));
+ };
+
+ protected onInternalDrop(e: Event, de: DragManager.DropEvent): boolean {
+ const { docDragData } = de.complete;
+ if (docDragData) {
+ ImageLabelBoxData.Instance.setData(ImageLabelBoxData.Instance._docs.concat(docDragData.droppedDocuments));
+ return false;
+ }
+ return false;
+ }
+
+ @computed get _labelGroups() {
+ return ImageLabelBoxData.Instance._labelGroups;
+ }
+
+ @computed get _selectedImages() {
+ // return DocListCast(this.dataDoc.data);
+ return ImageLabelBoxData.Instance._docs;
+ }
+ @observable _displayImageInformation: boolean = false;
+
+ constructor(props: any) {
+ super(props);
+ makeObservable(this);
+ ring.register();
+ ImageLabelBox.Instance = this;
+ }
+
+ // ImageLabelBox.Instance.setData()
+ /**
+ * This method is called when the SearchBox component is first mounted. When the user opens
+ * the search panel, the search input box is automatically selected. This allows the user to
+ * type in the search input box immediately, without needing clicking on it first.
+ */
+ componentDidMount() {
+ this.classifyImagesInBox();
+ reaction(
+ () => this._selectedImages,
+ () => this.classifyImagesInBox()
+ );
+ }
+
+ @action
+ groupImages = () => {
+ this.groupImagesInBox();
+ };
+
+ @action
+ startLoading = () => {
+ this._loading = true;
+ };
+
+ @action
+ endLoading = () => {
+ this._loading = false;
+ };
+
+ @action
+ toggleDisplayInformation = () => {
+ this._displayImageInformation = !this._displayImageInformation;
+ if (this._displayImageInformation) {
+ this._selectedImages.forEach(doc => (doc[DocData].showTags = true));
+ } else {
+ this._selectedImages.forEach(doc => (doc[DocData].showTags = false));
+ }
+ };
+
+ @action
+ submitLabel = () => {
+ const input = document.getElementById('new-label') as HTMLInputElement;
+ ImageLabelBoxData.Instance.addLabel(this._currentLabel);
+ this._currentLabel = '';
+ input.value = '';
+ };
+
+ onInputChange = action((e: React.ChangeEvent<HTMLInputElement>) => {
+ this._currentLabel = e.target.value;
+ });
+
+ classifyImagesInBox = async () => {
+ this.startLoading();
+
+ // Converts the images into a Base64 format, afterwhich the information is sent to GPT to label them.
+
+ const imageInfos = this._selectedImages.map(async doc => {
+ if (!doc[DocData].tags) {
+ const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
+ return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 =>
+ !hrefBase64 ? undefined :
+ gptImageLabel(hrefBase64).then(labels =>
+ ({ doc, labels }))) ; // prettier-ignore
+ }
+ });
+
+ (await Promise.all(imageInfos)).forEach(imageInfo => {
+ if (imageInfo) {
+ imageInfo.doc[DocData].tags = (imageInfo.doc[DocData].tags as List<string>) ?? new List<string>();
+
+ const labels = imageInfo.labels.split('\n');
+ labels.forEach(label => {
+ label =
+ '#' +
+ label
+ .replace(/^\d+\.\s*|-|f\*/, '')
+ .replace(/^#/, '')
+ .trim();
+ imageInfo.doc[DocData][label] = true;
+ (imageInfo.doc[DocData].tags as List<string>).push(label);
+ });
+ }
+ });
+
+ this.endLoading();
+ };
+
+ /**
+ * Groups images to most similar labels.
+ */
+ groupImagesInBox = action(async () => {
+ this.startLoading();
+
+ for (const doc of this._selectedImages) {
+ for (let index = 0; index < (doc[DocData].tags as List<string>).length; index++) {
+ const label = (doc[DocData].tags as List<string>)[index];
+ const embedding = await gptGetEmbedding(label);
+ doc[DocData][`tags_embedding_${index + 1}`] = new List<number>(embedding);
+ }
+ }
+
+ const labelToEmbedding = new Map<string, number[]>();
+ // Create embeddings for the labels.
+ await Promise.all(this._labelGroups.map(async label => gptGetEmbedding(label).then(labelEmbedding => labelToEmbedding.set(label, labelEmbedding))));
+
+ // For each image, loop through the labels, and calculate similarity. Associate it with the
+ // most similar one.
+ this._selectedImages.forEach(doc => {
+ const embedLists = numberRange((doc[DocData].tags as List<string>).length).map(n => Array.from(NumListCast(doc[DocData][`tags_embedding_${n + 1}`])));
+ const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map((l, index) => (embedding && similarity(Array.from(embedding), l)!) || 0));
+ const {label: mostSimilarLabelCollect} =
+ this._labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) }))
+ .reduce((prev, cur) => cur.similarityScore < 0.3 || cur.similarityScore <= prev.similarityScore ? prev: cur,
+ { label: '', similarityScore: 0, }); // prettier-ignore
+ doc[DocData].data_label = mostSimilarLabelCollect; // The label most similar to the image's contents.
+ });
+
+ this.endLoading();
+
+ if (this._selectedImages) {
+ MarqueeOptionsMenu.Instance.groupImages();
+ }
+
+ MainView.Instance.closeFlyout();
+ });
+
+ render() {
+ if (this._loading) {
+ return (
+ <div className="image-box-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
+ <l-ring size="60" color="white" />
+ </div>
+ );
+ }
+
+ if (this._selectedImages.length === 0) {
+ return (
+ <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }} ref={ele => this.createDropTarget(ele!)}>
+ <p style={{ fontSize: 'large' }}>In order to classify and sort images, marquee select the desired images and press the 'Classify and Sort Images' button. Then, add the desired groups for the images to be put in.</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }} ref={ele => this.createDropTarget(ele!)}>
+ <div className="searchBox-bar" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
+ <IconButton
+ tooltip={'See image information'}
+ onPointerDown={this.toggleDisplayInformation}
+ icon={this._displayImageInformation ? <FontAwesomeIcon icon="caret-up" /> : <FontAwesomeIcon icon="caret-down" />}
+ color={MarqueeOptionsMenu.Instance.userColor}
+ style={{ width: '19px' }}
+ />
+ <input
+ defaultValue=""
+ autoComplete="off"
+ onChange={this.onInputChange}
+ onKeyDown={e => {
+ e.key === 'Enter' ? this.submitLabel() : null;
+ e.stopPropagation();
+ }}
+ type="text"
+ placeholder="Input groups for images to be put into..."
+ aria-label="label-input"
+ id="new-label"
+ className="searchBox-input"
+ style={{ width: '100%', borderRadius: '5px' }}
+ ref={this._inputRef}
+ />
+ <IconButton
+ tooltip={'Add a label'}
+ onPointerDown={() => {
+ const input = document.getElementById('new-label') as HTMLInputElement;
+ ImageLabelBoxData.Instance.addLabel(this._currentLabel);
+ this._currentLabel = '';
+ input.value = '';
+ }}
+ icon={<FontAwesomeIcon icon="plus" />}
+ color={MarqueeOptionsMenu.Instance.userColor}
+ style={{ width: '19px' }}
+ />
+ {this._labelGroups.length > 0 ? <IconButton tooltip={'Group Images'} onPointerDown={this.groupImages} icon={<FontAwesomeIcon icon="object-group" />} color={Colors.MEDIUM_BLUE} style={{ width: '19px' }} /> : <div></div>}
+ </div>
+ <div>
+ <div className="image-label-list">
+ {this._labelGroups.map(group => {
+ return (
+ <div key={Utils.GenerateGuid()}>
+ <p style={{ color: MarqueeOptionsMenu.Instance.userColor }}>{group}</p>
+ <IconButton
+ tooltip={'Remove Label'}
+ onPointerDown={() => {
+ ImageLabelBoxData.Instance.removeLabel(group);
+ }}
+ icon={'x'}
+ color={MarqueeOptionsMenu.Instance.userColor}
+ style={{ width: '8px' }}
+ />
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ {this._displayImageInformation ? (
+ <div className="image-information-list">
+ {this._selectedImages.map(doc => {
+ const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
+ return (
+ <div className="image-information" style={{ borderColor: SettingsManager.userColor }} key={Utils.GenerateGuid()}>
+ <img
+ src={`${name}_o.${type}`}
+ onClick={async () => {
+ await DocumentView.showDocument(doc, { willZoomCentered: true });
+ }}></img>
+ <div className="image-information-labels" onClick={() => this._props.addDocTab(doc, OpenWhere.addRightKeyvalue)}>
+ {(doc[DocData].tags as List<string>).map(label => {
+ return (
+ <div key={Utils.GenerateGuid()} className="image-label" style={{ backgroundColor: SettingsManager.userVariantColor, borderColor: SettingsManager.userColor }}>
+ {label}
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ ) : (
+ <div></div>
+ )}
+ </div>
+ );
+ }
+}
+
+Docs.Prototypes.TemplateMap.set(DocumentType.IMAGEGROUPER, {
+ layout: { view: ImageLabelBox, dataField: 'data' },
+ options: { acl: '', _width: 400 },
+});
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx
index 7f27c6b5c..73befb205 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx
@@ -77,7 +77,7 @@ export class ImageLabelHandler extends ObservableReactComponent<{}> {
}}>
<div>
<IconButton tooltip={'Cancel'} onPointerDown={this.hideLabelhandler} icon={<FontAwesomeIcon icon="eye-slash" />} color={MarqueeOptionsMenu.Instance.userColor} style={{ width: '19px' }} />
- <input aria-label="label-input" id="new-label" type="text" style={{ color: 'black' }} />
+ <input aria-label="label-input" id="new-label" type="text" placeholder="Input a classification" style={{ color: 'black' }} />
<IconButton
tooltip={'Add Label'}
onPointerDown={() => {
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx
index b3fdd9379..de65b240f 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx
@@ -19,10 +19,10 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> {
public showMarquee: () => void = unimplementedFunction;
public hideMarquee: () => void = unimplementedFunction;
public pinWithView: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction;
- public classifyImages: (e: React.MouseEvent | undefined) => void = unimplementedFunction;
+ public classifyImages: () => void = unimplementedFunction;
public groupImages: () => void = unimplementedFunction;
public isShown = () => this._opacity > 0;
- constructor(props: any) {
+ constructor(props: AntimodeMenuProps) {
super(props);
makeObservable(this);
MarqueeOptionsMenu.Instance = this;
@@ -40,7 +40,7 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> {
<IconButton tooltip="Summarize Documents" onPointerDown={this.summarize} icon={<FontAwesomeIcon icon="compress-arrows-alt" />} color={this.userColor} />
<IconButton tooltip="Delete Documents" onPointerDown={this.delete} icon={<FontAwesomeIcon icon="trash-alt" />} color={this.userColor} />
<IconButton tooltip="Pin selected region" onPointerDown={this.pinWithView} icon={<FontAwesomeIcon icon="map-pin" />} color={this.userColor} />
- <IconButton tooltip="Classify Images" onPointerDown={this.classifyImages} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} />
+ <IconButton tooltip="Classify and Sort Images" onPointerDown={this.classifyImages} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} />
</>
);
return this.getElement(buttons);
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index bc1dfed92..dbc88cb19 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, unimplementedFunction } from '../../../../Utils';
-import { Doc, NumListCast, Opt } from '../../../../fields/Doc';
+import { intersectRect, unimplementedFunction } 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 { BoolCast, Cast, DocCast, FieldValue, ImageCast, NumCast, StrCast } from '../../../../fields/Types';
+import { BoolCast, Cast, DocCast, 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,10 +26,8 @@ import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveFillColor, ActiveIn
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 { collectionOf, points } from '@turf/turf';
@@ -59,6 +53,9 @@ interface MarqueeViewProps {
slowLoadDocuments: (files: File[] | string, options: DocumentOptions, generatedDocuments: Doc[], text: string, completed: ((doc: Doc[]) => void) | undefined, addDocument: (doc: Doc | Doc[]) => boolean) => Promise<void>;
}
+/**
+ * A component that deals with the marquee select in the freeform canvas.
+ */
@observer
export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps & MarqueeViewProps> {
public static CurViewBounds(pinDoc: Doc, panelWidth: number, panelHeight: number) {
@@ -66,9 +63,12 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
return { left: NumCast(pinDoc._freeform_panX) - panelWidth / 2 / ps, top: NumCast(pinDoc._freeform_panY) - panelHeight / 2 / ps, width: panelWidth / ps, height: panelHeight / ps };
}
- constructor(props: any) {
+ static Instance: MarqueeView;
+
+ constructor(props: SubCollectionViewProps & MarqueeViewProps) {
super(props);
makeObservable(this);
+ MarqueeView.Instance = this;
}
private _commandExecuted = false;
@@ -164,6 +164,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
} else if (e.key === 'b' && e.ctrlKey) {
document.body.focus(); // so that we can access the clipboard without an error
setTimeout(() =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
pasteImageBitmap((data: any, error: any) => {
error && console.log(error);
data &&
@@ -438,32 +439,14 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
/**
* Classifies images and assigns the labels as document fields.
- * TODO: Turn into lists of labels instead of individual fields.
*/
@undoBatch
- classifyImages = action(async (e: React.MouseEvent | undefined) => {
- this._selectedDocs = this.marqueeSelect(false, DocumentType.IMG);
-
- const imageInfos = this._selectedDocs.map(async doc => {
- const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
- return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 =>
- !hrefBase64 ? undefined :
- gptImageLabel(hrefBase64).then(labels =>
- Promise.all(labels.split('\n').map(label => gptGetEmbedding(label))).then(embeddings =>
- ({ doc, embeddings, labels }))) ); // prettier-ignore
- });
-
- (await Promise.all(imageInfos)).forEach(imageInfo => {
- if (imageInfo && Array.isArray(imageInfo.embeddings)) {
- imageInfo.doc[DocData].data_labels = imageInfo.labels;
- numberRange(3).forEach(n => {
- imageInfo.doc[`data_labels_embedding_${n + 1}`] = new List<number>(imageInfo.embeddings[n]);
- });
- }
- });
-
- if (e) {
- ImageLabelHandler.Instance.displayLabelHandler(e.pageX, e.pageY);
+ classifyImages = action(async () => {
+ const groupButton = DocListCast(Doc.MyLeftSidebarMenu.data).find(d => d.target === Doc.MyImageGrouper);
+ if (groupButton) {
+ this._selectedDocs = this.marqueeSelect(false, DocumentType.IMG);
+ ImageLabelBoxData.Instance.setData(this._selectedDocs);
+ MainView.Instance.expandFlyout(groupButton);
}
});
@@ -472,93 +455,44 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
*/
@undoBatch
groupImages = action(async () => {
- const labelGroups = ImageLabelHandler.Instance._labelGroups;
- const labelToEmbedding = new Map<string, number[]>();
- // Create embeddings for the labels.
- await Promise.all(labelGroups.map(async label => gptGetEmbedding(label).then(labelEmbedding => labelToEmbedding.set(label, labelEmbedding))));
-
- // For each image, loop through the labels, and calculate similarity. Associate it with the
- // most similar one.
- this._selectedDocs.forEach(doc => {
- const embedLists = numberRange(3).map(n => Array.from(NumListCast(doc[`data_labels_embedding_${n + 1}`])));
- const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map(l => (embedding && similarity(Array.from(embedding), l)) || 0));
- const {label: mostSimilarLabelCollect} =
- labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) }))
- .reduce((prev, cur) => cur.similarityScore < 0.3 || cur.similarityScore <= prev.similarityScore ? prev: cur,
- { label: '', similarityScore: 0, }); // prettier-ignore
-
- numberRange(3).forEach(n => {
- doc[`data_labels_embedding_${n + 1}`] = undefined;
- });
- doc[DocData].data_label = mostSimilarLabelCollect;
- });
- this._props.Document._type_collection = CollectionViewType.Time;
- this._props.Document.pivotField = 'data_label';
- });
+ const labelGroups: string[] = ImageLabelBoxData.Instance._labelGroups;
+ const labelToCollection: Map<string, Doc> = new Map();
+ const selectedImages = ImageLabelBoxData.Instance._docs;
+
+ // Create new collections associated with each label and get the embeddings for the labels.
+ let x_offset = 0;
+ let y_offset = 0;
+ let row_count = 0;
+ for (const label of labelGroups) {
+ const newCollection = this.getCollection([], undefined, false);
+ newCollection._width = 900;
+ newCollection._height = 900;
+ newCollection._x = this.Bounds.left;
+ newCollection._y = this.Bounds.top;
+ newCollection._freeform_panX = this.Bounds.left + this.Bounds.width / 2;
+ newCollection._freeform_panY = this.Bounds.top + this.Bounds.height / 2;
+ newCollection._x = (newCollection._x as number) + x_offset;
+ newCollection._y = (newCollection._y as number) + y_offset;
+ x_offset += (newCollection._width as number) + 40;
+ row_count += 1;
+ if (row_count == 3) {
+ y_offset += (newCollection._height as number) + 40;
+ x_offset = 0;
+ row_count = 0;
+ }
+ labelToCollection.set(label, newCollection);
+ this._props.addDocument?.(newCollection);
+ }
- @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 }));
- });
+ for (const doc of selectedImages) {
+ if (doc[DocData].data_label) {
+ Doc.AddDocToList(labelToCollection.get(doc[DocData].data_label as string)!, undefined, doc);
+ this._props.removeDocument?.(doc);
+ }
}
+
+ //this._props.Document._type_collection = CollectionViewType.Time; // Change the collection view to a Time view.
+ //this._props.Document.pivotField = 'data_label'; // Sets the pivot to be the 'data_label'.
});
@undoBatch
@@ -590,13 +524,14 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
@action
marqueeCommand = (e: KeyboardEvent) => {
- if (this._commandExecuted || (e as any).propagationIsStopped) {
+ const ee = e as unknown as KeyboardEvent & { propagationIsStopped?: boolean };
+ if (this._commandExecuted || ee.propagationIsStopped) {
return;
}
if (e.key === 'Backspace' || e.key === 'Delete' || e.key === 'd' || e.key === 'h') {
this._commandExecuted = true;
e.stopPropagation();
- (e as any).propagationIsStopped = true;
+ ee.propagationIsStopped = true;
this.delete(e, e.key === 'h');
e.stopPropagation();
}
@@ -604,7 +539,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
this._commandExecuted = true;
e.stopPropagation();
e.preventDefault();
- (e as any).propagationIsStopped = true;
+ ee.propagationIsStopped = true;
if (e.key === 'g') this.collection(e, true);
if (e.key === 'c' || e.key === 't') this.collection(e);
if (e.key === 's' || e.key === 'S') this.summary();
@@ -705,8 +640,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,
}}>
{' '}
@@ -715,7 +650,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"
/>
@@ -735,8 +670,9 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
*/
@action
onDragMovePause = (e: CustomEvent<React.DragEvent>) => {
- if ((e as any).handlePan || this._props.isAnnotationOverlay) return;
- (e as any).handlePan = true;
+ const ee = e as CustomEvent<React.DragEvent> & { handlePan?: boolean };
+ if (ee.handlePan || this._props.isAnnotationOverlay) return;
+ ee.handlePan = true;
const bounds = this.MarqueeRef?.getBoundingClientRect();
if (!this._props.Document._freeform_noAutoPan && !this._props.renderDepth && bounds) {
@@ -754,10 +690,10 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
};
render() {
return (
- // eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div
className="marqueeView"
ref={r => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
r?.addEventListener('dashDragMovePause', this.onDragMovePause as any);
this.MarqueeRef = r;
}}