aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/nodes/ImageBox.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/views/nodes/ImageBox.tsx')
-rw-r--r--src/client/views/nodes/ImageBox.tsx401
1 files changed, 391 insertions, 10 deletions
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index ec5e062c8..31f6df2ea 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -1,10 +1,12 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Tooltip } from '@mui/material';
+import axios from 'axios';
import { Colors } from 'browndash-components';
import { action, computed, IReactionDisposer, makeObservable, observable, ObservableMap, reaction } from 'mobx';
import { observer } from 'mobx-react';
import { extname } from 'path';
import * as React from 'react';
+import ReactLoading from 'react-loading';
import { ClientUtils, DashColor, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents } from '../../../ClientUtils';
import { Doc, DocListCast, Opt } from '../../../fields/Doc';
import { DocData } from '../../../fields/DocSymbols';
@@ -15,13 +17,15 @@ import { Cast, ImageCast, NumCast, RTFCast, StrCast } from '../../../fields/Type
import { ImageField } from '../../../fields/URLField';
import { TraceMobx } from '../../../fields/util';
import { emptyFunction } from '../../../Utils';
+import { gptAPICall, GPTCallType, gptImageLabel } from '../../apis/gpt/GPT';
import { Docs } from '../../documents/Documents';
import { DocumentType } from '../../documents/DocumentTypes';
-import { DocUtils } from '../../documents/DocUtils';
+import { DocUtils, FollowLinkScript } from '../../documents/DocUtils';
import { Networking } from '../../Network';
import { DragManager } from '../../util/DragManager';
+import { dropActionType } from '../../util/DropActionTypes';
import { SnappingManager } from '../../util/SnappingManager';
-import { undoBatch } from '../../util/UndoManager';
+import { undoable, undoBatch } from '../../util/UndoManager';
import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView';
import { ContextMenu } from '../ContextMenu';
import { ContextMenuProps } from '../ContextMenuItem';
@@ -34,8 +38,16 @@ import { StyleProp } from '../StyleProp';
import { DocumentView } from './DocumentView';
import { FieldView, FieldViewProps } from './FieldView';
import { FocusViewOptions } from './FocusViewOptions';
+import { ImageUtility } from './generativeFill/generativeFillUtils/ImageHandler';
import './ImageBox.scss';
import { OpenWhere } from './OpenWhere';
+// import stringSimilarity from 'string-similarity';
+
+enum quizMode {
+ SMART = 'smart',
+ NORMAL = 'normal',
+ NONE = 'none',
+}
export class ImageEditorData {
// eslint-disable-next-line no-use-before-define
@@ -60,6 +72,8 @@ export class ImageEditorData {
public static get AddDoc() { return ImageEditorData.imageData.addDoc; } // prettier-ignore
public static set AddDoc(addDoc: Opt<(doc: Doc | Doc[], annotationKey?: string) => boolean>) { ImageEditorData.set(this.imageData.open, this.imageData.rootDoc, this.imageData.source, addDoc); } // prettier-ignore
}
+
+const API_URL = 'https://api.unsplash.com/search/photos';
@observer
export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
public static LayoutString(fieldKey: string) {
@@ -74,10 +88,15 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
private _marqueeref = React.createRef<MarqueeAnnotator>();
private _mainCont: React.RefObject<HTMLDivElement> = React.createRef();
private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef();
- @observable _savedAnnotations = new ObservableMap<number, (HTMLDivElement & { marqueeing?: boolean })[]>();
- @observable _curSuffix = '';
- @observable _error = '';
- @observable _isHovering = false; // flag to switch between primary and alternate images on hover
+ private _imageRef: HTMLImageElement | null = null; // <video> ref
+ @observable private _quizBoxes: Doc[] = [];
+ @observable private _searchInput = '';
+ @observable private _quizMode = quizMode.NONE;
+ @observable private _savedAnnotations = new ObservableMap<number, (HTMLDivElement & { marqueeing?: boolean })[]>();
+ @observable private _curSuffix = '';
+ @observable private _error = '';
+ @observable private _loading = false;
+ @observable private _isHovering = false; // flag to switch between primary and alternate images on hover
_ffref = React.createRef<CollectionFreeFormView>();
constructor(props: FieldViewProps) {
@@ -149,8 +168,34 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
Object.values(this._disposers).forEach(disposer => disposer?.());
}
- @undoBatch
- drop = (e: Event, de: DragManager.DropEvent) => {
+ /**
+ * Find images from the unsplash api to add to flashcards.
+ */
+ fetchImages = async () => {
+ try {
+ const { data } = await axios.get(`${API_URL}?query=${this._searchInput}&page=1&per_page=${1}&client_id=${process.env.VITE_API_KEY}`);
+ const imageSnapshot = Docs.Create.ImageDocument(data.results[0].urls.small, {
+ _nativeWidth: Doc.NativeWidth(this.layoutDoc),
+ _nativeHeight: Doc.NativeHeight(this.layoutDoc),
+ x: NumCast(this.layoutDoc.x),
+ y: NumCast(this.layoutDoc.y),
+ onClick: FollowLinkScript(),
+ _width: 150,
+ _height: 150,
+ title: '--snapshot' + NumCast(this.layoutDoc._layout_currentTimecode) + ' image-',
+ });
+ this._props.addDocument?.(imageSnapshot);
+ } catch (error) {
+ console.log(error);
+ }
+ };
+
+ handleSelection = async (selection: string) => {
+ this._searchInput = selection;
+ const images = await this.fetchImages();
+ };
+
+ drop = undoable((e: Event, de: DragManager.DropEvent) => {
if (de.complete.docDragData) {
let added: boolean | undefined;
const targetIsBullseye = (ele: HTMLElement): boolean => {
@@ -179,7 +224,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
return added;
}
return false;
- };
+ }, 'image drop');
@undoBatch
resolution = () => {
@@ -270,10 +315,331 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
return cropping;
};
+ createCanvas = async () => {
+ const canvas = document.createElement('canvas');
+ const scaling = 1 / (this._props.NativeDimScaling?.() || 1);
+ const w = AnchorMenu.Instance.marqueeWidth * scaling;
+ const h = AnchorMenu.Instance.marqueeHeight * scaling;
+ canvas.width = w;
+ canvas.height = h;
+ const ctx = canvas.getContext('2d'); // draw image to canvas. scale to target dimensions
+ if (ctx) {
+ this._imageRef && ctx.drawImage(this._imageRef, NumCast(this._marqueeref.current?.left) * scaling, NumCast(this._marqueeref.current?.top) * scaling, w, h, 0, 0, w, h);
+ }
+ const blob = await ImageUtility.canvasToBlob(canvas);
+ return ImageBox.selectUrlToBase64(blob);
+ };
+
+ createSnapshotLink = (imagePath: string, downX?: number, downY?: number) => {
+ const url = !imagePath.startsWith('/') ? ClientUtils.CorsProxy(imagePath) : imagePath;
+ const width = NumCast(this.layoutDoc._width) || 1;
+ const height = NumCast(this.layoutDoc._height);
+ const imageSnapshot = Docs.Create.ImageDocument(url, {
+ _nativeWidth: Doc.NativeWidth(this.layoutDoc),
+ _nativeHeight: Doc.NativeHeight(this.layoutDoc),
+ x: NumCast(this.layoutDoc.x) + width,
+ y: NumCast(this.layoutDoc.y),
+ onClick: FollowLinkScript(),
+ _width: 150,
+ _height: (height / width) * 150,
+ title: '--snapshot' + NumCast(this.layoutDoc._layout_currentTimecode) + ' image-',
+ });
+ Doc.SetNativeWidth(imageSnapshot[DocData], Doc.NativeWidth(this.layoutDoc));
+ Doc.SetNativeHeight(imageSnapshot[DocData], Doc.NativeHeight(this.layoutDoc));
+ this._props.addDocument?.(imageSnapshot);
+ DocUtils.MakeLink(imageSnapshot, this.getAnchor(true), { link_relationship: 'video snapshot' });
+ setTimeout(() => downX !== undefined && downY !== undefined && DocumentView.getFirstDocumentView(imageSnapshot)?.startDragging(downX, downY, dropActionType.move, true));
+ };
+
+ static selectUrlToBase64 = async (blob: Blob): Promise<string> => {
+ try {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(blob);
+ reader.onloadend = () => resolve(reader.result as string);
+ reader.onerror = error => reject(error);
+ });
+ } catch (error) {
+ console.error('Error:', error);
+ throw error;
+ }
+ };
+
+ /**
+ * Calls backend to find any text on an image. Gets the text and the
+ * coordinates of the text and creates label boxes at those locations.
+ * @param quiz
+ * @param i
+ */
+ pushInfo = async (quiz: quizMode, i?: string) => {
+ this._quizMode = quiz;
+ this._loading = true;
+
+ const img = {
+ file: i ? i : this.paths[0],
+ drag: i ? 'drag' : 'full',
+ smart: quiz,
+ };
+ const response = await axios.post('http://localhost:105/labels/', img, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ if (response.data['boxes'].length != 0) {
+ this.createBoxes(response.data['boxes'], response.data['text']);
+ } else {
+ this._loading = false;
+ }
+ };
+
+ /**
+ * Creates label boxes over text on the image to be filled in.
+ * @param boxes
+ * @param texts
+ */
+ createBoxes = (boxes: [[[number, number]]], texts: [string]) => {
+ for (let i = 0; i < boxes.length; i++) {
+ const coords = boxes[i] ? boxes[i] : [];
+ const width = coords[1][0] - coords[0][0];
+ const height = coords[2][1] - coords[0][1];
+ const text = texts[i];
+
+ const newCol = Docs.Create.LabelDocument({
+ _width: width,
+ _height: height,
+ _layout_fitWidth: true,
+ title: '',
+ });
+ const scaling = 1 / (this._props.NativeDimScaling?.() || 1);
+ newCol.x = coords[0][0] + NumCast(this._marqueeref.current?.left) * scaling;
+ newCol.y = coords[0][1] + NumCast(this._marqueeref.current?.top) * scaling;
+
+ newCol.zIndex = 1000;
+ newCol.forceActive = true;
+ newCol.quiz = text;
+ newCol.showQuiz = false;
+ newCol[DocData].textTransform = 'none';
+ this._quizBoxes.push(newCol);
+ this.addDocument(newCol);
+ this._loading = false;
+ }
+ };
+
+ /**
+ * Create flashcards from an image.
+ */
+ getImageDesc = async () => {
+ this._loading = true;
+ try {
+ const hrefBase64 = await this.createCanvas();
+ const response = await gptImageLabel(hrefBase64, 'Make flashcards out of this image with each question and answer labeled as "question" and "answer". Do not label each flashcard and do not include asterisks: ');
+ AnchorMenu.Instance.transferToFlashcard(response, NumCast(this.layoutDoc['x']), NumCast(this.layoutDoc['y']));
+ } catch (error) {
+ console.log('Error', error);
+ }
+ this._loading = false;
+ };
+
+ /**
+ * Calls the createCanvas and pushInfo methods to convert the
+ * image to a form that can be passed to GPT and find the locations
+ * of the text.
+ */
+ makeLabels = async () => {
+ try {
+ const hrefBase64 = await this.createCanvas();
+ this.pushInfo(quizMode.NORMAL, hrefBase64);
+ } catch (error) {
+ console.log('Error', error);
+ }
+ };
+
+ /**
+ * Determines whether two words should be considered
+ * the same, allowing minor typos.
+ * @param str1
+ * @param str2
+ * @returns
+ */
+ levenshteinDistance = (str1: string, str2: string) => {
+ const len1 = str1.length;
+ const len2 = str2.length;
+ const dp = Array.from(Array(len1 + 1), () => Array(len2 + 1).fill(0));
+
+ if (len1 === 0) return len2;
+ if (len2 === 0) return len1;
+
+ for (let i = 0; i <= len1; i++) dp[i][0] = i;
+ for (let j = 0; j <= len2; j++) dp[0][j] = j;
+
+ for (let i = 1; i <= len1; i++) {
+ for (let j = 1; j <= len2; j++) {
+ const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
+ dp[i][j] = Math.min(
+ dp[i - 1][j] + 1, // deletion
+ dp[i][j - 1] + 1, // insertion
+ dp[i - 1][j - 1] + cost // substitution
+ );
+ }
+ }
+
+ return dp[len1][len2];
+ };
+
+ /**
+ * Different algorithm for determining string similarity.
+ * @param str1
+ * @param str2
+ * @returns
+ */
+ jaccardSimilarity = (str1: string, str2: string) => {
+ const set1 = new Set(str1.split(' '));
+ const set2 = new Set(str2.split(' '));
+
+ const intersection = new Set([...set1].filter(x => set2.has(x)));
+ const union = new Set([...set1, ...set2]);
+
+ return intersection.size / union.size;
+ };
+
+ /**
+ * Averages the jaccardSimilarity and levenshteinDistance scores
+ * to determine string similarity for the labelboxes answers and
+ * the users response.
+ * @param str1
+ * @param str2
+ * @returns
+ */
+ stringSimilarity(str1: string, str2: string) {
+ const levenshteinDist = this.levenshteinDistance(str1, str2);
+ const levenshteinScore = 1 - levenshteinDist / Math.max(str1.length, str2.length);
+
+ const jaccardScore = this.jaccardSimilarity(str1, str2);
+
+ // Combine the scores with a higher weight on Jaccard similarity
+ return 0.5 * levenshteinScore + 0.5 * jaccardScore;
+ }
+
+ @computed get checkIcon() {
+ return (
+ <Tooltip title={<div className="dash-tooltip">Check</div>}>
+ <div className="check-icon" onPointerDown={this.check}>
+ <FontAwesomeIcon icon="circle-check" size="lg" />
+ </div>
+ </Tooltip>
+ );
+ }
+
+ @computed get redoIcon() {
+ return (
+ <Tooltip title={<div className="dash-tooltip">Redo</div>}>
+ <div className="redo-icon" onPointerDown={this.redo}>
+ <FontAwesomeIcon icon="redo-alt" size="lg" />
+ </div>
+ </Tooltip>
+ );
+ }
+
+ /**
+ * Returns whether two strings are similar
+ * @param input
+ * @param target
+ * @returns
+ */
+ compareWords = (input: string, target: string) => {
+ const distance = this.stringSimilarity(input.toLowerCase(), target.toLowerCase());
+ return distance >= 0.7;
+ };
+
+ /**
+ * GPT returns a hex color for what color the label box should be based on
+ * the correctness of the users answer.
+ * @param inputString
+ * @returns
+ */
+ extractHexAndSentences = (inputString: string) => {
+ // Regular expression to match a hexadecimal number at the beginning followed by a period and sentences
+ const regex = /^#([0-9A-Fa-f]+)\.\s*(.+)$/s;
+ const match = inputString.match(regex);
+
+ if (match) {
+ const hexNumber = match[1];
+ const sentences = match[2].trim();
+ return { hexNumber, sentences };
+ } else {
+ return { error: 'The input string does not match the expected format.' };
+ }
+ };
+
+ /**
+ * Check whether the contents of the label boxes on an image are correct.
+ */
+ check = () => {
+ this._loading = true;
+ this._quizBoxes.forEach(async doc => {
+ const input = StrCast(doc[DocData].title);
+ if (this._quizMode == quizMode.SMART && input) {
+ const questionText = 'Question: What was labeled in this image?';
+ const rubricText = ' Rubric: ' + StrCast(doc.quiz);
+ const queryText =
+ questionText +
+ ' UserAnswer: ' +
+ input +
+ '. ' +
+ rubricText +
+ '. One sentence and evaluate based on meaning, not wording. Provide a hex color at the beginning with a period after it on a scale of green (minor details missed) to red (big error) for how correct the answer is. Example: "#FFFFFF. Pasta is delicious."';
+ const response = await gptAPICall(queryText, GPTCallType.QUIZ);
+ const hexSent = this.extractHexAndSentences(response);
+ doc.quiz = hexSent.sentences?.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric');
+ doc.backgroundColor = '#' + hexSent.hexNumber;
+ } else {
+ const match = this.compareWords(input, StrCast(doc.quiz));
+ doc.backgroundColor = match ? '#11c249' : '#eb2d2d';
+ }
+ doc.showQuiz = true;
+ });
+ this._loading = false;
+ };
+
+ redo = () => {
+ this._quizBoxes.forEach(doc => {
+ doc[DocData].title = '';
+ doc.backgroundColor = '#e4e4e4';
+ doc.showQuiz = false;
+ });
+ };
+
+ /**
+ * Get rid of all the label boxes on the images.
+ */
+ exitQuizMode = () => {
+ this._quizMode = quizMode.NONE;
+ this._quizBoxes.forEach(doc => {
+ this.removeDocument?.(doc);
+ });
+ this._quizBoxes = [];
+ };
+
+ @action
+ setRef = (iref: HTMLImageElement | null) => {
+ this._imageRef = iref;
+ };
+
specificContextMenu = (): void => {
const field = Cast(this.dataDoc[this.fieldKey], ImageField);
if (field) {
const funcs: ContextMenuProps[] = [];
+ const quizes: ContextMenuProps[] = [];
+ quizes.push({
+ description: 'Smart Check',
+ event: this._quizMode == quizMode.NONE ? () => this.pushInfo(quizMode.SMART) : this.exitQuizMode,
+ icon: 'pen-to-square',
+ });
+ quizes.push({
+ description: 'Normal',
+ event: this._quizMode == quizMode.NONE ? () => this.pushInfo(quizMode.NORMAL) : this.exitQuizMode,
+ icon: 'pencil',
+ });
funcs.push({ description: 'Rotate Clockwise 90', event: this.rotate, icon: 'redo-alt' });
funcs.push({ description: `Show ${this.layoutDoc._showFullRes ? 'Dynamic Res' : 'Full Res'}`, event: this.resolution, icon: 'expand' });
funcs.push({ description: 'Set Native Pixel Size', event: this.setNativeSize, icon: 'expand-arrows-alt' });
@@ -288,6 +654,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
}),
icon: 'pencil-alt',
});
+ ContextMenu.Instance?.addItem({ description: 'Quiz Mode', subitems: quizes, icon: 'file-pen' });
ContextMenu.Instance?.addItem({ description: 'Options...', subitems: funcs, icon: 'asterisk' });
}
};
@@ -403,6 +770,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
<div className="imageBox-fader" style={{ opacity: backAlpha }}>
<img
alt=""
+ ref={this.setRef}
key="paths"
src={srcpath}
style={{ transform, transformOrigin }}
@@ -455,6 +823,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
@action
finishMarquee = () => {
this._getAnchor = AnchorMenu.Instance?.GetAnchor;
+ AnchorMenu.Instance.gptFlashcards = this.getImageDesc;
+ AnchorMenu.Instance.addToCollection = this._props.DocumentView?.()._props.addDocument;
+ AnchorMenu.Instance.makeLabels = this.makeLabels;
+ AnchorMenu.Instance.marqueeWidth = this._marqueeref.current?.Width ?? 0;
+ AnchorMenu.Instance.marqueeHeight = this._marqueeref.current?.Height ?? 0;
this._marqueeref.current?.onTerminateSelection();
this._props.select(false);
};
@@ -489,7 +862,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
height: this._props.PanelWidth() ? undefined : `100%`,
pointerEvents: this.layoutDoc._lockedPosition ? 'none' : undefined,
borderRadius,
- overflow: this.layoutDoc.layout_fitWidth || this._props.fitWidth?.(this.Document) ? 'auto' : undefined,
+ overflow: this.layoutDoc.layout_fitWidth || this._props.fitWidth?.(this.Document) ? 'auto' : 'hidden',
}}>
<CollectionFreeFormView
ref={this._ffref}
@@ -516,6 +889,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
addDocument={this.addDocument}>
{this.content}
</CollectionFreeFormView>
+ {this._loading ? (
+ <div className="loading-spinner" style={{ position: 'absolute' }}>
+ <ReactLoading type="spin" height={50} width={50} color={'blue'} />
+ </div>
+ ) : null}
{this.annotationLayer}
{!this._mainCont.current || !this.DocumentView || !this._annotationLayer.current ? null : (
<MarqueeAnnotator
@@ -534,8 +912,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
marqueeContainer={this._mainCont.current}
highlightDragSrcColor=""
anchorMenuCrop={this.crop}
+ // anchorMenuFlashcard={() => this.getImageDesc()}
/>
)}
+ {this._quizMode != quizMode.NONE ? this.checkIcon : null}
+ {this._quizMode != quizMode.NONE ? this.redoIcon : null}
</div>
);
}