diff options
Diffstat (limited to 'src/client/views/nodes/ComparisonBox.tsx')
-rw-r--r-- | src/client/views/nodes/ComparisonBox.tsx | 196 |
1 files changed, 103 insertions, 93 deletions
diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx index 80ef126dc..38ce5f2f7 100644 --- a/src/client/views/nodes/ComparisonBox.tsx +++ b/src/client/views/nodes/ComparisonBox.tsx @@ -35,22 +35,21 @@ import { FormattedTextBox } from './formattedText/FormattedTextBox'; const API_URL = 'https://api.unsplash.com/search/photos'; /** - * This view serves three distinct functions depending on the metadata field layout_isFlashcard - * 1) it provides a before/after animated sliding transition between two Docs - * 2) it provides a question/answer switch between two Docs (flashcard) - * 3) it provides a quiz view that displays a question and a user answer that can be "scored" by GPT + * 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 * - * In the case of the flashcard, there is an icon that allows the user to choose between a - * hover and a flip action to switch between cards. The transition is stored in the 'revealOp' field. - * In addition, if a flashcard is created without data in the front/back fields, this will - * create Text documents with placeholder text indicating to the user how to fill in the cards. - * One option is to allow the user to enter a topic and, by clicking on the flashcard stack button, - * convert the comparision box into a stack of comparison boxes filled in by GPT about the topic. + * 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. * - * Quiz mode is activated when the parent collection has its 'quiz' field set when it renders a flashcard. - * NOTE: this should probably be changed to passing down a prop to the flashcard telling it to render as a quiz. + * 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. * */ @@ -67,7 +66,6 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() */ public static createFlashcard(tuple: string, frontKey: string, backKey: string, useDoc?: Doc) { const [ktoken, atoken] = [ComparisonBox.ktoken, ComparisonBox.atoken]; - const newDoc = useDoc ?? Docs.Create.ComparisonDocument('', { _layout_isFlashcard: true, _width: 300, _height: 300 }); const question = (tuple.includes(ktoken) ? tuple.split(ktoken)[0] : tuple).split(atoken)[0]; const rest = tuple.replace(question, ''); // prettier-ignore @@ -78,9 +76,14 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() 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) => { - newDoc[DocData][frontKey] = FormattedTextBox.centeredTextCreator('question', question, img); - newDoc[DocData][backKey] = FormattedTextBox.centeredTextCreator('answer', answer); - return newDoc; + 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(); } @@ -98,6 +101,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() .map(tuple => ComparisonBox.createFlashcard(tuple, front, back)) ).then(docs => { return Docs.Create.CarouselDocument(docs, { + title: 'flashcard deck', _width: width, _height: height, _layout_fitWidth: false, @@ -116,12 +120,11 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() private _sideBtnWidth = 35; private _closeRef = React.createRef<HTMLDivElement>(); private _disposers: { [key: string]: DragManager.DragDropDisposer | undefined } = {}; - private _reactDisposer: IReactionDisposer | 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; @@ -135,18 +138,28 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() componentDidMount() { this._props.setContentViewBox?.(this); - this._reactDisposer = reaction( - () => this._props.isSelected(), // when this reaction should update + this._reactDisposer.select = reaction( + () => this._props.isSelected(), selected => { - if (selected && this.isFlashcard) this.activateContent(); + 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() { - this._reactDisposer?.(); + Object.values(this._reactDisposer).forEach(disposer => disposer?.()); } protected createDropTarget = (ele: HTMLDivElement | null, fieldKey: string) => { @@ -169,7 +182,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() return undefined; }, 'internal drop'); - @computed get isQuizMode() { return DocCast(this.Document.embedContainer)?.practiceMode === practiceMode.QUIZ; } // prettier-ignore + @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 @@ -178,10 +192,10 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() @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 @@ -193,13 +207,13 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() onPointerDown={e => setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => { if (!this.revealOp || this.revealOp === flashcardRevealOp.FLIP) { - this.flipFlashcard(); + this.animateFlipping(); } }) } style={{ - background: this.revealOp === flashcardRevealOp.HOVER ? 'gray' : this._renderSide === this.backKey ? 'white' : 'black', - color: this.revealOp === flashcardRevealOp.HOVER ? 'black' : this._renderSide === this.backKey ? '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" /> @@ -223,7 +237,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() @computed get flashcardMenu() { return SnappingManager.HideDecorations ? null : ( <div className="comparisonBox-bottomMenu" style={{ transform: `scale(${this.uiBtnScaling})` }}> - {this.revealOp === flashcardRevealOp.HOVER || !this._props.isSelected() ? null : this.overlayAlternateIcon} + {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)}> @@ -296,13 +310,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; } @@ -334,6 +346,26 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() 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) { @@ -351,13 +383,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() }), false, undefined, - action(() => { - if (!this._childActive) { - 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 - } - }) + action(() => !this._childActive && this.animateSliding(targetWidth)) ); } }; @@ -584,16 +610,6 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() } }; - @action - flipFlashcard = () => { - this._renderSide = this._renderSide === this.frontKey ? this.backKey : this.frontKey; - }; - - @action - hoverFlip = (side: string) => { - if (this.revealOp === flashcardRevealOp.HOVER) this._renderSide = side; - }; - flashcardContextMenu = () => { const appearance = ContextMenu.Instance.findByDescription('Appearance...'); const appearanceItems = appearance?.subitems ?? []; @@ -680,16 +696,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() }; 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); - this.isFlashcard && this.activateContent(); - }} - ref={ele => this.createDropTarget(ele, which)}> - {!this._isEmpty ? this.displayDoc(which) : null} + <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> ); @@ -727,7 +735,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() <button className="submit-buttonpronunciation" onClick={this.evaluatePronunciation}> Evaluate Pronunciation </button> - <button className="submit-buttonsubmit" type="button" onClick={this._renderSide === this.backKey ? this.flipFlashcard : this.handleRenderGPTClick}> + <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> @@ -736,40 +744,33 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() ); // 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); - } else { - // add text box to each side when comparison box is first created - if (!this.dataDoc[this.backKey] && !this._isEmpty) { - this.dataDoc[this.backKey] = FormattedTextBox.centeredTextCreator('answer', 'answer here', undefined, true); - } - - if (!this.dataDoc[this.frontKey] && !this._isEmpty) { - this.dataDoc[this.frontKey] = FormattedTextBox.centeredTextCreator('question', 'hint: Enter a topic, select this document and click the stack button to have GPT create a deck of cards', undefined, true); - } - } - }; - - renderAsFlashcard = () => ( + // 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 - className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}`} /* change className to easily disable/enable pointer events in CSS */ - onContextMenu={this.flashcardContextMenu} - onMouseEnter={() => this.hoverFlip(this.backKey)} - onMouseLeave={() => this.hoverFlip(this.frontKey)}> - {this.displayBox(this._renderSide, this._props.PanelWidth() - 3)} - {this.loading ? ( - <div className="loading-spinner"> - <ReactLoading type="spin" height={30} width={30} color="blue" /> - </div> - ) : null} + 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 comparison box that compares items side by side + // render a slider that reveals front and back as slider is dragged horizonally renderAsBeforeAfter = () => ( - <div className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}` /* change className to easily disable/enable pointer events in CSS */}> + <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)} @@ -789,11 +790,20 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() ); render() { - this.isFlashcard && this.addPlaceholdersForEmptyFlashcard(); - return this.isFlashcard ? - this.isQuizMode ? this.renderAsQuiz(this.frontText) : - this.renderAsFlashcard() : - this.renderAsBeforeAfter(); // prettier-ignore + const renderMode = new Map<flashcardRevealOp, () => JSX.Element>([ + [flashcardRevealOp.FLIP, this.renderAsFlip], + [flashcardRevealOp.SLIDE, this.renderAsBeforeAfter]]); // prettier-ignore + if (this.isQuizMode) this.renderAsQuiz(this.frontText); + return ( + <div className="comparisonBox" style={{ pointerEvents: this._props.isContentActive() ? '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> + ); } } |