import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; import { action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; 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'; import { DragManager } from '../../util/DragManager'; import { dropActionType } from '../../util/DropActionTypes'; import { undoable } from '../../util/UndoManager'; import { ViewBoxAnnotatableComponent } from '../DocComponent'; import { PinDocView, PinProps } from '../PinFuncs'; import { StyleProp } from '../StyleProp'; import './ComparisonBox.scss'; import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import { FormattedTextBox } from './formattedText/FormattedTextBox'; import ReactLoading from 'react-loading'; import { ContextMenu } from '../ContextMenu'; import { ContextMenuProps } from '../ContextMenuItem'; import { tickStep } from 'd3'; import { CollectionCarouselView } from '../collections/CollectionCarouselView'; @observer export class ComparisonBox extends ViewBoxAnnotatableComponent() { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ComparisonBox, fieldKey); } private _disposers: (DragManager.DragDropDisposer | undefined)[] = [undefined, undefined]; constructor(props: FieldViewProps) { super(props); makeObservable(this); } @observable private _inputValue = ''; @observable private _outputValue = ''; @observable private _loading = false; @observable private _isEmpty = false; @observable _yRelativeToTop: boolean = true; @action handleInputChange = (e: React.ChangeEvent) => { this._inputValue = e.target.value; console.log(this._inputValue); }; @observable _animating = ''; @computed get clipWidth() { return NumCast(this.layoutDoc[this.clipWidthKey], 50); } get clipWidthKey() { return '_' + this._props.fieldKey + '_clipWidth'; } @computed get clipHeight() { return NumCast(this.layoutDoc[this.clipHeightKey], 200); } get revealOp() { return this.layoutDoc[`_${this._props.fieldKey}_revealOp`]; } get clipHeightKey() { return '_' + this._props.fieldKey + '_clipHeight'; } 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) => { 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'); private registerSliding = (e: React.PointerEvent, targetWidth: number) => { if (e.button !== 2) { setupMoveUpEvents( this, e, this.onPointerMove, emptyFunction, 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); } }), 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(); // this.layoutDoc[this.clipHeightKey] = (targetWidth * 100) / this._props.PanelHeight(); setTimeout( action(() => { this._animating = ''; }), 200 ); }) ); } }; // private onClick(e: React.PointerEvent) { // setupMoveUpEvents( // this, e, this.onPointerMOve, emptyFunction(), () => {this._isAnyChildContentActive = true;}, emptyFunction(), emptyFunction() // ) // } @action private onPointerMove = ({ movementX }: PointerEvent) => { const width = movementX * this.ScreenToLocalBoxXf().Scale + (this.clipWidth / 100) * this._props.PanelWidth(); if (width && width > 5 && width < this._props.PanelWidth()) { this.layoutDoc[this.clipWidthKey] = (width * 100) / this._props.PanelWidth(); } return false; }; getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const anchor = Docs.Create.ConfigDocument({ title: 'CompareAnchor:' + this.Document.title, // set presentation timing properties for restoring view presentation_transition: 1000, annotationOn: this.Document, }); if (anchor) { if (!addAsAnnotation) anchor.backgroundColor = 'transparent'; /* addAsAnnotation && */ this.addDocument(anchor); PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), clippable: true } }, this.Document); return anchor; } return this.Document; }; clearDoc = undoable((fieldKey: string) => { // 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._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; }; closeDown = (e: React.PointerEvent, which: string) => { setupMoveUpEvents( this, e, moveEv => { const de = new DragManager.DocumentDragData([DocCast(this.dataDoc[which])], dropActionType.move); de.moveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[]) => boolean): boolean => addDocument(doc); de.canEmbed = true; DragManager.StartDocumentDrag([this._closeRef.current!], de, moveEv.clientX, moveEv.clientY); return true; }, emptyFunction, () => this.clearDoc(which) ); }; docStyleProvider = (doc: Opt, props: Opt, property: string): any => { if (property === StyleProp.PointerEvents) return 'none'; return this._props.styleProvider?.(doc, props, property); }; moveDoc1 = (docs: Doc | Doc[], targetCol: Doc | undefined, addDoc: any) => toList(docs).reduce((res, doc: Doc) => res && this.moveDoc(doc, addDoc, this.fieldKey + '_1'), true); moveDoc2 = (docs: Doc | Doc[], targetCol: Doc | undefined, addDoc: any) => 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); /** * Tests for whether a comparison box slot (ie, before or after) has renderable text content. * If it does, render a FormattedTextBox for that slot that references the comparisonBox's slot field * @param whichSlot field key for start or end slot * @returns a JSX layout string if a text field is found, othwerise undefined */ testForTextFields = (whichSlot: string) => { const slotData = Doc.Get(this.dataDoc, whichSlot, true); const slotHasText = slotData instanceof RichTextField || typeof slotData === 'string'; const subjectText = RTFCast(this.Document[this.fieldKey])?.Text.trim(); 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 // 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) // and the fieldKey + "_alternate" has text that includes a GPT query (indicated by (( && )) ) that is parameterized (optionally) by the fieldKey text (this) or other metadata (this.). // 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)) { 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 } } return layoutTemplateString; }; _closeRef = React.createRef(); 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. */ flipFlashcard = () => { const usePath = this.layoutDoc[`_${this._props.fieldKey}_usePath`]; this.layoutDoc[`_${this._props.fieldKey}_usePath`] = usePath === undefined ? 'alternate' : undefined; }; /** * Changes the view option to hover for a flashcard. */ hoverFlip = (side: string | undefined) => { if (this.layoutDoc[`_${this._props.fieldKey}_revealOp`] === 'hover') this.layoutDoc[`_${this._props.fieldKey}_usePath`] = side; }; /** * Creates the button used to flip the flashcards. */ @computed get overlayAlternateIcon() { const usepath = this.layoutDoc[`_${this._props.fieldKey}_usePath`]; return ( flip}>
setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => { if (!this.layoutDoc[`_${this._props.fieldKey}_revealOp`] || this.layoutDoc[`_${this._props.fieldKey}_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 ?? '')); } }) } style={{ background: this.revealOp === 'hover' ? 'gray' : usepath === 'alternate' ? 'white' : 'black', color: this.revealOp === 'hover' ? 'black' : usepath === 'alternate' ? 'black' : 'white', display: 'inline-block', }}>
); } @computed get flashcardMenu() { return (
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
) }>
(!this.layoutDoc[`_${this._props.fieldKey}_usePath`] ? this.askGPT(GPTCallType.CHATCARD) : null)}>
{DocCast(this.Document.embedContainer).type_collection === 'carousel' ? null : (
Create a flashcard pile
}>
this.createFlashcardPile([this.Document], false)}>
Create new flashcard stack based on text}>
this.gptFlashcardPile()}>
)} Hover to reveal}>
this.handleHover()}>
{/* Remove this side of the flashcard}>
this.closeDown(e, this.layoutDoc[`_${this._props.fieldKey}_usePath`] === 'alternate' ? this._props.fieldKey + '_1' : this._props.fieldKey + '_0')}>
*/} {/* {this.overlayAlternateIcon} */} ); } // 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.layoutDoc[`_${this._props.fieldKey}_usePath`] = 'alternate'; this._outputValue = ''; if (this._inputValue) this.askGPT(GPTCallType.QUIZ); }; @action handleHover = () => { if (this.revealOp === 'hover') { this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'flip'; this.Document.forceActive = false; } else { this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'hover'; this.Document.forceActive = true; } //this.revealOp === 'hover' ? (this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'flip') : (this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'hover'); }; @action handleRenderClick = () => { // Call the GPT model and get the output this.layoutDoc[`_${this._props.fieldKey}_usePath`] = undefined; }; 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 (callType: GPTCallType): Promise => { 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(callType == GPTCallType.QUIZ ? queryText : questionText, callType); if (!res) { console.error('GPT call failed'); return; } // 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) => ( remove}>
this.closeDown(e, which)} // prevent triggering slider movement in registerSliding >
); const displayDoc = (whichSlot: string) => { 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 ? ( <> this.childActive} isDocumentActive={returnFalse} dontSelect={returnTrue} whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} styleProvider={this.childActive ? this._props.styleProvider : this.docStyleProvider} hideLinkButton pointerEvents={this.childActive ? undefined : returnNone} /> {/*
{layoutString ? null : clearButton(whichSlot)}
*/} // placeholder image if doc is missingleft: `${NumCast(this.layoutDoc.width, 200) - 33}px` ) : (
); }; const displayBox = (which: string, index: number, cover: number) => (
{ 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} */}
); if (this.Document._layout_isFlashcard) { const side = this.layoutDoc[`_${this._props.fieldKey}_usePath`] === 'alternate' ? 1 : 0; // add text box to each side when comparison box is first created // (!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.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._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]; // 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 // 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 (

{text}

Return to all flashcards and add text to both sides.

{this._loading ? (
) : null}
); } //console.log('HEREEE2' + StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text)); // render a normal flashcard when not a QuizCard return (
{ this.hoverFlip('alternate'); }} onMouseLeave={() => { this.hoverFlip(undefined); }} // onPointerUp={() => (this._isAnyChildContentActive = true)} > {!this.layoutDoc[`_${this._props.fieldKey}_usePath`] && StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text) === '' && !this.childActive ?

Enter text in the flashcard.

: null} {displayBox(`${this.fieldKey}_${side === 0 ? 1 : 0}`, side, this._props.PanelWidth() - 3)} {this._loading ? (
) : null} {this._props.isContentActive() ? this.flashcardMenu : null} {this.overlayAlternateIcon}
); } // render a comparison box that compares items side by side return (
{displayBox(`${this.fieldKey}_2`, 1, this._props.PanelWidth() - 3)}
{displayBox(`${this.fieldKey}_1`, 0, 0)}
(this._props.PanelWidth() - 5) / this._props.PanelWidth() ? 'w-resize' : undefined, }} onPointerDown={e => !this.childActive && this.registerSliding(e, this._props.PanelWidth() / 2)} /* if clicked, return slide-bar to center */ >
); } } Docs.Prototypes.TemplateMap.set(DocumentType.COMPARISON, { data: '', layout: { view: ComparisonBox, dataField: 'data' }, options: { acl: '', backgroundColor: 'gray', dropAction: dropActionType.move, waitForDoubleClickToClick: 'always', _layout_reflowHorizontal: true, _layout_reflowVertical: true, _layout_nativeDimEditable: true, systemIcon: 'BsLayoutSplit', }, });