diff options
20 files changed, 974 insertions, 192 deletions
diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts index 05007960d..7bcd541c7 100644 --- a/src/client/apis/gpt/GPT.ts +++ b/src/client/apis/gpt/GPT.ts @@ -25,7 +25,7 @@ const callTypeMap: { [type: string]: GPTCallOpts } = { // newest model: gpt-4 summary: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Summarize the text given in simpler terms.' }, edit: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Reword the text.' }, - flashcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Make flashcards out of this text with each question and answer labeled. Do not label each flashcard and do not include asterisks: ' }, + flashcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Make flashcards out of this text with each question and answer labeled as question and answer. Do not label each flashcard and do not include asterisks: ' }, completion: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: "You are a helpful assistant. Answer the user's prompt." }, mermaid: { model: 'gpt-4-turbo', @@ -51,7 +51,7 @@ const callTypeMap: { [type: string]: GPTCallOpts } = { model: 'gpt-4-turbo', maxTokens: 1024, temp: 0, - prompt: 'List unique differences between the content of the UserAnswer and Rubric. Before each difference, label it and provide any additional information the UserAnswer missed and explain it in second person without separating it into UserAnswer and Rubric content and additional information. If there are no differences, say correct', + prompt: 'List unique differences between the content of the UserAnswer and Rubric. Before each difference, label it and provide any additional information the UserAnswer missed and explain it in second person without separating it into UserAnswer and Rubric content and additional information. If the Rubric is incorrect, explain why. If there are no differences, say correct. If it is empty, say there is nothing for me to evaluate.', }, }; @@ -120,7 +120,7 @@ const gptGetEmbedding = async (src: string): Promise<number[]> => { return []; } }; -const gptImageLabel = async (src: string): Promise<string> => { +const gptImageLabel = async (src: string, prompt: string): Promise<string> => { try { const response = await openai.chat.completions.create({ model: 'gpt-4o', @@ -128,7 +128,7 @@ const gptImageLabel = async (src: string): Promise<string> => { { role: 'user', content: [ - { type: 'text', text: 'Give three to five labels to describe this image.' }, + { type: 'text', text: prompt }, { type: 'image_url', image_url: { @@ -141,6 +141,7 @@ const gptImageLabel = async (src: string): Promise<string> => { ], }); if (response.choices[0].message.content) { + console.log(response.choices[0].message.content); return response.choices[0].message.content; } return 'Missing labels'; diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 31d88fb87..541b15006 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -548,6 +548,7 @@ export class MainView extends ObservableReactComponent<{}> { fa.faRobot, fa.faSatellite, fa.faStar, + fa.faFilePen, ] ); } diff --git a/src/client/views/MarqueeAnnotator.tsx b/src/client/views/MarqueeAnnotator.tsx index c18ac6738..c7ffce2b4 100644 --- a/src/client/views/MarqueeAnnotator.tsx +++ b/src/client/views/MarqueeAnnotator.tsx @@ -34,6 +34,7 @@ export interface MarqueeAnnotatorProps { getPageFromScroll?: (top: number) => number; finishMarquee: (x?: number, y?: number) => void; anchorMenuClick?: () => undefined | ((anchor: Doc) => void); + anchorMenuFlashcard?: () => Promise<String>; anchorMenuCrop?: (anchor: Doc | undefined, addCrop: boolean) => Doc | undefined; highlightDragSrcColor?: string; } diff --git a/src/client/views/collections/CollectionCardDeckView.tsx b/src/client/views/collections/CollectionCardDeckView.tsx index de46180e6..50af9df9e 100644 --- a/src/client/views/collections/CollectionCardDeckView.tsx +++ b/src/client/views/collections/CollectionCardDeckView.tsx @@ -373,7 +373,7 @@ export class CollectionCardView extends CollectionSubView() { const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`; try { const hrefBase64 = await CollectionCardView.imageUrlToBase64(hrefComplete); - const response = await gptImageLabel(hrefBase64); + const response = await gptImageLabel(hrefBase64, 'Give three to five labels to describe this image.'); image[DocData].description = response.trim(); return response; // Return the response from gptImageLabel } catch (error) { diff --git a/src/client/views/collections/CollectionCarouselView.scss b/src/client/views/collections/CollectionCarouselView.scss index f115bb40a..b402a7a32 100644 --- a/src/client/views/collections/CollectionCarouselView.scss +++ b/src/client/views/collections/CollectionCarouselView.scss @@ -1,5 +1,7 @@ .collectionCarouselView-outer { height: 100%; + position: relative; + overflow: hidden; .collectionCarouselView-caption { height: 50; display: inline-block; @@ -11,17 +13,26 @@ width: 100%; user-select: none; } + .message { + justify-content: center; + align-items: center; + display: flex; + height: 60%; + z-index: -1; + // margin: 15px; + } } + .carouselView-back, .carouselView-fwd, .carouselView-star, .carouselView-remove, -.carouselView-check { +.carouselView-check, +.carouselView-add { position: absolute; display: flex; - top: 42.5%; width: 30; - height: 15%; + height: 30; align-items: center; border-radius: 5px; justify-content: center; @@ -32,14 +43,21 @@ } } .carouselView-fwd { + top: 42.5%; right: 20; } .carouselView-back { + top: 42.5%; left: 20; } .carouselView-star { top: 0; - right: 20; + left: 0; +} +.carouselView-add { + position: absolute; + bottom: 0; + left: 0; } .carouselView-remove { top: 80%; @@ -49,6 +67,46 @@ top: 80%; right: 52%; } +.carouselView-quiz { + position: absolute; + display: flex; + top: 5px; + right: 8px; + &:hover { + color: white; + } +} + +.carouselView-practice { + position: absolute; + display: flex; + top: 22px; + right: 8px; + &:hover { + color: white; + } +} +.carouselView-starFilter { + position: absolute; + display: flex; + top: 40px; + right: 7px; + &:hover { + color: white; + } +} + +.carouselView-menu { + position: absolute; + display: flex; + top: 2px; + right: 2px; + width: 30; + height: 60; + border-radius: 5px; + color: rgba(255, 255, 255, 0.5); + background: rgba(0, 0, 0, 0.1); +} .carouselView-back:hover, .carouselView-fwd:hover { diff --git a/src/client/views/collections/CollectionCarouselView.tsx b/src/client/views/collections/CollectionCarouselView.tsx index 2adad68e0..6976deea5 100644 --- a/src/client/views/collections/CollectionCarouselView.tsx +++ b/src/client/views/collections/CollectionCarouselView.tsx @@ -2,8 +2,9 @@ /* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable react/jsx-props-no-spreading */ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { computed, makeObservable } from 'mobx'; +import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; +import { Docs } from '../../documents/Documents'; import * as React from 'react'; import { StopEvent, returnFalse, returnOne, returnTrue, returnZero } from '../../../ClientUtils'; import { emptyFunction } from '../../../Utils'; @@ -19,11 +20,20 @@ import { FieldViewProps } from '../nodes/FieldView'; import { FormattedTextBox } from '../nodes/formattedText/FormattedTextBox'; import './CollectionCarouselView.scss'; import { CollectionSubView } from './CollectionSubView'; +import { Tooltip } from '@mui/material'; +import { DocUtils } from '../../documents/DocUtils'; +import { Any } from '@react-spring/web'; enum cardMode { - PRACTICE = 'practice', + // PRACTICE = 'practice', STAR = 'star', + // QUIZ = 'quiz', + ALL = 'all', +} +enum practiceMode { + PRACTICE = 'practice', QUIZ = 'quiz', + NORMAL = 'normal', } enum practiceVal { MISSED = 'missed', @@ -32,12 +42,20 @@ enum practiceVal { @observer export class CollectionCarouselView extends CollectionSubView() { private _dropDisposer?: DragManager.DragDropDisposer; + @observable private _practiceMessage: string | undefined; + @observable private _filterMessage: string | undefined; get practiceField() { return this.fieldKey + "_practice"; } // prettier-ignore - get starField() { return this.fieldKey + "_star"; } // prettier-ignore + get sideField() { return "_" + this.fieldKey + "_usePath"; } // prettier-ignore + get starField() { return "star"; } // prettier-ignore constructor(props: any) { super(props); makeObservable(this); + // this.setModes(); + this.layoutDoc.filterOp = cardMode.ALL; + Doc.setDocFilter(this.Document, 'star', undefined, 'match'); + this.layoutDoc.practiceMode = practiceMode.NORMAL; + this.layoutDoc._carousel_index = 0; } componentWillUnmount() { @@ -52,16 +70,33 @@ export class CollectionCarouselView extends CollectionSubView() { }; @computed get carouselItems() { + this.childLayoutPairs.map(pair => { + pair.layout.embedContainer = this.Document; + }); return this.childLayoutPairs.filter(pair => pair.layout.type !== DocumentType.LINK); } @computed get marginX() { return NumCast(this.layoutDoc.caption_xMargin, 50); } + @action setPracticeMessage = (mes: string | undefined) => { + this._practiceMessage = mes; + }; + @action setFilterMessage = (mes: string | undefined) => { + this._filterMessage = mes; + }; + + setModes = () => { + this.layoutDoc.filterOp = cardMode.ALL; + Doc.setDocFilter(this.Document, 'data_star', undefined, 'match'); + this.layoutDoc.practiceMode = practiceMode.NORMAL; + this.layoutDoc._carousel_index = 0; + }; + move = (dir: number) => { const moveToCardWithField = (match: (doc: Doc) => boolean): boolean => { let startInd = (NumCast(this.layoutDoc._carousel_index) + dir) % this.carouselItems.length; - while (!match(this.carouselItems?.[startInd].layout) && (startInd + dir + this.carouselItems.length) % this.carouselItems.length !== this.layoutDoc._carousel_index) { + while (!match(this.carouselItems?.[startInd].layout) && (startInd + this.carouselItems.length) % this.carouselItems.length !== this.layoutDoc._carousel_index) { startInd = (startInd + dir + this.carouselItems.length) % this.carouselItems.length; } if (match(this.carouselItems?.[startInd].layout)) { @@ -70,23 +105,28 @@ export class CollectionCarouselView extends CollectionSubView() { } return match(this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)].layout); }; - switch (StrCast(this.layoutDoc.filterOp)) { - case cardMode.STAR: // go to a flashcard that is starred, skip the ones that aren't + + switch (this.layoutDoc.practiceMode && this.layoutDoc.filterOp) { + case practiceMode.PRACTICE && cardMode.ALL: + if (!moveToCardWithField((doc: Doc) => doc[this.practiceField] !== practiceVal.CORRECT)) { + this._practiceMessage = 'Finished! Unselect practice mode to view all flashcards.'; + this.carouselItems.forEach(item => { item.layout[this.practiceField] = undefined}); //prettier-ignore + } + break; + case !practiceMode.PRACTICE && cardMode.STAR: if (!moveToCardWithField((doc: Doc) => !!doc[this.starField])) { - this.layoutDoc.filterOp = undefined; // if there aren't any starred, show all cards + this._filterMessage = 'No starred items. Unselect this view to see all flashcards and star them.'; } break; - case cardMode.PRACTICE: // go to a new index that is missed, skip the ones that are correct - if (!moveToCardWithField((doc: Doc) => doc[this.practiceField] !== practiceVal.CORRECT)) { - this.layoutDoc.filterOp = undefined; // if all of the cards are correct, show all cards and exit practice mode - - this.carouselItems.forEach(item => { // reset all the practice values - item.layout[this.practiceField] = undefined; - }); + case practiceMode.PRACTICE && cardMode.STAR: + if (!moveToCardWithField((doc: Doc) => doc[this.practiceField] !== practiceVal.CORRECT && doc[this.starField] === true)) { + this._filterMessage = 'No flashcards to show! Unselect mode to view all flashcards.'; + this._practiceMessage = undefined; } break; - default: moveToCardWithField(returnTrue); - } // prettier-ignore + default: + moveToCardWithField(returnTrue); + } }; /** @@ -112,6 +152,8 @@ export class CollectionCarouselView extends CollectionSubView() { e.stopPropagation(); const curDoc = this.carouselItems[NumCast(this.layoutDoc._carousel_index)]; curDoc.layout[this.starField] = curDoc.layout[this.starField] ? undefined : true; + // if (!curDoc.layout[this.starField]) this.move(1); + // this.layoutDoc._carousel_index = undefined; }; /* @@ -123,7 +165,6 @@ export class CollectionCarouselView extends CollectionSubView() { curDoc.layout[this.practiceField] = val; this.advance(e); }; - captionStyleProvider = (doc: Doc | undefined, captionProps: Opt<FieldViewProps>, property: string): any => { // first look for properties on the document in the carousel, then fallback to properties on the container const childValue = doc?.['caption_' + property] ? this._props.styleProvider?.(doc, captionProps, property) : undefined; @@ -133,22 +174,42 @@ export class CollectionCarouselView extends CollectionSubView() { onContentDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick); onContentClick = () => ScriptCast(this.layoutDoc.onChildClick); captionWidth = () => this._props.PanelWidth() - 2 * this.marginX; - specificMenu = (): void => { - const cm = ContextMenu.Instance; - - const revealOptions = cm.findByDescription('Filter Flashcards'); - const revealItems: ContextMenuProps[] = revealOptions && 'subitems' in revealOptions ? revealOptions.subitems : []; - revealItems.push({description: 'All', event: () => {this.layoutDoc.filterOp = undefined;}, icon: 'layer-group',}); // prettier-ignore - revealItems.push({description: 'Star', event: () => {this.layoutDoc.filterOp = cardMode.STAR;}, icon: 'star',}); // prettier-ignore - revealItems.push({description: 'Practice Mode', event: () => {this.layoutDoc.filterOp = cardMode.PRACTICE;}, icon: 'check',}); // prettier-ignore - revealItems.push({description: 'Quiz Cards', event: () => {this.layoutDoc.filterOp = cardMode.QUIZ;}, icon: 'pencil',}); // prettier-ignore - !revealOptions && cm.addItem({ description: 'Filter Flashcards', addDivider: false, noexpand: true, subitems: revealItems, icon: 'layer-group' }); + + setPracticeMode = (mode: practiceMode) => { + this.layoutDoc.practiceMode = mode; + this.carouselItems?.map(doc => (doc.layout[this.practiceField] = undefined)); + switch (mode) { + case practiceMode.QUIZ: + this.carouselItems?.map(doc => (doc.layout[this.sideField] = undefined)); + break; + case practiceMode.NORMAL: + this.setPracticeMessage(undefined); + break; + } }; + + setFilterMode = (mode: cardMode) => { + this.layoutDoc.filterOp = mode; + switch (mode) { + case cardMode.STAR: + // Doc.setDocFilter(this.Document, 'data_star', true, 'match'); + this.move(1); + break; + default: + this.setFilterMessage(undefined); // prettier-ignore + // Doc.setDocFilter(this.Document, 'data_star', true, 'remove'); + } + }; + @computed get content() { + if (this.layoutDoc._carousel_index === this.carouselItems.length && this.layoutDoc._carousel_index !== 0) { + this.move(1); + } const index = NumCast(this.layoutDoc._carousel_index); const curDoc = this.carouselItems?.[index]; const captionProps = { ...this._props, NativeScaling: returnOne, PanelWidth: this.captionWidth, fieldKey: 'caption', setHeight: undefined, setContentView: undefined }; const carouselShowsCaptions = StrCast(this.layoutDoc._layout_showCaption); + return !(curDoc?.layout instanceof Doc) ? null : ( <> <div className="collectionCarouselView-image" key="image"> @@ -158,10 +219,12 @@ export class CollectionCarouselView extends CollectionSubView() { NativeHeight={returnZero} fitWidth={undefined} setContentViewBox={undefined} + childFilters={this.childDocFilters} onDoubleClickScript={this.onContentDoubleClick} onClickScript={this.onContentClick} isDocumentActive={this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this._props.isContentActive} isContentActive={this._props.childContentsActive ?? this._props.isContentActive() === false ? returnFalse : emptyFunction} + addDocument={this._props.addDocument} hideCaptions={!!carouselShowsCaptions} // hide captions if the carousel is configured to show the captions renderDepth={this._props.renderDepth + 1} LayoutTemplate={this._props.childLayoutTemplate} @@ -188,6 +251,25 @@ export class CollectionCarouselView extends CollectionSubView() { </> ); } + + containsDifTypes = (): boolean => { + return this.carouselItems.filter(doc => !doc.layout._layout_isFlashcard).length !== 0; + }; + + addFlashcard() { + const newDoc = Docs.Create.ComparisonDocument('', { _layout_isFlashcard: true, _width: 300, _height: 300 }); + this.addDocument?.(newDoc); + // DocUtils.copyDragFactory(newDoc); + // this._props.addDocument?.(); + // newDoc.layout = this.layoutDoc; + // newDoc.data = this.dataDoc; + // Doc.AddDocToList() + // this._props.parent._props.addDocument(); + // this.childLayoutPairs.push({ newDoc.layout, newDoc.data}); + // this._props.addDocument?.(newDoc); + // console.log('HERE'); + } + @computed get buttons() { if (!this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)]) return null; return ( @@ -198,54 +280,108 @@ export class CollectionCarouselView extends CollectionSubView() { <div key="fwd" className="carouselView-fwd" onClick={this.advance}> <FontAwesomeIcon icon="chevron-right" size="2x" /> </div> - <div key="star" className="carouselView-star" onClick={this.star}> - <FontAwesomeIcon icon="star" color={this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)].layout[this.starField] ? 'yellow' : 'gray'} size="1x" /> - </div> - <div key="remove" className="carouselView-remove" onClick={e => this.setPracticeVal(e, practiceVal.MISSED)} style={{ visibility: this.layoutDoc.filterOp === cardMode.PRACTICE ? 'visible' : 'hidden' }}> - <FontAwesomeIcon icon="xmark" color="red" size="1x" /> - </div> - <div key="check" className="carouselView-check" onClick={e => this.setPracticeVal(e, practiceVal.CORRECT)} style={{ visibility: this.layoutDoc.filterOp === cardMode.PRACTICE ? 'visible' : 'hidden' }}> - <FontAwesomeIcon icon="check" color="green" size="1x" /> - </div> + {!this.containsDifTypes() ? ( + <div> + <Tooltip title="star"> + <div key="star" className="carouselView-star" onClick={this.star}> + <FontAwesomeIcon icon="star" color={this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)].layout[this.starField] ? 'yellow' : 'gray'} size="1x" /> + </div> + </Tooltip> + {/* <Tooltip title="add new flashcard to pile"> + <div key="add" className="carouselView-add" onClick={this.addFlashcard}> + <FontAwesomeIcon icon="plus" color={this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)].layout[this.starField] ? 'yellow' : 'gray'} size="1x" /> + </div> + </Tooltip> */} + </div> + ) : null} + {this.layoutDoc.practiceMode === practiceMode.PRACTICE ? ( + <div> + <Tooltip title="Incorrect. View again later."> + <div key="remove" className="carouselView-remove" onClick={e => this.setPracticeVal(e, practiceVal.MISSED)}> + <FontAwesomeIcon icon="xmark" color="red" size="1x" /> + </div> + </Tooltip> + <Tooltip title="Correct"> + <div key="check" className="carouselView-check" onClick={e => this.setPracticeVal(e, practiceVal.CORRECT)}> + <FontAwesomeIcon icon="check" color="green" size="1x" /> + </div> + </Tooltip> + </div> + ) : null} </> ); } + togglePracticeMode = (mode: practiceMode) => { + if (mode === this.layoutDoc.practiceMode) { + this.setPracticeMode(practiceMode.NORMAL); + // this.setPracticeMessage("undefined"); + } else this.setPracticeMode(mode); + }; + toggleFilterMode = () => { this.layoutDoc.filterOp === cardMode.STAR ? this.setFilterMode(cardMode.ALL) : this.setFilterMode(cardMode.STAR)}; //prettier-ignore + setColor = (mode: practiceMode | cardMode, which: string) => { return which === mode ? 'white' : 'light gray'}; //prettier-ignore + + @computed get menu() { + return ( + <div className="carouselView-menu"> + <Tooltip title="Practice flashcards using GPT"> + <div key="back" className="carouselView-quiz" onClick={e => this.togglePracticeMode(practiceMode.QUIZ)}> + <FontAwesomeIcon icon="file-pen" color={this.setColor(practiceMode.QUIZ, StrCast(this.layoutDoc.practiceMode))} size="1x" /> + </div> + </Tooltip> + <Tooltip title={this.layoutDoc.practiceMode === practiceMode.PRACTICE ? 'Exit practice mode' : 'Practice flashcards manually'}> + <div key="back" className="carouselView-practice" onClick={e => this.togglePracticeMode(practiceMode.PRACTICE)}> + <FontAwesomeIcon icon="check" color={this.setColor(practiceMode.PRACTICE, StrCast(this.layoutDoc.practiceMode))} size="1x" /> + </div> + </Tooltip> + <Tooltip title={this.layoutDoc.filterOp === cardMode.STAR ? 'Show all cards' : 'Show only starred cards'}> + <div key="back" className="carouselView-starFilter" onClick={e => this.toggleFilterMode()}> + <FontAwesomeIcon icon="filter" color={this.setColor(cardMode.STAR, StrCast(this.layoutDoc.filterOp))} size="1x" /> + </div> + </Tooltip> + </div> + ); + } + render() { return ( <div className="collectionCarouselView-outer" ref={this.createDashEventsTarget} - onContextMenu={this.specificMenu} style={{ background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor), color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color), }}> - {this.content} - {/* Displays a message to the user to add more flashcards if they are in practice mode and no flashcards are there. */} - <p - style={{ - display: !this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)] && this.layoutDoc.filterOp === cardMode.PRACTICE ? 'flex' : 'none', - justifyContent: 'center', - alignItems: 'center', - height: '100%', - zIndex: '-1', - }}> - Add flashcards! - </p> - {/* Displays a message to the user that a flashcard was recently missed if they had previously gotten it wrong. */} + {!this._practiceMessage && !this._filterMessage ? ( + this.content + ) : ( + <p className="message"> + {this._filterMessage} + {'\n'} + {this._practiceMessage} + </p> + )} + {!this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)] && this.layoutDoc.practiceMode === practiceMode.PRACTICE ? <p className="message">Add flashcards </p> : null} <p + className="missed-message" style={{ color: 'red', + fontWeight: 'bold', zIndex: '999', position: 'relative', left: '10px', top: '10px', - display: this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)] ? (this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)].layout[this.practiceField] === practiceVal.MISSED ? 'block' : 'none') : 'none', + width: '10px', + display: this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)] + ? this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)].layout[this.practiceField] === practiceVal.MISSED && this.layoutDoc.practiceMode === practiceMode.PRACTICE && !this._practiceMessage + ? 'block' + : 'none' + : 'none', }}> Recently missed! </p> - {this.Document._chromeHidden ? null : this.buttons} + {!this.containsDifTypes() && this.carouselItems.length !== 0 ? this.menu : null} + {this.Document._chromeHidden || (!this._filterMessage && !this._practiceMessage) ? this.buttons : null} </div> ); } diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index dc15c83c5..0afda3e64 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -440,7 +440,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps 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 => + gptImageLabel(hrefBase64, 'Give three to five labels to describe this image.').then(labels => Promise.all(labels.split('\n').map(label => gptGetEmbedding(label))).then(embeddings => ({ doc, embeddings, labels }))) ); // prettier-ignore }); diff --git a/src/client/views/nodes/ComparisonBox.scss b/src/client/views/nodes/ComparisonBox.scss index 08d9e6010..7848add24 100644 --- a/src/client/views/nodes/ComparisonBox.scss +++ b/src/client/views/nodes/ComparisonBox.scss @@ -5,31 +5,35 @@ width: 100%; height: 100%; position: relative; + background: gray; z-index: 0; pointer-events: none; display: flex; p { + // bcz: what is this styling for? if text in the comparison box is colored, then this causes it to render with a black outline color: rgb(0, 0, 0); -webkit-text-stroke-color: black; -webkit-text-stroke-width: 0.2px; } - .input-box { - position: relative; + position: absolute; + top: 50; padding: 10px; width: 100%; - height: 100%; + height: 70%; display: flex; } .submit-button { - position: relative; + position: absolute; padding-bottom: 10px; padding-left: 5px; padding-right: 5px; width: 100%; + border-radius: 2px; height: 15%; display: flex; + bottom: 0; button { flex: 1; @@ -39,8 +43,13 @@ textarea { flex: 1; padding: 10px; - position: relative; + // position: relative; resize: none; + position: 'absolute'; + width: '91%'; + height: '80%'; + z-index: '-1'; + overscroll-behavior: contain; } .clip-div { @@ -117,6 +126,33 @@ opacity: 0.5; } } + + .loading-spinner { + display: flex; + justify-content: center; + align-items: center; + height: 90%; + width: 93%; + font-size: 20px; + font-weight: bold; + color: #0b0a0a; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } +} + +.comparisonBox-explain { + position: absolute; + top: 10px; + left: 10px; + z-index: 200; + // padding: 5px; + background: #dfdfdf; + pointer-events: none; } .comparisonBox-interactive { @@ -128,8 +164,9 @@ display: flex; } } + // .input-box { - // position: relative; + // position: absolute; // padding: 10px; // } // input[type='text'] { @@ -216,24 +253,5 @@ } } } - - .loading-circle { - position: relative; - width: 50px; - height: 50px; - border-radius: 50%; - border: 3px solid #ccc; - border-top-color: #333; - animation: spin 1s infinite linear; - } - - @keyframes spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } - } } } diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx index efaf6807a..3e1c415d6 100644 --- a/src/client/views/nodes/ComparisonBox.tsx +++ b/src/client/views/nodes/ComparisonBox.tsx @@ -1,15 +1,16 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; -import { action, computed, makeObservable, observable } from 'mobx'; +import { action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { returnFalse, returnNone, setupMoveUpEvents } from '../../../ClientUtils'; +import { returnFalse, returnNone, returnTrue, setupMoveUpEvents } from '../../../ClientUtils'; import { emptyFunction } from '../../../Utils'; import { Doc, Opt } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { RichTextField } from '../../../fields/RichTextField'; import { DocCast, NumCast, RTFCast, StrCast, toList } from '../../../fields/Types'; import { GPTCallType, gptAPICall } from '../../apis/gpt/GPT'; +import '../pdf/GPTPopup/GPTPopup.scss'; import { DocUtils } from '../../documents/DocUtils'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; @@ -23,7 +24,15 @@ import './ComparisonBox.scss'; import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import { FormattedTextBox } from './formattedText/FormattedTextBox'; +import ReactLoading from 'react-loading'; +enum RevealOp { + Hover = 'hover', + Flip = 'flip', +} +enum UsePath { + Alternate = 'alternate', +} @observer export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { public static LayoutString(fieldKey: string) { @@ -37,14 +46,24 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() @observable _errorMessage = ''; @observable _outputMessage = ''; @observable _animating = ''; + @observable private _isEmpty = false; + @observable _yRelativeToTop: boolean = true; constructor(props: FieldViewProps) { super(props); makeObservable(this); } + @action handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { + this._inputValue = e.target.value; + console.log(this._inputValue); + }; componentDidMount() { this._props.setContentViewBox?.(this); + reaction( + () => this._props.isSelected(), // when this reaction should update + selected => !selected && (this.childActive = false) // what it should update to + ); } protected createDropTarget = (ele: HTMLDivElement | null, fieldKey: string, disposerId: number) => { @@ -54,11 +73,12 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() } }; - @computed get revealOp() { return this.layoutDoc[`_${this.fieldKey}_revealOp`] as ('flip'|'hover'|undefined); } // prettier-ignore + @computed get useAlternate() { return this.layoutDoc[`_${this.fieldKey}_usePath`] === UsePath.Alternate; } // prettier-ignore + @computed get revealOp() { return this.layoutDoc[`_${this.fieldKey}_revealOp`] as Opt<RevealOp>; } // prettier-ignore @computed get clipWidth() { return NumCast(this.layoutDoc[`_${this.fieldKey}_clipWidth`], 50); } // prettier-ignore + set useAlternate(alt: boolean) { this.layoutDoc[`_${this.fieldKey}_usePath`] = alt ? UsePath.Alternate : undefined; } // prettier-ignore + set revealOp(op: Opt<RevealOp>){ this.layoutDoc[`_${this.fieldKey}_revealOp`] = op; } // prettier-ignore set clipWidth(width: number) { this.layoutDoc[`_${this.fieldKey}_clipWidth`] = width; } // prettier-ignore - @computed get useAlternate() { return this.layoutDoc[`_${this.fieldKey}_usePath`] === 'alternate'; } // prettier-ignore - set useAlternate(alt: boolean) { this.layoutDoc[`_${this.fieldKey}_usePath`] = alt ? 'alternate' : undefined; } // prettier-ignore animateClipWidth = action((clipWidth: number, duration = 200 /* ms */) => { this._animating = `all ${duration}ms`; // turn on clip animation transition, then turn it off at end of animation @@ -73,6 +93,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() Doc.SetContainer(droppedDocuments.lastElement(), this.dataDoc); !added && e.preventDefault(); e.stopPropagation(); // prevent parent Doc from registering new position so that it snaps back into place + // this.childActive = false; return added; } return undefined; @@ -87,7 +108,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() emptyFunction, action((clickEv, doubleTap) => { if (doubleTap) { - this._isAnyChildContentActive = true; + this.childActive = true; if (!this.dataDoc[this.fieldKey + '_1'] && !this.dataDoc[this.fieldKey]) this.dataDoc[this.fieldKey + '_1'] = DocUtils.copyDragFactory(Doc.UserDoc().emptyNote as Doc); if (!this.dataDoc[this.fieldKey + '_2'] && !this.dataDoc[this.fieldKey + '_alternate']) this.dataDoc[this.fieldKey + '_2'] = DocUtils.copyDragFactory(Doc.UserDoc().emptyNote as Doc); // DocumentView.addViewRenderedCb(DocCast(this.dataDoc[this.fieldKey + '_1']), dv => { @@ -127,23 +148,29 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() }; clearDoc = undoable((fieldKey: string) => { - delete this.dataDoc[fieldKey]; - this.dataDoc[fieldKey] = 'empty'; + // delete this.dataDoc[fieldKey]; + this.dataDoc[fieldKey] = undefined; + this._isEmpty = true; + // this.dataDoc[fieldKey] = 'empty'; + console.log('HERE' + fieldKey + ';'); }, 'clear doc'); // clearDoc = (fieldKey: string) => delete this.dataDoc[fieldKey]; moveDoc = (doc: Doc, addDocument: (document: Doc | Doc[]) => boolean, which: string) => this.remDoc(doc, which) && addDocument(doc); addDoc = (doc: Doc, which: string) => { - if (this.dataDoc[which] && this.dataDoc[which] !== 'empty') return false; + if (this.dataDoc[which] && !this._isEmpty) return false; this.dataDoc[which] = doc; return true; }; remDoc = (doc: Doc, which: string) => { if (this.dataDoc[which] === doc) { + this._isEmpty = true; // this.dataDoc[which] = 'empty'; + console.log('HEREEEE'); this.dataDoc[which] = undefined; return true; } + console.log('FALSE'); return false; }; @@ -205,6 +232,41 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() return layoutTemplateString; }; + createFlashcardPile(collectionArr: Doc[], gpt: boolean) { + const newCol = Docs.Create.CarouselDocument(collectionArr, { + _width: NumCast(this.layoutDoc['_' + this._props.fieldKey + '_width'], 250) + 50, + _height: NumCast(this.layoutDoc['_' + this._props.fieldKey + '_width'], 200) + 50, + _layout_fitWidth: false, + _layout_autoHeight: true, + }); + newCol['x'] = this.layoutDoc['x']; + newCol['y'] = NumCast(this.layoutDoc['y']) + 50; + newCol.type_collection = 'carousel'; + // console.log(newCol.data); + + if (gpt) { + this._props.DocumentView?.()._props.addDocument?.(newCol); + this._props.removeDocument?.(this.Document); + } else { + this._props.addDocument?.(newCol); + this._props.removeDocument?.(this.Document); + this.Document.embedContainer = newCol; + } + } + + gptFlashcardPile = async () => { + var text = await this.askGPT(GPTCallType.FLASHCARD); + + var senArr = text?.split('Question: '); + var collectionArr: Doc[] = []; + for (let i = 1; i < senArr?.length!; i++) { + const newDoc = Docs.Create.ComparisonDocument(senArr![i], { _layout_isFlashcard: true, _width: 300, _height: 300 }); + newDoc.text = senArr![i]; + collectionArr.push(newDoc); + } + this.createFlashcardPile(collectionArr, true); + }; + /** * Flips a flashcard to the alternate side for the user to view. */ @@ -216,9 +278,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() * Changes the view option to hover for a flashcard. */ hoverFlip = (alternate: boolean) => { - if (this.revealOp === 'hover') this.useAlternate = alternate; + if (this.revealOp === RevealOp.Hover) this.useAlternate = alternate; }; - /** * Creates the button used to flip the flashcards. */ @@ -229,10 +290,11 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() className="formattedTextBox-alternateButton" onPointerDown={e => setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => { - if (!this.revealOp || this.revealOp === 'flip') { + if (!this.revealOp || this.revealOp === RevealOp.Flip) { this.flipFlashcard(); - console.log('Print Front of cards: ' + (RTFCast(DocCast(this.dataDoc[this.fieldKey + '_0']).text)?.Text ?? '')); - console.log('Print Back of cards: ' + (RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text ?? '')); + + // console.log('Print Front of cards: ' + (RTFCast(DocCast(this.dataDoc[this.fieldKey + '_0']).text)?.Text ?? '')); + // console.log('Print Back of cards: ' + (RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text ?? '')); } }) } @@ -240,17 +302,73 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() background: this.useAlternate ? 'white' : 'black', color: this.useAlternate ? 'black' : 'white', }}> - <FontAwesomeIcon icon="turn-up" size="sm" /> + <div key="alternate" className="formattedTextBox-flip"> + <FontAwesomeIcon icon="turn-up" size="1x" /> + </div> </div> </Tooltip> ); } + @computed get flashcardMenu() { + return ( + <div> + <Tooltip + title={this.useAlternate ? <div className="dash-tooltip">Flip to front side to use GPT</div> : <div className="dash-tooltip">Ask GPT to create an answer on the back side of the flashcard based on your question on the front</div>}> + <div style={{ position: 'absolute', bottom: '3px', right: '50px', cursor: 'pointer' }} onPointerDown={e => (!this.layoutDoc[`_${this._props.fieldKey}_usePath`] ? this.askGPT(GPTCallType.CHATCARD) : null)}> + <FontAwesomeIcon icon="lightbulb" size="xl" /> + </div> + </Tooltip> + {DocCast(this.Document.embedContainer).type_collection === 'carousel' ? null : ( + <div> + <Tooltip title={<div>Create a flashcard pile</div>}> + <div style={{ position: 'absolute', bottom: '3px', right: '74px', cursor: 'pointer' }} onPointerDown={e => this.createFlashcardPile([this.Document], false)}> + <FontAwesomeIcon icon="folder-plus" size="xl" /> + </div> + </Tooltip> + <Tooltip title={<div className="dash-tooltip">Create new flashcard stack based on text</div>}> + <div style={{ position: 'absolute', bottom: '3px', right: '104px', cursor: 'pointer' }} onClick={e => this.gptFlashcardPile()}> + <FontAwesomeIcon icon="layer-group" size="xl" /> + </div> + </Tooltip> + </div> + )} + <Tooltip title={<div className="dash-tooltip">Hover to reveal</div>}> + <div style={{ position: 'absolute', bottom: '3px', right: '25px', cursor: 'pointer' }} onClick={e => this.handleHover()}> + <FontAwesomeIcon color={this.revealOp === RevealOp.Hover ? 'blue' : 'black'} icon="hand-point-up" size="xl" /> + </div> + </Tooltip> + {/* <Tooltip title={<div className="dash-tooltip">Remove this side of the flashcard</div>}> + <div + style={{ position: 'absolute', bottom: '3px', right: '80px', cursor: 'pointer' }} + onPointerDown={e => this.closeDown(e, this.layoutDoc[`_${this._props.fieldKey}_usePath`] === 'alternate' ? this._props.fieldKey + '_1' : this._props.fieldKey + '_0')}> + <FontAwesomeIcon color={this.revealOp === 'hover' ? 'blue' : 'black'} icon="trash-can" size="xl" /> + </div> + </Tooltip> */} + {/* {this.overlayAlternateIcon} */} + </div> + ); + } + // this.closeDown(e, this.layoutDoc[`_${this._props.fieldKey}_usePath`] === 'alternate' ? this.fieldKey + '_0' : this.fieldKey + '_1')} + @action activateContent = () => { + this.childActive = true; + }; + @action handleRenderGPTClick = () => { // Call the GPT model and get the output this.useAlternate = true; this._outputValue = ''; - if (this._inputValue) this.askGPT(); + if (this._inputValue) this.askGPT(GPTCallType.QUIZ); + }; + + @action handleHover = () => { + if (this.revealOp === RevealOp.Hover) { + this.revealOp = RevealOp.Flip; + this.Document.forceActive = false; + } else { + this.revealOp = RevealOp.Hover; + this.Document.forceActive = true; + } }; @action handleRenderClick = () => { @@ -258,38 +376,86 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() this.useAlternate = false; }; + animateRes = (resIndex: number, newText: string, callType: GPTCallType) => { + if (resIndex < newText.length) { + // const marks = this._editorView?.state.storedMarks ?? []; + switch (callType) { + case GPTCallType.CHATCARD: + DocCast(this.dataDoc[this.props.fieldKey + '_0'])[DocData].text += newText[resIndex]; + break; + case GPTCallType.QUIZ: + this._outputValue += newText[resIndex]; + break; + default: + return; + } + + // this._editorView?.dispatch(this._editorView?.state.tr.insertText(newText[resIndex]).setStoredMarks(this._outputValue)); + setTimeout(() => this.animateRes(resIndex + 1, newText, callType), 20); + } + }; + /** * Calls the GPT model to create QuizCards. Evaluates how similar the user's response is to the alternate * side of the flashcard. */ - askGPT = async (): Promise<string | undefined> => { + askGPT = async (callType: GPTCallType): Promise<string | undefined> => { const questionText = 'Question: ' + StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text); const rubricText = ' Rubric: ' + StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_0']).text)?.Text); const queryText = questionText + ' UserAnswer: ' + this._inputValue + '. ' + rubricText; - + this._loading = true; + const doc = DocCast(this.dataDoc[this.props.fieldKey + '_0']); + if (callType == GPTCallType.CHATCARD) { + if (StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text) === '') { + this._loading = false; + return; + } + this.flipFlashcard(); + } try { - const res = await gptAPICall(queryText, GPTCallType.QUIZ); + const res = await gptAPICall(callType == GPTCallType.QUIZ ? queryText : questionText, callType); if (!res) { console.error('GPT call failed'); return; } - this._outputValue = res; + // this.animateRes(0, res, callType); + if (callType == GPTCallType.CHATCARD) { + DocCast(this.dataDoc[this.props.fieldKey + '_0'])[DocData].text = res; + // this.flipFlashcard(); + } + if (callType == GPTCallType.QUIZ) this._outputValue = res; + // DocCast(this.dataDoc[this.props.fieldKey + '_0'])[DocData].text = res; + // this._outputValue = res; + else if (callType === GPTCallType.FLASHCARD) { + // console.log(res); + this._loading = false; + return res; + } + // console.log(res); } catch (err) { console.error('GPT call failed'); } + this._loading = false; }; layoutWidth = () => NumCast(this.layoutDoc.width, 200); layoutHeight = () => NumCast(this.layoutDoc.height, 200); + // specificMenu = (): void => { + // const cm = ContextMenu.Instance; + // cm.addItem({ description: 'Create an Answer on the Back', event: () => this.askGPT(GPTCallType.CHATCARD), icon: 'pencil' }); + // }; + @observable childActive = false; + render() { const clearButton = (which: string) => ( <Tooltip title={<div className="dash-tooltip">remove</div>}> <div + // style={{ position: 'relative', top: '0px', left: '10px' }} ref={this._closeRef} className={`clear-button ${which}`} onPointerDown={e => this.closeDown(e, which)} // prevent triggering slider movement in registerSliding > - <FontAwesomeIcon className={`clear-button ${which}`} icon="times" size="sm" /> + <FontAwesomeIcon className={`clear-button ${which}`} icon="times" size="xs" /> </div> </Tooltip> ); @@ -297,6 +463,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() const whichDoc = DocCast(this.dataDoc[whichSlot]); const targetDoc = DocCast(whichDoc?.annotationOn, whichDoc); const layoutString = targetDoc ? '' : this.testForTextFields(whichSlot); + // whichDoc['backgroundColor'] = this.layoutDoc['backgroundColor']; return targetDoc || layoutString ? ( <> @@ -312,15 +479,16 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() removeDocument={whichSlot.endsWith('1') ? this.remDoc1 : this.remDoc2} NativeWidth={this.layoutWidth} NativeHeight={this.layoutHeight} - isContentActive={emptyFunction} + isContentActive={() => this.childActive} isDocumentActive={returnFalse} + dontSelect={returnTrue} whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} - styleProvider={this._isAnyChildContentActive ? this._props.styleProvider : this.docStyleProvider} + styleProvider={this.childActive ? this._props.styleProvider : this.docStyleProvider} hideLinkButton - pointerEvents={this._isAnyChildContentActive ? undefined : returnNone} + pointerEvents={this.childActive ? undefined : returnNone} /> - {layoutString ? null : clearButton(whichSlot)} - </> // placeholder image if doc is missing + {/* <div style={{ position: 'absolute', top: '-5px', left: '2px' }}>{layoutString ? null : clearButton(whichSlot)}</div> */} + </> // placeholder image if doc is missingleft: `${NumCast(this.layoutDoc.width, 200) - 33}px` ) : ( <div className="placeholder"> <FontAwesomeIcon className="upload-icon" icon="cloud-upload-alt" size="lg" /> @@ -328,8 +496,17 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() ); }; const displayBox = (which: string, index: number, cover: number) => ( - <div className={`${index === 0 ? 'before' : 'after'}Box-cont`} key={which} style={{ width: this._props.PanelWidth() }} onPointerDown={e => this.registerSliding(e, cover)} ref={ele => this.createDropTarget(ele, which, index)}> - {displayDoc(which)} + <div + className={`${index === 0 ? 'before' : 'after'}Box-cont`} + key={which} + style={{ width: this._props.PanelWidth() }} + onPointerDown={e => { + this.registerSliding(e, cover); + this.activateContent(); + }} + ref={ele => this.createDropTarget(ele, which, index)}> + {!this._isEmpty ? displayDoc(which) : null} + {/* {this.dataDoc[this.fieldKey + '_0'] !== 'empty' ? displayDoc(which) : null} */} </div> ); @@ -337,60 +514,90 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() const side = this.useAlternate ? 1 : 0; // add text box to each side when comparison box is first created - if (!(this.dataDoc[this.fieldKey + '_0'] || this.dataDoc[this.fieldKey + '_0'] === 'empty')) { - const dataSplit = StrCast(this.dataDoc.data).split('Answer'); + // (!this.dataDoc[this.fieldKey + '_0'] && this.dataDoc[this._props.fieldKey + '_0'] !== 'empty') + if (!this.dataDoc[this.fieldKey + '_0'] && !this._isEmpty) { + const dataSplit = StrCast(this.dataDoc.data).split('Answer: '); const newDoc = Docs.Create.TextDocument(dataSplit[1]); // if there is text from the pdf ai cards, put the question on the front side. // eslint-disable-next-line prefer-destructuring - newDoc[DocData].text = dataSplit[1]; + // newDoc.text = dataSplit[1]; + newDoc['backgroundColor'] = 'lightgray'; this.addDoc(newDoc, this.fieldKey + '_0'); + // DocCast(this.dataDoc[this.fieldKey + '_0'])[DocData].text = dataSplit[1]; + // DocCast(this.dataDoc[this.fieldKey + '_0']).text = dataSplit[1]; + // console.log('HI' + DocCast(this.dataDoc[this.fieldKey + '_0']).text); + //console.log('HEREEE' + StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_0']).text)?.Text)); } - if (!(this.dataDoc[this.fieldKey + '_1'] || this.dataDoc[this.fieldKey + '_1'] === 'empty')) { - const dataSplit = StrCast(this.dataDoc.data).split('Answer'); + + if (!this.dataDoc[this.fieldKey + '_1'] && !this._isEmpty) { + const dataSplit = StrCast(this.dataDoc.data).split('Answer: '); const newDoc = Docs.Create.TextDocument(dataSplit[0]); + this.addDoc(newDoc, this.fieldKey + '_1'); // if there is text from the pdf ai cards, put the answer on the alternate side. // eslint-disable-next-line prefer-destructuring - newDoc[DocData].text = dataSplit[0]; - this.addDoc(newDoc, this.fieldKey + '_1'); + + // newDoc[DocData].text = dataSplit[0]; + // console.log('HEREEE' + StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text)); + // console.log('HI' + DocCast(this.dataDoc[this.fieldKey + '_1']).text); + // DocCast(this.dataDoc[this.props.fieldKey + '_1'])[DocData].text = dataSplit[0]; + // console.log('HEREEE' + StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_0']).text)?.Text)); + // DocCast(this.dataDoc[this.fieldKey + '_1'])[DocData].text = dataSplit[0]; } // render the QuizCards - if (DocCast(this.Document.embedContainer) && DocCast(this.Document.embedContainer).filterOp === 'quiz') { + // console.log('GERE' + DocCast(this.Document.embedContainer).filterOp); + if (DocCast(this.Document.embedContainer) && DocCast(this.Document.embedContainer).practiceMode === 'quiz') { + const text = StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text); return ( <div className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}`} style={{ display: 'flex', flexDirection: 'column' }}> - <p style={{ color: 'white', padding: 10 }}>{StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text)}</p> - {/* {StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text)} */} + <p style={{ color: 'white', padding: 10 }}>{text}</p> + <p style={{ display: text === '' ? 'flex' : 'none', color: 'white', marginLeft: '10px' }}>Return to all flashcards and add text to both sides. </p> <div className="input-box"> <textarea value={this.useAlternate ? this._outputValue : this._inputValue} - onChange={action(e => { - this._inputValue = e.target.value; - })} + onChange={this.handleInputChange} + onScroll={e => e.stopPropagation()} + placeholder={!this.useAlternate ? 'Enter a response for GPT to evaluate.' : ''} readOnly={this.useAlternate} /> + + {this._loading ? ( + <div className="loading-spinner" style={{ position: 'absolute' }}> + <ReactLoading type="spin" height={30} width={30} color={'blue'} /> + </div> + ) : null} </div> - <div className="submit-button" style={{ display: this.useAlternate ? 'none' : 'flex' }}> - <button type="button" onClick={this.handleRenderGPTClick}> + <div className="submit-button" style={{ overflow: 'hidden', display: this.useAlternate ? 'none' : 'flex' }}> + <button type="button" onClick={this.handleRenderGPTClick} style={{ borderRadius: '2px', marginBottom: '3px' }}> Submit </button> </div> - <div className="submit-button" style={{ display: this.useAlternate ? 'flex' : 'none' }}> - <button type="button" onClick={this.handleRenderClick}> - Edit Your Response + <div className="submit-button" style={{ overflow: 'hidden', marginBottom: '2px', display: this.useAlternate ? 'flex' : 'none' }}> + <button type="button" onClick={this.handleRenderClick} style={{ borderRadius: '2px' }}> + Redo the Question </button> </div> </div> ); } + //console.log('HEREEE2' + StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text)); // render a normal flashcard when not a QuizCard return ( <div className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}`} /* change className to easily disable/enable pointer events in CSS */ - style={{ display: 'flex', flexDirection: 'column' }} + // onContextMenu={this.specificMenu} + style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onMouseEnter={() => this.hoverFlip(true)} onMouseLeave={() => this.hoverFlip(false)}> + {!this.useAlternate && StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text) === '' && !this.childActive ? <p className="comparisonBox-explain">Enter text in the flashcard. </p> : null} {displayBox(`${this.fieldKey}_${side === 0 ? 1 : 0}`, side, this._props.PanelWidth() - 3)} + {this._loading ? ( + <div className="loading-spinner" style={{ position: 'absolute' }}> + <ReactLoading type="spin" height={30} width={30} color={'blue'} /> + </div> + ) : null} + {this._props.isContentActive() ? this.flashcardMenu : null} {this.overlayAlternateIcon} </div> ); diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 7a1f94948..d4c31a5b3 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -111,6 +111,7 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document private _downTime: number = 0; private _lastTap: number = 0; private _doubleTap = false; + private _loading = false; private _mainCont = React.createRef<HTMLDivElement>(); private _titleRef = React.createRef<EditableView>(); private _dropDisposer?: DragManager.DragDropDisposer; @@ -501,22 +502,8 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document input.click(); }; - askGPT = async (): Promise<string | undefined> => { - const queryText = RTFCast(DocCast(this.dataDoc[this.props.fieldKey + '_1']).text)?.Text; - try { - const res = await gptAPICall('Question: ' + StrCast(queryText), GPTCallType.CHATCARD); - if (!res) { - console.error('GPT call failed'); - return; - } - DocCast(this.dataDoc[this.props.fieldKey + '_0'])[DocData].text = res; - console.log(res); - } catch (err) { - console.error('GPT call failed'); - } - }; - onContextMenu = (e?: React.MouseEvent, pageX?: number, pageY?: number) => { + if (this._props.dontSelect?.()) return; if (e && this.layoutDoc.layout_hideContextMenu && Doc.noviceMode) { e.preventDefault(); e.stopPropagation(); @@ -574,21 +561,18 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document appearanceItems.splice(0, 0, { description: 'Open in Lightbox', event: () => DocumentView.SetLightboxDoc(this.Document), icon: 'external-link-alt' }); } appearanceItems.push({ description: 'Pin', event: () => this._props.pinToPres(this.Document, {}), icon: 'eye' }); - if (this.Document._layout_isFlashcard) { - appearanceItems.push({ description: 'Create ChatCard', event: () => this.askGPT(), icon: 'id-card' }); - } !Doc.noviceMode && templateDoc && appearanceItems.push({ description: 'Open Template ', event: () => this._props.addDocTab(templateDoc, OpenWhere.addRight), icon: 'eye' }); !appearance && appearanceItems.length && cm.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'compass' }); // creates menu for the user to select how to reveal the flashcards - if (this.Document._layout_isFlashcard) { - const revealOptions = cm.findByDescription('Reveal Options'); - const revealItems: ContextMenuProps[] = revealOptions && 'subitems' in revealOptions ? revealOptions.subitems : []; - revealItems.push({ description: 'Hover', event: () => { this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'hover'; }, icon: 'hand-point-up' }); // prettier-ignore - revealItems.push({ description: 'Flip', event: () => { this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'flip'; }, icon: 'rotate' }); // prettier-ignore - !revealOptions && cm.addItem({ description: 'Reveal Options', addDivider: false, noexpand: true, subitems: revealItems, icon: 'layer-group' }); - } + // if (this.Document._layout_isFlashcard) { + // const revealOptions = cm.findByDescription('Reveal Options'); + // const revealItems: ContextMenuProps[] = revealOptions && 'subitems' in revealOptions ? revealOptions.subitems : []; + // revealItems.push({ description: 'Hover', event: () => { this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'hover'; }, icon: 'hand-point-up' }); // prettier-ignore + // revealItems.push({ description: 'Flip', event: () => { this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'flip'; }, icon: 'rotate' }); // prettier-ignore + // !revealOptions && cm.addItem({ description: 'Reveal Options', addDivider: false, noexpand: true, subitems: revealItems, icon: 'layer-group' }); + // } if (this._props.bringToFront) { const zorders = cm.findByDescription('ZOrder...'); @@ -1395,7 +1379,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { screenToLocalScale = () => this._props.ScreenToLocalTransform().Scale; isSelected = () => this.IsSelected; select = (extendSelection: boolean, focusSelection?: boolean) => { - DocumentView.SelectView(this, extendSelection); + if (!this._props.dontSelect?.()) DocumentView.SelectView(this, extendSelection); if (focusSelection) { DocumentView.showDocument(this.Document, { willZoomCentered: true, diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index 818d26956..138f00492 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -50,6 +50,7 @@ export interface FieldViewSharedProps { PanelHeight: () => number; isDocumentActive?: () => boolean | undefined; // whether a document should handle pointer events isContentActive: () => boolean | undefined; // whether document contents should handle pointer events + dontSelect: () => boolean | undefined; childFilters: () => string[]; childFiltersByRanges: () => string[]; styleProvider: Opt<StyleProviderFuncType>; diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index 3ffda5a35..be68ac8cd 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -139,3 +139,24 @@ .imageBox-fadeBlocker-hover { opacity: 0; } + +.loading-spinner { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + // left: 50%; + // top: 50%; + z-index: 200; + font-size: 20px; + font-weight: bold; + color: #17175e; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 68c313480..8068407bb 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -35,6 +35,15 @@ import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; import './ImageBox.scss'; import { OpenWhere } from './OpenWhere'; +import { URLField } from '../../../fields/URLField'; +import { gptImageLabel } from '../../apis/gpt/GPT'; +import ReactLoading from 'react-loading'; +import { FollowLinkScript } from '../../documents/DocUtils'; +import { basename } from 'path'; +import { ImageUtility } from './generativeFill/generativeFillUtils/ImageHandler'; +import { dropActionType } from '../../util/DropActionTypes'; +import { canvasSize } from './generativeFill/generativeFillUtils/generativeFillConstants'; +import axios from 'axios'; export class ImageEditorData { // eslint-disable-next-line no-use-before-define @@ -59,6 +68,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) { @@ -73,9 +84,14 @@ 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(); + private _imageRef: HTMLImageElement | null = null; // <video> ref + @observable private _width: number = 0; + @observable private _height: number = 0; + @observable private searchInput = ''; @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); @observable _curSuffix = ''; @observable _error = ''; + @observable private _loading = false; @observable _isHovering = false; // flag to switch between primary and alternate images on hover _ffref = React.createRef<CollectionFreeFormView>(); @@ -148,6 +164,32 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { Object.values(this._disposers).forEach(disposer => disposer?.()); } + 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}`); + console.log('data', data); + console.log(data.results); + 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(); + }; + @undoBatch drop = (e: Event, de: DragManager.DropEvent) => { if (de.complete.docDragData) { @@ -259,10 +301,189 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { return cropping; }; + createCanvas = async (downX?: number, downY?: number, cb?: (filename: string, x: number | undefined, y: number | undefined) => void) => { + const width = NumCast(this.layoutDoc._width); + const canvas = document.createElement('canvas'); + // canvas.width = 640; + // canvas.height = (640 * Doc.NativeHeight(this.layoutDoc)) / (Doc.NativeWidth(this.layoutDoc) || 1); + canvas.width = NumCast(this.layoutDoc._width); + canvas.height = NumCast(this.layoutDoc._height); + const ctx = canvas.getContext('2d'); // draw image to canvas. scale to target dimensions + if (ctx) { + // this._imageRef && ctx.drawImage(this._imageRef, 0, 0, canvas.width, canvas.height); + this._imageRef && ctx.drawImage(this._imageRef, NumCast(this._marqueeref.current?.left), NumCast(this._marqueeref.current?.top), this._width, this._height, 0, 0, 1000, 1000); + //this._imageRef && ctx.drawImage(this._imageRef, 0, 0, 2000, 1000, 0, 0, canvas.width, canvas.height); + // console.log(NumCast(this._marqueeref.current?.left) + 100); + } + const blob = await ImageUtility.canvasToBlob(canvas); + return ImageBox.selectUrlToBase64(blob); + + // if (this._imageRef) { + // const canv = ImageUtility.getCroppedImg(this._imageRef, this._imageRef.width, this._imageRef.height); + // console.log(this._imageRef.width); + // if (canv) { + // const blob = await ImageUtility.canvasToBlob(canv); + // return ImageBox.selectUrlToBase64(blob); + // } + // } + if (!this._imageRef) { + const b = Docs.Create.LabelDocument({ + x: NumCast(this.layoutDoc.x) + width, + y: NumCast(this.layoutDoc.y, 1), + _width: 150, + _height: 50, + // title: (this.layoutDoc._layout_currentTimecode || 0).toString(), + onClick: FollowLinkScript(), + }); + this._props.addDocument?.(b); + DocUtils.MakeLink(b, this.Document, { link_relationship: 'image snapshot' }); + } else { + // convert to desired file format + // const dataUrl = canvas.toDataURL('image/png'); // can also use 'image/png' + // // if you want to preview the captured image, + // const retitled = StrCast(this.Document.title).replace(/[ -.:]/g, ''); + // const encodedFilename = encodeURIComponent(('snapshot' + retitled + '_' + (this.layoutDoc._layout_currentTimecode || 0).toString()).replace(/[./?=]/g, '_')); + // const filename = basename(encodedFilename); + // ClientUtils.convertDataUri(dataUrl, filename).then((returnedFilename: string) => returnedFilename && (cb ?? this.createSnapshotLink)(returnedFilename, downX, downY)); + } + // convert to desired file format + + // const dataUrl = canvas.toDataURL('image/png'); // can also use 'image/png' + // // if you want to preview the captured image, + // const retitled = StrCast(this.Document.title).replace(/[ -.:]/g, ''); + // const encodedFilename = encodeURIComponent(('snapshot' + retitled + '_' + (this.layoutDoc._layout_currentTimecode || 0).toString()).replace(/[./?=]/g, '_')); + // const filename = basename(encodedFilename); + //ClientUtils.convertDataUri(dataUrl, filename).then((returnedFilename: string) => returnedFilename && (cb ?? this.createSnapshotLink)(returnedFilename, downX, downY)); + // } + // const docViewContent = this.DocumentView?.().ContentDiv!; + // if (docViewContent instanceof HTMLCanvasElement) { + // const canvas = docViewContent; + // const img = document.createElement('img'); // create a Image Element + // img.src = canvas.toDataURL(); // image sourcez + // img.style.width = canvas.style.width; + // img.style.height = canvas.style.height; + // const parEle = newCan.parentElement as HTMLElement; + // parEle.removeChild(newCan); + // parEle.appendChild(img); + // } + }; + + 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' }); + // link && (DocCast(link.link_anchor_2)[DocData].timecodeToHide = NumCast(DocCast(link.link_anchor_2).timecodeToShow) + 3); // do we need to set an end time? should default to +0.1 + setTimeout(() => downX !== undefined && downY !== undefined && DocumentView.getFirstDocumentView(imageSnapshot)?.startDragging(downX, downY, dropActionType.move, true)); + }; + + /** + * + if (oldDiv instanceof HTMLCanvasElement) { + const canvas = oldDiv; + const img = document.createElement('img'); // create a Image Element + img.src = canvas.toDataURL(); // image sourcez + img.style.width = canvas.style.width; + img.style.height = canvas.style.height; + const newCan = newDiv as HTMLCanvasElement; + const parEle = newCan.parentElement as HTMLElement; + parEle.removeChild(newCan); + parEle.appendChild(img); + } + */ + + 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; + } + }; + + static imageUrlToBase64 = async (imageUrl: string): Promise<string> => { + try { + const response = await fetch(imageUrl); + const blob = await response.blob(); + + 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; + } + }; + + getImageDesc = async () => { + // if (StrCast(this.dataDoc.description)) return StrCast(this.dataDoc.description); // Return existing description + const { href } = (this.dataDoc.data as URLField).url; + const hrefParts = href.split('.'); + const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`; + this._loading = true; + try { + // const hrefBase64 = await ImageBox.imageUrlToBase64(hrefComplete); + const hrefBase64 = await this.createCanvas(); + const response = await gptImageLabel(hrefBase64, 'Tell me what words you see on this image.'); + //const response = await gptImageLabel(hrefBase64, 'Make flashcards out of this text with each question and answer labeled as question and answer. Do not label each flashcard and do not include asterisks: '); + console.log(response); + // AnchorMenu.Instance.transferToFlashcard(response); + // this.Document[DocData].description = response.trim(); + // return response; // Return the response + } catch (error) { + console.log('Error'); + } + this._loading = false; + // return ''; + }; + + @action + setRef = (iref: HTMLImageElement | null) => { + this._imageRef = iref; + // if (iref) { + // this._videoRef!.ontimeupdate = this.updateTimecode; + // // @ts-ignore + // // vref.onfullscreenchange = action((e) => this._fullScreen = vref.webkitDisplayingFullscreen); + // this._disposers.reactionDisposer?.(); + // this._disposers.reactionDisposer = reaction( + // () => NumCast(this.layoutDoc._layout_currentTimecode), + // time => { + // !this._playing && (vref.currentTime = time); + // }, + // { fireImmediately: true } + // ); + + // (!this.dataDoc[this.fieldKey + '_thumbnails'] || StrListCast(this.dataDoc[this.fieldKey + '_thumbnails']).length !== VideoThumbnails.DENSE) && this.getVideoThumbnails(); + // } + }; + specificContextMenu = (): void => { const field = Cast(this.dataDoc[this.fieldKey], ImageField); if (field) { const funcs: ContextMenuProps[] = []; + // funcs.push({ description: 'Create ai flashcards', event: () => this.getImageDesc(), icon: 'id-card' }); + funcs.push({ description: 'Get Images', event: () => this.handleSelection('Cats'), icon: 'redo-alt' }); 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' }); @@ -391,6 +612,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 }} @@ -432,6 +654,8 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { action(moveEv => { MarqueeAnnotator.clearAnnotations(this._savedAnnotations); this._marqueeref.current?.onInitiateSelection([moveEv.clientX, moveEv.clientY]); + this._width = moveEv.clientX; + this._height = moveEv.clientY; return true; }), returnFalse, @@ -443,6 +667,8 @@ 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; this._marqueeref.current?.onTerminateSelection(); this._props.select(false); }; @@ -505,6 +731,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 @@ -523,6 +754,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { marqueeContainer={this._mainCont.current} highlightDragSrcColor="" anchorMenuCrop={this.crop} + // anchorMenuFlashcard={() => this.getImageDesc()} /> )} </div> diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss index 99b4a84fc..54643b4a5 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss @@ -88,10 +88,16 @@ audiotag:hover { background: black; right: 0; bottom: 0; - width: 11; - height: 11; + width: 15; + height: 22; cursor: default; } +.formattedTextBox-flip { + align-items: center; + position: absolute; + right: 2px; + bottom: 4px; +} .formattedTextBox-outer { position: relative; @@ -171,6 +177,8 @@ audiotag:hover { border-style: inset; border-width: 1px; } + // margin-left: 5px; + // margin-right: 5px; } .gpt-typing-wrapper { diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 9f2a9b8e1..3e2befb5f 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -181,7 +181,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB @observable private gptRes: string = ''; - public makeAIFlashcards: () => void = unimplementedFunction; + // public makeAIFlashcards: () => void = unimplementedFunction; public addToCollection: ((doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) | undefined; public static PasteOnLoad: ClipboardEvent | undefined; @@ -816,6 +816,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB isTargetToggler = (anchor: Doc) => BoolCast(anchor.followLinkToggle); specificContextMenu = (e: React.MouseEvent): void => { + if (this._props.dontSelect?.()) return; const cm = ContextMenu.Instance; let target = e.target as any; // hrefs are stored on the database of the <a> node that wraps the hyerlink <span> @@ -969,7 +970,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB icon: 'star', }); optionItems.push({ description: `Generate Dall-E Image`, event: () => this.generateImage(), icon: 'star' }); - optionItems.push({ description: `Make AI Flashcards`, event: () => this.makeAIFlashcards(), icon: 'lightbulb' }); + // optionItems.push({ description: `Make AI Flashcards`, event: () => this.makeAIFlashcards(), icon: 'lightbulb' }); optionItems.push({ description: `Ask GPT-3`, event: this.askGPT, icon: 'lightbulb' }); this._props.renderDepth && optionItems.push({ diff --git a/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss index 55b8446e9..bc0810f22 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss @@ -13,7 +13,7 @@ box-shadow: 3px 3px 1.5px grey; max-width: 400; max-height: 235; - height:max-content; + height: max-content; .formattedTextBox-tooltipText { height: max-content; text-overflow: ellipsis; @@ -21,7 +21,7 @@ } .formattedTextBox-tooltip:before { - content: ""; + content: ''; height: 0; width: 0; position: absolute; @@ -34,7 +34,7 @@ } .formattedTextBox-tooltip:after { - content: ""; + content: ''; height: 0; width: 0; position: absolute; @@ -44,4 +44,4 @@ border: 5px solid transparent; border-bottom-width: 0; border-top-color: white; -}
\ No newline at end of file +} diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx index 2f6824466..777117f26 100644 --- a/src/client/views/pdf/AnchorMenu.tsx +++ b/src/client/views/pdf/AnchorMenu.tsx @@ -15,6 +15,7 @@ import { LinkPopup } from '../linking/LinkPopup'; import { DocumentView } from '../nodes/DocumentView'; import './AnchorMenu.scss'; import { GPTPopup, GPTPopupMode } from './GPTPopup/GPTPopup'; +import ReactLoading from 'react-loading'; @observer export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { @@ -24,6 +25,9 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { private _disposer: IReactionDisposer | undefined; private _commentRef = React.createRef<HTMLDivElement>(); private _cropRef = React.createRef<HTMLDivElement>(); + @observable private _loading = false; + // @observable protected _top: number = -300; + // @observable protected _left: number = -300; constructor(props: any) { super(props); @@ -38,11 +42,21 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { // GPT additions @observable private _selectedText: string = ''; + @observable private _x: number = 0; + @observable private _y: number = 0; @action public setSelectedText = (txt: string) => { this._selectedText = txt.trim(); }; + @action + public setLocation = (x: number, y: number) => { + this._x = x; + this._y = y; + }; + @computed public get selectedText() { + return this._selectedText; + } public onMakeAnchor: () => Opt<Doc> = () => undefined; // Method to get anchor from text search public OnCrop: (e: PointerEvent) => void = unimplementedFunction; @@ -57,6 +71,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { public MakeTargetToggle: () => void = unimplementedFunction; public ShowTargetTrail: () => void = unimplementedFunction; public IsTargetToggler: () => boolean = returnFalse; + public gptFlashcards: () => void = unimplementedFunction; public get Active() { return this._left > 0; } @@ -99,26 +114,27 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { * Invokes the API with the selected text and stores it in the selected text. * @param e pointer down event */ - gptFlashcards = async () => { - const queryText = this._selectedText; - try { - const res = await gptAPICall(queryText, GPTCallType.FLASHCARD); - console.log(res); - GPTPopup.Instance.setText(res || 'Something went wrong.'); - this.transferToFlashcard(res || 'Something went wrong'); - } catch (err) { - console.error(err); - } - GPTPopup.Instance.setLoading(false); - }; + // gptPDFFlashcards = async () => { + // const queryText = this._selectedText; + // this._loading = true; + // try { + // const res = await gptAPICall(queryText, GPTCallType.FLASHCARD); + // console.log(res); + // // GPTPopup.Instance.setText(res || 'Something went wrong.'); + // this.transferToFlashcard(res || 'Something went wrong'); + // } catch (err) { + // console.error(err); + // } + // // GPTPopup.Instance.setLoading(false); + // }; - /* - * Transfers the flashcard text generated by GPT on flashcards and creates a collection out them. - */ + // /* + // * Transfers the flashcard text generated by GPT on flashcards and creates a collection out them. + // */ transferToFlashcard = (text: string) => { // put each question generated by GPT on the front of the flashcard - const senArr = text.split('Question'); - const collectionArr: Doc[] = []; + var senArr = text.trim().split('Question: '); + var collectionArr: Doc[] = []; for (let i = 1; i < senArr.length; i++) { console.log('Arr ' + i + ': ' + senArr[i]); const newDoc = Docs.Create.ComparisonDocument(senArr[i], { _layout_isFlashcard: true, _width: 300, _height: 300 }); @@ -133,7 +149,12 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { _layout_autoHeight: true, }); + newCol.x = this._x; + newCol.y = this._y; + newCol.zIndex = 100; + this.addToCollection?.(newCol); + this._loading = false; }; pointerDown = (e: React.PointerEvent) => { @@ -219,12 +240,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { /> )} {/* Adds a create flashcards option to the anchor menu, which calls the gptFlashcard method. */} - <IconButton - tooltip="Create flashcards" // - onPointerDown={this.gptFlashcards} - icon={<FontAwesomeIcon icon="id-card" size="lg" />} - color={SettingsManager.userColor} - /> + <IconButton tooltip="Create flashcards" onPointerDown={this.gptFlashcards} icon={<FontAwesomeIcon icon="id-card" size="lg" />} color={SettingsManager.userColor} /> {AnchorMenu.Instance.OnAudio === unimplementedFunction ? null : ( <IconButton tooltip="Click to Record Annotation" // @@ -250,6 +266,11 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { /> </div> )} + {/* {this._loading ? ( + <div className="loading-spinner" style={{ position: 'absolute' }}> + <ReactLoading type="spin" height={30} width={30} color={'white'} /> + </div> + ) : null} */} </> ) : ( <> diff --git a/src/client/views/pdf/Annotation.scss b/src/client/views/pdf/Annotation.scss index 1de60ffed..26856b74e 100644 --- a/src/client/views/pdf/Annotation.scss +++ b/src/client/views/pdf/Annotation.scss @@ -7,4 +7,21 @@ &:hover { cursor: pointer; } -}
\ No newline at end of file +} +// .loading-spinner { +// display: flex; +// justify-content: center; +// align-items: center; +// height: 90%; +// width: 93%; +// left: 10; +// font-size: 20px; +// font-weight: bold; +// color: #0b0a0a; +// } + +// @keyframes spin { +// to { +// transform: rotate(360deg); +// } +// } diff --git a/src/client/views/pdf/PDFViewer.scss b/src/client/views/pdf/PDFViewer.scss index d3dd9f727..e70102ce9 100644 --- a/src/client/views/pdf/PDFViewer.scss +++ b/src/client/views/pdf/PDFViewer.scss @@ -107,3 +107,24 @@ .pdfViewerDash-interactive { pointer-events: all; } + +.loading-spinner { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + // left: 50%; + // top: 50%; + z-index: 200; + font-size: 20px; + font-weight: bold; + color: #17175e; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index db47a84e1..38d14014f 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -30,6 +30,8 @@ import { AnchorMenu } from './AnchorMenu'; import { Annotation } from './Annotation'; import { GPTPopup } from './GPTPopup/GPTPopup'; import './PDFViewer.scss'; +import { GPTCallType, gptAPICall } from '../../apis/gpt/GPT'; +import ReactLoading from 'react-loading'; // pdfjsLib.GlobalWorkerOptions.workerSrc = `/assets/pdf.worker.js`; // The workerSrc property shall be specified. @@ -68,6 +70,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { @observable _textSelecting = true; @observable _showWaiting = true; @observable Index: number = -1; + @observable private _loading = false; private _pdfViewer: any; private _styleRule: any; // stylesheet rule for making hyperlinks clickable @@ -404,6 +407,49 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { } }; + gptPDFFlashcards = async () => { + const queryText = this._selectionText; + this._loading = true; + try { + const res = await gptAPICall(queryText, GPTCallType.FLASHCARD); + console.log(res); + // GPTPopup.Instance.setText(res || 'Something went wrong.'); + AnchorMenu.Instance.transferToFlashcard(res || 'Something went wrong'); + // this.transferToFlashcard(res || 'Something went wrong'); + } catch (err) { + console.error(err); + } + this._loading = false; + // GPTPopup.Instance.setLoading(false); + }; + + // transferToFlashcard = (text: string) => { + // // put each question generated by GPT on the front of the flashcard + // var senArr = text.trim().split('Question: '); + // var collectionArr: Doc[] = []; + // for (let i = 1; i < senArr.length; i++) { + // console.log('Arr ' + i + ': ' + senArr[i]); + // const newDoc = Docs.Create.ComparisonDocument(senArr[i], { _layout_isFlashcard: true, _width: 300, _height: 300 }); + // newDoc.text = senArr[i]; + // collectionArr.push(newDoc); + // } + // // create a new carousel collection of these flashcards + // const newCol = Docs.Create.CarouselDocument(collectionArr, { + // _width: 250, + // _height: 200, + // _layout_fitWidth: false, + // _layout_autoHeight: true, + // }); + + // newCol.x = this._props.layoutDoc['x']; + // newCol.y = this._props.layoutDoc['y']; + // newCol.zIndex = 100; + + // this._props.DocumentView?.()._props.addDocument?.(newCol); + // console.log('HERE'); + // this._loading = false; + // }; + @action finishMarquee = (/* x?: number, y?: number */) => { this._getAnchor = AnchorMenu.Instance?.GetAnchor; @@ -423,6 +469,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { const sel = window.getSelection(); if (sel) { AnchorMenu.Instance.setSelectedText(sel.toString()); + AnchorMenu.Instance.setLocation(NumCast(this._props.layoutDoc['x']), NumCast(this._props.layoutDoc['y'])); } if (sel?.type === 'Range') { @@ -434,6 +481,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { GPTPopup.Instance.addDoc = this._props.sidebarAddDoc; // allows for creating collection AnchorMenu.Instance.addToCollection = this._props.DocumentView?.()._props.addDocument; + AnchorMenu.Instance.gptFlashcards = this.gptPDFFlashcards; }; @action @@ -461,6 +509,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { this._mainCont.current!.style.transform = ''; } this._selectionContent = selRange.cloneContents(); + this._selectionText = this._selectionContent?.textContent || ''; // clear selection @@ -621,6 +670,11 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { /> )} </div> + {this._loading ? ( + <div className="loading-spinner" style={{ position: 'absolute' }}> + <ReactLoading type="spin" height={80} width={80} color={'blue'} /> + </div> + ) : null} </div> ); } |