diff options
Diffstat (limited to 'src/client/views/nodes/ComparisonBox.tsx')
-rw-r--r-- | src/client/views/nodes/ComparisonBox.tsx | 883 |
1 files changed, 430 insertions, 453 deletions
diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx index ccbe98257..f6c33d6ba 100644 --- a/src/client/views/nodes/ComparisonBox.tsx +++ b/src/client/views/nodes/ComparisonBox.tsx @@ -8,63 +8,195 @@ import ReactLoading from 'react-loading'; import { imageUrlToBase64, returnFalse, returnNone, returnTrue, returnZero, setupMoveUpEvents } from '../../../ClientUtils'; import { emptyFunction } from '../../../Utils'; import { Doc, Opt } from '../../../fields/Doc'; -import { DocData } from '../../../fields/DocSymbols'; -import { Id } from '../../../fields/FieldSymbols'; +import { Animation, DocData } from '../../../fields/DocSymbols'; import { RichTextField } from '../../../fields/RichTextField'; -import { DocCast, NumCast, RTFCast, StrCast, toList } from '../../../fields/Types'; +import { BoolCast, DocCast, NumCast, RTFCast, StrCast, toList } from '../../../fields/Types'; import { nullAudio } from '../../../fields/URLField'; import { GPTCallType, gptAPICall, gptImageLabel } from '../../apis/gpt/GPT'; import { DocUtils, FollowLinkScript } from '../../documents/DocUtils'; -import { CollectionViewType, DocumentType } from '../../documents/DocumentTypes'; +import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; import { DragManager } from '../../util/DragManager'; import { dropActionType } from '../../util/DropActionTypes'; -import { SnappingManager } from '../../util/SnappingManager'; import { undoable } from '../../util/UndoManager'; import { ContextMenu } from '../ContextMenu'; import { ViewBoxAnnotatableComponent } from '../DocComponent'; import { PinDocView, PinProps } from '../PinFuncs'; import { StyleProp } from '../StyleProp'; +import { flashcardRevealOp, practiceMode } from '../collections/FlashcardPracticeUI'; +import { CollectionFreeFormView } from '../collections/collectionFreeForm'; import '../pdf/GPTPopup/GPTPopup.scss'; import './ComparisonBox.scss'; import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import { FormattedTextBox } from './formattedText/FormattedTextBox'; -import { practiceMode } from '../collections/FlashcardPracticeUI'; const API_URL = 'https://api.unsplash.com/search/photos'; +/** + * This view serves two distinct functions depending on the revealOp field ('slide' or 'flip) + * 1) ('slide') - provides a before/after animated sliding transition between two Docs + * 2) ('flip') - provides a question/answer flip between two Docs + * And a third function that overrides the first two if the doc's container has its 'practiceMode' set to 'quiz' + * 3) ('quiz') - it provides a quiz view that displays a question and a user answer that can be "scored" by GPT + * NOTE: this should probably be changed to passing down a prop to the flashcard telling it to render as a quiz. + * + * In each case, the two docs are stored in the <fieldKey>_front and <fieldKey>_back fields + * + * For 'flip' and 'slide', the trigger can either be clicking, or hovering as determined by the revealOp_hover field. + * For 'quiz' the data of both Docs are shown in a single-view quiz display. + * + * Users can create a stack of flashcards all at once (only) from an empty flashcard by entering a topic into the front card + * and clicking on the flashcard stack button. This will convert the comparision box into a stack of comparison boxes + * filled in by GPT about the topic. + * + */ + @observer export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ComparisonBox, fieldKey); } + /** + * Creates a flashcard (or fills in flashcard data to a specified Doc) from a control string containing a question and answer + * @param tuple string containing Question:, Answer: and optionally a Keyword: + * @param useDoc doc to fill in instead of creating a Doc + * @returns the resulting flashcard Doc + */ + public static createFlashcard(tuple: string, frontKey: string, backKey: string, useDoc?: Doc) { + const [ktoken, atoken] = [ComparisonBox.ktoken, ComparisonBox.atoken]; + const question = (tuple.includes(ktoken) ? tuple.split(ktoken)[0] : tuple).split(atoken)[0]; + const rest = tuple.replace(question, ''); + // prettier-ignore + const answer = rest.startsWith(ktoken) ? // if keyword comes first, + tuple.includes(atoken) ? tuple.split(atoken)[1] : "" : //if tuple includes answer, split at answer and take what's left, otherwise there's no answer + rest.includes(ktoken) ? // otherwise if keyword is present it must come after answer, + rest.split(ktoken)[0].split(atoken)[1] : // split at keyword and take what comes first and split that at answer and take what's left + rest.replace(atoken,""); // finally if there's no keyword, just get rid of answer token and take what's left + const keyword = rest.replace(atoken, '').replace(answer, '').replace(ktoken, '').trim(); + const fillInFlashcard = (img?: Doc) => { + const front = Docs.Create.CenteredTextCreator('question', question, {}, img); + const back = Docs.Create.CenteredTextCreator('answer', answer, {}); + if (useDoc) { + useDoc[DocData][frontKey] = front; + useDoc[DocData][backKey] = back; + return useDoc; + } + return Docs.Create.FlashcardDocument('flashcard', front, back, { _width: 300, _height: 300 }); + }; + return keyword && keyword.toLowerCase() !== 'none' ? ComparisonBox.fetchImages(keyword).then(img => fillInFlashcard(img)) : fillInFlashcard(); + } + + /** + * Create a carousel of flashcards from a GPT response string where questions and answers are given in a format loosely defined by: + * Question: ... Answer: ... Keyword: ... + * Note that Keyword or Answer may not be present, or their orders may be reversed. + */ + public static createFlashcardDeck(text: string, width: number, height: number, front: string, back: string) { + return Promise.all( + text + .split(ComparisonBox.qtoken) + .filter(t => t) + .map(tuple => ComparisonBox.createFlashcard(tuple, front, back)) + ).then(docs => { + return Docs.Create.CarouselDocument(docs, { + title: 'flashcard deck', + _width: width, + _height: height, + _layout_fitWidth: false, + _layout_autoHeight: true, + _xMargin: 5, + _yMargin: 5, + }); + }); + } private SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + + static qtoken = 'Question: '; + static ktoken = 'Keyword: '; + static atoken = 'Answer: '; + private _slideTiming = 200; + private _sideBtnWidth = 35; private _closeRef = React.createRef<HTMLDivElement>(); - private _disposers: (DragManager.DragDropDisposer | undefined)[] = [undefined, undefined]; - private _reactDisposer: IReactionDisposer | undefined; - constructor(props: FieldViewProps) { - super(props); - makeObservable(this); - } + private _disposers: { [key: string]: DragManager.DragDropDisposer | undefined } = {}; + private _reactDisposer: { [key: string]: IReactionDisposer } = {}; @observable private _inputValue = ''; @observable private _outputValue = ''; @observable private _loading = false; - @observable private _isEmpty = false; @observable private _childActive = false; @observable private _animating = ''; @observable private _listening = false; - @observable private _frontSide = false; - @observable recognition = new this.SpeechRecognition(); + @observable private _renderSide = this.frontKey; + @observable private _recognition = new this.SpeechRecognition(); + + constructor(props: FieldViewProps) { + super(props); + makeObservable(this); + } + + componentDidMount() { + this._props.setContentViewBox?.(this); + this._reactDisposer.select = reaction( + () => this._props.isSelected(), + selected => { + if (selected && this.revealOp !== flashcardRevealOp.SLIDE) this.activateContent(); + !selected && (this._childActive = false); + }, // what it should update to + { fireImmediately: true } + ); + this._reactDisposer.hover = reaction( + () => this._props.isContentActive(), + hover => { + if (!hover) { + this.revealOp === flashcardRevealOp.FLIP && this.animateFlipping(this.frontKey); + this.revealOp === flashcardRevealOp.SLIDE && this.animateSliding(this._props.PanelWidth() - 3); + } + }, // what it should update to + { fireImmediately: true } + ); + } + + componentWillUnmount() { + Object.values(this._reactDisposer).forEach(disposer => disposer?.()); + } + + protected createDropTarget = (ele: HTMLDivElement | null, fieldKey: string) => { + this._disposers[fieldKey]?.(); + if (ele) { + this._disposers[fieldKey] = DragManager.MakeDropTarget(ele, (e, dropEvent) => this.internalDrop(e, dropEvent, fieldKey), this.layoutDoc); + } + }; + + private internalDrop = undoable((e: Event, dropEvent: DragManager.DropEvent, fieldKey: string) => { + if (dropEvent.complete.docDragData) { + const { droppedDocuments } = dropEvent.complete.docDragData; + const added = dropEvent.complete.docDragData.moveDocument?.(droppedDocuments, this.Document, (doc: Doc | Doc[]) => this.addDoc(toList(doc).lastElement(), fieldKey)); + 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; + }, 'internal drop'); + @computed get containerDoc() { return this._props.docViewPath().slice(-2)[0]?.Document; } // prettier-ignore + @computed get isQuizMode() { return this.containerDoc?.practiceMode === practiceMode.QUIZ; } // prettier-ignore + @computed get isFlashcard() { return BoolCast(this.Document.layout_isFlashcard); } // prettier-ignore + @computed get frontKey() { return this._props.fieldKey + '_front'; } // prettier-ignore + @computed get backKey() { return this._props.fieldKey + '_back'; } // prettier-ignore + @computed get frontText() { return RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text; } // prettier-ignore + @computed get backText() { return RTFCast(DocCast(this.dataDoc[this.backKey]).text)?.Text; } // prettier-ignore @computed get revealOpKey() { return `_${this._props.fieldKey}_revealOp`; } // prettier-ignore @computed get clipHeightKey() { return `_${this._props.fieldKey}_clipHeight`; } // prettier-ignore @computed get clipWidthKey() { return `_${this._props.fieldKey}_clipWidth`; } // prettier-ignore - @computed get clipWidth() { return NumCast(this.layoutDoc[this.clipWidthKey], 50); } // prettier-ignore + @computed get clipWidth() { return NumCast(this.layoutDoc[this.clipWidthKey], this.isFlashcard ? 100: 50); } // prettier-ignore @computed get clipHeight() { return NumCast(this.layoutDoc[this.clipHeightKey], 200); } // prettier-ignore - @computed get revealOp() { return StrCast(this.layoutDoc[this.revealOpKey], StrCast(this._props.docViewPath().slice(-2)[0]?.Document.revealOp)); } // prettier-ignore - set revealOp(value:string) { this.layoutDoc[this.revealOpKey] = value; } // prettier-ignore + @computed get revealOp() { return StrCast(this.layoutDoc[this.revealOpKey], StrCast(this.containerDoc?.revealOp, this.isFlashcard ? flashcardRevealOp.FLIP : flashcardRevealOp.SLIDE)) as flashcardRevealOp; } // prettier-ignore + @computed get revealOpHover() { return BoolCast(this.layoutDoc[this.revealOpKey+"_hover"], BoolCast(this.containerDoc?.revealOp_hover)); } // prettier-ignore + @computed get loading() { return this._loading; } // prettier-ignore + set loading(value) { runInAction(() => { this._loading = value; })} // prettier-ignore @computed get overlayAlternateIcon() { return ( @@ -73,14 +205,14 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() className="comparisonBox-alternateButton ccomparisonBox-button" onPointerDown={e => setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => { - if (!this.revealOp || this.revealOp === 'flip') { - this.flipFlashcard(); + if (!this.revealOp || this.revealOp === flashcardRevealOp.FLIP) { + this.animateFlipping(); } }) } style={{ - background: this.revealOp === 'hover' ? 'gray' : this._frontSide ? 'white' : 'black', - color: this.revealOp === 'hover' ? 'black' : this._frontSide ? 'black' : 'white', + background: this.revealOpHover ? 'gray' : this._renderSide === this.backKey ? 'white' : 'black', + color: this.revealOpHover ? 'black' : this._renderSide === this.backKey ? 'black' : 'white', display: 'inline-block', }}> <FontAwesomeIcon icon="turn-up" size="xl" /> @@ -88,8 +220,6 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() </Tooltip> ); } - - _sideBtnWidth = 35; /** * How much the content of the view is being scaled based on its nesting and its fit-to-width settings */ @@ -104,57 +234,49 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() @computed get uiBtnScaling() { return Math.max(this.maxWidgetSize / this._sideBtnWidth, 1) * Math.min(1, this.viewScaling)* (this._props.NativeDimScaling?.() ?? 1); } // prettier-ignore @computed get flashcardMenu() { - return SnappingManager.HideDecorations ? null : ( + return ( <div className="comparisonBox-bottomMenu" style={{ transform: `scale(${this.uiBtnScaling})` }}> - {this.revealOp === 'hover' || !this._props.isSelected() ? null : this.overlayAlternateIcon} - {!this._props.isSelected() ? null : ( - <> - {!this._frontSide ? null : ( - <Tooltip - title={ - <div className="dash-tooltip">{ - !this._frontSide ? "Flip to front side to use GPT": - "Ask GPT to create an answer on the back side of the flashcard based on your question on the front"} - </div> // prettier-ignore - }> - <div className="comparisonBox-button" onPointerDown={() => (this._frontSide ? this.findImageTags() : null)}> - <FontAwesomeIcon icon="lightbulb" size="xl" /> - </div> - </Tooltip> - )} - {DocCast(this.Document.embedContainer)?.type_collection !== CollectionViewType.Freeform ? null : ( - <Tooltip title={<div className="dash-tooltip">Create new flashcard stack based on text</div>}> - <div className="comparisonBox-button" onClick={this.gptFlashcardPile}> - <FontAwesomeIcon icon="layer-group" size="xl" /> - </div> - </Tooltip> - )} - </> + {this.revealOpHover || !this._props.isSelected() ? null : this.overlayAlternateIcon} + {!this._props.isSelected() || this._renderSide === this.frontKey ? null : ( + <Tooltip title={<div className="dash-tooltip">Ask GPT to create an answer for the question on the front</div>}> + <div className="comparisonBox-button" onPointerDown={() => this.askGPT(GPTCallType.CHATCARD)}> + <FontAwesomeIcon icon="lightbulb" size="xl" /> + </div> + </Tooltip> + )} + {!this._props.isSelected() || this._renderSide === this.backKey || !CollectionFreeFormView.from(this.DocumentView?.()) || (this.dataDoc[this.backKey] && !DocCast(this.dataDoc[this.backKey])?.text_placeholder) ? null : ( + <Tooltip title={<div className="dash-tooltip">Create new flashcard stack based on text</div>}> + <div + className="comparisonBox-button" + onClick={() => + this.askGPT(GPTCallType.STACK).then(async text => { + const newCol = await ComparisonBox.createFlashcardDeck(text, NumCast(this.layoutDoc._width, 250) + 50, NumCast(this.layoutDoc._height, 200), this.frontKey, this.backKey); + newCol.x = NumCast(this.layoutDoc.x); + newCol.y = NumCast(this.layoutDoc.y); + this._props.DocumentView?.()._props.addDocument?.(newCol); + this._props.removeDocument?.(this.Document); + }) + }> + <FontAwesomeIcon icon="layer-group" size="xl" /> + </div> + </Tooltip> )} </div> ); } - @action handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { - this._inputValue = e.target.value; - }; - @action activateContent = () => { this._childActive = true; }; - @action handleRenderClick = () => { - this._frontSide = !this._frontSide; - }; - @action handleRenderGPTClick = () => { const phonTrans = DocCast(this.Document.audio) ? DocCast(this.Document.audio).phoneticTranscription : undefined; if (phonTrans) { this._inputValue = StrCast(phonTrans); this.askGPTPhonemes(this._inputValue); + this._renderSide = this.backKey; + this._outputValue = ''; } else if (this._inputValue) this.askGPT(GPTCallType.QUIZ); - this._frontSide = false; - this._outputValue = ''; }; onPointerMove = ({ movementX }: PointerEvent) => { @@ -165,38 +287,6 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() return false; }; - componentDidMount() { - this._props.setContentViewBox?.(this); - this._reactDisposer = reaction( - () => this._props.isSelected(), // when this reaction should update - selected => !selected && (this._childActive = false) // what it should update to - ); - } - - componentWillUnmount() { - this._reactDisposer?.(); - } - - protected createDropTarget = (ele: HTMLDivElement | null, fieldKey: string, disposerId: number) => { - this._disposers[disposerId]?.(); - if (ele) { - this._disposers[disposerId] = DragManager.MakeDropTarget(ele, (e, dropEvent) => this.internalDrop(e, dropEvent, fieldKey), this.layoutDoc); - } - }; - - private internalDrop = undoable((e: Event, dropEvent: DragManager.DropEvent, fieldKey: string) => { - if (dropEvent.complete.docDragData) { - const { droppedDocuments } = dropEvent.complete.docDragData; - const added = dropEvent.complete.docDragData.moveDocument?.(droppedDocuments, this.Document, (doc: Doc | Doc[]) => this.addDoc(toList(doc).lastElement(), fieldKey)); - 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; - }, 'internal drop'); - getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const anchor = Docs.Create.ConfigDocument({ title: 'CompareAnchor:' + this.Document.title, @@ -219,13 +309,11 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() 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._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] = undefined; return true; } @@ -253,10 +341,30 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() default: return this._props.styleProvider?.(doc, props, property); } // prettier-ignore }; - moveDoc1 = (docs: Doc | Doc[], targetCol: Doc | undefined, addDoc: (doc: Doc | Doc[]) => boolean) => toList(docs).reduce((res, doc: Doc) => res && this.moveDoc(doc, addDoc, this.fieldKey + '_1'), true); - moveDoc2 = (docs: Doc | Doc[], targetCol: Doc | undefined, addDoc: (doc: Doc | Doc[]) => boolean) => toList(docs).reduce((res, doc: Doc) => res && this.moveDoc(doc, addDoc, this.fieldKey + '_2'), true); - remDoc1 = (docs: Doc | Doc[]) => toList(docs).reduce((res, doc) => res && this.remDoc(doc, this.fieldKey + '_1'), true); - remDoc2 = (docs: Doc | Doc[]) => toList(docs).reduce((res, doc) => res && this.remDoc(doc, this.fieldKey + '_2'), true); + moveDocFront = (docs: Doc | Doc[], targetCol: Doc | undefined, addDoc: (doc: Doc | Doc[]) => boolean) => toList(docs).reduce((res, doc: Doc) => res && this.moveDoc(doc, addDoc, this.frontKey), true); + moveDocBack = (docs: Doc | Doc[], targetCol: Doc | undefined, addDoc: (doc: Doc | Doc[]) => boolean) => toList(docs).reduce((res, doc: Doc) => res && this.moveDoc(doc, addDoc, this.backKey), true); + remDocFront = (docs: Doc | Doc[]) => toList(docs).reduce((res, doc) => res && this.remDoc(doc, this.frontKey), true); + remDocBack = (docs: Doc | Doc[]) => toList(docs).reduce((res, doc) => res && this.remDoc(doc, this.backKey), true); + animateSliding = action((targetWidth: number) => { + this._animating = `all ${this._slideTiming}ms`; // on click, animate slider movement to the targetWidth + this.layoutDoc[this.clipWidthKey] = (targetWidth * 100) / this._props.PanelWidth(); + setTimeout(action(() => {this._animating = ''; }), this._slideTiming); // prettier-ignore + }); + + _flipAnim: NodeJS.Timeout | undefined; + animateFlipping = action((side?: string) => { + if (side !== this._renderSide) { + this._renderSide = side ?? (this._renderSide === this.frontKey ? this.backKey : this.frontKey); // switches to new front + this._animating = '0'; // reveals old front on the bottom layer by making top layer transparent + setTimeout( + action(() => { + this._animating = `all ${this._slideTiming * 5}ms`; // makes new front fade in + clearTimeout(this._flipAnim); + this._flipAnim = setTimeout( action(() => { this._animating = ''; }), this._slideTiming * 5 ); // prettier-ignore + }) + ); + } + }); registerSliding = (e: React.PointerEvent<HTMLDivElement>, targetWidth: number) => { if (e.button !== 2) { @@ -268,25 +376,13 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() action((moveEv, doubleTap) => { if (doubleTap) { 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); + if (!this.dataDoc[this.frontKey] && !this.dataDoc[this.fieldKey]) this.dataDoc[this.frontKey] = DocUtils.copyDragFactory(Doc.UserDoc().emptyNote as Doc); + if (!this.dataDoc[this.backKey] && !this.dataDoc[this.fieldKey + '_alternate']) this.dataDoc[this.backKey] = DocUtils.copyDragFactory(Doc.UserDoc().emptyNote as Doc); } }), false, undefined, - action(() => { - if (this._childActive) return; - this._animating = 'all 200ms'; - // on click, animate slider movement to the targetWidth - this.layoutDoc[this.clipWidthKey] = (targetWidth * 100) / this._props.PanelWidth(); - - setTimeout( - action(() => { - this._animating = ''; - }), - 200 - ); - }) + action(() => !this._childActive && this.animateSliding(targetWidth)) ); } }; @@ -296,26 +392,26 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() */ setListening = () => { if (this.SpeechRecognition) { - this.recognition.continuous = true; - this.recognition.interimResults = true; - this.recognition.lang = 'en-US'; - this.recognition.onresult = this.handleResult.bind(this); + this._recognition.continuous = true; + this._recognition.interimResults = true; + this._recognition.lang = 'en-US'; + this._recognition.onresult = this.handleResult.bind(this); } ContextMenu.Instance.setLangIndex(0); }; startListening = () => { - this.recognition.start(); + this._recognition.start(); this._listening = true; }; stopListening = () => { - this.recognition.stop(); + this._recognition.stop(); this._listening = false; }; setLanguage = (language: string, ind: number) => { - this.recognition.lang = language; + this._recognition.lang = language; ContextMenu.Instance.setLangIndex(ind); }; @@ -324,7 +420,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() * @returns */ convertAbr = () => { - switch (this.recognition.lang) { + switch (this._recognition.lang) { case 'en-US': return 'English'; //prettier-ignore case 'es-ES': return 'Spanish'; //prettier-ignore case 'fr-FR': return 'French'; //prettier-ignore @@ -360,17 +456,16 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() * Gets the transcription of an audio recording by sending the * recording to backend. */ - pushInfo = async () => { - const audio = { - file: DocCast(this.Document.audio)[DocData].url, - }; - const response = await axios.post('http://localhost:105/recognize/', audio, { - headers: { - 'Content-Type': 'application/json', - }, - }); - this.Document.phoneticTranscription = response.data.transcription; - }; + pushInfo = () => + axios + .post( + 'http://localhost:105/recognize/', // + { file: DocCast(this.Document.audio)[DocData].url }, + { headers: { 'Content-Type': 'application/json' } } + ) + .then(response => { + this.Document.phoneticTranscription = response.data.transcription; + }); /** * Extracts the id of the youtube video url. @@ -387,147 +482,55 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() * Gets the transcript of a youtube video by sending the video url to the backend. * @returns transcription of youtube recording */ - youtubeUpload = async () => { - const audio = { - file: this.getYouTubeVideoId(StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text)), - }; - const response = await axios.post('http://localhost:105/youtube/', audio, { - headers: { - 'Content-Type': 'application/json', - }, - }); - return response.data.transcription; - }; - - 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, - _xMargin: 5, - _yMargin: 5, - }); - newCol.x = this.layoutDoc.x; - newCol.y = NumCast(this.layoutDoc.y) + 50; - newCol.type_collection = CollectionViewType.Carousel as string; - for (let i = 0; i < collectionArr.length; i++) { - DocCast(collectionArr[i])[DocData].embedContainer = newCol; - } - - 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; - } - } - - /** - * Transfers the content of flashcards into a flashcard pile. - */ - gptFlashcardPile = async () => { - const text = await this.askGPT(GPTCallType.STACK); - const senArr = text?.split('Question: ') ?? []; - const collectionArr: Doc[] = []; - for (let i = 1; i < senArr.length; i++) { - const newDoc = Docs.Create.ComparisonDocument(senArr[i], { _layout_isFlashcard: true, _width: 300, _height: 300 }); - - if (senArr[i].includes('Keyword: ')) { - const question = StrCast(senArr![i]).split('Keyword: '); - const questionTxt = question[0].includes('Answer: ') ? question[0].split('Answer: ')[0] : question[0]; - const answerTxt = question[0].includes('Answer: ') ? question[0].split('Answer: ')[1] : question[1]; - this.fetchImages(question[1]).then(img => { - const rtfiel = new RichTextField( - JSON.stringify({ - // this is a RichText json that has the question text placed above a related image - doc: { - type: 'doc', - content: [ - { - type: 'paragraph', - attrs: { align: null, color: null, id: null, indent: null, inset: null, lineSpacing: null, paddingBottom: null, paddingTop: null }, - content: [{ type: 'text', text: questionTxt }, img ? { type: 'dashDoc', attrs: { width: '200px', height: '200px', title: 'dashDoc', float: 'unset', hidden: false, docId: img[Id] } } : {}], - }, - ], - }, - selection: { type: 'text', anchor: 2, head: 2 }, - }), - questionTxt - ); - newDoc[DocData][this.fieldKey + '_1'] = Docs.Create.TextDocument(questionTxt, { text: rtfiel }); - newDoc[DocData][this.fieldKey + '_0'] = Docs.Create.TextDocument(answerTxt); - }); - } - - collectionArr.push(newDoc); - } - this.createFlashcardPile(collectionArr, true); - }; + youtubeUpload = async () => + axios + .post( + 'http://localhost:105/youtube/', // + { file: this.getYouTubeVideoId(this.frontText) }, + { headers: { 'Content-Type': 'application/json' } } + ) + .then(response => response.data.transcription); /** * Calls GPT for each flashcard type. */ - 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; - - if (callType == GPTCallType.CHATCARD) { - if (StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text) === '') { - this._loading = false; - return; - } - } - try { - const res = await gptAPICall(questionText, GPTCallType.FLASHCARD); - runInAction(() => { - if (!res) { - console.error('GPT call failed'); - return; - } - if (callType == GPTCallType.CHATCARD) { - DocCast(this.dataDoc[this.props.fieldKey + '_0'])[DocData].text = res; - } else if (callType == GPTCallType.QUIZ) { - this._frontSide = true; - this._outputValue = res.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric'); - } else if (callType === GPTCallType.FLASHCARD) { - this._loading = false; - return res; - } - this._loading = false; - }); - return res; - } catch (err) { - console.error('GPT call failed', err); - } - this._loading = false; + askGPT = async (callType: GPTCallType) => { + const questionText = 'Question: ' + this.frontText; + const queryText = questionText + (callType == GPTCallType.QUIZ ? ' UserAnswer: ' + this._inputValue + '. ' + ' Rubric: ' + this.backText : ''); + + this.loading = true; + const res = !this.frontText + ? '' + : await gptAPICall(queryText, callType).then( + action(resp => { + switch (resp && callType) { + case GPTCallType.CHATCARD: + DocCast(this.dataDoc[this.backKey])[DocData].text = resp; + break; + case GPTCallType.QUIZ: + this._renderSide = this.backKey; + this._outputValue = resp.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric'); + break; + case GPTCallType.FLASHCARD: + default: + } + return resp; + }) + ); + this.loading = false; + if (!res) console.error('GPT call failed'); + return res; }; layoutWidth = () => NumCast(this.layoutDoc.width, 200); layoutHeight = () => NumCast(this.layoutDoc.height, 200); - findImageTags = async () => { - const c = this.DocumentView?.().ContentDiv?.getElementsByTagName('img'); - if (c?.length === 0) this.askGPT(GPTCallType.CHATCARD); - if (c) { - this._loading = true; - for (const i of c) { - if (i.className !== 'ProseMirror-separator') this.getImageDesc(i.src); - } - this._loading = false; - } - }; - /** * Ask GPT for advice on how to improve speech by comparing the phonetic transcription of * a users audio recording with the phonetic transcription of their intended sentence. * @param phonemes */ askGPTPhonemes = async (phonemes: string) => { - const sentence = StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text); + const sentence = this.frontText; const phon6 = 'huː ɑɹ juː tədeɪ'; const phon4 = 'kamo estas hɔi'; const promptEng = @@ -553,17 +556,11 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() this.convertAbr() + ' speech and they are not allophones of the same phoneme and they are far away from each on the ipa vowel chat and that pronunciation is not normal for the meaning of the word, note this difference and explain how it is supposed to sound. Just so you know, "i" sounds like "ee" as in "bee", not "ih" as an "lick". Interpret "ɹ" as the same as "r". Interpret "ʌ" as the same as "ə". Do not make "θ" and "f" interchangable. Do not make "n" and "ɲ" interchangable. Do not make "e" and "i" interchangable. If "ɚ", "ɔː", and "ɔ" are options for pronunciation, do not choose "ɚ". Ignore differences with colons. Ignore redundant letters and words and sounds and the splitting of words; do not mention this since there could be repeated words in the sentence. Provide a response like this: "Lets work on improving the pronunciation of "coffee." You said "cawffee," which is close, but we need to adjust the vowel sound. In American English, "coffee" is pronounced /ˈkɔːfi/, with a long "aw" sound. Try saying "kah-fee." Your intonation is good, but try putting a bit more stress on "like" in the sentence "I would like a coffee with milk." This will make your speech sound more natural. Keep practicing, and lets try saying the whole sentence again!"'; - switch (this.recognition.lang) { - case 'en-US': - this._outputValue = await gptAPICall(promptEng, GPTCallType.PRONUNCIATION); - break; - case 'es-ES': - this._outputValue = await gptAPICall(promptSpa, GPTCallType.PRONUNCIATION); - break; - default: - this._outputValue = await gptAPICall(promptAll, GPTCallType.PRONUNCIATION); - break; - } + switch (this._recognition.lang) { + case 'en-US': this._outputValue = await gptAPICall(promptEng, GPTCallType.PRONUNCIATION); break; + case 'es-ES': this._outputValue = await gptAPICall(promptSpa, GPTCallType.PRONUNCIATION); break; + default: this._outputValue = await gptAPICall(promptAll, GPTCallType.PRONUNCIATION); break; + } // prettier-ignore }; /** @@ -586,45 +583,37 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() * @param selection * @returns Image Document */ - fetchImages = async (selection: string) => { + public static async fetchImages(selection: string) { try { const { data } = await axios.get(`${API_URL}?query=${selection}&page=1&per_page=${1}&client_id=Q4zruu6k6lum2kExiGhLNBJIgXDxD6NNj0SRHH_XXU0`); 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-', + title: selection, }); - imageSnapshot.x = this.layoutDoc.x; - imageSnapshot.y = this.layoutDoc.y; return imageSnapshot; } catch (error) { console.log(error); } - }; + } getImageDesc = async (u: string) => { try { const hrefBase64 = await imageUrlToBase64(u); const response = await gptImageLabel(hrefBase64, 'Answer the following question as a short flashcard response. Do not include a label.' + (this.dataDoc.text as RichTextField)?.Text); - DocCast(this.dataDoc[this.props.fieldKey + '_0'])[DocData].text = response; + DocCast(this.dataDoc[this.backKey])[DocData].text = response; } catch (error) { console.log('Error', error); } }; - @action - flipFlashcard = () => { - this._frontSide = !this._frontSide; - }; - - hoverFlip = (side: boolean) => { - if (this.revealOp === 'hover') this._frontSide = side; + flashcardContextMenu = () => { + const appearance = ContextMenu.Instance.findByDescription('Appearance...'); + const appearanceItems = appearance?.subitems ?? []; + appearanceItems.push({ description: 'Create ChatCard', event: () => this.askGPT(GPTCallType.CHATCARD), icon: 'id-card' }); + !appearance && ContextMenu.Instance.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'eye' }); }; testForTextFields = (whichSlot: string) => { @@ -634,8 +623,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() const altText = RTFCast(this.Document[this.fieldKey + '_alternate'])?.Text.trim(); const layoutTemplateString = slotHasText ? FormattedTextBox.LayoutString(whichSlot): - whichSlot.endsWith('1') ? (subjectText !== undefined ? FormattedTextBox.LayoutString(this.fieldKey) : undefined) : - altText !== undefined ? FormattedTextBox.LayoutString(this.fieldKey + '_alternate'): undefined; // prettier-ignore + whichSlot === this.frontKey ? (subjectText !== undefined ? FormattedTextBox.LayoutString(this.fieldKey) : undefined) : + altText !== undefined ? FormattedTextBox.LayoutString(this.fieldKey + '_alternate'): undefined; // prettier-ignore // A bit hacky to try out the concept of using GPT to fill in flashcards // If the second slot doesn't have anything in it, but the fieldKey slot has text (e.g., this.text is a string) @@ -643,8 +632,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() // eg., this.text_alternate is // "((Provide a one sentence definition for (this) that doesn't use any word in (this.excludeWords) ))" // where (this) is replaced by the text in the fieldKey slot abd this.excludeWords is repalced by the conetnts of the excludeWords field - // The GPT call will put the "answer" in the second slot of the comparison (eg., text_2) - if (whichSlot.endsWith('2') && !layoutTemplateString?.includes(whichSlot)) { + // The GPT call will put the "answer" in the second slot of the comparison (eg., text_0) + if (whichSlot === this.backKey && !layoutTemplateString?.includes(whichSlot)) { const queryText = altText?.replace('(this)', subjectText); // TODO: this should be done in Doc.setField but it doesn't know about the fieldKey ... if (queryText?.match(/\(\(.*\)\)/)) { Doc.SetField(this.Document, whichSlot, ':=' + queryText, false); // make the second slot be a computed field on the data doc that calls ChatGpt @@ -652,179 +641,167 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() } return layoutTemplateString; }; - childActiveFunc = () => this._childActive; contentScreenToLocalXf = () => this._props.ScreenToLocalTransform().scale(this._props.NativeDimScaling?.() || 1); - render() { - const clearButton = (which: string) => ( - <Tooltip title={<div className="dash-tooltip">remove</div>}> - <div - 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="xs" /> - </div> - </Tooltip> - ); - const displayDoc = (whichSlot: string) => { - const whichDoc = DocCast(this.dataDoc[whichSlot]); - const targetDoc = DocCast(whichDoc?.annotationOn, whichDoc); - const layoutString = targetDoc ? '' : this.testForTextFields(whichSlot); - - return targetDoc || layoutString ? ( - <> - <DocumentView - {...this._props} - showTags={undefined} - fitWidth={undefined} // set to returnTrue to make images fill the comparisonBox-- should be a user option - ignoreUsePath={layoutString ? true : undefined} - renderDepth={this.props.renderDepth + 1} - LayoutTemplateString={layoutString} - Document={layoutString ? this.Document : targetDoc} - containerViewPath={this._props.docViewPath} - moveDocument={whichSlot.endsWith('1') ? this.moveDoc1 : this.moveDoc2} - removeDocument={whichSlot.endsWith('1') ? this.remDoc1 : this.remDoc2} - NativeWidth={returnZero} - NativeHeight={returnZero} - ScreenToLocalTransform={this.contentScreenToLocalXf} - isContentActive={this.childActiveFunc} - isDocumentActive={returnFalse} - dontSelect={returnTrue} - whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} - styleProvider={this._childActive ? this._props.styleProvider : this.docStyleProvider} - hideLinkButton - pointerEvents={this._childActive ? undefined : returnNone} - /> - {!this.Document._layout_isFlashcard ? clearButton(whichSlot) : null} - </> - ) : ( - <div className="placeholder"> - <FontAwesomeIcon className="upload-icon" icon="cloud-upload-alt" size="lg" /> - </div> - ); - }; - const displayBox = (which: string, index: number, cover: number) => ( + + clearButton = (which: string) => ( + <Tooltip title={<div className="dash-tooltip">remove</div>}> <div - className={`${index === 0 ? 'before' : 'after'}Box-cont`} - key={which} - style={{ width: this._props.PanelWidth() }} - onPointerDown={e => { - this.registerSliding(e, cover); - this.Document._layout_isFlashcard && this.activateContent(); - }} - ref={ele => this.createDropTarget(ele, which, index)}> - {!this._isEmpty ? displayDoc(which) : null} + 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="xs" /> + </div> + </Tooltip> + ); + displayDoc = (whichSlot: string) => { + const whichDoc = DocCast(this.dataDoc[whichSlot]); + const targetDoc = DocCast(whichDoc?.annotationOn, whichDoc); + const layoutString = targetDoc ? '' : this.testForTextFields(whichSlot); + + return targetDoc || layoutString ? ( + <> + <DocumentView + {...this._props} + Document={layoutString ? this.Document : targetDoc} + NativeWidth={returnZero} + NativeHeight={returnZero} + renderDepth={this.props.renderDepth + 1} + LayoutTemplateString={layoutString} + containerViewPath={this._props.docViewPath} + ScreenToLocalTransform={this.contentScreenToLocalXf} + isDocumentActive={returnFalse} + isContentActive={this.childActiveFunc} + showTags={undefined} + fitWidth={undefined} // set to returnTrue to make images fill the comparisonBox-- should be a user option + ignoreUsePath={layoutString ? true : undefined} + moveDocument={whichSlot === this.frontKey ? this.moveDocFront : this.moveDocBack} + removeDocument={whichSlot === this.frontKey ? this.remDocFront : this.remDocBack} + dontSelect={returnTrue} + whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} + styleProvider={this._childActive ? this._props.styleProvider : this.docStyleProvider} + hideLinkButton + pointerEvents={this._childActive ? undefined : returnNone} + /> + {!this.isFlashcard ? this.clearButton(whichSlot) : null} + </> + ) : ( + <div className="placeholder"> + <FontAwesomeIcon className="upload-icon" icon="cloud-upload-alt" size="lg" /> </div> ); + }; - if (this.Document._layout_isFlashcard) { - const side = this._frontSide ? 1 : 0; - const dataSplit = StrCast(this.dataDoc.data).includes('Keyword: ') ? StrCast(this.dataDoc.data).split('Keyword: ') : StrCast(this.dataDoc.data).split('Answer: '); - const textCreator = (which: number, title: string, text: string) => { - const newDoc = Docs.Create.TextDocument(text, { - title, // - _layout_autoHeight: true, - _layout_centered: true, - text_align: 'center', - _layout_fitWidth: true, - }); - this.addDoc(newDoc, this.fieldKey + '_' + which); - return newDoc; - }; - - // add text box to each side when comparison box is first created - if (!this.dataDoc[this.fieldKey + '_0'] && !this._isEmpty) { - textCreator(0, 'answer', dataSplit[1]); - } - - if (!this.dataDoc[this.fieldKey + '_1'] && !this._isEmpty) { - const question = textCreator(1, 'question', dataSplit[0] || 'hint: Enter a topic, select this document and click the stack button to have GPT create a deck of cards'); - Doc.SelectOnLoad = dataSplit[0] ? undefined : question; - } - - if (DocCast(this.Document.embedContainer).practiceMode === practiceMode.QUIZ) { - const text = StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text); - return ( - <div className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}`}> - <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._frontSide ? this._outputValue : this._inputValue} - onChange={this.handleInputChange} - onScroll={e => { - e.stopPropagation(); - e.preventDefault(); - }} - placeholder={!this.layoutDoc[`_${this._props.fieldKey}_usePath`] ? 'Enter a response for GPT to evaluate.' : ''} - readOnly={this._frontSide}></textarea> - - {this._loading ? ( - <div className="loading-spinner"> - <ReactLoading type="spin" height={30} width={30} color={'blue'} /> - </div> - ) : null} - </div> - <div> - <div className="submit-button"> - <div className="submit-buttonschema-header-button" onPointerDown={e => this.openContextMenu(e.clientX, e.clientY, false)}> - <FontAwesomeIcon color="white" icon="caret-down" /> - </div> - <button className="submit-buttonrecord" onClick={this._listening ? this.stopListening : this.startListening} style={{ background: this._listening ? 'lightgray' : '' }}> - {<FontAwesomeIcon icon="microphone" size="lg" />} - </button> - <div className="submit-buttonschema-header-button" onPointerDown={e => this.openContextMenu(e.clientX, e.clientY, true)} style={{ left: '50px', zIndex: '100' }}> - <FontAwesomeIcon color="white" icon="caret-down" /> - </div> - <button className="submit-buttonpronunciation" onClick={this.evaluatePronunciation}> - Evaluate Pronunciation - </button> - <button className="submit-buttonsubmit" type="button" onClick={this._frontSide ? this.handleRenderClick : this.handleRenderGPTClick}> - {this._frontSide ? 'Redo the Question' : 'Submit'} - </button> - </div> - </div> + displayBox = (which: string, cover: number) => ( + <div className={`${which === this.frontKey ? 'before' : 'after'}Box-cont`} key={which} style={{ width: this._props.PanelWidth() }} onPointerDown={e => this.registerSliding(e, cover)} ref={ele => this.createDropTarget(ele, which)}> + {this.displayDoc(which)} + </div> + ); + + /* renders front(qustion) and back(answer) at the same time, then on user input replaces the answer with a GPT analysis of the answer */ + renderAsQuiz = (text: string) => ( + <div className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}`}> + <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._renderSide === this.backKey ? this._outputValue : this._inputValue} + onChange={action(e => { + this._inputValue = e.target.value; + })} + placeholder={!this.layoutDoc[`_${this._props.fieldKey}_usePath`] ? 'Enter a response for GPT to evaluate.' : ''} + readOnly={this._renderSide === this.backKey} + /> + {!this.loading ? null : ( + <div className="loading-spinner"> + <ReactLoading type="spin" height={30} width={30} color="blue" /> </div> - ); - } - - // 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' }} - onMouseEnter={() => this.hoverFlip(true)} - onMouseLeave={() => this.hoverFlip(false)}> - {displayBox(`${this.fieldKey}_${side === 0 ? 1 : 0}`, side, this._props.PanelWidth() - 3)} - {this._loading ? ( - <div className="loading-spinner"> - <ReactLoading type="spin" height={30} width={30} color={'blue'} /> - </div> - ) : null} - {this.flashcardMenu} - </div> - ); - } - // render a comparison box that compares items side by side - return ( - <div className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}` /* change className to easily disable/enable pointer events in CSS */}> - {displayBox(`${this.fieldKey}_2`, 1, this._props.PanelWidth() - 3)} - <div className="clip-div" style={{ width: this.clipWidth + '%', transition: this._animating, background: StrCast(this.layoutDoc._backgroundColor, 'gray') }}> - {displayBox(`${this.fieldKey}_1`, 0, 0)} + )} + </div> + <div> + <div className="submit-button"> + <div className="submit-buttonschema-header-button" onPointerDown={e => this.openContextMenu(e.clientX, e.clientY, false)}> + <FontAwesomeIcon color="white" icon="caret-down" /> + </div> + <button className="submit-buttonrecord" onClick={this._listening ? this.stopListening : this.startListening} style={{ background: this._listening ? 'lightgray' : '' }}> + {<FontAwesomeIcon icon="microphone" size="lg" />} + </button> + <div className="submit-buttonschema-header-button" onPointerDown={e => this.openContextMenu(e.clientX, e.clientY, true)} style={{ left: '50px', zIndex: '100' }}> + <FontAwesomeIcon color="white" icon="caret-down" /> + </div> + <button className="submit-buttonpronunciation" onClick={this.evaluatePronunciation}> + Evaluate Pronunciation + </button> + <button className="submit-buttonsubmit" type="button" onClick={this._renderSide === this.backKey ? () => this.animateFlipping(this.frontKey) : this.handleRenderGPTClick}> + {this._renderSide === this.backKey ? 'Redo the Question' : 'Submit'} + </button> </div> + </div> + </div> + ); + + // if flashcard is rendered that has no data, then add some placeholders for question and answer + // addPlaceholdersForEmptyFlashcard = () => { + // if (this.dataDoc.data) { + // if (!this.dataDoc[this.backKey] || !this.dataDoc[this.frontKey]) ComparisonBox.createFlashcard(StrCast(this.dataDoc.data), this.frontKey, this.backKey, this.Document); + // } + // }; + + // render a button that flips between front and back + renderAsFlip = () => ( + <div + style={{ display: 'flex', pointerEvents: this.revealOpHover && this._props.isContentActive() ? 'unset' : undefined }} // + onMouseEnter={() => this.revealOpHover && this.animateFlipping(this.backKey)} + onMouseLeave={() => this.revealOpHover && this.animateFlipping(this.frontKey)}> + <div style={{ position: 'absolute', width: '100%', height: '100%', transition: this._animating === '0' ? undefined : this._animating, opacity: this._animating === '0' ? 1 : 0 }}> + {this.displayBox(this._renderSide === this.backKey ? this.frontKey : this.backKey, 0)} + </div> + <div style={{ position: 'absolute', width: '100%', height: '100%', transition: this._animating === '0' ? undefined : this._animating, opacity: this._animating === '0' ? 0 : 1 }}>{this.displayBox(this._renderSide, 0)}</div> + {this.flashcardMenu} + </div> + ); + + // render a slider that reveals front and back as slider is dragged horizonally + renderAsBeforeAfter = () => ( + <div + className="comparisonBox-slide" + style={{ display: 'flex', pointerEvents: this.revealOpHover && this._props.isContentActive() ? 'unset' : undefined }} + onMouseEnter={() => this.revealOpHover && this.animateSliding(0)} + onMouseLeave={() => this.revealOpHover && this.animateSliding(this._props.PanelWidth() - 3)}> + {this.displayBox(this.backKey, this._props.PanelWidth() - 3)} + <div className="clip-div" style={{ width: this.clipWidth + '%', transition: this._animating, background: StrCast(this.layoutDoc._backgroundColor, 'gray') }}> + {this.displayBox(this.frontKey, 0)} + </div> - <div - className="slide-bar" - style={{ - left: `calc(${this.clipWidth + '%'} - 0.5px)`, - cursor: this.clipWidth < 5 ? 'e-resize' : this.clipWidth / 100 > (this._props.PanelWidth() - 5) / this._props.PanelWidth() ? 'w-resize' : undefined, - }} - onPointerDown={e => !this._isAnyChildContentActive && this.registerSliding(e, this._props.PanelWidth() / 2)} /* if clicked, return slide-bar to center */ - > - <div className="slide-handle" /> - </div> + <div + className="slide-bar" + style={{ + left: `calc(${this.clipWidth + '%'} - 0.5px)`, + cursor: this.clipWidth < 5 ? 'e-resize' : this.clipWidth / 100 > (this._props.PanelWidth() - 5) / this._props.PanelWidth() ? 'w-resize' : undefined, + }} + onPointerDown={e => !this._isAnyChildContentActive && this.registerSliding(e, this._props.PanelWidth() / 2)} /* if clicked, return slide-bar to center */ + > + <div className="slide-handle" /> + </div> + </div> + ); + + render() { + const renderMode = new Map<flashcardRevealOp, () => JSX.Element>([ + [flashcardRevealOp.FLIP, this.renderAsFlip], + [flashcardRevealOp.SLIDE, this.renderAsBeforeAfter]]); // prettier-ignore + return this.isQuizMode ? ( + this.renderAsQuiz(this.frontText) + ) : ( + <div className="comparisonBox" style={{ pointerEvents: this._props.isContentActive() && !this.Document[Animation] ? 'unset' : undefined }} onContextMenu={this.flashcardContextMenu}> + {renderMode.get(this.revealOp)?.() ?? null} + {this.loading ? ( + <div className="loading-spinner"> + <ReactLoading type="spin" height={30} width={30} color="blue" /> + </div> + ) : null} </div> ); } |