From de4cc95e406b10bd92975abd5eef8f708cbf8f02 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Thu, 10 Oct 2024 22:32:52 -0400
Subject: fixed being able to use text menu on text in comparison box.  Allow
 TextDocuments to be created with a RichText field.  Changed comparisonBox to
 horizontally center flashcard text.

---
 src/client/views/nodes/ComparisonBox.tsx | 44 +++++++++++++++++---------------
 1 file changed, 24 insertions(+), 20 deletions(-)

(limited to 'src/client/views/nodes/ComparisonBox.tsx')

diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx
index ccbe98257..f8cf0f464 100644
--- a/src/client/views/nodes/ComparisonBox.tsx
+++ b/src/client/views/nodes/ComparisonBox.tsx
@@ -425,6 +425,27 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         }
     }
 
+    textToRtf = (text: string, img?: Doc) =>
+        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: 'center', color: null, id: null, indent: null, inset: null, lineSpacing: null, paddingBottom: null, paddingTop: null },
+                            content: [
+                                ...(text ? [{ type: 'text', text }] : []), //
+                                ...(img ? [{ type: 'dashDoc', attrs: { width: '200px', height: '200px', title: 'dashDoc', float: 'unset', hidden: false, docId: img[Id] } }] : []),
+                            ],
+                        },
+                    ],
+                },
+                selection: { type: 'text', anchor: 2, head: 2 },
+            }),
+            text
+        );
     /**
      * Transfers the content of flashcards into a flashcard pile.
      */
@@ -440,25 +461,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                 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);
+                    newDoc[DocData][this.fieldKey + '_1'] = Docs.Create.TextDocument(this.textToRtf(questionTxt));
+                    newDoc[DocData][this.fieldKey + '_0'] = Docs.Create.TextDocument(this.textToRtf(answerTxt, img));
                 });
             }
 
@@ -723,7 +727,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             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, {
+                const newDoc = Docs.Create.TextDocument(this.textToRtf(text), {
                     title, //
                     _layout_autoHeight: true,
                     _layout_centered: true,
-- 
cgit v1.2.3-70-g09d2


From 04fb4e772c6b7cdc6e266b2c661b2eb5f075f954 Mon Sep 17 00:00:00 2001
From: alyssaf16 <alyssa_feinberg@brown.edu>
Date: Fri, 11 Oct 2024 00:42:08 -0400
Subject: quiz changes

---
 src/client/views/nodes/ComparisonBox.tsx | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

(limited to 'src/client/views/nodes/ComparisonBox.tsx')

diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx
index f8cf0f464..b2a717c3c 100644
--- a/src/client/views/nodes/ComparisonBox.tsx
+++ b/src/client/views/nodes/ComparisonBox.tsx
@@ -117,7 +117,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                                                 "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)}>
+                                <div className="comparisonBox-button" onPointerDown={() => (this._frontSide ? this.askGPT(GPTCallType.CHATCARD) : null)}>
                                     <FontAwesomeIcon icon="lightbulb" size="xl" />
                                 </div>
                             </Tooltip>
@@ -476,8 +476,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
      */
     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;
+        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) {
@@ -487,7 +487,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             }
         }
         try {
-            const res = await gptAPICall(questionText, GPTCallType.FLASHCARD);
+            const res = await gptAPICall(callType == GPTCallType.QUIZ ? queryText : questionText, callType);
             runInAction(() => {
                 if (!res) {
                     console.error('GPT call failed');
-- 
cgit v1.2.3-70-g09d2


From 66f2b03283a1e42c48b1c16b4344b730c0a2e9f3 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Fri, 11 Oct 2024 14:19:35 -0400
Subject: cleaned up comparisonBox a bit - fixed text doc placeholder text and
 fixed parsing keyword/question/answer from flashcard creator.  also fixed
 text boxes within in comparisonBox

---
 .../views/collections/FlashcardPracticeUI.tsx      |  17 +-
 src/client/views/nodes/ComparisonBox.tsx           | 308 ++++++++++-----------
 src/client/views/nodes/DocumentView.tsx            |   9 -
 .../views/nodes/formattedText/FormattedTextBox.tsx |   9 +
 4 files changed, 170 insertions(+), 173 deletions(-)

(limited to 'src/client/views/nodes/ComparisonBox.tsx')

diff --git a/src/client/views/collections/FlashcardPracticeUI.tsx b/src/client/views/collections/FlashcardPracticeUI.tsx
index 7697d308b..45e040653 100644
--- a/src/client/views/collections/FlashcardPracticeUI.tsx
+++ b/src/client/views/collections/FlashcardPracticeUI.tsx
@@ -1,19 +1,19 @@
+import { IconProp } from '@fortawesome/fontawesome-svg-core';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { Tooltip } from '@mui/material';
+import { IconButton, MultiToggle, Type } from 'browndash-components';
 import { computed, makeObservable } from 'mobx';
 import { observer } from 'mobx-react';
 import * as React from 'react';
 import { returnFalse, returnZero, setupMoveUpEvents } from '../../../ClientUtils';
+import { emptyFunction } from '../../../Utils';
 import { Doc, DocListCast } from '../../../fields/Doc';
 import { BoolCast, NumCast, StrCast } from '../../../fields/Types';
+import { SnappingManager } from '../../util/SnappingManager';
 import { Transform } from '../../util/Transform';
 import { ObservableReactComponent } from '../ObservableReactComponent';
 import { DocumentView, DocumentViewProps } from '../nodes/DocumentView';
 import './FlashcardPracticeUI.scss';
-import { IconButton, MultiToggle, Type } from 'browndash-components';
-import { SnappingManager } from '../../util/SnappingManager';
-import { IconProp } from '@fortawesome/fontawesome-svg-core';
-import { emptyFunction } from '../../../Utils';
 
 export enum practiceMode {
     PRACTICE = 'practice',
@@ -24,6 +24,11 @@ enum practiceVal {
     CORRECT = 'correct',
 }
 
+export enum flashcardRevealOp {
+    HOVER = 'hover',
+    FLIP = 'flip',
+}
+
 interface PracticeUIProps {
     fieldKey: string;
     layoutDoc: Doc;
@@ -154,11 +159,11 @@ export class FlashcardPracticeUI extends ObservableReactComponent<PracticeUIProp
                     text={StrCast(this._props.layoutDoc.revealOp)}
                     color={SnappingManager.userColor}
                     background={SnappingManager.userVariantColor}
-                    icon={<FontAwesomeIcon color={SnappingManager.userColor} icon={this._props.layoutDoc.revealOp === 'hover' ? 'hand-point-up' : 'question'} size="sm" />}
+                    icon={<FontAwesomeIcon color={SnappingManager.userColor} icon={this._props.layoutDoc.revealOp === flashcardRevealOp.HOVER ? 'hand-point-up' : 'question'} size="sm" />}
                     label={StrCast(this._props.layoutDoc.revealOp)}
                     onPointerDown={e =>
                         setupMoveUpEvents(this, e, returnFalse, emptyFunction, () => {
-                            this._props.layoutDoc.revealOp = this._props.layoutDoc.revealOp === 'hover' ? 'flip' : 'hover';
+                            this._props.layoutDoc.revealOp = this._props.layoutDoc.revealOp === flashcardRevealOp.HOVER ? flashcardRevealOp.FLIP : flashcardRevealOp.HOVER;
                             this._props.layoutDoc.childDocumentsActive = this._props.layoutDoc.revealOp === 'hover' ? true : undefined;
                         })
                     }
diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx
index b2a717c3c..a57090e99 100644
--- a/src/client/views/nodes/ComparisonBox.tsx
+++ b/src/client/views/nodes/ComparisonBox.tsx
@@ -11,7 +11,7 @@ import { Doc, Opt } from '../../../fields/Doc';
 import { DocData } from '../../../fields/DocSymbols';
 import { Id } from '../../../fields/FieldSymbols';
 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';
@@ -25,12 +25,12 @@ import { ContextMenu } from '../ContextMenu';
 import { ViewBoxAnnotatableComponent } from '../DocComponent';
 import { PinDocView, PinProps } from '../PinFuncs';
 import { StyleProp } from '../StyleProp';
+import { flashcardRevealOp, practiceMode } from '../collections/FlashcardPracticeUI';
 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';
 
@@ -41,7 +41,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
     }
     private SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
     private _closeRef = React.createRef<HTMLDivElement>();
-    private _disposers: (DragManager.DragDropDisposer | undefined)[] = [undefined, undefined];
+    private _disposers: { [key: string]: DragManager.DragDropDisposer | undefined } = {};
     private _reactDisposer: IReactionDisposer | undefined;
     constructor(props: FieldViewProps) {
         super(props);
@@ -55,9 +55,12 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
     @observable private _childActive = false;
     @observable private _animating = '';
     @observable private _listening = false;
-    @observable private _frontSide = false;
-    @observable recognition = new this.SpeechRecognition();
+    @observable private _renderSide = this.fieldKey;
+    @observable private _recognition = new this.SpeechRecognition();
 
+    @computed get isFlashcard()   { return BoolCast(this.Document.layout_isFlashcard); } // prettier-ignore
+    @computed get frontKey()      { return this._props.fieldKey; } // prettier-ignore
+    @computed get backKey()       { return this._props.fieldKey + '_back'; } // 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
@@ -65,6 +68,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
     @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 loading()       { return this._loading; } // prettier-ignore
+    set loading(value)            { runInAction(() => { this._loading = value; })} // prettier-ignore
 
     @computed get overlayAlternateIcon() {
         return (
@@ -73,14 +78,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') {
+                            if (!this.revealOp || this.revealOp === flashcardRevealOp.FLIP) {
                                 this.flipFlashcard();
                             }
                         })
                     }
                     style={{
-                        background: this.revealOp === 'hover' ? 'gray' : this._frontSide ? 'white' : 'black',
-                        color: this.revealOp === 'hover' ? 'black' : this._frontSide ? 'black' : 'white',
+                        background: this.revealOp === flashcardRevealOp.HOVER ? 'gray' : this._renderSide === this.backKey ? 'white' : 'black',
+                        color: this.revealOp === flashcardRevealOp.HOVER ? 'black' : this._renderSide === this.backKey ? 'black' : 'white',
                         display: 'inline-block',
                     }}>
                     <FontAwesomeIcon icon="turn-up" size="xl" />
@@ -106,18 +111,15 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
     @computed get flashcardMenu() {
         return SnappingManager.HideDecorations ? null : (
             <div className="comparisonBox-bottomMenu" style={{ transform: `scale(${this.uiBtnScaling})` }}>
-                {this.revealOp === 'hover' || !this._props.isSelected() ? null : this.overlayAlternateIcon}
+                {this.revealOp === flashcardRevealOp.HOVER || !this._props.isSelected() ? null : this.overlayAlternateIcon}
                 {!this._props.isSelected() ? null : (
                     <>
-                        {!this._frontSide ? null : (
+                        {this._renderSide === this.frontKey ? 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="dash-tooltip">Ask GPT to create an answer for the question on the front</div> // prettier-ignore
                                 }>
-                                <div className="comparisonBox-button" onPointerDown={() => (this._frontSide ? this.askGPT(GPTCallType.CHATCARD) : null)}>
+                                <div className="comparisonBox-button" onPointerDown={() => this.askGPT(GPTCallType.CHATCARD)}>
                                     <FontAwesomeIcon icon="lightbulb" size="xl" />
                                 </div>
                             </Tooltip>
@@ -143,17 +145,13 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         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);
         } else if (this._inputValue) this.askGPT(GPTCallType.QUIZ);
-        this._frontSide = false;
+        this._renderSide = this.backKey;
         this._outputValue = '';
     };
 
@@ -169,7 +167,11 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         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
+            selected => {
+                if (selected && this.isFlashcard) this.activateContent();
+                !selected && (this._childActive = false);
+            }, // what it should update to
+            { fireImmediately: true }
         );
     }
 
@@ -177,10 +179,10 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         this._reactDisposer?.();
     }
 
-    protected createDropTarget = (ele: HTMLDivElement | null, fieldKey: string, disposerId: number) => {
-        this._disposers[disposerId]?.();
+    protected createDropTarget = (ele: HTMLDivElement | null, fieldKey: string) => {
+        this._disposers[fieldKey]?.();
         if (ele) {
-            this._disposers[disposerId] = DragManager.MakeDropTarget(ele, (e, dropEvent) => this.internalDrop(e, dropEvent, fieldKey), this.layoutDoc);
+            this._disposers[fieldKey] = DragManager.MakeDropTarget(ele, (e, dropEvent) => this.internalDrop(e, dropEvent, fieldKey), this.layoutDoc);
         }
     };
 
@@ -253,10 +255,10 @@ 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);
 
     registerSliding = (e: React.PointerEvent<HTMLDivElement>, targetWidth: number) => {
         if (e.button !== 2) {
@@ -268,8 +270,8 @@ 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,
@@ -296,26 +298,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 +326,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
@@ -389,7 +391,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
      */
     youtubeUpload = async () => {
         const audio = {
-            file: this.getYouTubeVideoId(StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text)),
+            file: this.getYouTubeVideoId(StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text)),
         };
         const response = await axios.post('http://localhost:105/youtube/', audio, {
             headers: {
@@ -407,13 +409,9 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             _layout_autoHeight: true,
             _xMargin: 5,
             _yMargin: 5,
+            x: NumCast(this.layoutDoc.x),
+            y: NumCast(this.layoutDoc.y) + 50,
         });
-        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);
@@ -421,7 +419,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         } else {
             this._props.addDocument?.(newCol);
             this._props.removeDocument?.(this.Document);
-            this.Document.embedContainer = newCol;
+            Doc.SetContainer(this.Document, newCol);
         }
     }
 
@@ -450,65 +448,69 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
      * 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 => {
-                    newDoc[DocData][this.fieldKey + '_1'] = Docs.Create.TextDocument(this.textToRtf(questionTxt));
-                    newDoc[DocData][this.fieldKey + '_0'] = Docs.Create.TextDocument(this.textToRtf(answerTxt, img));
+        this.askGPT(GPTCallType.STACK).then(text => {
+            const [qtoken, ktoken, atoken] = ['Question: ', 'Keyword: ', 'Answer: '];
+            const collectionArr: Doc[] = [];
+            const promises = text
+                .split(qtoken)
+                .filter(t => t)
+                .map(tuple => {
+                    const newDoc = 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
+                    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) => {
+                        newDoc[DocData][this.frontKey] = this.textCreator('question', question, img);
+                        newDoc[DocData][this.backKey] = this.textCreator('answer', answer);
+                        collectionArr.push(newDoc);
+                    };
+                    return keyword && keyword !== 'none' ? this.fetchImages(keyword).then(img => fillInFlashcard(img)) : fillInFlashcard();
                 });
-            }
-
-            collectionArr.push(newDoc);
-        }
-        this.createFlashcardPile(collectionArr, true);
+            Promise.all(promises).then(() => this.createFlashcardPile(collectionArr, true));
+        });
     };
 
     /**
      * 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);
+    askGPT = async (callType: GPTCallType) => {
+        const questionText = 'Question: ' + StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text);
+        const rubricText = ' Rubric: ' + StrCast(RTFCast(DocCast(this.dataDoc[this.backKey]).text)?.Text);
         const queryText = questionText + ' UserAnswer: ' + this._inputValue + '. ' + rubricText;
-        this._loading = true;
+        this.loading = true;
+        let res = '';
 
-        if (callType == GPTCallType.CHATCARD) {
-            if (StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text) === '') {
-                this._loading = false;
-                return;
-            }
-        }
-        try {
-            const res = await gptAPICall(callType == GPTCallType.QUIZ ? queryText : questionText, callType);
-            runInAction(() => {
+        if (callType !== GPTCallType.CHATCARD || StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text) !== '') {
+            try {
+                res = await gptAPICall(callType == GPTCallType.QUIZ ? queryText : questionText, callType);
                 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);
+                } else
+                    switch (callType) {
+                        case GPTCallType.CHATCARD:
+                            DocCast(this.dataDoc[this.backKey])[DocData].text = res;
+                            break;
+                        case GPTCallType.QUIZ:
+                            runInAction(() => {
+                                this._renderSide = this.frontKey;
+                                this._outputValue = res.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric');
+                            });
+                            break;
+                        case GPTCallType.FLASHCARD:
+                        default:
+                    }
+            } catch (err) {
+                console.error('GPT call failed', err);
+            }
         }
-        this._loading = false;
+        this.loading = false;
+        return res;
     };
     layoutWidth = () => NumCast(this.layoutDoc.width, 200);
     layoutHeight = () => NumCast(this.layoutDoc.height, 200);
@@ -517,11 +519,11 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         const c = this.DocumentView?.().ContentDiv?.getElementsByTagName('img');
         if (c?.length === 0) this.askGPT(GPTCallType.CHATCARD);
         if (c) {
-            this._loading = true;
+            this.loading = true;
             for (const i of c) {
                 if (i.className !== 'ProseMirror-separator') this.getImageDesc(i.src);
             }
-            this._loading = false;
+            this.loading = false;
         }
     };
 
@@ -531,7 +533,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
      * @param phonemes
      */
     askGPTPhonemes = async (phonemes: string) => {
-        const sentence = StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text);
+        const sentence = StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text);
         const phon6 = 'huː ɑɹ juː tədeɪ';
         const phon4 = 'kamo estas hɔi';
         const promptEng =
@@ -557,17 +559,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
     };
 
     /**
@@ -603,8 +599,6 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                 _height: 150,
                 title: '--snapshot' + NumCast(this.layoutDoc._layout_currentTimecode) + ' image-',
             });
-            imageSnapshot.x = this.layoutDoc.x;
-            imageSnapshot.y = this.layoutDoc.y;
             return imageSnapshot;
         } catch (error) {
             console.log(error);
@@ -616,7 +610,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             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);
         }
@@ -624,11 +618,12 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
 
     @action
     flipFlashcard = () => {
-        this._frontSide = !this._frontSide;
+        this._renderSide = this._renderSide === this.frontKey ? this.backKey : this.frontKey;
     };
 
-    hoverFlip = (side: boolean) => {
-        if (this.revealOp === 'hover') this._frontSide = side;
+    @action
+    hoverFlip = (side: string) => {
+        if (this.revealOp === flashcardRevealOp.HOVER) this._renderSide = side;
     };
 
     testForTextFields = (whichSlot: string) => {
@@ -638,8 +633,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)
@@ -647,8 +642,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
@@ -656,7 +651,16 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         }
         return layoutTemplateString;
     };
-
+    textCreator = (title: string, text: string, img?: Doc) => {
+        const newDoc = Docs.Create.TextDocument(this.textToRtf(text, img), {
+            title, //
+            _layout_autoHeight: true,
+            _layout_centered: true,
+            text_align: 'center',
+            _layout_fitWidth: true,
+        });
+        return newDoc;
+    };
     childActiveFunc = () => this._childActive;
 
     contentScreenToLocalXf = () => this._props.ScreenToLocalTransform().scale(this._props.NativeDimScaling?.() || 1);
@@ -688,8 +692,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                         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}
+                        moveDocument={whichSlot === this.frontKey ? this.moveDocFront : this.moveDocBack}
+                        removeDocument={whichSlot === this.frontKey ? this.remDocFront : this.remDocBack}
                         NativeWidth={returnZero}
                         NativeHeight={returnZero}
                         ScreenToLocalTransform={this.contentScreenToLocalXf}
@@ -701,7 +705,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                         hideLinkButton
                         pointerEvents={this._childActive ? undefined : returnNone}
                     />
-                    {!this.Document._layout_isFlashcard ? clearButton(whichSlot) : null}
+                    {!this.isFlashcard ? clearButton(whichSlot) : null}
                 </>
             ) : (
                 <div className="placeholder">
@@ -709,67 +713,56 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                 </div>
             );
         };
-        const displayBox = (which: string, index: number, cover: number) => (
+        const displayBox = (which: string, cover: number) => (
             <div
-                className={`${index === 0 ? 'before' : 'after'}Box-cont`}
+                className={`${which === this.frontKey ? 'before' : 'after'}Box-cont`}
                 key={which}
                 style={{ width: this._props.PanelWidth() }}
                 onPointerDown={e => {
                     this.registerSliding(e, cover);
-                    this.Document._layout_isFlashcard && this.activateContent();
+                    this.isFlashcard && this.activateContent();
                 }}
-                ref={ele => this.createDropTarget(ele, which, index)}>
+                ref={ele => this.createDropTarget(ele, which)}>
                 {!this._isEmpty ? displayDoc(which) : null}
             </div>
         );
 
-        if (this.Document._layout_isFlashcard) {
-            const side = this._frontSide ? 1 : 0;
+        if (this.isFlashcard) {
             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(this.textToRtf(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.backKey] && !this._isEmpty) {
+                this.dataDoc[this.backKey] = this.textCreator('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 (!this.dataDoc[this.frontKey] && !this._isEmpty) {
+                const question = this.textCreator('question', dataSplit[0] || 'hint: Enter a topic, select this document and click the stack button to have GPT create a deck of cards');
+                this.dataDoc[this.frontKey] = question;
+                !dataSplit[0] && (question[DocData].text_placeholder = true);
             }
 
             if (DocCast(this.Document.embedContainer).practiceMode === practiceMode.QUIZ) {
-                const text = StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text);
+                const text = StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).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}
+                                value={this._renderSide ? 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>
+                                readOnly={this._renderSide === this.frontKey}></textarea>
 
-                            {this._loading ? (
+                            {!this.loading ? null : (
                                 <div className="loading-spinner">
                                     <ReactLoading type="spin" height={30} width={30} color={'blue'} />
                                 </div>
-                            ) : null}
+                            )}
                         </div>
                         <div>
                             <div className="submit-button">
@@ -785,8 +778,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                                 <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 className="submit-buttonsubmit" type="button" onClick={this._renderSide === this.frontKey ? this.flipFlashcard : this.handleRenderGPTClick}>
+                                    {this._renderSide === this.frontKey ? 'Redo the Question' : 'Submit'}
                                 </button>
                             </div>
                         </div>
@@ -798,11 +791,10 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             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 ? (
+                    onMouseEnter={() => this.hoverFlip(this.backKey)}
+                    onMouseLeave={() => this.hoverFlip(this.frontKey)}>
+                    {displayBox(this._renderSide, this._props.PanelWidth() - 3)}
+                    {this.loading ? (
                         <div className="loading-spinner">
                             <ReactLoading type="spin" height={30} width={30} color={'blue'} />
                         </div>
@@ -814,9 +806,9 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         // 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)}
+                {displayBox(this.backKey, 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)}
+                    {displayBox(this.frontKey, 0)}
                 </div>
 
                 <div
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index f7aba7542..c052a2823 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -576,15 +576,6 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
             !Doc.noviceMode && templateDoc && appearanceItems.push({ description: 'Open Template   ', event: () => this._props.addDocTab(templateDoc, OpenWhere.addRight), icon: 'eye' });
             !appearance && appearanceItems.length && cm.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'compass' });
 
-            // creates menu for the user to select how to reveal the flashcards
-            // if (this.Document._layout_isFlashcard) {
-            //     const revealOptions = cm.findByDescription('Reveal Options');
-            //     const revealItems: ContextMenuProps[] = revealOptions && 'subitems' in revealOptions ? revealOptions.subitems : [];
-            //     revealItems.push({ description: 'Hover', event: () => { this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'hover'; }, icon: 'hand-point-up' }); // prettier-ignore
-            //     revealItems.push({ description: 'Flip', event: () => { this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'flip'; }, icon: 'rotate' }); // prettier-ignore
-            //     !revealOptions && cm.addItem({ description: 'Reveal Options', addDivider: false, noexpand: true, subitems: revealItems, icon: 'layer-group' });
-            // }
-
             if (this._props.bringToFront) {
                 const zorders = cm.findByDescription('ZOrder...');
                 const zorderItems = zorders?.subitems ?? [];
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index c57307974..bf19a2f82 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -360,6 +360,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                         // if no template, or there's text that didn't come from the layout template, write it to the document. (if this is driven by a template, then this overwrites the template text which is intended)
                         if (force || ((this._finishingLink || this._props.isContentActive() || this._inDrop) && (textChange || removeSelection(newJson) !== removeSelection(prevData?.Data)))) {
                             textChange && (dataDoc[this.fieldKey + '_modificationDate'] = new DateField(new Date(Date.now())));
+                            textChange && (dataDoc[this.fieldKey + '_placeholder'] = undefined);
                             const numstring = NumCast(dataDoc[this.fieldKey], null);
                             dataDoc[this.fieldKey] =
                                 numstring !== undefined ? Number(newText) : newText || (DocCast(dataDoc.proto)?.[this.fieldKey] === undefined && this.layoutDoc[this.fieldKey] === undefined) ? new RichTextField(newJson, newText) : undefined;
@@ -1330,6 +1331,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         this._disposers.selected = reaction(
             () => this._props.rootSelected?.() || this._props.isContentActive(),
             action(selected => {
+                if (selected && this.dataDoc[this.fieldKey + '_placeholder']) {
+                    setTimeout(() => {
+                        selectAll(this._editorView!.state, (tx: Transaction) => {
+                            this._editorView?.dispatch(tx);
+                            this._editorView!.focus();
+                        });
+                    });
+                }
                 this.prepareForTyping();
                 if (FormattedTextBox._globalHighlights.has('Bold Text')) {
                     this.layoutDoc[DocCss] = this.layoutDoc[DocCss] + 1; // css change happens outside of mobx/react, so this will notify anyone interested in the layout that it has changed
-- 
cgit v1.2.3-70-g09d2


From 76abb174684f2cd231a0dd9f6b71484c16e0498a Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Fri, 11 Oct 2024 15:55:19 -0400
Subject: fixes for quiz mode - comparisonbox renderSide fixes. scrolling
 doesn't propagate out of carousel or card views.   fix for text with image
 Doc - now gets saved to UPDATE_CACHE working  set.

---
 .../views/collections/CollectionCardDeckView.tsx   |   6 ++
 .../views/collections/CollectionCarousel3DView.tsx |   6 ++
 .../views/collections/CollectionCarouselView.tsx   |   6 ++
 src/client/views/nodes/ComparisonBox.tsx           | 114 +++++++++++----------
 .../nodes/formattedText/DashDocCommentView.tsx     |   4 +-
 src/fields/Doc.ts                                  |  13 +++
 src/server/GarbageCollector.ts                     |   3 -
 7 files changed, 95 insertions(+), 57 deletions(-)

(limited to 'src/client/views/nodes/ComparisonBox.tsx')

diff --git a/src/client/views/collections/CollectionCardDeckView.tsx b/src/client/views/collections/CollectionCardDeckView.tsx
index 286df30aa..14ce9d2af 100644
--- a/src/client/views/collections/CollectionCardDeckView.tsx
+++ b/src/client/views/collections/CollectionCardDeckView.tsx
@@ -46,6 +46,7 @@ export class CollectionCardView extends CollectionSubView() {
     private _dropDisposer?: DragManager.DragDropDisposer;
     private _disposers: { [key: string]: IReactionDisposer } = {};
     private _textToDoc = new Map<string, Doc>();
+    private _oldWheel: HTMLElement | null = null;
     private _dropped = false; // set when a card doc has just moved and the drop method has been called - prevents the pointerUp method from hiding doc decorations (which needs to be done when clicking on a card to animate it to front/center)
     private _clickScript = () => ScriptField.MakeScript('scriptContext._curDoc=this', { scriptContext: 'any' })!;
 
@@ -66,6 +67,10 @@ export class CollectionCardView extends CollectionSubView() {
         if (ele) {
             this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc);
         }
+        this._oldWheel?.removeEventListener('wheel', this.onPassiveWheel);
+        this._oldWheel = ele;
+        // prevent wheel events from passively propagating up through containers and prevents containers from preventDefault which would block scrolling
+        ele?.addEventListener('wheel', this.onPassiveWheel, { passive: false });
     };
     /**
      * Callback to ensure gpt's text versions of the child docs are updated
@@ -621,6 +626,7 @@ export class CollectionCardView extends CollectionSubView() {
             );
         });
     }
+    onPassiveWheel = (e: WheelEvent) => e.stopPropagation();
 
     contentScreenToLocalXf = () => this._props.ScreenToLocalTransform().scale(this._props.NativeDimScaling?.() || 1);
     docViewProps = (): DocumentViewProps => ({
diff --git a/src/client/views/collections/CollectionCarousel3DView.tsx b/src/client/views/collections/CollectionCarousel3DView.tsx
index f2ba90c78..05be376ca 100644
--- a/src/client/views/collections/CollectionCarousel3DView.tsx
+++ b/src/client/views/collections/CollectionCarousel3DView.tsx
@@ -22,6 +22,7 @@ const { CAROUSEL3D_CENTER_SCALE, CAROUSEL3D_SIDE_SCALE, CAROUSEL3D_TOP } = requi
 @observer
 export class CollectionCarousel3DView extends CollectionSubView() {
     private _dropDisposer?: DragManager.DragDropDisposer;
+    private _oldWheel: HTMLElement | null = null;
 
     constructor(props: SubCollectionViewProps) {
         super(props);
@@ -37,6 +38,10 @@ export class CollectionCarousel3DView extends CollectionSubView() {
         if (ele) {
             this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc);
         }
+        this._oldWheel?.removeEventListener('wheel', this.onPassiveWheel);
+        this._oldWheel = ele;
+        // prevent wheel events from passively propagating up through containers and prevents containers from preventDefault which would block scrolling
+        ele?.addEventListener('wheel', this.onPassiveWheel, { passive: false });
     };
 
     @computed get scrollSpeed() {
@@ -194,6 +199,7 @@ export class CollectionCarousel3DView extends CollectionSubView() {
         return this.panelWidth() * (1 - index);
     }
 
+    onPassiveWheel = (e: WheelEvent) => e.stopPropagation();
     curDoc = () => this.carouselItems[NumCast(this.layoutDoc._carousel_index)]?.layout;
     answered = (correct: boolean) => (!correct || !this.curDoc()) && this.changeSlide(1);
     docViewProps = () => ({
diff --git a/src/client/views/collections/CollectionCarouselView.tsx b/src/client/views/collections/CollectionCarouselView.tsx
index aa447c7bf..ef66a2c83 100644
--- a/src/client/views/collections/CollectionCarouselView.tsx
+++ b/src/client/views/collections/CollectionCarouselView.tsx
@@ -17,6 +17,7 @@ import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView';
 export class CollectionCarouselView extends CollectionSubView() {
     private _dropDisposer?: DragManager.DragDropDisposer;
 
+    _oldWheel: HTMLElement | null = null;
     _fadeTimer: NodeJS.Timeout | undefined;
     @observable _last_index = this.carouselIndex;
     @observable _last_opacity = 1;
@@ -35,6 +36,10 @@ export class CollectionCarouselView extends CollectionSubView() {
         if (ele) {
             this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc);
         }
+        this._oldWheel?.removeEventListener('wheel', this.onPassiveWheel);
+        this._oldWheel = ele;
+        // prevent wheel events from passively propagating up through containers and prevents containers from preventDefault which would block scrolling
+        ele?.addEventListener('wheel', this.onPassiveWheel, { passive: false });
     };
 
     @computed get captionMarginX(){ return NumCast(this.layoutDoc.caption_xMargin, 50); } // prettier-ignore
@@ -91,6 +96,7 @@ export class CollectionCarouselView extends CollectionSubView() {
               : this._props.childDocumentsActive?.() === false || this.Document.childDocumentsActive === false
                 ? false
                 : undefined;
+    onPassiveWheel = (e: WheelEvent) => e.stopPropagation();
     renderDoc = (doc: Doc, showCaptions: boolean, overlayFunc?: (r: DocumentView | null) => void) => {
         return (
             <DocumentView
diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx
index a57090e99..111fabca3 100644
--- a/src/client/views/nodes/ComparisonBox.tsx
+++ b/src/client/views/nodes/ComparisonBox.tsx
@@ -39,6 +39,9 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
     public static LayoutString(fieldKey: string) {
         return FieldView.LayoutString(ComparisonBox, fieldKey);
     }
+    static qtoken = 'Question: ';
+    static ktoken = 'Keyword: ';
+    static atoken = 'Answer: ';
     private SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
     private _closeRef = React.createRef<HTMLDivElement>();
     private _disposers: { [key: string]: DragManager.DragDropDisposer | undefined } = {};
@@ -55,11 +58,11 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
     @observable private _childActive = false;
     @observable private _animating = '';
     @observable private _listening = false;
-    @observable private _renderSide = this.fieldKey;
+    @observable private _renderSide = this.frontKey;
     @observable private _recognition = new this.SpeechRecognition();
 
     @computed get isFlashcard()   { return BoolCast(this.Document.layout_isFlashcard); } // prettier-ignore
-    @computed get frontKey()      { return this._props.fieldKey; } // prettier-ignore
+    @computed get frontKey()      { return this._props.fieldKey + '_front'; } // prettier-ignore
     @computed get backKey()       { return this._props.fieldKey + '_back'; } // prettier-ignore
     @computed get revealOpKey()   { return `_${this._props.fieldKey}_revealOp`; } // prettier-ignore
     @computed get clipHeightKey() { return `_${this._props.fieldKey}_clipHeight`; } // prettier-ignore
@@ -124,7 +127,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                                 </div>
                             </Tooltip>
                         )}
-                        {DocCast(this.Document.embedContainer)?.type_collection !== CollectionViewType.Freeform ? null : (
+                        {DocCast(this.Document.embedContainer)?.type_collection !== CollectionViewType.Freeform || this._renderSide === this.backKey ? 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" />
@@ -150,9 +153,9 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         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._renderSide = this.backKey;
-        this._outputValue = '';
     };
 
     onPointerMove = ({ movementX }: PointerEvent) => {
@@ -444,38 +447,41 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             }),
             text
         );
-    /**
-     * Transfers the content of flashcards into a flashcard pile.
-     */
-    gptFlashcardPile = async () => {
-        this.askGPT(GPTCallType.STACK).then(text => {
-            const [qtoken, ktoken, atoken] = ['Question: ', 'Keyword: ', 'Answer: '];
-            const collectionArr: Doc[] = [];
-            const promises = text
-                .split(qtoken)
+
+    createFlashcard = (tuple: 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
+        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) => {
+            newDoc[DocData][this.frontKey] = this.textCreator('question', question, img);
+            newDoc[DocData][this.backKey] = this.textCreator('answer', answer);
+            return newDoc;
+        };
+        return keyword && keyword !== 'none' ? this.fetchImages(keyword).then(img => fillInFlashcard(img)) : fillInFlashcard();
+    };
+
+    createFlashcardDeck = (text: string) => {
+        Promise.all(
+            text
+                .split(ComparisonBox.qtoken)
                 .filter(t => t)
-                .map(tuple => {
-                    const newDoc = 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
-                    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) => {
-                        newDoc[DocData][this.frontKey] = this.textCreator('question', question, img);
-                        newDoc[DocData][this.backKey] = this.textCreator('answer', answer);
-                        collectionArr.push(newDoc);
-                    };
-                    return keyword && keyword !== 'none' ? this.fetchImages(keyword).then(img => fillInFlashcard(img)) : fillInFlashcard();
-                });
-            Promise.all(promises).then(() => this.createFlashcardPile(collectionArr, true));
-        });
+                .map(tuple => this.createFlashcard(tuple))
+        ).then(docs => this.createFlashcardPile(docs, true));
     };
 
+    /**
+     * queries GPT about a topic and then creates a flashcard deck from the results.
+     */
+    gptFlashcardPile = () => this.askGPT(GPTCallType.STACK).then(this.createFlashcardDeck);
+
     /**
      * Calls GPT for each flashcard type.
      */
@@ -498,7 +504,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                             break;
                         case GPTCallType.QUIZ:
                             runInAction(() => {
-                                this._renderSide = this.frontKey;
+                                this._renderSide = this.backKey;
                                 this._outputValue = res.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric');
                             });
                             break;
@@ -728,20 +734,24 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         );
 
         if (this.isFlashcard) {
-            const dataSplit = StrCast(this.dataDoc.data).includes('Keyword: ') ? StrCast(this.dataDoc.data).split('Keyword: ') : StrCast(this.dataDoc.data).split('Answer: ');
-
-            // add text box to each side when comparison box is first created
-            if (!this.dataDoc[this.backKey] && !this._isEmpty) {
-                this.dataDoc[this.backKey] = this.textCreator('answer', dataSplit[1]);
+            if (this.dataDoc.data) {
+                if (!this.dataDoc[this.backKey] || !this.dataDoc[this.frontKey]) this.createFlashcard(StrCast(this.dataDoc.data), this.Document);
+            } else {
+                // add text box to each side when comparison box is first created
+                if (!this.dataDoc[this.backKey] && !this._isEmpty) {
+                    const answer = this.textCreator('answer', 'answer here');
+                    this.dataDoc[this.backKey] = answer;
+                    answer[DocData].text_placeholder = true;
+                }
+
+                if (!this.dataDoc[this.frontKey] && !this._isEmpty) {
+                    const question = this.textCreator('question', 'hint: Enter a topic, select this document and click the stack button to have GPT create a deck of cards');
+                    this.dataDoc[this.frontKey] = question;
+                    question[DocData].text_placeholder = true;
+                }
             }
 
-            if (!this.dataDoc[this.frontKey] && !this._isEmpty) {
-                const question = this.textCreator('question', dataSplit[0] || 'hint: Enter a topic, select this document and click the stack button to have GPT create a deck of cards');
-                this.dataDoc[this.frontKey] = question;
-                !dataSplit[0] && (question[DocData].text_placeholder = true);
-            }
-
-            if (DocCast(this.Document.embedContainer).practiceMode === practiceMode.QUIZ) {
+            if (DocCast(this.Document.embedContainer)?.practiceMode === practiceMode.QUIZ) {
                 const text = StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text);
                 return (
                     <div className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}`}>
@@ -749,15 +759,15 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                         <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._outputValue : this._inputValue}
+                                value={this._renderSide === this.backKey ? 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._renderSide === this.frontKey}></textarea>
-
+                                readOnly={this._renderSide === this.backKey}
+                            />
                             {!this.loading ? null : (
                                 <div className="loading-spinner">
                                     <ReactLoading type="spin" height={30} width={30} color={'blue'} />
@@ -778,8 +788,8 @@ 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.frontKey ? this.flipFlashcard : this.handleRenderGPTClick}>
-                                    {this._renderSide === this.frontKey ? 'Redo the Question' : 'Submit'}
+                                <button className="submit-buttonsubmit" type="button" onClick={this._renderSide === this.backKey ? this.flipFlashcard : this.handleRenderGPTClick}>
+                                    {this._renderSide === this.backKey ? 'Redo the Question' : 'Submit'}
                                 </button>
                             </div>
                         </div>
diff --git a/src/client/views/nodes/formattedText/DashDocCommentView.tsx b/src/client/views/nodes/formattedText/DashDocCommentView.tsx
index 0304ddc86..967f4aa5b 100644
--- a/src/client/views/nodes/formattedText/DashDocCommentView.tsx
+++ b/src/client/views/nodes/formattedText/DashDocCommentView.tsx
@@ -68,7 +68,7 @@ export class DashDocCommentViewInternal extends React.Component<IDashDocCommentV
                 expand && this._dashDoc.then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc));
                 try {
                     this.props.view.dispatch(this.props.view.state.tr.setSelection(TextSelection.create(this.props.view.state.tr.doc, (this.props.getPos() ?? 0) + (expand ? 2 : 1))));
-                } catch (err) {
+                } catch {
                     /* empty */
                 }
             }, 0);
@@ -95,7 +95,7 @@ export class DashDocCommentViewInternal extends React.Component<IDashDocCommentV
         setTimeout(() => {
             try {
                 this.props.view.dispatch(state.tr.setSelection(TextSelection.create(state.tr.doc, this.props.getPos() + 2)));
-            } catch (err) {
+            } catch {
                 /* empty */
             }
         }, 0);
diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts
index 81241f9fe..45dfe233f 100644
--- a/src/fields/Doc.ts
+++ b/src/fields/Doc.ts
@@ -960,6 +960,19 @@ export namespace Doc {
                         }
                     } else if (field instanceof PrefetchProxy) {
                         Doc.FindReferences(field.value, references, system);
+                    } else if (field instanceof RichTextField) {
+                        const re = /"docId"\s*:\s*"(.*?)"/g;
+                        let match: string[] | null;
+                        while ((match = re.exec(field.Data)) !== null) {
+                            const urlString = match[1];
+                            if (urlString) {
+                                const rdoc = DocServer.GetCachedRefField(urlString);
+                                if (rdoc) {
+                                    references.add(rdoc);
+                                    Doc.FindReferences(rdoc, references, system);
+                                }
+                            }
+                        }
                     }
                 } else if (field instanceof Promise) {
                     // eslint-disable-next-line no-debugger
diff --git a/src/server/GarbageCollector.ts b/src/server/GarbageCollector.ts
index 041f65592..74e8c288a 100644
--- a/src/server/GarbageCollector.ts
+++ b/src/server/GarbageCollector.ts
@@ -1,7 +1,4 @@
 /* eslint-disable no-await-in-loop */
-/* eslint-disable no-continue */
-/* eslint-disable no-cond-assign */
-/* eslint-disable no-restricted-syntax */
 import * as fs from 'fs';
 import * as path from 'path';
 import { Database } from './database';
-- 
cgit v1.2.3-70-g09d2


From d7770e94876540a81bae06d233b6196a91650325 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Fri, 11 Oct 2024 16:20:09 -0400
Subject: added some commenting and code tweaks to ComparisonBox

---
 src/client/views/nodes/ComparisonBox.tsx | 98 +++++++++++++++-----------------
 src/fields/RichTextField.ts              | 23 ++++++++
 2 files changed, 68 insertions(+), 53 deletions(-)

(limited to 'src/client/views/nodes/ComparisonBox.tsx')

diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx
index 111fabca3..672008968 100644
--- a/src/client/views/nodes/ComparisonBox.tsx
+++ b/src/client/views/nodes/ComparisonBox.tsx
@@ -34,6 +34,22 @@ import { FormattedTextBox } from './formattedText/FormattedTextBox';
 
 const API_URL = 'https://api.unsplash.com/search/photos';
 
+/**
+ * This view serves two 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)
+ *
+ * In either 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.
+ *
+ */
+
 @observer
 export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
     public static LayoutString(fieldKey: string) {
@@ -129,7 +145,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                         )}
                         {DocCast(this.Document.embedContainer)?.type_collection !== CollectionViewType.Freeform || this._renderSide === this.backKey ? null : (
                             <Tooltip title={<div className="dash-tooltip">Create new flashcard stack based on text</div>}>
-                                <div className="comparisonBox-button" onClick={this.gptFlashcardPile}>
+                                <div className="comparisonBox-button" onClick={() => this.askGPT(GPTCallType.STACK).then(this.createFlashcardDeck)}>
                                     <FontAwesomeIcon icon="layer-group" size="xl" />
                                 </div>
                             </Tooltip>
@@ -404,50 +420,12 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         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,
-            x: NumCast(this.layoutDoc.x),
-            y: NumCast(this.layoutDoc.y) + 50,
-        });
-
-        if (gpt) {
-            this._props.DocumentView?.()._props.addDocument?.(newCol);
-            this._props.removeDocument?.(this.Document);
-        } else {
-            this._props.addDocument?.(newCol);
-            this._props.removeDocument?.(this.Document);
-            Doc.SetContainer(this.Document, newCol);
-        }
-    }
-
-    textToRtf = (text: string, img?: Doc) =>
-        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: 'center', color: null, id: null, indent: null, inset: null, lineSpacing: null, paddingBottom: null, paddingTop: null },
-                            content: [
-                                ...(text ? [{ type: 'text', text }] : []), //
-                                ...(img ? [{ type: 'dashDoc', attrs: { width: '200px', height: '200px', title: 'dashDoc', float: 'unset', hidden: false, docId: img[Id] } }] : []),
-                            ],
-                        },
-                    ],
-                },
-                selection: { type: 'text', anchor: 2, head: 2 },
-            }),
-            text
-        );
-
+    /**
+     * 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
+     */
     createFlashcard = (tuple: string, useDoc?: Doc) => {
         const [ktoken, atoken] = [ComparisonBox.ktoken, ComparisonBox.atoken];
         const newDoc = useDoc ?? Docs.Create.ComparisonDocument('', { _layout_isFlashcard: true, _width: 300, _height: 300 });
@@ -465,22 +443,36 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             newDoc[DocData][this.backKey] = this.textCreator('answer', answer);
             return newDoc;
         };
-        return keyword && keyword !== 'none' ? this.fetchImages(keyword).then(img => fillInFlashcard(img)) : fillInFlashcard();
+        return keyword && keyword.toLowerCase() !== 'none' ? this.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.
+     */
     createFlashcardDeck = (text: string) => {
         Promise.all(
             text
                 .split(ComparisonBox.qtoken)
                 .filter(t => t)
                 .map(tuple => this.createFlashcard(tuple))
-        ).then(docs => this.createFlashcardPile(docs, true));
-    };
+        ).then(docs => {
+            const newCol = Docs.Create.CarouselDocument(docs, {
+                _width: NumCast(this.layoutDoc._width, 250) + 50,
+                _height: NumCast(this.layoutDoc._height, 200) + 50,
+                _layout_fitWidth: false,
+                _layout_autoHeight: true,
+                _xMargin: 5,
+                _yMargin: 5,
+                x: NumCast(this.layoutDoc.x),
+                y: NumCast(this.layoutDoc.y),
+            });
 
-    /**
-     * queries GPT about a topic and then creates a flashcard deck from the results.
-     */
-    gptFlashcardPile = () => this.askGPT(GPTCallType.STACK).then(this.createFlashcardDeck);
+            this._props.DocumentView?.()._props.addDocument?.(newCol);
+            this._props.removeDocument?.(this.Document);
+        });
+    };
 
     /**
      * Calls GPT for each flashcard type.
@@ -658,7 +650,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         return layoutTemplateString;
     };
     textCreator = (title: string, text: string, img?: Doc) => {
-        const newDoc = Docs.Create.TextDocument(this.textToRtf(text, img), {
+        const newDoc = Docs.Create.TextDocument(RichTextField.textToRtf(text, img?.[Id]), {
             title, //
             _layout_autoHeight: true,
             _layout_centered: true,
diff --git a/src/fields/RichTextField.ts b/src/fields/RichTextField.ts
index 613bb0fd1..dc636031a 100644
--- a/src/fields/RichTextField.ts
+++ b/src/fields/RichTextField.ts
@@ -48,4 +48,27 @@ export class RichTextField extends ObjectField {
             ''
         );
     }
+
+    public static textToRtf(text: string, imgDocId?: string) {
+        return 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: 'center', color: null, id: null, indent: null, inset: null, lineSpacing: null, paddingBottom: null, paddingTop: null },
+                            content: [
+                                ...(text ? [{ type: 'text', text }] : []), //
+                                ...(imgDocId ? [{ type: 'dashDoc', attrs: { width: '200px', height: '200px', title: 'dashDoc', float: 'unset', hidden: false, docId: imgDocId } }] : []),
+                            ],
+                        },
+                    ],
+                },
+                selection: { type: 'text', anchor: 2, head: 2 },
+            }),
+            text
+        );
+    }
 }
-- 
cgit v1.2.3-70-g09d2


From bb8fe2933154c6db70cfe5da1e890535bc9012d4 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Fri, 11 Oct 2024 20:21:33 -0400
Subject: fixed verticalalign of text boxes on load when existing text was
 there.  fixe scrolling of vertical align textboxes when fitwidth is set. 
 added flashcard contextmenu to comparisonbox and

---
 src/client/views/StyleProvider.tsx                 |   3 +-
 src/client/views/nodes/ComparisonBox.tsx           | 217 ++++++++++-----------
 src/client/views/nodes/DocumentView.tsx            |  21 +-
 .../views/nodes/formattedText/FormattedTextBox.tsx |  18 +-
 4 files changed, 115 insertions(+), 144 deletions(-)

(limited to 'src/client/views/nodes/ComparisonBox.tsx')

diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx
index 02e0a34d8..8859f6464 100644
--- a/src/client/views/StyleProvider.tsx
+++ b/src/client/views/StyleProvider.tsx
@@ -54,7 +54,6 @@ export function styleFromLayoutString(doc: Doc, props: FieldViewProps, scale: nu
 }
 
 export function border(doc: Doc, pw: number, ph: number, rad: number = 0, inset: number = 0) {
-    if (!rad) rad = 0;
     const width = pw * inset;
     const height = ph * inset;
 
@@ -218,7 +217,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps &
             const radiusRatio = borderRadius / docWidth;
             const radius = radiusRatio * ((2 * borderWidth) + docWidth);
 
-            const borderPath = doc && border(doc, NumCast(doc._width), NumCast(doc._height), radius, -ratio/2 ?? 0);
+            const borderPath = doc && border(doc, NumCast(doc._width), NumCast(doc._height), radius, -ratio/2);
                 return !borderPath
                     ? null
                     : {
diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx
index 672008968..0582bc996 100644
--- a/src/client/views/nodes/ComparisonBox.tsx
+++ b/src/client/views/nodes/ComparisonBox.tsx
@@ -15,7 +15,7 @@ import { BoolCast, DocCast, NumCast, RTFCast, StrCast, toList } from '../../../f
 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';
@@ -31,6 +31,7 @@ import './ComparisonBox.scss';
 import { DocumentView } from './DocumentView';
 import { FieldView, FieldViewProps } from './FieldView';
 import { FormattedTextBox } from './formattedText/FormattedTextBox';
+import { CollectionFreeFormView } from '../collections/collectionFreeForm';
 
 const API_URL = 'https://api.unsplash.com/search/photos';
 
@@ -55,17 +56,16 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
     public static LayoutString(fieldKey: string) {
         return FieldView.LayoutString(ComparisonBox, fieldKey);
     }
+    private SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
+
     static qtoken = 'Question: ';
     static ktoken = 'Keyword: ';
     static atoken = 'Answer: ';
-    private SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
+    private _slideTiming = 200;
+    private _sideBtnWidth = 35;
     private _closeRef = React.createRef<HTMLDivElement>();
     private _disposers: { [key: string]: DragManager.DragDropDisposer | undefined } = {};
     private _reactDisposer: IReactionDisposer | undefined;
-    constructor(props: FieldViewProps) {
-        super(props);
-        makeObservable(this);
-    }
 
     @observable private _inputValue = '';
     @observable private _outputValue = '';
@@ -77,6 +77,47 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
     @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 = reaction(
+            () => this._props.isSelected(), // when this reaction should update
+            selected => {
+                if (selected && this.isFlashcard) this.activateContent();
+                !selected && (this._childActive = false);
+            }, // what it should update to
+            { fireImmediately: true }
+        );
+    }
+
+    componentWillUnmount() {
+        this._reactDisposer?.();
+    }
+
+    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 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
@@ -112,8 +153,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
      */
@@ -131,35 +170,24 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         return SnappingManager.HideDecorations ? null : (
             <div className="comparisonBox-bottomMenu" style={{ transform: `scale(${this.uiBtnScaling})` }}>
                 {this.revealOp === flashcardRevealOp.HOVER || !this._props.isSelected() ? null : this.overlayAlternateIcon}
-                {!this._props.isSelected() ? null : (
-                    <>
-                        {this._renderSide === this.frontKey ? null : (
-                            <Tooltip
-                                title={
-                                    <div className="dash-tooltip">Ask GPT to create an answer for the question on the front</div> // prettier-ignore
-                                }>
-                                <div className="comparisonBox-button" onPointerDown={() => this.askGPT(GPTCallType.CHATCARD)}>
-                                    <FontAwesomeIcon icon="lightbulb" size="xl" />
-                                </div>
-                            </Tooltip>
-                        )}
-                        {DocCast(this.Document.embedContainer)?.type_collection !== CollectionViewType.Freeform || this._renderSide === this.backKey ? 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(this.createFlashcardDeck)}>
-                                    <FontAwesomeIcon icon="layer-group" size="xl" />
-                                </div>
-                            </Tooltip>
-                        )}
-                    </>
+                {!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?.()) ? 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(this.createFlashcardDeck)}>
+                            <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;
     };
@@ -182,42 +210,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 => {
-                if (selected && this.isFlashcard) this.activateContent();
-                !selected && (this._childActive = false);
-            }, // what it should update to
-            { fireImmediately: true }
-        );
-    }
-
-    componentWillUnmount() {
-        this._reactDisposer?.();
-    }
-
-    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');
-
     getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => {
         const anchor = Docs.Create.ConfigDocument({
             title: 'CompareAnchor:' + this.Document.title,
@@ -296,17 +288,11 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                 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
-                    );
+                    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
+                    }
                 })
             );
         }
@@ -381,17 +367,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.
@@ -408,17 +393,14 @@ 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.frontKey]).text)?.Text)),
-        };
-        const response = await axios.post('http://localhost:105/youtube/', audio, {
-            headers: {
-                'Content-Type': 'application/json',
-            },
-        });
-        return response.data.transcription;
-    };
+    youtubeUpload = async () =>
+        axios
+            .post(
+                'http://localhost:105/youtube/', //
+                { file: this.getYouTubeVideoId(StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text)) },
+                { headers: { 'Content-Type': 'application/json' } }
+            )
+            .then(response => response.data.transcription);
 
     /**
      * Creates a flashcard (or fills in flashcard data to a specified Doc) from a control string containing a question and answer
@@ -478,15 +460,16 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
      * Calls GPT for each flashcard type.
      */
     askGPT = async (callType: GPTCallType) => {
-        const questionText = 'Question: ' + StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text);
-        const rubricText = ' Rubric: ' + StrCast(RTFCast(DocCast(this.dataDoc[this.backKey]).text)?.Text);
-        const queryText = questionText + ' UserAnswer: ' + this._inputValue + '. ' + rubricText;
+        const frontText = RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text;
+        const backText = RTFCast(DocCast(this.dataDoc[this.backKey]).text)?.Text;
+        const questionText = 'Question: ' + frontText;
+        const queryText = questionText + (callType == GPTCallType.QUIZ ? ' UserAnswer: ' + this._inputValue + '. ' + ' Rubric: ' + backText : '');
         this.loading = true;
         let res = '';
 
-        if (callType !== GPTCallType.CHATCARD || StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text) !== '') {
+        if (callType !== GPTCallType.CHATCARD || frontText) {
             try {
-                res = await gptAPICall(callType == GPTCallType.QUIZ ? queryText : questionText, callType);
+                res = await gptAPICall(queryText, callType);
                 if (!res) {
                     console.error('GPT call failed');
                 } else
@@ -624,6 +607,15 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         if (this.revealOp === flashcardRevealOp.HOVER) this._renderSide = side;
     };
 
+    flashcardContextMenu = () => {
+        const appearance = ContextMenu.Instance.findByDescription('Appearance...');
+        const appearanceItems = appearance?.subitems ?? [];
+        if (this.Document._layout_isFlashcard) {
+            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) => {
         const slotData = Doc.Get(this.dataDoc, whichSlot, true);
         const slotHasText = slotData instanceof RichTextField || typeof slotData === 'string';
@@ -752,17 +744,15 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                         <div className="input-box">
                             <textarea
                                 value={this._renderSide === this.backKey ? this._outputValue : this._inputValue}
-                                onChange={this.handleInputChange}
-                                onScroll={e => {
-                                    e.stopPropagation();
-                                    e.preventDefault();
-                                }}
+                                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'} />
+                                    <ReactLoading type="spin" height={30} width={30} color='blue' />
                                 </div>
                             )}
                         </div>
@@ -793,12 +783,13 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             return (
                 <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)}>
                     {displayBox(this._renderSide, this._props.PanelWidth() - 3)}
                     {this.loading ? (
                         <div className="loading-spinner">
-                            <ReactLoading type="spin" height={30} width={30} color={'blue'} />
+                            <ReactLoading type="spin" height={30} width={30} color="blue" />
                         </div>
                     ) : null}
                     {this.flashcardMenu}
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index c052a2823..a343b9a39 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -16,12 +16,11 @@ import { List } from '../../../fields/List';
 import { PrefetchProxy } from '../../../fields/Proxy';
 import { listSpec } from '../../../fields/Schema';
 import { ScriptField } from '../../../fields/ScriptField';
-import { BoolCast, Cast, DocCast, ImageCast, NumCast, RTFCast, ScriptCast, StrCast } from '../../../fields/Types';
+import { BoolCast, Cast, DocCast, ImageCast, NumCast, ScriptCast, StrCast } from '../../../fields/Types';
 import { AudioField } from '../../../fields/URLField';
 import { GetEffectiveAcl, TraceMobx } from '../../../fields/util';
 import { AudioAnnoState } from '../../../server/SharedMediaTypes';
 import { DocServer } from '../../DocServer';
-import { GPTCallType, gptAPICall } from '../../apis/gpt/GPT';
 import { DocUtils, FollowLinkScript } from '../../documents/DocUtils';
 import { CollectionViewType, DocumentType } from '../../documents/DocumentTypes';
 import { Docs } from '../../documents/Documents';
@@ -492,21 +491,6 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
         input.click();
     };
 
-    askGPT = async (): Promise<string | undefined> => {
-        const queryText = RTFCast(DocCast(this.dataDoc[this.props.fieldKey + '_1']).text)?.Text;
-        try {
-            const res = await gptAPICall('Question: ' + StrCast(queryText), GPTCallType.CHATCARD);
-            if (!res) {
-                console.error('GPT call failed');
-                return;
-            }
-            DocCast(this.dataDoc[this.props.fieldKey + '_0'])[DocData].text = res;
-            console.log(res);
-        } catch (err) {
-            console.error('GPT call failed', err);
-        }
-    };
-
     onContextMenu = (e?: React.MouseEvent, pageX?: number, pageY?: number) => {
         if (this._props.dontSelect?.()) return;
         if (e && this.layoutDoc.layout_hideContextMenu && Doc.noviceMode) {
@@ -569,9 +553,6 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
                 appearanceItems.splice(0, 0, { description: 'Open in Lightbox', event: () => DocumentView.SetLightboxDoc(this.Document), icon: 'external-link-alt' });
             }
             appearanceItems.push({ description: 'Pin', event: () => this._props.pinToPres(this.Document, {}), icon: 'map-pin' });
-            if (this.Document._layout_isFlashcard) {
-                appearanceItems.push({ description: 'Create ChatCard', event: () => this.askGPT(), icon: 'id-card' });
-            }
 
             !Doc.noviceMode && templateDoc && appearanceItems.push({ description: 'Open Template   ', event: () => this._props.addDocTab(templateDoc, OpenWhere.addRight), icon: 'eye' });
             !appearance && appearanceItems.length && cm.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'compass' });
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 36f06aaf2..29be8d285 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -1515,20 +1515,20 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                 clipboardTextSerializer: this.clipboardTextSerializer,
                 handlePaste: this.handlePaste,
             });
-            const { state, dispatch } = this._editorView;
+            const { state } = this._editorView;
             if (!rtfField) {
                 const dataDoc = Doc.IsDelegateField(DocCast(this.layoutDoc.proto), this.fieldKey) ? DocCast(this.layoutDoc.proto) : this.dataDoc;
                 const startupText = Field.toString(dataDoc[fieldKey] as FieldType);
-                if (startupText) {
-                    dispatch(state.tr.insertText(startupText));
-                }
-                const textAlign = StrCast(this.dataDoc.text_align, StrCast(Doc.UserDoc().textAlign, 'left'));
-                if (textAlign && textAlign !== 'left') {
+                const textAlign = StrCast(this.dataDoc.text_align, StrCast(Doc.UserDoc().textAlign)) || 'left';
+                if (textAlign !== 'left') {
                     selectAll(this._editorView.state, tr => {
-                        this._editorView!.dispatch(tr.replaceSelectionWith(state.schema.nodes.paragraph.create({ align: textAlign })));
+                        this._editorView?.dispatch(tr.replaceSelectionWith(state.schema.nodes.paragraph.create({ align: textAlign })));
                     });
-                    this.tryUpdateDoc(true);
                 }
+                if (startupText) {
+                    this._editorView?.dispatch(this._editorView.state.tr.insertText(startupText));
+                }
+                this.tryUpdateDoc(true);
             }
             this._editorView.TextView = this;
         }
@@ -2147,7 +2147,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                         onScroll={this.onScroll}
                         onDrop={this.ondrop}>
                         <div
-                            className={`formattedTextBox-inner${rounded} ${this.layoutDoc._layout_centered && this.scrollHeight < NumCast(this.layoutDoc._height) ? 'centered' : ''} ${this.layoutDoc.hCentering}`}
+                            className={`formattedTextBox-inner${rounded} ${this.layoutDoc._layout_centered && this.scrollHeight <= (this._props.fitWidth?.(this.Document) ? this._props.PanelHeight() : NumCast(this.layoutDoc._height)) ? 'centered' : ''} ${this.layoutDoc.hCentering}`}
                             ref={this.createDropTarget}
                             style={{
                                 padding: StrCast(this.layoutDoc._textBoxPadding),
-- 
cgit v1.2.3-70-g09d2


From 737efc3e6cada537536c7bc5e46f5b57970da276 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Fri, 11 Oct 2024 22:01:50 -0400
Subject: Lots of cleanup in comparisonBox to get rid of duplicated code and
 siimplify long functions.  Utility functions were added to ComparisonBox to
 simplify menu functions, and to FormattedTextBox to simplify creating
 centered text views.

---
 src/client/apis/gpt/GPT.ts                         |   8 +-
 src/client/documents/Documents.ts                  |   1 +
 src/client/views/nodes/ComparisonBox.tsx           | 526 ++++++++++-----------
 .../views/nodes/formattedText/FormattedTextBox.tsx |  20 +
 src/client/views/pdf/AnchorMenu.tsx                |  34 +-
 src/fields/util.ts                                 |   1 -
 6 files changed, 287 insertions(+), 303 deletions(-)

(limited to 'src/client/views/nodes/ComparisonBox.tsx')

diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts
index 66c49abc7..8a2c91269 100644
--- a/src/client/apis/gpt/GPT.ts
+++ b/src/client/apis/gpt/GPT.ts
@@ -5,8 +5,8 @@ enum GPTCallType {
     SUMMARY = 'summary',
     COMPLETION = 'completion',
     EDIT = 'edit',
-    CHATCARD = 'chatcard',
-    FLASHCARD = 'flashcard',
+    CHATCARD = 'chatcard', // a single flashcard style response to a question
+    FLASHCARD = 'flashcard', // a set of flashcard qustion/answer responses to a topic
     QUIZ = 'quiz',
     SORT = 'sort',
     DESCRIBE = 'describe',
@@ -38,7 +38,6 @@ const callTypeMap: { [type: string]: GPTCallOpts } = {
     // newest model: gpt-4
     summary: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Summarize the text given in simpler terms.' },
     edit: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Reword the text.' },
-    flashcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Make flashcards out of this text with each question and answer labeled as question and answer. Do not label each flashcard and do not include asterisks: ' },
     stack: {
         model: 'gpt-4o',
         maxTokens: 2048,
@@ -66,6 +65,7 @@ const callTypeMap: { [type: string]: GPTCallOpts } = {
         prompt: "The user is going to give you a list of descriptions. Each one is separated by `======` on either side. Descriptions will vary in length, so make sure to only separate when you see `======`. Sort them by the user's specifications. Make sure each description is only in the list once. Each item should be separated by `======`. Immediately afterward, surrounded by `------` on BOTH SIDES, provide some insight into your reasoning for the way you sorted (and mention nothing about the formatting details given in this description). It is VERY important that you format it exactly as described, ensuring the proper number of `=` and `-` (6 of each) and NO commas",
     },
     describe: { model: 'gpt-4-vision-preview', maxTokens: 2048, temp: 0, prompt: 'Describe these images in 3-5 words' },
+    flashcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Make flashcards out of this text with each question and answer labeled as question and answer. Do not label each flashcard and do not include asterisks: ' },
     chatcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Answer the following question as a short flashcard response. Do not include a label.' },
     quiz: {
         model: 'gpt-4-turbo',
@@ -127,7 +127,7 @@ let lastResp = '';
  */
 const gptAPICall = async (inputTextIn: string, callType: GPTCallType, prompt?: string, dontCache?: boolean) => {
     const inputText = [GPTCallType.SUMMARY, GPTCallType.FLASHCARD, GPTCallType.QUIZ, GPTCallType.STACK].includes(callType) ? inputTextIn + '.' : inputTextIn;
-    const opts: GPTCallOpts = callTypeMap[callType];
+    const opts = callTypeMap[callType];
     if (lastCall === inputText && dontCache !== true) return lastResp;
     try {
         lastCall = inputText;
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 0d7e0b20e..f71b9f879 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -305,6 +305,7 @@ export class DocumentOptions {
     _text_fontFamily?: string;
     _text_fontWeight?: string;
     text_align?: STRt = new StrInfo('horizontal text alignment default');
+    text_placeholder?: BOOLt = new BoolInfo('makes the text act like a placeholder and automatically select when the text box is selected');
     fontSize?: string;
     _pivotField?: string; // field key used to determine headings for sections in stacking, masonry, pivot views
 
diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx
index 0582bc996..80ef126dc 100644
--- a/src/client/views/nodes/ComparisonBox.tsx
+++ b/src/client/views/nodes/ComparisonBox.tsx
@@ -9,7 +9,6 @@ import { imageUrlToBase64, returnFalse, returnNone, returnTrue, returnZero, setu
 import { emptyFunction } from '../../../Utils';
 import { Doc, Opt } from '../../../fields/Doc';
 import { DocData } from '../../../fields/DocSymbols';
-import { Id } from '../../../fields/FieldSymbols';
 import { RichTextField } from '../../../fields/RichTextField';
 import { BoolCast, DocCast, NumCast, RTFCast, StrCast, toList } from '../../../fields/Types';
 import { nullAudio } from '../../../fields/URLField';
@@ -26,21 +25,22 @@ 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 { CollectionFreeFormView } from '../collections/collectionFreeForm';
 
 const API_URL = 'https://api.unsplash.com/search/photos';
 
 /**
- * This view serves two distinct functions depending on the metadata field layout_isFlashcard
+ * 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
  *
- * In either case, the two docs are stored in the <fieldKey>_front and <fieldKey>_back fields
+ * 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.
@@ -49,6 +49,9 @@ const API_URL = 'https://api.unsplash.com/search/photos';
  * 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.
  *
+ * 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.
+ *
  */
 
 @observer
@@ -56,6 +59,54 @@ 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 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
+        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) => {
+            newDoc[DocData][frontKey] = FormattedTextBox.centeredTextCreator('question', question, img);
+            newDoc[DocData][backKey] = FormattedTextBox.centeredTextCreator('answer', answer);
+            return newDoc;
+        };
+        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, {
+                _width: width,
+                _height: height,
+                _layout_fitWidth: false,
+                _layout_autoHeight: true,
+                _xMargin: 5,
+                _yMargin: 5,
+            });
+        });
+    }
     private SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
 
     static qtoken = 'Question: ';
@@ -118,9 +169,12 @@ 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 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
@@ -177,9 +231,19 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                         </div>
                     </Tooltip>
                 )}
-                {!this._props.isSelected() || this._renderSide === this.backKey || CollectionFreeFormView.from(this.DocumentView?.()) ? null : (
+                {!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(this.createFlashcardDeck)}>
+                        <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>
@@ -397,124 +461,51 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         axios
             .post(
                 'http://localhost:105/youtube/', //
-                { file: this.getYouTubeVideoId(StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text)) },
+                { file: this.getYouTubeVideoId(this.frontText) },
                 { headers: { 'Content-Type': 'application/json' } }
             )
             .then(response => response.data.transcription);
 
-    /**
-     * 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
-     */
-    createFlashcard = (tuple: 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
-        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) => {
-            newDoc[DocData][this.frontKey] = this.textCreator('question', question, img);
-            newDoc[DocData][this.backKey] = this.textCreator('answer', answer);
-            return newDoc;
-        };
-        return keyword && keyword.toLowerCase() !== 'none' ? this.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.
-     */
-    createFlashcardDeck = (text: string) => {
-        Promise.all(
-            text
-                .split(ComparisonBox.qtoken)
-                .filter(t => t)
-                .map(tuple => this.createFlashcard(tuple))
-        ).then(docs => {
-            const newCol = Docs.Create.CarouselDocument(docs, {
-                _width: NumCast(this.layoutDoc._width, 250) + 50,
-                _height: NumCast(this.layoutDoc._height, 200) + 50,
-                _layout_fitWidth: false,
-                _layout_autoHeight: true,
-                _xMargin: 5,
-                _yMargin: 5,
-                x: NumCast(this.layoutDoc.x),
-                y: NumCast(this.layoutDoc.y),
-            });
-
-            this._props.DocumentView?.()._props.addDocument?.(newCol);
-            this._props.removeDocument?.(this.Document);
-        });
-    };
-
     /**
      * Calls GPT for each flashcard type.
      */
     askGPT = async (callType: GPTCallType) => {
-        const frontText = RTFCast(DocCast(this.dataDoc[this.frontKey]).text)?.Text;
-        const backText = RTFCast(DocCast(this.dataDoc[this.backKey]).text)?.Text;
-        const questionText = 'Question: ' + frontText;
-        const queryText = questionText + (callType == GPTCallType.QUIZ ? ' UserAnswer: ' + this._inputValue + '. ' + ' Rubric: ' + backText : '');
+        const questionText = 'Question: ' + this.frontText;
+        const queryText = questionText + (callType == GPTCallType.QUIZ ? ' UserAnswer: ' + this._inputValue + '. ' + ' Rubric: ' + this.backText : '');
+
         this.loading = true;
-        let res = '';
-
-        if (callType !== GPTCallType.CHATCARD || frontText) {
-            try {
-                res = await gptAPICall(queryText, callType);
-                if (!res) {
-                    console.error('GPT call failed');
-                } else
-                    switch (callType) {
-                        case GPTCallType.CHATCARD:
-                            DocCast(this.dataDoc[this.backKey])[DocData].text = res;
-                            break;
-                        case GPTCallType.QUIZ:
-                            runInAction(() => {
-                                this._renderSide = this.backKey;
-                                this._outputValue = res.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric');
-                            });
-                            break;
-                        case GPTCallType.FLASHCARD:
-                        default:
-                    }
-            } catch (err) {
-                console.error('GPT call failed', err);
-            }
-        }
+        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.frontKey]).text)?.Text);
+        const sentence = this.frontText;
         const phon6 = 'huː ɑɹ juː tədeɪ';
         const phon4 = 'kamo estas hɔi';
         const promptEng =
@@ -567,24 +558,20 @@ 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,
             });
             return imageSnapshot;
         } catch (error) {
             console.log(error);
         }
-    };
+    }
 
     getImageDesc = async (u: string) => {
         try {
@@ -610,9 +597,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
     flashcardContextMenu = () => {
         const appearance = ContextMenu.Instance.findByDescription('Appearance...');
         const appearanceItems = appearance?.subitems ?? [];
-        if (this.Document._layout_isFlashcard) {
-            appearanceItems.push({ description: 'Create ChatCard', event: () => this.askGPT(GPTCallType.CHATCARD), icon: 'id-card' });
-        }
+        appearanceItems.push({ description: 'Create ChatCard', event: () => this.askGPT(GPTCallType.CHATCARD), icon: 'id-card' });
         !appearance && ContextMenu.Instance.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'eye' });
     };
 
@@ -641,181 +626,174 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         }
         return layoutTemplateString;
     };
-    textCreator = (title: string, text: string, img?: Doc) => {
-        const newDoc = Docs.Create.TextDocument(RichTextField.textToRtf(text, img?.[Id]), {
-            title, //
-            _layout_autoHeight: true,
-            _layout_centered: true,
-            text_align: 'center',
-            _layout_fitWidth: true,
-        });
-        return newDoc;
-    };
     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 === this.frontKey ? this.moveDocFront : this.moveDocBack}
-                        removeDocument={whichSlot === this.frontKey ? this.remDocFront : this.remDocBack}
-                        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.isFlashcard ? clearButton(whichSlot) : null}
-                </>
-            ) : (
-                <div className="placeholder">
-                    <FontAwesomeIcon className="upload-icon" icon="cloud-upload-alt" size="lg" />
-                </div>
-            );
-        };
-        const displayBox = (which: string, cover: number) => (
+
+    clearButton = (which: string) => (
+        <Tooltip title={<div className="dash-tooltip">remove</div>}>
             <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 ? 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}
+                    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 === this.frontKey ? this.moveDocFront : this.moveDocBack}
+                    removeDocument={whichSlot === this.frontKey ? this.remDocFront : this.remDocBack}
+                    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.isFlashcard ? this.clearButton(whichSlot) : null}
+            </>
+        ) : (
+            <div className="placeholder">
+                <FontAwesomeIcon className="upload-icon" icon="cloud-upload-alt" size="lg" />
             </div>
         );
+    };
 
-        if (this.isFlashcard) {
-            if (this.dataDoc.data) {
-                if (!this.dataDoc[this.backKey] || !this.dataDoc[this.frontKey]) this.createFlashcard(StrCast(this.dataDoc.data), this.Document);
-            } else {
-                // add text box to each side when comparison box is first created
-                if (!this.dataDoc[this.backKey] && !this._isEmpty) {
-                    const answer = this.textCreator('answer', 'answer here');
-                    this.dataDoc[this.backKey] = answer;
-                    answer[DocData].text_placeholder = true;
-                }
-
-                if (!this.dataDoc[this.frontKey] && !this._isEmpty) {
-                    const question = this.textCreator('question', 'hint: Enter a topic, select this document and click the stack button to have GPT create a deck of cards');
-                    this.dataDoc[this.frontKey] = question;
-                    question[DocData].text_placeholder = true;
-                }
-            }
-
-            if (DocCast(this.Document.embedContainer)?.practiceMode === practiceMode.QUIZ) {
-                const text = StrCast(RTFCast(DocCast(this.dataDoc[this.frontKey]).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._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>
-                            )}
-                        </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.flipFlashcard : this.handleRenderGPTClick}>
-                                    {this._renderSide === this.backKey ? '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);
+                this.isFlashcard && this.activateContent();
+            }}
+            ref={ele => this.createDropTarget(ele, which)}>
+            {!this._isEmpty ? this.displayDoc(which) : null}
+        </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>
+                )}
+            </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.flipFlashcard : 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);
+        } 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);
             }
 
-            // 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 */
-                    onContextMenu={this.flashcardContextMenu}
-                    onMouseEnter={() => this.hoverFlip(this.backKey)}
-                    onMouseLeave={() => this.hoverFlip(this.frontKey)}>
-                    {displayBox(this._renderSide, this._props.PanelWidth() - 3)}
-                    {this.loading ? (
-                        <div className="loading-spinner">
-                            <ReactLoading type="spin" height={30} width={30} color="blue" />
-                        </div>
-                    ) : null}
-                    {this.flashcardMenu}
-                </div>
-            );
+            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);
+            }
         }
-        // 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.backKey, this._props.PanelWidth() - 3)}
-                <div className="clip-div" style={{ width: this.clipWidth + '%', transition: this._animating, background: StrCast(this.layoutDoc._backgroundColor, 'gray') }}>
-                    {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" />
+    renderAsFlashcard = () => (
+        <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}
+            {this.flashcardMenu}
+        </div>
+    );
+
+    // render a comparison box that compares items side by side
+    renderAsBeforeAfter = () => (
+        <div className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}` /* change className to easily disable/enable pointer events in CSS */}>
+            {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>
+    );
+
+    render() {
+        this.isFlashcard && this.addPlaceholdersForEmptyFlashcard();
+        return this.isFlashcard ? 
+                    this.isQuizMode ? this.renderAsQuiz(this.frontText) :
+                                      this.renderAsFlashcard() : 
+                    this.renderAsBeforeAfter(); // prettier-ignore
     }
 }
 
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 29be8d285..9d3a899f5 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -76,6 +76,26 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     public static LayoutString(fieldStr: string) {
         return FieldView.LayoutString(FormattedTextBox, fieldStr);
     }
+    /**
+     * Creates a text box where the supplied text (and optional iimage) will be vertically
+     * and horizontally centered.  If text_placeholder is set to true, then the text will be
+     * treated as placeholder text and automatically selected when the text box is selected.
+     * @param title name of text box
+     * @param text text to display in textbox
+     * @param img optional image to add to text box
+     * @param text_placeholder makes the text automatially select
+     * @returns
+     */
+    public static centeredTextCreator(title: string, text: string, img?: Doc, text_placeholder?: boolean) {
+        return Docs.Create.TextDocument(RichTextField.textToRtf(text, img?.[Id]), {
+            title, //
+            _layout_autoHeight: true,
+            _layout_centered: true,
+            text_align: 'center',
+            text_placeholder,
+            _layout_fitWidth: true,
+        });
+    }
     public static MakeConfig(rules?: RichTextRules, props?: FormattedTextBoxProps) {
         return {
             schema,
diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx
index 7243473e0..5ab9b556c 100644
--- a/src/client/views/pdf/AnchorMenu.tsx
+++ b/src/client/views/pdf/AnchorMenu.tsx
@@ -10,7 +10,6 @@ import { emptyFunction, unimplementedFunction } from '../../../Utils';
 import { Doc, Opt } from '../../../fields/Doc';
 import { DocData } from '../../../fields/DocSymbols';
 import { GPTCallType, gptAPICall } from '../../apis/gpt/GPT';
-import { Docs } from '../../documents/Documents';
 import { SettingsManager } from '../../util/SettingsManager';
 import { undoBatch } from '../../util/UndoManager';
 import { AntimodeMenu, AntimodeMenuProps } from '../AntimodeMenu';
@@ -19,6 +18,7 @@ import { DocumentView } from '../nodes/DocumentView';
 import { DrawingOptions, SmartDrawHandler } from '../smartdraw/SmartDrawHandler';
 import './AnchorMenu.scss';
 import { GPTPopup, GPTPopupMode } from './GPTPopup/GPTPopup';
+import { ComparisonBox } from '../nodes/ComparisonBox';
 
 @observer
 export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
@@ -117,29 +117,15 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
      */
 
     transferToFlashcard = (text: string, x: number, y: number) => {
-        // put each question generated by GPT on the front of the flashcard
-        const senArr = text.trim().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 });
-            newDoc.text = senArr[i];
-
-            collectionArr.push(newDoc);
-        }
-        // create a new carousel collection of these flashcards
-        const newCol = Docs.Create.CarouselDocument(collectionArr, {
-            _width: 250,
-            _height: 200,
-            _layout_fitWidth: false,
-            _layout_autoHeight: true,
-        });
-
-        newCol.x = x;
-        newCol.y = y;
-        newCol.zIndex = 1000;
-
-        this.addToCollection?.(newCol);
-        this._loading = false;
+        ComparisonBox.createFlashcardDeck(text, 250, 200, 'data_front', 'data_back').then(
+            action(newCol => {
+                newCol.x = x;
+                newCol.y = y;
+                newCol.zIndex = 1000;
+                this.addToCollection?.(newCol);
+                this._loading = false;
+            })
+        );
     };
 
     /**
diff --git a/src/fields/util.ts b/src/fields/util.ts
index 60eadcdfd..33764aca5 100644
--- a/src/fields/util.ts
+++ b/src/fields/util.ts
@@ -227,7 +227,6 @@ function getEffectiveAcl(target: Doc | ListImpl<FieldType>, user?: string): symb
  * @param allowUpgrade whether permissions can be made less restrictive
  * @param layoutOnly just sets the layout doc's ACL (unless the data doc has no entry for the ACL, in which case it will be set as well)
  */
-// eslint-disable-next-line default-param-last
 export function distributeAcls(key: string, acl: SharingPermissions, target: Doc, visited: Doc[] = [], allowUpgrade?: boolean, layoutOnly = false) {
     const selfKey = `acl_${normalizeEmail(ClientUtils.CurrentUserEmail())}`;
     if (!target || visited.includes(target) || key === selfKey) return;
-- 
cgit v1.2.3-70-g09d2


From 29b83f023442c313ca5cf95f70f6430f101060e6 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 14 Oct 2024 19:55:32 -0400
Subject: reorganized comparisonBox related components -- moved stuff down into
 Docs.Crete and CurrentUserUtils.  changed Doc.Copy to copy Doc's in fields
 tagged with cloneOnCopy.  Changed ComparisonBox to support hover for slide or
 flip views. Fixed pointerEfvents for hover in comparisonBox

---
 src/client/documents/DocUtils.ts                   |   2 -
 src/client/documents/Documents.ts                  |  43 ++++-
 src/client/util/CurrentUserUtils.ts                |   4 +-
 .../views/collections/CollectionCarousel3DView.tsx |  33 ++--
 .../views/collections/CollectionCarouselView.tsx   |  58 +++---
 src/client/views/collections/CollectionSubView.tsx |   2 +-
 src/client/views/collections/CollectionView.tsx    |   6 +-
 .../views/collections/FlashcardPracticeUI.tsx      |  36 ++--
 src/client/views/nodes/ComparisonBox.scss          |   3 +-
 src/client/views/nodes/ComparisonBox.tsx           | 196 +++++++++++----------
 .../views/nodes/formattedText/FormattedTextBox.tsx |  20 ---
 src/fields/Doc.ts                                  |   2 +-
 12 files changed, 223 insertions(+), 182 deletions(-)

(limited to 'src/client/views/nodes/ComparisonBox.tsx')

diff --git a/src/client/documents/DocUtils.ts b/src/client/documents/DocUtils.ts
index 5f54f9d0a..19f3c89ef 100644
--- a/src/client/documents/DocUtils.ts
+++ b/src/client/documents/DocUtils.ts
@@ -103,7 +103,6 @@ export namespace DocUtils {
                       return false;
                   }
                   const facetKeys = Object.keys(filterFacets).filter(fkey => fkey !== 'cookies' && fkey !== ClientUtils.noDragDocsFilter.split(Doc.FilterSep)[0]);
-                  // eslint-disable-next-line no-restricted-syntax
                   for (const facetKey of facetKeys) {
                       const facet = filterFacets[facetKey];
 
@@ -288,7 +287,6 @@ export namespace DocUtils {
         return doc;
     }
     export function AssignDocField(doc: Doc, field: string, creator: (reqdOpts: DocumentOptions, items?: Doc[]) => Doc, reqdOpts: DocumentOptions, items?: Doc[], scripts?: { [key: string]: string }, funcs?: { [key: string]: string }) {
-        // eslint-disable-next-line no-return-assign
         return DocUtils.AssignScripts(DocUtils.AssignOpts(DocCast(doc[field]), reqdOpts, items) ?? (doc[field] = creator(reqdOpts, items)), scripts, funcs);
     }
 
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index f71b9f879..99af1f1a9 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -18,6 +18,7 @@ import { PointData } from '../../pen-gestures/GestureTypes';
 import { DocServer } from '../DocServer';
 import { dropActionType } from '../util/DropActionTypes';
 import { CollectionViewType, DocumentType } from './DocumentTypes';
+import { Id } from '../../fields/FieldSymbols';
 
 class EmptyBox {
     public static LayoutString() {
@@ -360,6 +361,7 @@ export class DocumentOptions {
     isFolder?: BOOLt = new BoolInfo('is document a tree view folder');
     _isTimelineLabel?: BOOLt = new BoolInfo('is document a timeline label');
     _isLightbox?: BOOLt = new BoolInfo('whether a collection acts as a lightbox by opening lightbox links by hiding all other documents in collection besides link target');
+    cloneOnCopy?: BOOLt = new BoolInfo('if this Doc is a field of another Doc, then it should be copied when the other Doc is copied');
 
     mapPin?: DOCt = new DocInfo('pin associated with a config anchor', false);
     config_latitude?: NUMt = new NumInfo('latitude of a map', false);
@@ -420,6 +422,12 @@ export class DocumentOptions {
     flexGap?: NUMt = new NumInfo('Linear view flex gap');
     flexDirection?: 'unset' | 'row' | 'column' | 'row-reverse' | 'column-reverse';
 
+    // Comparison
+    data_revealOp?: STRt = new StrInfo("visual reveal type for front and back of comparison - 'slide' or 'flip' ");
+    data_revealOp_hover?: BOOLt = new BoolInfo('reveal back of comparison manually or by hovering');
+    data_front?: DOCt = new DocInfo('contents of front of flashcard/comparison');
+    data_back?: DOCt = new DocInfo('contents of back of flashcard/comparison');
+
     link?: string;
     link_description?: string; // added for links
     link_relationship?: string; // type of relatinoship a link represents
@@ -784,8 +792,39 @@ export namespace Docs {
             return InstanceFromProto(Prototypes.get(DocumentType.SCREENSHOT), '', options);
         }
 
-        export function ComparisonDocument(text: string, options: DocumentOptions = { title: 'Comparison Box' }) {
-            return InstanceFromProto(Prototypes.get(DocumentType.COMPARISON), text, options);
+        export function ComparisonDocument(title: string, options: DocumentOptions) {
+            return InstanceFromProto(Prototypes.get(DocumentType.COMPARISON), '', options);
+        }
+        /**
+         * Creates a text box where the supplied text (and optional iimage) will be vertically
+         * and horizontally centered.  If text_placeholder is set to true, then the text will be
+         * treated as placeholder text and automatically selected when the text box is selected.
+         * @param title name of text box
+         * @param text text to display in text box
+         * @param opts metadata fields to set on text box
+         * @param img optional image to add to text box
+         * @returns
+         */
+        export function CenteredTextCreator(title: string, text: string, opts: DocumentOptions, img?: Doc) {
+            return TextDocument(RichTextField.textToRtf(text, img?.[Id]), {
+                title, //
+                _layout_autoHeight: true,
+                _layout_centered: true,
+                text_align: 'center',
+                _layout_fitWidth: true,
+                ...opts,
+            });
+        }
+
+        export function FlashcardDocument(title: string, front?: Doc, back?: Doc, options: DocumentOptions = { title: 'Flashcard' }) {
+            return InstanceFromProto(Prototypes.get(DocumentType.COMPARISON), '', {
+                data_front: front ?? CenteredTextCreator('question', 'hint: Enter a topic, select this document and click the stack button to have GPT create a deck of cards', { text_placeholder: true, cloneOnCopy: true }, undefined),
+                data_back: back ?? CenteredTextCreator('answer', 'answer here', { text_placeholder: true, cloneOnCopy: true }, undefined),
+                _layout_fitWidth: true,
+                _layout_isFlashcard: true,
+                title,
+                ...options,
+            });
         }
         export function DiagramDocument(options: DocumentOptions = { title: '' }) {
             return InstanceFromProto(Prototypes.get(DocumentType.DIAGRAM), undefined, options);
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index 798cdf5a9..555ccdd88 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -369,14 +369,14 @@ pie title Minerals in my tap water
                           creator:(opts:DocumentOptions)=> Doc // how to create the empty thing if it doesn't exist
               }[] = [
             {key: "Note",       creator: opts => Docs.Create.TextDocument("", opts),        opts: { _width: 200, _layout_autoHeight: true }},
-            {key: "Flashcard",  creator: opts => Docs.Create.ComparisonDocument("", opts),  opts: { _layout_isFlashcard: true, _layout_fitWidth: true, _width: 300, _height: 300}},
+            {key: "Flashcard",  creator: opts => Docs.Create.FlashcardDocument("", undefined, undefined, opts),opts: { _width: 300, _height: 300}},
             {key: "Image",      creator: opts => Docs.Create.ImageDocument("", opts),       opts: { _width: 400, _height:400 }},
             {key: "Equation",   creator: opts => Docs.Create.EquationDocument("",opts),     opts: { _width: 300, _height: 35, }},
             {key: "Noteboard",  creator: opts => Docs.Create.NoteTakingDocument([], opts),  opts: { _width: 250, _height: 200, _layout_fitWidth: true}},
             {key: "Simulation", creator: opts => Docs.Create.SimulationDocument(opts),      opts: { _width: 300, _height: 300, }},
             {key: "Collection", creator: opts => Docs.Create.FreeformDocument([], opts),    opts: { _width: 150, _height: 100, _layout_fitWidth: true }},
             {key: "Webpage",    creator: opts => Docs.Create.WebDocument("",opts),          opts: { _width: 400, _height: 512, _nativeWidth: 850, data_useCors: true, }},
-            {key: "Comparison", creator: opts => Docs.Create.ComparisonDocument("",opts),   opts: { _width: 300, _height: 300 }},
+            {key: "Comparison", creator: opts => Docs.Create.ComparisonDocument("", opts),  opts: { _width: 300, _height: 300 }},
             {key: "Diagram",    creator: Docs.Create.DiagramDocument,                       opts: { _width: 300, _height: 300, _type_collection: CollectionViewType.Freeform, layout_diagramEditor: CollectionView.LayoutString("data") }, scripts: { onPaint: `toggleDetail(documentView, "diagramEditor","")`}},
             {key: "Audio",      creator: opts => Docs.Create.AudioDocument(nullAudio, opts),opts: { _width: 200, _height: 100, }},
             {key: "Audio",      creator: opts => Docs.Create.AudioDocument(nullAudio, opts),opts: { _width: 200, _height: 100, _layout_fitWidth: true, }},
diff --git a/src/client/views/collections/CollectionCarousel3DView.tsx b/src/client/views/collections/CollectionCarousel3DView.tsx
index 05be376ca..e9ace733e 100644
--- a/src/client/views/collections/CollectionCarousel3DView.tsx
+++ b/src/client/views/collections/CollectionCarousel3DView.tsx
@@ -15,6 +15,7 @@ import { DocumentView } from '../nodes/DocumentView';
 import { FocusViewOptions } from '../nodes/FocusViewOptions';
 import './CollectionCarousel3DView.scss';
 import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView';
+import { computedFn } from 'mobx-utils';
 
 // eslint-disable-next-line @typescript-eslint/no-require-imports
 const { CAROUSEL3D_CENTER_SCALE, CAROUSEL3D_SIDE_SCALE, CAROUSEL3D_TOP } = require('../global/globalCssVariables.module.scss');
@@ -53,18 +54,22 @@ export class CollectionCarousel3DView extends CollectionSubView() {
 
     centerScale = Number(CAROUSEL3D_CENTER_SCALE);
     sideScale = Number(CAROUSEL3D_SIDE_SCALE);
-    panelWidth = () => this._props.PanelWidth() / 3;
-    panelHeight = () => this._props.PanelHeight() * this.sideScale;
+    panelWidth = () => this._props.PanelWidth() / 3 / this.nativeScaling();
+    panelHeight = () => (this._props.PanelHeight() * this.sideScale) / this.nativeScaling();
     onChildDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick);
     isContentActive = () => this._props.isSelected() || this._props.isContentActive() || this._props.isAnyChildContentActive();
-    isChildContentActive = () =>
-        this._props.isContentActive?.() === false
-            ? false
-            : this._props.isDocumentActive?.() && (this._props.childDocumentsActive?.() || BoolCast(this.Document.childDocumentsActive))
-              ? true
-              : this._props.childDocumentsActive?.() === false || this.Document.childDocumentsActive === false
+    isChildContentActive = computedFn(
+        (doc: Doc) => () =>
+            this._props.isContentActive?.() === false
                 ? false
-                : undefined;
+                : this._props.isDocumentActive?.() && (this._props.childDocumentsActive?.() || BoolCast(this.Document.childDocumentsActive))
+                  ? true
+                  : this._props.isContentActive?.() && this.curDoc() === doc
+                    ? true
+                    : this._props.childDocumentsActive?.() === false || this.Document.childDocumentsActive === false
+                      ? false
+                      : undefined
+    );
     contentScreenToLocalXf = () => this._props.ScreenToLocalTransform().scale(this._props.NativeDimScaling?.() || 1);
     childScreenLeftToLocal = () =>
         this.contentScreenToLocalXf()
@@ -110,7 +115,7 @@ export class CollectionCarousel3DView extends CollectionSubView() {
                 LayoutTemplateString={this._props.childLayoutString}
                 focus={this.focus}
                 ScreenToLocalTransform={dxf}
-                isContentActive={this.isChildContentActive}
+                isContentActive={this.isChildContentActive(child)}
                 isDocumentActive={this._props.childDocumentsActive?.() || this.Document._childDocumentsActive ? this._props.isDocumentActive : this.isContentActive}
                 PanelWidth={this.panelWidth}
                 PanelHeight={this.panelHeight}
@@ -125,7 +130,6 @@ export class CollectionCarousel3DView extends CollectionSubView() {
     }
 
     changeSlide = (direction: number) => {
-        DocumentView.DeselectAll();
         this.layoutDoc._carousel_index = !this.curDoc() ? 0 : (NumCast(this.layoutDoc._carousel_index) + direction + this.carouselItems.length) % (this.carouselItems.length || 1);
     };
 
@@ -205,9 +209,10 @@ export class CollectionCarousel3DView extends CollectionSubView() {
     docViewProps = () => ({
         ...this._props, //
         isDocumentActive: this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this._props.isContentActive,
-        isContentActive: this.isChildContentActive,
+        isContentActive: this._props.isContentActive,
         ScreenToLocalTransform: this.contentScreenToLocalXf,
     });
+    nativeScaling = () => this._props.NativeDimScaling?.() || 1;
     render() {
         return (
             <div
@@ -216,6 +221,10 @@ export class CollectionCarousel3DView extends CollectionSubView() {
                 style={{
                     background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor) as string,
                     color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string,
+                    transformOrigin: 'top left',
+                    transform: `scale(${this.nativeScaling()})`,
+                    width: `${100 / this.nativeScaling()}%`,
+                    height: `${100 / this.nativeScaling()}%`,
                 }}>
                 <div className="carousel-wrapper" style={{ transform: `translateX(${this.translateX}px)` }}>
                     {this.content}
diff --git a/src/client/views/collections/CollectionCarouselView.tsx b/src/client/views/collections/CollectionCarouselView.tsx
index ef66a2c83..ff587b199 100644
--- a/src/client/views/collections/CollectionCarouselView.tsx
+++ b/src/client/views/collections/CollectionCarouselView.tsx
@@ -58,18 +58,12 @@ export class CollectionCarouselView extends CollectionSubView() {
     /**
      * Goes to the next Doc in the stack subject to the currently selected filter option.
      */
-    advance = (e?: React.MouseEvent) => {
-        e?.stopPropagation();
-        this.move(1);
-    };
+    advance = () => this.move(1);
 
     /**
      * Goes to the previous Doc in the stack subject to the currently selected filter option.
      */
-    goback = (e: React.MouseEvent) => {
-        e.stopPropagation();
-        this.move(-1);
-    };
+    goback = () => this.move(-1);
 
     curDoc = () => this.carouselItems[this.carouselIndex]?.layout;
 
@@ -78,24 +72,23 @@ export class CollectionCarouselView extends CollectionSubView() {
         const childValue = doc?.['caption_' + property] ? this._props.styleProvider?.(doc, captionProps, property) : undefined;
         return childValue ?? this._props.styleProvider?.(this.layoutDoc, captionProps, property);
     };
-    contentPanelWidth = () => this._props.PanelWidth() - 2 * NumCast(this.layoutDoc.xMargin);
-    contentPanelHeight = () => this._props.PanelHeight() - (StrCast(this.layoutDoc._layout_showCaption) ? 50 : 0) - 2 * NumCast(this.layoutDoc.yMargin);
+    contentPanelWidth = () => (this._props.PanelWidth() - 2 * NumCast(this.layoutDoc.xMargin)) / this.nativeScaling();
+    contentPanelHeight = () => (this._props.PanelHeight() - (StrCast(this.layoutDoc._layout_showCaption) ? 50 : 0) - 2 * NumCast(this.layoutDoc.yMargin)) / this.nativeScaling();
     onContentDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick);
     onContentClick = () => ScriptCast(this.layoutDoc.onChildClick);
     captionWidth = () => this._props.PanelWidth() - 2 * this.captionMarginX;
     contentScreenToLocalXf = () =>
         this._props
-            .ScreenToLocalTransform()
-            .translate(-NumCast(this.layoutDoc.xMargin), -NumCast(this.layoutDoc.yMargin))
-            .scale(this._props.NativeDimScaling?.() || 1);
+            .ScreenToLocalTransform() //
+            .translate(-NumCast(this.layoutDoc.xMargin) / this.nativeScaling(), -NumCast(this.layoutDoc.yMargin) / this.nativeScaling());
     isChildContentActive = () =>
         this._props.isContentActive?.() === false
             ? false
-            : this._props.isDocumentActive?.() && (this._props.childDocumentsActive?.() || BoolCast(this.Document.childDocumentsActive))
+            : this._props.isContentActive()
               ? true
               : this._props.childDocumentsActive?.() === false || this.Document.childDocumentsActive === false
                 ? false
-                : undefined;
+                : undefined; // prettier-ignore
     onPassiveWheel = (e: WheelEvent) => e.stopPropagation();
     renderDoc = (doc: Doc, showCaptions: boolean, overlayFunc?: (r: DocumentView | null) => void) => {
         return (
@@ -202,6 +195,8 @@ export class CollectionCarouselView extends CollectionSubView() {
         );
     }
 
+    nativeScaling = () => this._props.NativeDimScaling?.() || 1;
+
     docViewProps = () => ({
         ...this._props, //
         isDocumentActive: this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this._props.isContentActive,
@@ -212,20 +207,25 @@ export class CollectionCarouselView extends CollectionSubView() {
 
     render() {
         return (
-            <div
-                className="collectionCarouselView-outer"
-                ref={this.createDashEventsTarget}
-                style={{
-                    background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor) as string,
-                    color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string,
-                    width: `calc(100% - ${NumCast(this.layoutDoc._xMargin)}px)`,
-                    height: `calc(100% - ${NumCast(this.layoutDoc._yMargin)}px)`,
-                    left: NumCast(this.layoutDoc._xMargin),
-                    top: NumCast(this.layoutDoc._yMargin),
-                }}>
-                {this.content}
-                {this.flashCardUI(this.curDoc, this.docViewProps, this.answered)}
-                {this.navButtons}
+            <div>
+                <div
+                    className="collectionCarouselView-outer"
+                    ref={this.createDashEventsTarget}
+                    style={{
+                        background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor) as string,
+                        color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string,
+                        left: NumCast(this.layoutDoc._xMargin),
+                        top: NumCast(this.layoutDoc._yMargin),
+                        transformOrigin: 'top left',
+                        transform: `scale(${this.nativeScaling()})`,
+                        width: `calc(${100 / this.nativeScaling()}% - ${(2 * NumCast(this.layoutDoc._xMargin)) / this.nativeScaling()}px)`,
+                        height: `calc(${100 / this.nativeScaling()}% - ${(2 * NumCast(this.layoutDoc._yMargin)) / this.nativeScaling()}px)`,
+                        position: 'relative',
+                    }}>
+                    {this.content}
+                    {this.flashCardUI(this.curDoc, this.docViewProps, this.answered)}
+                    {this.navButtons}
+                </div>
             </div>
         );
     }
diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx
index f85b0b433..ab5b70a85 100644
--- a/src/client/views/collections/CollectionSubView.tsx
+++ b/src/client/views/collections/CollectionSubView.tsx
@@ -523,7 +523,7 @@ export function CollectionSubView<X>() {
         /**
          * How much the content of the collection is being scaled based on its nesting and its fit-to-width settings
          */
-        @computed get contentScaling() { return this.ScreenToLocalBoxXf().Scale * (this._props.NativeDimScaling?.() ?? 1); } // prettier-ignore
+        @computed get contentScaling() { return this.ScreenToLocalBoxXf().Scale; } // prettier-ignore
         /**
          * The maximum size a UI widget can be in collection coordinates based on not wanting the widget to visually obscure too much of the collection
          * This takes the desired screen space size and converts into collection coordinates.  It then returns the smaller of the converted
diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx
index 7418d4360..6f0833a22 100644
--- a/src/client/views/collections/CollectionView.tsx
+++ b/src/client/views/collections/CollectionView.tsx
@@ -83,7 +83,7 @@ export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewPr
         return viewField;
     }
 
-    screenToLocalTransform = () => (this._props.renderDepth ? this.ScreenToLocalBoxXf() : this.ScreenToLocalBoxXf().scale(this._props.PanelWidth() / this.bodyPanelWidth()));
+    screenToLocalTransform = this.ScreenToLocalBoxXf;
     // prettier-ignore
     private renderSubView = (type: CollectionViewType | undefined, props: SubCollectionViewProps) => {
         TraceMobx();
@@ -202,8 +202,6 @@ export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewPr
         }
     };
 
-    bodyPanelWidth = () => this._props.PanelWidth();
-
     childLayoutTemplate = () => this._props.childLayoutTemplate?.() || Cast(this.Document.childLayoutTemplate, Doc, null);
     isContentActive = () => this._isContentActive;
 
@@ -221,7 +219,7 @@ export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewPr
             removeDocument: this.removeDocument,
             isContentActive: this.isContentActive,
             isAnyChildContentActive: this.isAnyChildContentActive,
-            PanelWidth: this.bodyPanelWidth,
+            PanelWidth: this._props.PanelWidth,
             PanelHeight: this._props.PanelHeight,
             ScreenToLocalTransform: this.screenToLocalTransform,
             childLayoutTemplate: this.childLayoutTemplate,
diff --git a/src/client/views/collections/FlashcardPracticeUI.tsx b/src/client/views/collections/FlashcardPracticeUI.tsx
index 45e040653..79eb7f107 100644
--- a/src/client/views/collections/FlashcardPracticeUI.tsx
+++ b/src/client/views/collections/FlashcardPracticeUI.tsx
@@ -1,7 +1,7 @@
 import { IconProp } from '@fortawesome/fontawesome-svg-core';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { Tooltip } from '@mui/material';
-import { IconButton, MultiToggle, Type } from 'browndash-components';
+import { MultiToggle, Type } from 'browndash-components';
 import { computed, makeObservable } from 'mobx';
 import { observer } from 'mobx-react';
 import * as React from 'react';
@@ -25,8 +25,8 @@ enum practiceVal {
 }
 
 export enum flashcardRevealOp {
-    HOVER = 'hover',
     FLIP = 'flip',
+    SLIDE = 'slide',
 }
 
 interface PracticeUIProps {
@@ -153,20 +153,28 @@ export class FlashcardPracticeUI extends ObservableReactComponent<PracticeUIProp
                     selectedItems={this.practiceMode}
                     onSelectionChange={(val: (string | number) | (string | number)[]) => togglePracticeMode(val as practiceMode)}
                 />
-                <IconButton
-                    tooltip="hover over card to reveal answer"
-                    type={Type.TERT}
-                    text={StrCast(this._props.layoutDoc.revealOp)}
+                <MultiToggle
+                    tooltip="How to reveal flashcard answer"
+                    type={Type.PRIM}
                     color={SnappingManager.userColor}
                     background={SnappingManager.userVariantColor}
-                    icon={<FontAwesomeIcon color={SnappingManager.userColor} icon={this._props.layoutDoc.revealOp === flashcardRevealOp.HOVER ? 'hand-point-up' : 'question'} size="sm" />}
-                    label={StrCast(this._props.layoutDoc.revealOp)}
-                    onPointerDown={e =>
-                        setupMoveUpEvents(this, e, returnFalse, emptyFunction, () => {
-                            this._props.layoutDoc.revealOp = this._props.layoutDoc.revealOp === flashcardRevealOp.HOVER ? flashcardRevealOp.FLIP : flashcardRevealOp.HOVER;
-                            this._props.layoutDoc.childDocumentsActive = this._props.layoutDoc.revealOp === 'hover' ? true : undefined;
-                        })
-                    }
+                    multiSelect={false}
+                    isToggle={false}
+                    toggleStatus={!!this.practiceMode}
+                    label={StrCast(this._props.layoutDoc.revealOp, flashcardRevealOp.FLIP)}
+                    items={[
+                        ['reveal', StrCast(this._props.layoutDoc.revealOp) === flashcardRevealOp.SLIDE ? 'expand' : 'question', StrCast(this._props.layoutDoc.revealOp, flashcardRevealOp.FLIP)],
+                        ['trigger', this._props.layoutDoc.revealOp_hover ? 'hand-point-up' : 'hand', this._props.layoutDoc.revealOp_hover ? 'show on hover' : 'show on click'],
+                    ].map(([item, icon, tooltip]) => ({
+                        icon: <FontAwesomeIcon className={`FlashcardPracticeUI-${item}`} color={setColor(item as practiceMode)} icon={icon as IconProp} size="sm" />,
+                        tooltip: tooltip,
+                        val: item,
+                    }))}
+                    selectedItems={this._props.layoutDoc.revealOp_hover ? ['reveal', 'trigger'] : 'reveal'}
+                    onSelectionChange={(val: (string | number) | (string | number)[]) => {
+                        if (val === 'reveal') this._props.layoutDoc.revealOp = this._props.layoutDoc.revealOp === flashcardRevealOp.SLIDE ? flashcardRevealOp.FLIP : flashcardRevealOp.SLIDE;
+                        if (val === 'trigger') this._props.layoutDoc.revealOp_hover = !this._props.layoutDoc.revealOp_hover;
+                    }}
                 />
             </div>
         );
diff --git a/src/client/views/nodes/ComparisonBox.scss b/src/client/views/nodes/ComparisonBox.scss
index c328ef4bf..d1cc48051 100644
--- a/src/client/views/nodes/ComparisonBox.scss
+++ b/src/client/views/nodes/ComparisonBox.scss
@@ -246,8 +246,7 @@
     pointer-events: none;
 }
 
-.comparisonBox-interactive {
-    pointer-events: unset;
+.comparisonBox-slide {
     cursor: ew-resize;
 
     .slide-bar {
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>
+        );
     }
 }
 
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 9d3a899f5..29be8d285 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -76,26 +76,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     public static LayoutString(fieldStr: string) {
         return FieldView.LayoutString(FormattedTextBox, fieldStr);
     }
-    /**
-     * Creates a text box where the supplied text (and optional iimage) will be vertically
-     * and horizontally centered.  If text_placeholder is set to true, then the text will be
-     * treated as placeholder text and automatically selected when the text box is selected.
-     * @param title name of text box
-     * @param text text to display in textbox
-     * @param img optional image to add to text box
-     * @param text_placeholder makes the text automatially select
-     * @returns
-     */
-    public static centeredTextCreator(title: string, text: string, img?: Doc, text_placeholder?: boolean) {
-        return Docs.Create.TextDocument(RichTextField.textToRtf(text, img?.[Id]), {
-            title, //
-            _layout_autoHeight: true,
-            _layout_centered: true,
-            text_align: 'center',
-            text_placeholder,
-            _layout_fitWidth: true,
-        });
-    }
     public static MakeConfig(rules?: RichTextRules, props?: FormattedTextBoxProps) {
         return {
             schema,
diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts
index 45dfe233f..6ec195910 100644
--- a/src/fields/Doc.ts
+++ b/src/fields/Doc.ts
@@ -1003,7 +1003,7 @@ export namespace Doc {
                     } else if (field instanceof ObjectField) {
                         const docAtKey = doc[key];
                         copy[key] =
-                            docAtKey instanceof Doc && key.includes('layout[')
+                            docAtKey instanceof Doc && (key.includes('layout[') || docAtKey.cloneOnCopy)
                                 ? new ProxyField(Doc.MakeCopy(docAtKey)) // copy the expanded render template
                                 : ObjectField.MakeCopy(field);
                     } else if (field instanceof Promise) {
-- 
cgit v1.2.3-70-g09d2


From 9779182e74e427d3a8a2004d0efd01fac8387d55 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Wed, 16 Oct 2024 17:31:12 -0400
Subject: major fixes to cardDeck view to simplify code and to make arch follow
 a true circle arc and to fix doc sizing when fitwidth/lightbox/etc.  fixes to
 flashcard UI for advancing to next Doc in cardView and carousel3D.

---
 src/client/documents/Documents.ts                  |   4 +-
 src/client/views/DocumentDecorations.tsx           |   4 +-
 .../views/collections/CollectionCardDeckView.scss  |  24 +-
 .../views/collections/CollectionCardDeckView.tsx   | 336 +++++++++++----------
 .../views/collections/CollectionCarousel3DView.tsx |   2 +-
 .../views/collections/CollectionCarouselView.tsx   |   2 +-
 .../views/collections/FlashcardPracticeUI.scss     |   6 +
 .../views/collections/FlashcardPracticeUI.tsx      |   6 +-
 src/client/views/global/globalScripts.ts           |  26 +-
 src/client/views/nodes/ComparisonBox.tsx           |  25 +-
 src/client/views/nodes/DocumentContentsView.tsx    |   1 -
 11 files changed, 230 insertions(+), 206 deletions(-)

(limited to 'src/client/views/nodes/ComparisonBox.tsx')

diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 99af1f1a9..e539e3c65 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -508,8 +508,8 @@ export class DocumentOptions {
     userBackgroundColor?: STRt = new StrInfo('background color associated with a Dash user (seen in header fields of shared documents)');
     userColor?: STRt = new StrInfo('color associated with a Dash user (seen in header fields of shared documents)');
 
-    cardSort?: STRt = new StrInfo('way cards are sorted in deck view');
-    cardSort_isDesc?: BOOLt = new BoolInfo('whether the cards are sorted ascending or descending');
+    card_sort?: STRt = new StrInfo('way cards are sorted in deck view');
+    card_sort_isDesc?: BOOLt = new BoolInfo('whether the cards are sorted ascending or descending');
 }
 
 export const DocOptions = new DocumentOptions();
diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx
index 62f2de776..5a48b6c62 100644
--- a/src/client/views/DocumentDecorations.tsx
+++ b/src/client/views/DocumentDecorations.tsx
@@ -10,7 +10,7 @@ import { lightOrDark, returnFalse, setupMoveUpEvents } from '../../ClientUtils';
 import { Utils, emptyFunction, numberValue } from '../../Utils';
 import { DateField } from '../../fields/DateField';
 import { Doc, DocListCast, Field, FieldType, HierarchyMapping, ReverseHierarchyMap } from '../../fields/Doc';
-import { AclAdmin, AclAugment, AclEdit, DocData } from '../../fields/DocSymbols';
+import { AclAdmin, AclAugment, AclEdit, Animation, DocData } from '../../fields/DocSymbols';
 import { Id } from '../../fields/FieldSymbols';
 import { InkField } from '../../fields/InkField';
 import { ScriptField } from '../../fields/ScriptField';
@@ -650,7 +650,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora
         this._forceRender;
         const { b, r, x, y } = this.Bounds;
         const seldocview = DocumentView.Selected().lastElement();
-        if (SnappingManager.IsDragging || r - x < 1 || x === Number.MAX_VALUE || !seldocview || SnappingManager.HideDecorations || isNaN(r) || isNaN(b) || isNaN(x) || isNaN(y)) {
+        if (SnappingManager.IsDragging || r - x < 1 || x === Number.MAX_VALUE || !seldocview || seldocview?.Document[Animation] || SnappingManager.HideDecorations || isNaN(r) || isNaN(b) || isNaN(x) || isNaN(y)) {
             setTimeout(
                 action(() => {
                     this._editingTitle = false;
diff --git a/src/client/views/collections/CollectionCardDeckView.scss b/src/client/views/collections/CollectionCardDeckView.scss
index 0637cd4e9..5283601bf 100644
--- a/src/client/views/collections/CollectionCardDeckView.scss
+++ b/src/client/views/collections/CollectionCardDeckView.scss
@@ -7,23 +7,31 @@
     background-color: white;
     overflow: hidden;
     display: flex;
+    .collectionCardView-inner {
+        display: flex;
+        transform-origin: top left;
+        align-items: center;
+    }
 
     button {
         border-radius: 50%;
     }
 }
 
-.card-wrapper {
-    display: grid;
-    grid-template-columns: repeat(10, 1fr);
+.collectionCardView-flashcardUI {
+    top: 0;
+    position: absolute;
+    width: 100%;
+    height: 100%;
     transform-origin: top left;
+}
 
-    position: absolute;
+.collectionCardView-cardwrapper {
+    display: grid;
+    grid-template-columns: repeat(10, 1fr);
+    transform-origin: left 50%;
     align-items: center;
-    justify-items: center;
-    justify-content: center;
-
-    transition: transform 0.3s cubic-bezier(0.455, 0.03, 0.515, 0.955);
+    z-index: 0; // so that setting z-index of active card doesn't make it land on top of things outside of the card-wrapper
 }
 
 .no-card-span {
diff --git a/src/client/views/collections/CollectionCardDeckView.tsx b/src/client/views/collections/CollectionCardDeckView.tsx
index 14ce9d2af..5faabacf4 100644
--- a/src/client/views/collections/CollectionCardDeckView.tsx
+++ b/src/client/views/collections/CollectionCardDeckView.tsx
@@ -5,7 +5,7 @@ import * as React from 'react';
 import { ClientUtils, DashColor, imageUrlToBase64, returnFalse, returnNever, returnZero } from '../../../ClientUtils';
 import { emptyFunction } from '../../../Utils';
 import { Doc } from '../../../fields/Doc';
-import { DocData } from '../../../fields/DocSymbols';
+import { Animation, DocData } from '../../../fields/DocSymbols';
 import { Id } from '../../../fields/FieldSymbols';
 import { List } from '../../../fields/List';
 import { ScriptField } from '../../../fields/ScriptField';
@@ -96,7 +96,7 @@ export class CollectionCardView extends CollectionSubView() {
                 if (isVis) {
                     this.openChatPopup();
                 } else {
-                    this.Document.cardSort = this.cardSort === cardSortings.Chat ? '' : this.Document.cardSort;
+                    this.Document.card_sort = this.cardSort === cardSortings.Chat ? '' : this.Document.card_sort;
                 }
             }
         );
@@ -119,7 +119,25 @@ export class CollectionCardView extends CollectionSubView() {
     }
 
     @computed get cardSort() {
-        return StrCast(this.Document.cardSort) as cardSortings;
+        return StrCast(this.Document.card_sort) as cardSortings;
+    }
+    /**
+     * Number of rows of cards to be rendered
+     */
+    @computed get numRows() {
+        return Math.ceil(this.sortedDocs.length / this._maxRowCount);
+    }
+    /**
+     * Circle arc size, in radians, to layout cards
+     */
+    @computed get archAngle() {
+        return NumCast(this.layoutDoc.card_arch, 90) * (Math.PI / 180) * (this.childCards.length < this._maxRowCount ? this.childCards.length / this._maxRowCount : 1);
+    }
+    /**
+     * Spacing card rows as a percent of Doc size.   100 means rows spread out to fill 100% of the Doc vertically.  Default is 60%
+     */
+    @computed get cardSpacing() {
+        return NumCast(this.layoutDoc.card_spacing, 60);
     }
 
     /**
@@ -137,6 +155,10 @@ export class CollectionCardView extends CollectionSubView() {
         return (this.childPanelWidth() * length) / this._props.PanelWidth();
     }
 
+    @computed get nativeScaling() {
+        return this._props.NativeDimScaling?.() || 1;
+    }
+
     /**
      * When in quiz mode, randomly selects a document
      */
@@ -145,70 +167,17 @@ export class CollectionCardView extends CollectionSubView() {
         this._curDoc = this.childDocs[randomIndex];
     });
 
-    /**
-     * Number of rows of cards to be rendered
-     */
-    @computed get numRows() {
-        return Math.ceil(this.sortedDocs.length / this._maxRowCount);
-    }
-
-    @action
-    setHoveredNodeIndex = (index: number) => {
+    setHoveredNodeIndex = action((index: number) => {
         if (!SnappingManager.IsDragging) this._hoveredNodeIndex = index;
-    };
+    });
 
     isSelected = (doc: Doc) => this._docRefs.get(doc)?.IsSelected;
-    childPanelWidth = () => NumCast(this.layoutDoc.childPanelWidth, this._props.PanelWidth() / 2);
+    childPanelWidth = () => NumCast(this.layoutDoc.childPanelWidth, Math.max(150, this._props.PanelWidth() / (this.childCards.length > this._maxRowCount ? this._maxRowCount : this.childCards.length) / this.nativeScaling));
     childPanelHeight = () => this._props.PanelHeight() * this.fitContentScale;
     onChildDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick);
     isContentActive = () => this._props.isSelected() || this._props.isContentActive() || this.isAnyChildContentActive();
     isAnyChildContentActive = this._props.isAnyChildContentActive;
 
-    /**
-     * Returns the degree to rotate a card dependind on the amount of cards in their row and their index in said row
-     * @param amCards
-     * @param index
-     * @returns
-     */
-    rotate = (amCards: number, index: number) => {
-        if (amCards == 1) return 0;
-
-        const possRotate = -30 + index * (30 / ((amCards - (amCards % 2)) / 2));
-        if (amCards % 2 === 0) {
-            if (possRotate === 0) {
-                return possRotate + Math.abs(-30 + (index - 1) * (30 / (amCards / 2)));
-            }
-            if (index > (amCards + 1) / 2) {
-                const stepMag = Math.abs(-30 + (amCards / 2 - 1) * (30 / ((amCards - (amCards % 2)) / 2)));
-                return possRotate + stepMag;
-            }
-        }
-
-        return possRotate;
-    };
-    /**
-     * Returns the degree to which a card should be translated in the y direction for the arch effect
-     */
-    translateY = (amCards: number, index: number, realIndex: number) => {
-        const evenOdd = amCards % 2;
-        const apex = (amCards - evenOdd) / 2;
-        const Magnitude = this.childPanelWidth() / 2; // 400
-        const stepMag = Magnitude / 2 / ((amCards - evenOdd) / 2) + Math.abs((apex - index) * 25);
-
-        let rowOffset = 0;
-        if (realIndex > this._maxRowCount - 1) {
-            rowOffset = Magnitude * ((realIndex - (realIndex % this._maxRowCount)) / this._maxRowCount);
-        }
-        if (evenOdd === 1 || index < apex - 1) {
-            return Math.abs(stepMag * (apex - index)) - rowOffset;
-        }
-        if (index === apex || index === apex - 1) {
-            return 0 - rowOffset;
-        }
-
-        return Math.abs(stepMag * (apex - index - 1)) - rowOffset;
-    };
-
     /**
      * When dragging a card, determines the index the card should be set to if dropped
      * @param mouseX mouse's x location
@@ -216,28 +185,27 @@ export class CollectionCardView extends CollectionSubView() {
      * @returns the card's new index
      */
     findCardDropIndex = (mouseX: number, mouseY: number) => {
-        const amCardsTotal = this.sortedDocs.length;
+        const cardCount = this.sortedDocs.length;
         let index = 0;
-        const cardWidth = amCardsTotal < this._maxRowCount ? this._props.PanelWidth() / amCardsTotal : this._props.PanelWidth() / this._maxRowCount;
+        const cardWidth = cardCount < this._maxRowCount ? this._props.PanelWidth() / cardCount : this._props.PanelWidth() / this._maxRowCount;
 
         // Calculate the adjusted X position accounting for the initial offset
         let adjustedX = mouseX;
 
-        const amRows = Math.ceil(amCardsTotal / this._maxRowCount);
-        const rowHeight = this._props.PanelHeight() / amRows;
+        const rowHeight = this._props.PanelHeight() / this.numRows;
         const currRow = Math.floor((mouseY - 100) / rowHeight); //rows start at 0
 
         if (adjustedX < 0) {
             return 0; // Before the first column
         }
 
-        if (amCardsTotal < this._maxRowCount) {
+        if (cardCount < this._maxRowCount) {
             index = Math.floor(adjustedX / cardWidth);
-        } else if (currRow != amRows - 1) {
+        } else if (currRow != this.numRows - 1) {
             index = Math.floor(adjustedX / cardWidth) + currRow * this._maxRowCount;
         } else {
-            const rowAmCards = amCardsTotal - currRow * this._maxRowCount;
-            const offset = ((this._maxRowCount - rowAmCards) / 2) * cardWidth;
+            const cardsInRow = cardCount - currRow * this._maxRowCount;
+            const offset = ((this._maxRowCount - cardsInRow) / 2) * cardWidth;
             adjustedX = mouseX - offset;
 
             index = Math.floor(adjustedX / cardWidth) + currRow * this._maxRowCount;
@@ -246,11 +214,14 @@ export class CollectionCardView extends CollectionSubView() {
     };
 
     /**
-     * Checks to see if a card is being dragged and calls the appropriate methods if so
+     * if pointer moves over cardDeck while dragging a Doc that is in the Deck or that can be dropped in the deck,
+     * then this sets the card index where the dragged card would be added.
      */
     @action
     onPointerMove = (x: number, y: number) => {
-        this._docDraggedIndex = DragManager.docsBeingDragged.length ? this.findCardDropIndex(x, y) : -1;
+        if (DragManager.docsBeingDragged.some(doc => this.sortedDocs.includes(doc)) || SnappingManager.CanEmbed) {
+            this._docDraggedIndex = this.findCardDropIndex(x, y);
+        }
     };
 
     /**
@@ -269,7 +240,7 @@ export class CollectionCardView extends CollectionSubView() {
                     const sorted = this.sortedDocs;
                     const originalIndex = sorted.findIndex(doc => doc === draggedDoc);
 
-                    this.Document.cardSort = '';
+                    this.Document.card_sort = '';
                     originalIndex !== -1 && sorted.splice(originalIndex, 1);
                     sorted.splice(dragIndex, 0, draggedDoc);
                     if (de.complete.docDragData.removeDocument?.(draggedDoc)) {
@@ -285,15 +256,6 @@ export class CollectionCardView extends CollectionSubView() {
         ''
     );
 
-    @computed get sortedDocs() {
-        return this.sort(
-            this.childCards.map(card => card.layout),
-            this.cardSort,
-            BoolCast(this.Document.cardSort_isDesc),
-            this._docDraggedIndex
-        );
-    }
-
     /**
      * Used to determine how to sort cards based on tags. The leftmost tags are given lower values while cards to the right are
      * given higher values. Decimals are used to determine placement for cards with multiple tags
@@ -341,6 +303,15 @@ export class CollectionCardView extends CollectionSubView() {
         return docs;
     };
 
+    @computed get sortedDocs() {
+        return this.sort(
+            this.childCards.map(card => card.layout),
+            this.cardSort,
+            BoolCast(this.Document.card_sort_isDesc),
+            this._docDraggedIndex
+        );
+    }
+
     isChildContentActive = computedFn(
         (doc: Doc) => () =>
             this._props.isContentActive?.() === false
@@ -359,74 +330,99 @@ export class CollectionCardView extends CollectionSubView() {
             Document={doc}
             NativeWidth={returnZero}
             NativeHeight={returnZero}
-            fitWidth={returnFalse}
-            onDoubleClickScript={this.onChildDoubleClick}
+            PanelWidth={this.childPanelWidth}
+            PanelHeight={this.childPanelHeight}
             renderDepth={this._props.renderDepth + 1}
             LayoutTemplate={this._props.childLayoutTemplate}
             LayoutTemplateString={this._props.childLayoutString}
             containerViewPath={this.childContainerViewPath}
             ScreenToLocalTransform={screenToLocalTransform} // makes sure the box wrapper thing is in the right spot
             isDocumentActive={this._props.childDocumentsActive?.() || this.Document._childDocumentsActive ? this._props.isDocumentActive : this.isContentActive}
-            PanelWidth={this.childPanelWidth}
-            PanelHeight={this.childPanelHeight}
+            isContentActive={this.isChildContentActive(doc)}
+            fitWidth={returnFalse}
             waitForDoubleClickToClick={returnNever}
             scriptContext={this}
+            onDoubleClickScript={this.onChildDoubleClick}
             onClickScript={this._curDoc === doc ? undefined : this._clickScript}
             dontCenter="y" // Don't center it vertically, because the grid it's in is already doing that and we don't want to do it twice.
             dragAction={(this.Document.childDragAction ?? this._props.childDragAction) as dropActionType}
             showTags={BoolCast(this.layoutDoc.showChildTags)}
             whenChildContentsActiveChanged={this._props.whenChildContentsActiveChanged}
-            isContentActive={this.isChildContentActive(doc)}
             dontHideOnDrag
         />
     );
 
     /**
      * Determines how many cards are in the row of a card at a specific index
-     * @param index
-     * @returns
+     * @param index numerical index of card in total list of all cards
+     * @returns number of cards in row that contains index
      */
-    overflowAmCardsCalc = (index: number) => {
-        if (this.sortedDocs.length < this._maxRowCount) {
-            return this.sortedDocs.length;
+    cardsInRowThatIncludesCardIndex = (index: number) => {
+        if (this.childCards.length < this._maxRowCount) {
+            return this.childCards.length;
         }
-        const totalCards = this.sortedDocs.length;
-        // if 9 or less
+        const totalCards = this.childCards.length;
         if (index < totalCards - (totalCards % this._maxRowCount)) {
             return this._maxRowCount;
         }
         return totalCards % this._maxRowCount;
     };
     /**
-     * Determines the index a card is in in a row
-     * @param realIndex
-     * @returns
+     * Determines the index a card is in in a row.  If the row is not full, then the cards
+     * are centered within the row (as if unrendered cards had been added to the start and end
+     * of the row) and the retuned index is the index the card in this virtual full row.
+     * @param index numerical index of card in total list of all cards
+     * @returns index of card in its row, normalized to a full size row
      */
-    overflowIndexCalc = (realIndex: number) => realIndex % this._maxRowCount;
+    centeredIndexOfCardInRow = (index: number) => {
+        const cardsInRow = this.cardsInRowThatIncludesCardIndex(index);
+        const lineIndex = index % this._maxRowCount;
+        if (cardsInRow === this._maxRowCount) return lineIndex;
+        return lineIndex + (this._maxRowCount - cardsInRow) / 2;
+    };
     /**
-     * Translates the cards in the second rows and beyond over to the right
-     * @param realIndex
-     * @param calcIndex
-     * @param calcRowCards
-     * @returns
+     * Returns the rotation of a card in radians based on its horizontal location (and thus m apping to a circle arc).
+     * The amount of rotation is goverend by the Doc's card_arch field which specifies, in degrees, the range of a circle
+     * arc that cards should cover -- by default, -45 to 45 degrees.
+     * @param index numerical index of card in total list of all cards
+     * @returns angle of rotation in radians
+     */
+    rotate = (index: number) => {
+        const cardsInRow = this.cardsInRowThatIncludesCardIndex(index);
+        const centeredIndexInRow = (cardsInRow < this._maxRowCount ? index + (this._maxRowCount - cardsInRow) / 2 : index) % this._maxRowCount;
+        const rowIndexMax = this._maxRowCount - 1;
+        return ((this.archAngle / 2) * (centeredIndexInRow - rowIndexMax / 2)) / (rowIndexMax / 2);
+    };
+    /**
+     * Provides a vertical adjustment to a card's grid position so that it will lie along an arch.
+     * @param index numerical index of card in total list of all cards
+     */
+    translateY = (index: number) => {
+        const Magnitude = ((this._props.PanelHeight() * this.fitContentScale) / 2) * Math.sqrt(((this.archAngle * (180 / Math.PI)) / 60) * 4);
+        return Magnitude * (1 - Math.sin(this.rotate(index) + Math.PI / 2) - (1 - Math.sin(this.archAngle / 2 + Math.PI / 2)) / 2);
+    };
+    /**
+     * When the card index is for a row (not the first row) that is not full, this returns a horizontal adjustment that centers the row
+     * @param index index of card from start of deck
+     * @param cardsInRow number of cards in the row containing the indexed card
+     * @returns horizontal pixel translation
      */
-    translateOverflowX = (realIndex: number, calcRowCards: number) => (realIndex < this._maxRowCount ? 0 : (this._maxRowCount - calcRowCards) * (this.childPanelWidth() / 2));
+    horizontalAdjustmentForPartialRows = (index: number, cardsInRow: number) => (index < this._maxRowCount ? 0 : (this._maxRowCount - cardsInRow) * (this.childPanelWidth() / 2));
 
     /**
-     * Determines how far to translate a card in the y direction depending on its index and if it's selected
+     * Adjusts the vertical placement of the card from its grid position so that it will either line on a
+     * circular arc if the card isn't active, or so that it will be centered otherwise.
      * @param isActive whether the card is focused for interaction
-     * @param realIndex index of card from start of deck
-     * @param amCards ??
-     * @param calcRowIndex index of card from start of row
-     * @returns Y translation of card
+     * @param index index of card from start of deck
+     * @returns vertical pixel translation
      */
-    calculateTranslateY = (isActive: boolean, realIndex: number, amCards: number, calcRowIndex: number) => {
-        const rowHeight = (this._props.PanelHeight() * this.fitContentScale) / this.numRows;
-        const rowIndex = Math.trunc(realIndex / this._maxRowCount);
+    adjustCardYtoFitArch = (isActive: boolean, index: number) => {
+        const rowHeight = this._props.PanelHeight() / this.numRows;
+        const rowIndex = Math.floor(index / this._maxRowCount);
         const rowToCenterShift = this.numRows / 2 - rowIndex;
-        if (isActive) return rowToCenterShift * rowHeight - rowHeight / 2;
-        if (amCards == 1) return 50 * this.fitContentScale;
-        return this.translateY(amCards, calcRowIndex, realIndex);
+        return isActive
+            ? (rowToCenterShift * rowHeight - rowHeight / 2) * ((this.cardSpacing * this.fitContentScale) / 100) //
+            : this.translateY(index);
     };
 
     /**
@@ -493,7 +489,7 @@ export class CollectionCardView extends CollectionSubView() {
         }
 
         if (questionType === '6') {
-            this.Document.cardSort = 'chat';
+            this.Document.card_sort = 'chat';
         }
 
         listItems.forEach((item, index) => {
@@ -549,7 +545,7 @@ export class CollectionCardView extends CollectionSubView() {
         await this.childPairStringListAndUpdateSortDesc();
     };
 
-    childScreenToLocal = computedFn((doc: Doc, index: number, calcRowIndex: number, isSelected: boolean, amCards: number) => () => {
+    childScreenToLocal = computedFn((doc: Doc, index: number, isSelected: boolean) => () => {
         // need to explicitly trigger an invalidation since we're reading everything from the Dom
         this._forceChildXf;
         this._props.ScreenToLocalTransform();
@@ -560,24 +556,40 @@ export class CollectionCardView extends CollectionSubView() {
 
         return new Transform(-translateX + (dref?.centeringX || 0) * scale, 
                              -translateY + (dref?.centeringY || 0) * scale, 1)
-                  .scale(1 / scale).rotate(!isSelected ? -this.rotate(amCards, calcRowIndex) : 0); // prettier-ignore
+                  .scale(1 / scale).rotate(!isSelected ? -this.rotate(this.centeredIndexOfCardInRow(index)) : 0); // prettier-ignore
     });
 
+    /**
+     * Releases the currently focused Doc by deselecting it and returning it to its location on the arch, and selecting the
+     * cardDeck itself.
+     * This will also force the Doc to recompute its layout transform when the animation completes.
+     * In addition, this sets an animating flag on the Doc so that it will receive no poiner events when animating, such as hover
+     * events that would trigger a flashcard to flip.
+     * @param doc doc that will be animated away from center focus
+     */
+    releaseCurDoc = action(() => {
+        const selDoc = this._curDoc;
+        this._curDoc = undefined;
+        const cardDocView = DocumentView.getDocumentView(selDoc, this.DocumentView?.());
+        if (cardDocView && selDoc) {
+            DocumentView.DeselectView(cardDocView);
+            this._props.select(false);
+            selDoc[Animation] = selDoc; // turns off pointer events & doc decorations while animating - useful for flashcards that reveal back on hover
+            setTimeout(action(() => {
+                selDoc[Animation] = undefined;
+                this._forceChildXf++;
+            }), 350); // prettier-ignore
+        }
+    });
+
+    /**
+     * turns off the _dropped flag at the end of a drag/drop, or releases the focused Doc if a different Doc is clicked
+     */
     cardPointerUp = action((doc: Doc) => {
-        // if a card doc has just moved, or a card is selected and in front, then ignore this event
         if (this._curDoc === doc || this._dropped) {
             this._dropped = false;
         } else {
-            // otherwise, turn off documentDecorations becase we're in a selection transition and want to avoid artifacts.
-            // Turn them back on when the animation has completed and the render and backend structures are in synch
-            SnappingManager.SetHideDecorations(true);
-            setTimeout(
-                action(() => {
-                    SnappingManager.SetHideDecorations(false);
-                    this._forceChildXf++;
-                }),
-                1000
-            );
+            this.releaseCurDoc(); // NOTE: the onClick script for the card will select the new card (ie, 'doc')
         }
     });
 
@@ -585,25 +597,17 @@ export class CollectionCardView extends CollectionSubView() {
      * Actually renders all the cards
      */
     @computed get renderCards() {
-        if (!this.childCards.length) {
-            return null;
-        }
-
+        console.log('CHILDPw = ' + this.childPanelWidth());
         // Map sorted documents to their rendered components
         return this.sortedDocs.map((doc, index) => {
-            const calcRowIndex = this.overflowIndexCalc(index);
-            const amCards = this.overflowAmCardsCalc(index);
+            const cardsInRow = this.cardsInRowThatIncludesCardIndex(index);
+
+            const childScreenToLocal = this.childScreenToLocal(doc, index, doc === this._curDoc);
 
-            const childScreenToLocal = this.childScreenToLocal(doc, index, calcRowIndex, doc === this._curDoc, amCards);
+            const translateToCenterIfActive = () => (doc === this._curDoc ? (cardsInRow / 2 - (index % this._maxRowCount)) * 100 - 50 : 0);
 
-            const translateIfSelected = () => {
-                const indexInRow = index % this._maxRowCount;
-                const rowIndex = Math.trunc(index / this._maxRowCount);
-                const rowCenterIndex = Math.min(this._maxRowCount, this.sortedDocs.length - rowIndex * this._maxRowCount) / 2;
-                return (rowCenterIndex - indexInRow) * 100 - 50;
-            };
             const aspect = NumCast(doc.height) / NumCast(doc.width, 1);
-            const vscale = Math.max(1,Math.min((this._props.PanelHeight() * 0.95 * this.fitContentScale) / (aspect * this.childPanelWidth()), 
+            const vscale = Math.max(1,Math.min((this._props.PanelHeight() * 0.95 * this.fitContentScale * this.nativeScaling) / (aspect * this.childPanelWidth()), 
                                     (this._props.PanelHeight() - 80) / (aspect * (this._props.PanelWidth() / 10)))); // prettier-ignore
             const hscale = Math.min(this.sortedDocs.length, this._maxRowCount) / 2; // bcz: hack - the grid is divided evenly into maxRowCount cells, so the max scaling would be maxRowCount -- but making things that wide is ugly, so cap it off at half the window size
             return (
@@ -614,9 +618,9 @@ export class CollectionCardView extends CollectionSubView() {
                     style={{
                         width: this.childPanelWidth(),
                         height: 'max-content',
-                        transform: `translateY(${this.calculateTranslateY(doc === this._curDoc, index, amCards, calcRowIndex)}px)
-                                    translateX(calc(${doc === this._curDoc ? translateIfSelected() : 0}% + ${this.translateOverflowX(index, amCards)}px))
-                                    rotate(${doc !== this._curDoc ? this.rotate(amCards, calcRowIndex) : 0}deg)
+                        transform: `translateY(${this.adjustCardYtoFitArch(doc === this._curDoc, index)}px)
+                                    translateX(calc(${translateToCenterIfActive()}% + ${this.horizontalAdjustmentForPartialRows(index, cardsInRow)}px))
+                                    rotate(${doc !== this._curDoc ? this.rotate(index) : 0}rad)
                                     scale(${doc === this._curDoc ? `${Math.min(hscale, vscale) * 100}%` : this._hoveredNodeIndex === index ? 1.1 : 1})`,
                     }} // prettier-ignore
                     onPointerEnter={() => this.setHoveredNodeIndex(index)}
@@ -636,26 +640,19 @@ export class CollectionCardView extends CollectionSubView() {
         ScreenToLocalTransform: this.contentScreenToLocalXf,
     });
     answered = action(() => {
-        this._curDoc = this.curDoc ? this.filteredChildDocs()[(this.filteredChildDocs().findIndex(this.curDoc) + 1) % (this.filteredChildDocs().length || 1)] : undefined;
+        this._curDoc = this.curDoc ? this.filteredChildDocs()[(this.filteredChildDocs().findIndex(d => d === this.curDoc()) + 1) % (this.filteredChildDocs().length || 1)] : undefined;
     });
     curDoc = () => this._curDoc;
 
     render() {
-        const isEmpty = this.childCards.length === 0;
+        const fitContentScale = this.childCards.length === 0 ? 1 : this.fitContentScale;
         return (
             <div
                 className="collectionCardView-outer"
                 ref={(ele: HTMLDivElement | null) => this.createDashEventsTarget(ele)}
-                onPointerDown={action(() => {
-                    this._curDoc = undefined;
-                    SnappingManager.SetHideDecorations(true);
-                    setTimeout(
-                        action(() => {
-                            SnappingManager.SetHideDecorations(false);
-                            this._forceChildXf++;
-                        }),
-                        1000
-                    );
+                onPointerDown={action(e => {
+                    if (e.button === 2 || e.ctrlKey) return;
+                    this.releaseCurDoc();
                 })}
                 onPointerLeave={action(() => (this._docDraggedIndex = -1))}
                 onPointerMove={e => this.onPointerMove(...this._props.ScreenToLocalTransform().transformPoint(e.clientX, e.clientY))}
@@ -665,16 +662,31 @@ export class CollectionCardView extends CollectionSubView() {
                     color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string,
                 }}>
                 <div
-                    className="card-wrapper"
+                    className="collectionCardView-inner"
                     style={{
-                        ...(!isEmpty && { transform: `scale(${1 / this.fitContentScale})` }),
-                        ...{ height: `${100 * (isEmpty ? 1 : this.fitContentScale)}%` },
-                        ...{ width: `${100 * (isEmpty ? 1 : this.fitContentScale)}%` },
-                        gridAutoRows: `${100 / this.numRows}%`,
+                        transform: `scale(${1 / fitContentScale})`,
+                        height: `${100 * fitContentScale}%`,
+                        width: `${100 * fitContentScale}%`,
                     }}>
-                    {this.renderCards}
+                    <div
+                        className="collectionCardView-cardwrapper"
+                        style={{
+                            gridAutoRows: `${100 / this.numRows}%`,
+                            height: `${this.cardSpacing}%`,
+                        }}>
+                        {this.renderCards}
+                    </div>
+                    <div
+                        className="collectionCardView-flashcardUI"
+                        style={{
+                            pointerEvents: this.childCards.length === 0 ? undefined : 'none',
+                            height: `${100 / this.nativeScaling / fitContentScale}%`,
+                            width: `${100 / this.nativeScaling / fitContentScale}%`,
+                            transform: `scale(${this.nativeScaling * fitContentScale})`,
+                        }}>
+                        {this.flashCardUI(this.curDoc, this.docViewProps, this.answered)}
+                    </div>
                 </div>
-                {this.flashCardUI(this.curDoc, this.docViewProps, this.answered)}
             </div>
         );
     }
diff --git a/src/client/views/collections/CollectionCarousel3DView.tsx b/src/client/views/collections/CollectionCarousel3DView.tsx
index e9ace733e..a71cc43ba 100644
--- a/src/client/views/collections/CollectionCarousel3DView.tsx
+++ b/src/client/views/collections/CollectionCarousel3DView.tsx
@@ -205,7 +205,7 @@ export class CollectionCarousel3DView extends CollectionSubView() {
 
     onPassiveWheel = (e: WheelEvent) => e.stopPropagation();
     curDoc = () => this.carouselItems[NumCast(this.layoutDoc._carousel_index)]?.layout;
-    answered = (correct: boolean) => (!correct || !this.curDoc()) && this.changeSlide(1);
+    answered = (correct: boolean) => (!correct || !this.curDoc() || NumCast(this.layoutDoc._carousel_index) === this.carouselItems.length - 1) && this.changeSlide(1);
     docViewProps = () => ({
         ...this._props, //
         isDocumentActive: this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this._props.isContentActive,
diff --git a/src/client/views/collections/CollectionCarouselView.tsx b/src/client/views/collections/CollectionCarouselView.tsx
index ff587b199..1f2bc908f 100644
--- a/src/client/views/collections/CollectionCarouselView.tsx
+++ b/src/client/views/collections/CollectionCarouselView.tsx
@@ -203,7 +203,7 @@ export class CollectionCarouselView extends CollectionSubView() {
         isContentActive: this.isChildContentActive,
         ScreenToLocalTransform: this.contentScreenToLocalXf,
     });
-    answered = () => this.advance();
+    answered = (correct: boolean) => (!correct || !this.curDoc() || NumCast(this.layoutDoc._carousel_index) === this.carouselItems.length - 1) && this.advance();
 
     render() {
         return (
diff --git a/src/client/views/collections/FlashcardPracticeUI.scss b/src/client/views/collections/FlashcardPracticeUI.scss
index 4ed27793d..210c6798f 100644
--- a/src/client/views/collections/FlashcardPracticeUI.scss
+++ b/src/client/views/collections/FlashcardPracticeUI.scss
@@ -1,3 +1,9 @@
+.FlashcardPracticeUI {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+}
 .FlashcardPracticeUI-remove,
 .FlashcardPracticeUI-check {
     position: absolute;
diff --git a/src/client/views/collections/FlashcardPracticeUI.tsx b/src/client/views/collections/FlashcardPracticeUI.tsx
index 79eb7f107..ec892ee3d 100644
--- a/src/client/views/collections/FlashcardPracticeUI.tsx
+++ b/src/client/views/collections/FlashcardPracticeUI.tsx
@@ -104,8 +104,8 @@ export class FlashcardPracticeUI extends ObservableReactComponent<PracticeUIProp
         const setPracticeVal = (e: React.MouseEvent, val: string) => {
             e.stopPropagation();
             const curDoc = this._props.curDoc();
-            curDoc && (curDoc[this.practiceField] = val);
             this._props.advance?.(val === practiceVal.CORRECT);
+            curDoc && (curDoc[this.practiceField] = val);
         };
 
         return this.practiceMode == practiceMode.PRACTICE && this._props.curDoc() ? (
@@ -182,7 +182,7 @@ export class FlashcardPracticeUI extends ObservableReactComponent<PracticeUIProp
     tryFilterOut = (doc: Doc) => (this.practiceMode && BoolCast(doc?._layout_isFlashcard) && doc[this.practiceField] === practiceVal.CORRECT ? true : false); // show only cards that aren't marked as correct
     render() {
         return (
-            <>
+            <div className="FlashcardPracticeUI">
                 {this.emptyMessage}
                 {this.practiceButtons}
                 {this._props.layoutDoc._chromeHidden ? null : (
@@ -208,7 +208,7 @@ export class FlashcardPracticeUI extends ObservableReactComponent<PracticeUIProp
                         {this.practiceModesMenu}
                     </div>
                 )}
-            </>
+            </div>
         );
     }
 }
diff --git a/src/client/views/global/globalScripts.ts b/src/client/views/global/globalScripts.ts
index 903e04ad7..954c79f7d 100644
--- a/src/client/views/global/globalScripts.ts
+++ b/src/client/views/global/globalScripts.ts
@@ -189,38 +189,38 @@ ScriptingGlobals.add(function showFreeform(
             setDoc: (doc: Doc, dv: DocumentView) => { Doc.UserDoc().defaultToFlashcards = !Doc.UserDoc().defaultToFlashcards}, // prettier-ignore
         }],
         ['time', {
-            checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "time",
-            setDoc: (doc: Doc, dv: DocumentView) => { doc.cardSort === "time" ? doc.cardSort = '' : doc.cardSort = 'time'}, // prettier-ignore
+            checkResult: (doc: Doc) => StrCast(doc?.card_sort) === "time",
+            setDoc: (doc: Doc, dv: DocumentView) => { doc.card_sort === "time" ? doc.card_sort = '' : doc.card_sort = 'time'}, // prettier-ignore
         }],
         ['docType', {
-            checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "type",
-            setDoc: (doc: Doc, dv: DocumentView) => { doc.cardSort === "type" ? doc.cardSort = '' : doc.cardSort = 'type'}, // prettier-ignore
+            checkResult: (doc: Doc) => StrCast(doc?.card_sort) === "type",
+            setDoc: (doc: Doc, dv: DocumentView) => { doc.card_sort === "type" ? doc.card_sort = '' : doc.card_sort = 'type'}, // prettier-ignore
         }],
         ['color', {
-            checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "color",
-            setDoc: (doc: Doc, dv: DocumentView) => { doc.cardSort === "color" ? doc.cardSort = '' : doc.cardSort = 'color'}, // prettier-ignore
+            checkResult: (doc: Doc) => StrCast(doc?.card_sort) === "color",
+            setDoc: (doc: Doc, dv: DocumentView) => { doc.card_sort === "color" ? doc.card_sort = '' : doc.card_sort = 'color'}, // prettier-ignore
         }],
         ['tag', {
-            checkResult: (doc: Doc) => StrCast(doc?.cardSort) === "tag",
-            setDoc: (doc: Doc, dv: DocumentView) => { doc.cardSort === "tag" ? doc.cardSort = '' : doc.cardSort = 'tag'}, // prettier-ignore
+            checkResult: (doc: Doc) => StrCast(doc?.card_sort) === "tag",
+            setDoc: (doc: Doc, dv: DocumentView) => { doc.card_sort === "tag" ? doc.card_sort = '' : doc.card_sort = 'tag'}, // prettier-ignore
         }],
         ['up', {
-            checkResult: (doc: Doc) => BoolCast(!doc?.cardSort_isDesc),
+            checkResult: (doc: Doc) => BoolCast(!doc?.card_sort_isDesc),
             setDoc: (doc: Doc, dv: DocumentView) => {
-                doc.cardSort_isDesc = false;
+                doc.card_sort_isDesc = false;
             },
         }],
         ['down', {
-            checkResult: (doc: Doc) => BoolCast(doc?.cardSort_isDesc),
+            checkResult: (doc: Doc) => BoolCast(doc?.card_sort_isDesc),
             setDoc: (doc: Doc, dv: DocumentView) => {
-                doc.cardSort_isDesc = true;
+                doc.card_sort_isDesc = true;
             },
         }],
         ['toggle-chat', {
             checkResult: (doc: Doc) => GPTPopup.Instance.visible,
             setDoc: (doc: Doc, dv: DocumentView) => {
                 if (GPTPopup.Instance.visible){
-                    doc.cardSort = ''
+                    doc.card_sort = ''
                     GPTPopup.Instance.setVisible(false);
    
                 } else {
diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx
index 38ce5f2f7..3c126ea4a 100644
--- a/src/client/views/nodes/ComparisonBox.tsx
+++ b/src/client/views/nodes/ComparisonBox.tsx
@@ -8,7 +8,7 @@ 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 { Animation, DocData } from '../../../fields/DocSymbols';
 import { RichTextField } from '../../../fields/RichTextField';
 import { BoolCast, DocCast, NumCast, RTFCast, StrCast, toList } from '../../../fields/Types';
 import { nullAudio } from '../../../fields/URLField';
@@ -18,7 +18,6 @@ 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';
@@ -235,7 +234,7 @@ 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.revealOpHover || !this._props.isSelected() ? null : this.overlayAlternateIcon}
                 {!this._props.isSelected() || this._renderSide === this.frontKey ? null : (
@@ -666,20 +665,20 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             <>
                 <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 === this.frontKey ? this.moveDocFront : this.moveDocBack}
-                    removeDocument={whichSlot === this.frontKey ? this.remDocFront : this.remDocBack}
                     NativeWidth={returnZero}
                     NativeHeight={returnZero}
+                    renderDepth={this.props.renderDepth + 1}
+                    LayoutTemplateString={layoutString}
+                    containerViewPath={this._props.docViewPath}
                     ScreenToLocalTransform={this.contentScreenToLocalXf}
-                    isContentActive={this.childActiveFunc}
                     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}
@@ -795,7 +794,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             [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}>
+            <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">
diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx
index 9aa000ba7..aab8a183a 100644
--- a/src/client/views/nodes/DocumentContentsView.tsx
+++ b/src/client/views/nodes/DocumentContentsView.tsx
@@ -1,4 +1,3 @@
-/* eslint-disable react/require-default-props */
 import { computed, makeObservable } from 'mobx';
 import { observer } from 'mobx-react';
 import * as React from 'react';
-- 
cgit v1.2.3-70-g09d2

