From e30da29f28f6b3ce71928c6c910e2d1a408cd560 Mon Sep 17 00:00:00 2001
From: geireann <geireann.lindfield@gmail.com>
Date: Wed, 22 Feb 2023 19:04:29 -0500
Subject: added simple AI image generation proof of concept

---
 .../views/nodes/formattedText/FormattedTextBox.tsx | 46 ++++++++++++++++++++++
 1 file changed, 46 insertions(+)

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

diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 619c59f0e..652971298 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -63,6 +63,7 @@ import { schema } from './schema_rts';
 import { SummaryView } from './SummaryView';
 import applyDevTools = require('prosemirror-dev-tools');
 import React = require('react');
+import { Configuration, OpenAIApi } from 'openai';
 const translateGoogleApi = require('translate-google-api');
 
 export const GoogleRef = 'googleDocId';
@@ -838,12 +839,57 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
 
         const options = cm.findByDescription('Options...');
         const optionItems = options && 'subitems' in options ? options.subitems : [];
+        optionItems.push({ description: `Generate Dall-E Image`, event: () => this.generateImage(), icon: 'star' });
         optionItems.push({ description: !this.Document._singleLine ? 'Make Single Line' : 'Make Multi Line', event: () => (this.layoutDoc._singleLine = !this.layoutDoc._singleLine), icon: 'expand-arrows-alt' });
         optionItems.push({ description: `${this.Document._autoHeight ? 'Lock' : 'Auto'} Height`, event: () => (this.layoutDoc._autoHeight = !this.layoutDoc._autoHeight), icon: 'plus' });
         !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' });
         this._downX = this._downY = Number.NaN;
     };
 
+    generateImage = async () => {
+        console.log("Generate image from text: ", (this.dataDoc.text as RichTextField)?.Text);
+        const newDoc = Docs.Create.ImageDocument("https://media.tenor.com/ffyK3EANQyYAAAAd/loading-chemical.gif", {
+            x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10, 
+            y: NumCast(this.rootDoc.y),
+            _height: 200,
+            _width: 200
+        })
+        try {
+            const configuration = new Configuration({
+                apiKey: process.env.OPENAI_KEY,
+            });
+            const openai = new OpenAIApi(configuration);
+            const response = await openai.createImage({
+                prompt: (this.dataDoc.text as RichTextField)?.Text,
+                n: 1,
+                size: "1024x1024",
+            });
+            let image_url = response.data.data[0].url;
+            console.log(image_url);
+            if (image_url) {
+                // const newDoc = Docs.Create.ImageDocument();
+                if (DocListCast(Doc.MyOverlayDocs?.data).includes(this.rootDoc)) {
+                    newDoc.overlayX = this.rootDoc.x;
+                    newDoc.overlayY = NumCast(this.rootDoc.y) + NumCast(this.rootDoc._height);
+                    Doc.AddDocToList(Doc.MyOverlayDocs, undefined, newDoc);
+                } else {
+                    this.props.addDocument?.(newDoc);
+                }
+                setTimeout(() => {
+                    newDoc.data = image_url;
+                    newDoc._height = 200;
+                    newDoc._width = 200;
+                    DocUtils.MakeLink({doc: this.rootDoc}, {doc: newDoc}, "Dall-E");
+                }, 500)
+            }
+        } catch (err) {
+            console.log(err);
+            return '';
+        }
+        
+    }
+
+
     breakupDictation = () => {
         if (this._editorView && this._recording) {
             this.stopDictation(true);
-- 
cgit v1.2.3-70-g09d2


From fba9b8e68e19e65e9823c5538b7b8b25baeb4670 Mon Sep 17 00:00:00 2001
From: geireann <geireann.lindfield@gmail.com>
Date: Wed, 22 Feb 2023 19:24:41 -0500
Subject: fixes

---
 src/client/views/nodes/formattedText/FormattedTextBox.tsx | 13 ++++++-------
 1 file changed, 6 insertions(+), 7 deletions(-)

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

diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 652971298..683ccb9c4 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -848,12 +848,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
 
     generateImage = async () => {
         console.log("Generate image from text: ", (this.dataDoc.text as RichTextField)?.Text);
-        const newDoc = Docs.Create.ImageDocument("https://media.tenor.com/ffyK3EANQyYAAAAd/loading-chemical.gif", {
-            x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10, 
-            y: NumCast(this.rootDoc.y),
-            _height: 200,
-            _width: 200
-        })
         try {
             const configuration = new Configuration({
                 apiKey: process.env.OPENAI_KEY,
@@ -867,7 +861,12 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
             let image_url = response.data.data[0].url;
             console.log(image_url);
             if (image_url) {
-                // const newDoc = Docs.Create.ImageDocument();
+                const newDoc = Docs.Create.ImageDocument(image_url, {
+                    x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10, 
+                    y: NumCast(this.rootDoc.y),
+                    _height: 200,
+                    _width: 200
+                })
                 if (DocListCast(Doc.MyOverlayDocs?.data).includes(this.rootDoc)) {
                     newDoc.overlayX = this.rootDoc.x;
                     newDoc.overlayY = NumCast(this.rootDoc.y) + NumCast(this.rootDoc._height);
-- 
cgit v1.2.3-70-g09d2


From ed94cc8fb516455dbeea223d0fab25e4fb1c1d78 Mon Sep 17 00:00:00 2001
From: Sophie Zhang <sophie_zhang@brown.edu>
Date: Mon, 27 Feb 2023 16:56:02 -0500
Subject: feat: added summarization functionality to WebBox and cleaned up code

---
 src/client/apis/gpt/Summarization.ts               |  20 +++-
 src/client/views/MainView.tsx                      |   1 +
 src/client/views/nodes/PDFBox.tsx                  |   1 +
 src/client/views/nodes/WebBox.tsx                  |   6 ++
 .../views/nodes/formattedText/FormattedTextBox.tsx |   1 -
 src/client/views/pdf/AnchorMenu.tsx                |  39 ++++---
 src/client/views/pdf/GPTPopup.scss                 |  91 ++++++++++++++--
 src/client/views/pdf/GPTPopup.tsx                  | 115 +++++++++++++++++++--
 src/client/views/pdf/PDFViewer.tsx                 |  19 ++--
 9 files changed, 243 insertions(+), 50 deletions(-)

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

diff --git a/src/client/apis/gpt/Summarization.ts b/src/client/apis/gpt/Summarization.ts
index 931e0e48f..c0dcc0267 100644
--- a/src/client/apis/gpt/Summarization.ts
+++ b/src/client/apis/gpt/Summarization.ts
@@ -1,22 +1,32 @@
 import { Configuration, OpenAIApi } from 'openai';
 
+/**
+ * Summarizes the inputted text with OpenAI.
+ * 
+ * @param text Text to summarize
+ * @returns Summary of text
+ */
 const gptSummarize = async (text: string) => {
     text += '.';
+    const model = 'text-curie-001'; // Most advanced: text-davinci-003
+    const maxTokens = 200;
+    const temp = 0.8; 
+
     try {
         const configuration = new Configuration({
             apiKey: process.env.OPENAI_KEY,
         });
         const openai = new OpenAIApi(configuration);
         const response = await openai.createCompletion({
-            model: 'text-curie-001',
-            max_tokens: 256,
-            temperature: 0.7,
-            prompt: `Summarize this text in one sentence: ${text}`,
+            model: model,
+            max_tokens: maxTokens,
+            temperature: temp,
+            prompt: `Summarize this text: ${text}`,
         });
         return response.data.choices[0].text;
     } catch (err) {
         console.log(err);
-        return '';
+        return 'Error connecting with API.';
     }
 };
 
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index da64e7c12..d7603cf5a 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -245,6 +245,7 @@ export class MainView extends React.Component {
 
         library.add(
             ...[
+                fa.faExclamationCircle,
                 fa.faEdit,
                 fa.faTrash,
                 fa.faTrashAlt,
diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx
index b88ac113e..40c04c08f 100644
--- a/src/client/views/nodes/PDFBox.tsx
+++ b/src/client/views/nodes/PDFBox.tsx
@@ -485,6 +485,7 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps
                     }}>
                     <PDFViewer
                         {...this.props}
+                        sidebarAddDoc={this.sidebarAddDocument}
                         rootDoc={this.rootDoc}
                         layoutDoc={this.layoutDoc}
                         dataDoc={this.dataDoc}
diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx
index 996028ec8..43b3b0536 100644
--- a/src/client/views/nodes/WebBox.tsx
+++ b/src/client/views/nodes/WebBox.tsx
@@ -37,6 +37,7 @@ import './WebBox.scss';
 import React = require('react');
 import { DragManager } from '../../util/DragManager';
 import { gptSummarize } from '../../apis/gpt/Summarization';
+import { GPTPopup } from '../pdf/GPTPopup';
 const { CreateImage } = require('./WebBoxRenderer');
 const _global = (window /* browser */ || global) /* node */ as any;
 const htmlToText = require('html-to-text');
@@ -358,8 +359,12 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps
             const sel = this._iframe.contentWindow.getSelection();
             if (sel) {
                 this._selectionText = sel.toString();
+                AnchorMenu.Instance.setSelectedText(sel.toString());
                 this._textAnnotationCreator = () => this.createTextAnnotation(sel, !sel.isCollapsed ? sel.getRangeAt(0) : undefined);
                 AnchorMenu.Instance.jumpTo(e.clientX * scale + mainContBounds.translateX, e.clientY * scale + mainContBounds.translateY - NumCast(this.layoutDoc._scrollTop) * scale);
+                // Changing which document to add the annotation to (the currently selected WebBox)
+                GPTPopup.Instance.setSidebarId(`${this.props.fieldKey}-${this._urlHash}-sidebar`);
+                GPTPopup.Instance.addDoc = this.sidebarAddDocument;
             }
         }
     };
@@ -780,6 +785,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps
     }
 
     addDocumentWrapper = (doc: Doc | Doc[], annotationKey?: string) => {
+        console.log(annotationKey);
         (doc instanceof Doc ? [doc] : doc).forEach(doc => (doc.webUrl = this._url));
         return this.addDocument(doc, annotationKey);
     };
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 683ccb9c4..b9327db0d 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -888,7 +888,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
         
     }
 
-
     breakupDictation = () => {
         if (this._editorView && this._recording) {
             this.stopDictation(true);
diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx
index 6bcfbe4c2..8f9261614 100644
--- a/src/client/views/pdf/AnchorMenu.tsx
+++ b/src/client/views/pdf/AnchorMenu.tsx
@@ -45,7 +45,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
     @observable public Highlighting: boolean = false;
     @observable public Status: 'marquee' | 'annotation' | '' = '';
 
-    // GPT additions (flow 2)
+    // GPT additions
     @observable private summarizedText: string = '';
     @observable private loadingSummary: boolean = false;
     @observable private showGPTPopup: boolean = false;
@@ -63,7 +63,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
     };
 
     private selectedText: string = '';
-    setSelectedText = (txt: string) => {
+    public setSelectedText = (txt: string) => {
         this.selectedText = txt;
     };
 
@@ -71,9 +71,6 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
 
     public OnCrop: (e: PointerEvent) => void = unimplementedFunction;
     public OnClick: (e: PointerEvent) => void = unimplementedFunction;
-    public OnSummary: (e: PointerEvent) => Promise<void> = () => {
-        return new Promise(() => {});
-    };
     public OnAudio: (e: PointerEvent) => void = unimplementedFunction;
     public StartDrag: (e: PointerEvent, ele: HTMLElement) => void = unimplementedFunction;
     public StartCropDrag: (e: PointerEvent, ele: HTMLElement) => void = unimplementedFunction;
@@ -123,14 +120,27 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
         );
     }
 
-    getGPTSummary = (e: React.PointerEvent) => {
-        setupMoveUpEvents(this, e, returnFalse, returnFalse, e => this.OnSummary?.(e));
+    /**
+     * Returns  a mock summary simulating an API call.
+     * @returns A Promise that resolves into a string
+     */
+    mockSummarize = async (): Promise<string> => {
+        return new Promise((resolve, reject) => {
+            setTimeout(() => {
+                resolve('Mock summary. This is a summary of the highlighted text.');
+            }, 1000);
+        });
     };
 
+    /**
+     * Invokes the API with the selected text and stores it in the summarized text.
+     * @param e pointer down event
+     */
     invokeGPT = async (e: React.PointerEvent) => {
         this.setGPTPopupVis(true);
         this.setLoading(true);
         const res = await gptSummarize(this.selectedText);
+        // const res = await this.mockSummarize();
         if (res) {
             this.setSummarizedText(res);
         } else {
@@ -243,12 +253,15 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
                             <FontAwesomeIcon icon="comment-alt" size="lg" />
                         </button>
                     </Tooltip>
-                    <Tooltip key="gpt" title={<div className="dash-tooltip">Summarize with AI</div>}>
-                        <button className="antimodeMenu-button annotate" onPointerDown={this.invokeGPT} style={{ cursor: 'grab' }}>
-                            <FontAwesomeIcon icon="comment-dots" size="lg" />
-                        </button>
-                    </Tooltip>
-                    <GPTPopup key="gptpopup" visible={this.showGPTPopup} text={this.summarizedText} loadingSummary={this.loadingSummary} />
+                    {/* GPT Summarize icon only shows up when text is highlighted, not on marquee selection*/}
+                    {AnchorMenu.Instance.StartCropDrag === unimplementedFunction && (
+                        <Tooltip key="gpt" title={<div className="dash-tooltip">Summarize with AI</div>}>
+                            <button className="antimodeMenu-button annotate" onPointerDown={this.invokeGPT} style={{ cursor: 'grab' }}>
+                                <FontAwesomeIcon icon="comment-dots" size="lg" />
+                            </button>
+                        </Tooltip>
+                    )}
+                    <GPTPopup key="gptpopup" visible={this.showGPTPopup} text={this.summarizedText} loadingSummary={this.loadingSummary} callApi={this.invokeGPT} />
                     {AnchorMenu.Instance.OnAudio === unimplementedFunction ? null : (
                         <Tooltip key="annoaudiotate" title={<div className="dash-tooltip">Click to Record Annotation</div>}>
                             <button className="antimodeMenu-button annotate" onPointerDown={this.audioDown} style={{ cursor: 'grab' }}>
diff --git a/src/client/views/pdf/GPTPopup.scss b/src/client/views/pdf/GPTPopup.scss
index 9605cfd07..7b7d2e3f7 100644
--- a/src/client/views/pdf/GPTPopup.scss
+++ b/src/client/views/pdf/GPTPopup.scss
@@ -1,33 +1,104 @@
 $textgrey: #707070;
-$bordergrey: #d3d3d3;
+$lighttextgrey: #a3a3a3;
+$greyborder: #d3d3d3;
+$lightgrey: #ececec;
+$button: #5b97ff;
 
 .summary-box {
+    display: flex;
+    flex-direction: column;
     background-color: #ffffff;
     box-shadow: 0 2px 5px #7474748d;
     color: $textgrey;
     position: absolute;
     bottom: 40px;
-    width: 200px;
-    height: 200px;
+    width: 250px;
     border-radius: 15px;
-    padding: 20px;
-    overflow: auto;
+    padding: 15px;
+    padding-bottom: 0px;
 
     .summary-heading {
         display: flex;
         align-items: center;
-        border-bottom: 1px solid $bordergrey;
-        margin-bottom: 10px;
+        border-bottom: 1px solid $greyborder;
         padding-bottom: 5px;
 
         .summary-text {
             font-size: 12px;
             font-weight: 500;
-            letter-spacing: 1px;
-            margin: 0;
-            padding-right: 10px;
         }
     }
+
+    label {
+        color: $textgrey;
+        font-size: 12px;
+        font-weight: 400;
+        letter-spacing: 1px;
+        margin: 0;
+        padding-right: 5px;
+    }
+
+    a {
+        cursor: pointer;
+    }
+
+    .content-wrapper {
+        padding-top: 10px;
+        min-height: 50px;
+        max-height: 150px;
+        overflow-y: auto;
+    }
+
+    .btns-wrapper {
+        height: 50px;
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+
+        .summarizing {
+            display: flex;
+            align-items: center;
+        }
+    }
+
+    button {
+        font-size: 9px;
+        padding: 10px;
+        color: #ffffff;
+        background-color: $button;
+        border-radius: 5px;
+    }
+
+    .text-btn {
+        &:hover {
+            background-color: $button;
+        }
+    }
+
+    .btn-secondary {
+        font-size: 8px;
+        padding: 10px 5px;
+        background-color: $lightgrey;
+        color: $textgrey;
+        &:hover {
+            background-color: $lightgrey;
+        }
+    }
+
+    .icon-btn {
+        background-color: #ffffff;
+        padding: 10px;
+        border-radius: 50%;
+        color: $button;
+        border: 1px solid $button;
+    }
+
+    .ai-warning {
+        padding: 10px 0;
+        font-size: 10px;
+        color: $lighttextgrey;
+        border-top: 1px solid $greyborder;
+    }
 }
 
 // Typist CSS
diff --git a/src/client/views/pdf/GPTPopup.tsx b/src/client/views/pdf/GPTPopup.tsx
index 76f3c8187..ec4fa58dc 100644
--- a/src/client/views/pdf/GPTPopup.tsx
+++ b/src/client/views/pdf/GPTPopup.tsx
@@ -1,30 +1,129 @@
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { action, observable } from 'mobx';
 import { observer } from 'mobx-react';
 import React = require('react');
 import ReactLoading from 'react-loading';
 import Typist from 'react-typist';
+import { Doc } from '../../../fields/Doc';
+import { Docs } from '../../documents/Documents';
 import './GPTPopup.scss';
 
 interface GPTPopupProps {
     visible: boolean;
     text: string;
     loadingSummary: boolean;
+    callApi: (e: React.PointerEvent) => Promise<void>;
 }
 
 @observer
 export class GPTPopup extends React.Component<GPTPopupProps> {
+    static Instance: GPTPopup;
+
+    @observable
+    private summaryDone: boolean = false;
+    @observable
+    private sidebarId: string = '';
+    @action
+    public setSummaryDone = (done: boolean) => {
+        this.summaryDone = done;
+    };
+    @action
+    public setSidebarId = (id: string) => {
+        this.sidebarId = id;
+    };
+
+    public addDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean = () => false;
+    
+    /**
+     * Transfers the summarization text to a sidebar annotation text document.
+     */
+    private transferToText = () => {
+        const newDoc = Docs.Create.TextDocument(this.props.text.trim(), {
+            _width: 200,
+            _height: 50,
+            _fitWidth: true,
+            _autoHeight: true,
+        });
+        this.addDoc(newDoc, this.sidebarId);
+    };
+
+    constructor(props: GPTPopupProps) {
+        super(props);
+        GPTPopup.Instance = this;
+    }
+
+    componentDidUpdate = () => {
+        if (this.props.loadingSummary) {
+            this.setSummaryDone(false);
+        }
+    };
+
     render() {
         return (
-            <div className="summary-box" style={{ display: this.props.visible ? 'block' : 'none' }}>
+            <div className="summary-box" style={{ display: this.props.visible ? 'flex' : 'none' }}>
                 <div className="summary-heading">
                     <label className="summary-text">SUMMARY</label>
-                    {this.props.loadingSummary ? <ReactLoading type="spin" color="#bcbcbc" width={14} height={14} /> : <></>}
+                    {this.props.loadingSummary && <ReactLoading type="spin" color="#bcbcbc" width={14} height={14} />}
                 </div>
-                {!this.props.loadingSummary ? (
-                    <Typist key={this.props.text} avgTypingDelay={20} cursor={{ hideWhenDone: true }}>
-                        {this.props.text}
-                    </Typist>
-                ) : (
-                    <></>
+                <div className="content-wrapper">
+                    {!this.props.loadingSummary &&
+                        (!this.summaryDone ? (
+                            <Typist
+                                key={this.props.text}
+                                avgTypingDelay={15}
+                                cursor={{ hideWhenDone: true }}
+                                onTypingDone={action(() => {
+                                    setTimeout(
+                                        action(() => {
+                                            this.summaryDone = true;
+                                        }),
+                                        500
+                                    );
+                                })}>
+                                {this.props.text}
+                            </Typist>
+                        ) : (
+                            this.props.text
+                        ))}
+                </div>
+                {!this.props.loadingSummary && (
+                    <div className="btns-wrapper">
+                        {this.summaryDone ? (
+                            <>
+                                <button className="icon-btn" onPointerDown={e => this.props.callApi(e)}>
+                                    <FontAwesomeIcon icon="redo-alt" size="lg" />
+                                </button>
+                                <button
+                                    className="text-btn"
+                                    onClick={e => {
+                                        this.transferToText();
+                                    }}>
+                                    Transfer to Text
+                                </button>
+                            </>
+                        ) : (
+                            <div className="summarizing">
+                                <label>Summarizing</label>
+                                <ReactLoading type="bubbles" color="#bcbcbc" width={20} height={20} />
+                                <button
+                                    className="btn-secondary"
+                                    onClick={e => {
+                                        this.setSummaryDone(true);
+                                    }}>
+                                    Stop Animation
+                                </button>
+                            </div>
+                        )}
+                    </div>
+                )}
+                {this.summaryDone && (
+                    <div className="ai-warning">
+                        <FontAwesomeIcon icon="exclamation-circle" size="sm" style={{ paddingRight: '5px' }} />
+                        AI generated responses can contain inaccurate or misleading content.{' '}
+                        <a target="_blank" href="https://www.nytimes.com/2023/02/08/technology/ai-chatbots-disinformation.html">
+                            Learn More
+                        </a>
+                    </div>
                 )}
             </div>
         );
diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx
index 324f31f23..408ba07d1 100644
--- a/src/client/views/pdf/PDFViewer.tsx
+++ b/src/client/views/pdf/PDFViewer.tsx
@@ -25,6 +25,7 @@ import { Annotation } from './Annotation';
 import './PDFViewer.scss';
 import React = require('react');
 import { gptSummarize } from '../../apis/gpt/Summarization';
+import { GPTPopup } from './GPTPopup';
 const PDFJSViewer = require('pdfjs-dist/web/pdf_viewer');
 const pdfjsLib = require('pdfjs-dist');
 const _global = (window /* browser */ || global) /* node */ as any;
@@ -41,6 +42,7 @@ interface IViewerProps extends FieldViewProps {
     fieldKey: string;
     pdf: Pdfjs.PDFDocumentProxy;
     url: string;
+    sidebarAddDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean;
     loaded?: (nw: number, nh: number, np: number) => void;
     setPdfViewer: (view: PDFViewer) => void;
     anchorMenuClick?: () => undefined | ((anchor: Doc, summarize?: boolean) => void);
@@ -83,19 +85,6 @@ export class PDFViewer extends React.Component<IViewerProps> {
         return AnchorMenu.Instance?.GetAnchor;
     }
 
-    // Fields for using GPT to summarize selected text
-    private _summaryText: string = '';
-    setSummaryText = async () => {
-        try {
-            const summary = await gptSummarize(this.selectionText());
-            this._summaryText = `Summary: ${summary}`;
-        } catch (err) {
-            console.log(err);
-            this._summaryText = 'Failed to fetch summary.';
-        }
-    };
-    summaryText = () => this._summaryText;
-
     selectionText = () => this._selectionText;
     selectionContent = () => this._selectionContent;
 
@@ -435,6 +424,10 @@ export class PDFViewer extends React.Component<IViewerProps> {
             this.createTextAnnotation(sel, sel.getRangeAt(0));
             AnchorMenu.Instance.jumpTo(e.clientX, e.clientY);
         }
+
+        // Changing which document to add the annotation to (the currently selected PDF)
+        GPTPopup.Instance.setSidebarId('data-sidebar');
+        GPTPopup.Instance.addDoc = this.props.sidebarAddDoc;
     };
 
     @action
-- 
cgit v1.2.3-70-g09d2


From f189ce6f25b91fcd402b7e81ba8ed378e39e6142 Mon Sep 17 00:00:00 2001
From: Sophie Zhang <sophie_zhang@brown.edu>
Date: Wed, 1 Mar 2023 23:33:01 -0500
Subject: Added text completion

---
 src/client/apis/gpt/Summarization.ts               |  45 ++++---
 .../nodes/formattedText/FormattedTextBox.scss      |   4 +
 .../views/nodes/formattedText/FormattedTextBox.tsx | 129 ++++++++++++++++-----
 src/client/views/pdf/AnchorMenu.tsx                |  26 ++++-
 src/client/views/pdf/PDFViewer.tsx                 |   1 -
 5 files changed, 154 insertions(+), 51 deletions(-)

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

diff --git a/src/client/apis/gpt/Summarization.ts b/src/client/apis/gpt/Summarization.ts
index ba98ad591..b65736237 100644
--- a/src/client/apis/gpt/Summarization.ts
+++ b/src/client/apis/gpt/Summarization.ts
@@ -1,27 +1,41 @@
 import { Configuration, OpenAIApi } from 'openai';
 
+enum GPTCallType {
+    SUMMARY = 'summary',
+    COMPLETION = 'completion',
+}
+
+type GPTCallOpts = {
+    model: string;
+    maxTokens: number;
+    temp: number;
+    prompt: string;
+};
+
+const callTypeMap: { [type: string]: GPTCallOpts } = {
+    summary: { model: 'text-davinci-003', maxTokens: 100, temp: 0.5, prompt: 'Summarize this text: ' },
+    completion: { model: 'text-davinci-003', maxTokens: 100, temp: 0.5, prompt: '' },
+};
+
 /**
- * Summarizes the inputted text with OpenAI.
- * 
- * @param text Text to summarize
- * @returns Summary of text
+ * Calls the OpenAI API.
+ *
+ * @param inputText Text to process
+ * @returns AI Output
  */
-const gptSummarize = async (text: string) => {
-    text += '.';
-    const model = 'text-curie-001'; // Most advanced: text-davinci-003
-    const maxTokens = 200;
-    const temp = 0.5; 
-
+const gptAPICall = async (inputText: string, callType: GPTCallType) => {
+    if (callType === GPTCallType.SUMMARY) inputText += '.';
+    const opts: GPTCallOpts = callTypeMap[callType];
     try {
         const configuration = new Configuration({
             apiKey: process.env.OPENAI_KEY,
         });
         const openai = new OpenAIApi(configuration);
         const response = await openai.createCompletion({
-            model: model,
-            max_tokens: maxTokens,
-            temperature: temp,
-            prompt: `Summarize this text: ${text}`,
+            model: opts.model,
+            max_tokens: opts.maxTokens,
+            temperature: opts.temp,
+            prompt: `${opts.prompt}${inputText}`,
         });
         return response.data.choices[0].text;
     } catch (err) {
@@ -30,4 +44,5 @@ const gptSummarize = async (text: string) => {
     }
 };
 
-export { gptSummarize };
+
+export { gptAPICall, GPTCallType};
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss
index cbe0a465d..fd7fbb333 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.scss
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss
@@ -149,6 +149,10 @@ audiotag:hover {
     }
 }
 
+.gpt-typing-wrapper {
+    padding: 10px;
+}
+
 // .menuicon {
 //     display: inline-block;
 //     border-right: 1px solid rgba(0, 0, 0, 0.2);
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index b9327db0d..35c845deb 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -64,12 +64,21 @@ import { SummaryView } from './SummaryView';
 import applyDevTools = require('prosemirror-dev-tools');
 import React = require('react');
 import { Configuration, OpenAIApi } from 'openai';
+import { gptAPICall, GPTCallType } from '../../../apis/gpt/Summarization';
+import ReactLoading from 'react-loading';
+import Typist from 'react-typist';
 const translateGoogleApi = require('translate-google-api');
 
 export const GoogleRef = 'googleDocId';
 
 type PullHandler = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => void;
 
+enum GPTStatus {
+    LOADING,
+    TYPING,
+    NONE,
+}
+
 @observer
 export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
     public static LayoutString(fieldStr: string) {
@@ -172,6 +181,19 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
         };
     }
 
+    // State for GPT Typing animation
+    @observable
+    private gptStatus: GPTStatus = GPTStatus.NONE;
+    @observable
+    private gptPrompt: string = '';
+    @observable
+    private gptRes: string = '';
+
+    @action
+    private setGPTStatus = (status: GPTStatus) => {
+        this.gptStatus = status;
+    };
+
     public static PasteOnLoad: ClipboardEvent | undefined;
     public static SelectOnLoad = '';
     public static DontSelectInitialText = false; // whether initial text should be selected or not
@@ -840,14 +862,48 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
         const options = cm.findByDescription('Options...');
         const optionItems = options && 'subitems' in options ? options.subitems : [];
         optionItems.push({ description: `Generate Dall-E Image`, event: () => this.generateImage(), icon: 'star' });
+        optionItems.push({ description: `Ask GPT-3`, event: () => this.askGPT(), icon: 'lightbulb' });
         optionItems.push({ description: !this.Document._singleLine ? 'Make Single Line' : 'Make Multi Line', event: () => (this.layoutDoc._singleLine = !this.layoutDoc._singleLine), icon: 'expand-arrows-alt' });
         optionItems.push({ description: `${this.Document._autoHeight ? 'Lock' : 'Auto'} Height`, event: () => (this.layoutDoc._autoHeight = !this.layoutDoc._autoHeight), icon: 'plus' });
         !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' });
         this._downX = this._downY = Number.NaN;
     };
 
+    mockGPT = async (): Promise<string> => {
+        return new Promise((resolve, reject) => {
+            setTimeout(() => {
+                resolve('Mock GPT Call.');
+            }, 2000);
+        });
+    };
+
+    askGPT = action(async () => {
+        try {
+            this.gptPrompt = (this.dataDoc.text as RichTextField)?.Text;
+            this.setGPTStatus(GPTStatus.LOADING);
+            let res = await gptAPICall((this.dataDoc.text as RichTextField)?.Text, GPTCallType.COMPLETION);
+            // let res = await this.mockGPT();
+            if (res) {
+                this.gptRes = res;
+                this.setGPTStatus(GPTStatus.TYPING);
+            } else {
+                this.setGPTStatus(GPTStatus.NONE);
+            }
+        } catch (err) {
+            console.log(err);
+            this.dataDoc.text = (this.dataDoc.text as RichTextField)?.Text + 'Something went wrong';
+            this.setGPTStatus(GPTStatus.NONE);
+        }
+    });
+
+    setGPTText = action(() => {
+        this.dataDoc.text = (this.dataDoc.text as RichTextField)?.Text + this.gptRes;
+        this.gptRes = '';
+        this.setGPTStatus(GPTStatus.NONE);
+    });
+
     generateImage = async () => {
-        console.log("Generate image from text: ", (this.dataDoc.text as RichTextField)?.Text);
+        console.log('Generate image from text: ', (this.dataDoc.text as RichTextField)?.Text);
         try {
             const configuration = new Configuration({
                 apiKey: process.env.OPENAI_KEY,
@@ -856,17 +912,17 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
             const response = await openai.createImage({
                 prompt: (this.dataDoc.text as RichTextField)?.Text,
                 n: 1,
-                size: "1024x1024",
+                size: '1024x1024',
             });
             let image_url = response.data.data[0].url;
             console.log(image_url);
             if (image_url) {
                 const newDoc = Docs.Create.ImageDocument(image_url, {
-                    x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10, 
+                    x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10,
                     y: NumCast(this.rootDoc.y),
                     _height: 200,
-                    _width: 200
-                })
+                    _width: 200,
+                });
                 if (DocListCast(Doc.MyOverlayDocs?.data).includes(this.rootDoc)) {
                     newDoc.overlayX = this.rootDoc.x;
                     newDoc.overlayY = NumCast(this.rootDoc.y) + NumCast(this.rootDoc._height);
@@ -878,15 +934,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
                     newDoc.data = image_url;
                     newDoc._height = 200;
                     newDoc._width = 200;
-                    DocUtils.MakeLink({doc: this.rootDoc}, {doc: newDoc}, "Dall-E");
-                }, 500)
+                    DocUtils.MakeLink({ doc: this.rootDoc }, { doc: newDoc }, 'Dall-E');
+                }, 500);
             }
         } catch (err) {
             console.log(err);
             return '';
         }
-        
-    }
+    };
 
     breakupDictation = () => {
         if (this._editorView && this._recording) {
@@ -1942,29 +1997,45 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
                     onPointerUp={this.onPointerUp}
                     onPointerDown={this.onPointerDown}
                     onDoubleClick={this.onDoubleClick}>
-                    <div
-                        className={`formattedTextBox-outer${selected ? '-selected' : ''}`}
-                        ref={this._scrollRef}
-                        style={{
-                            width: this.props.dontSelectOnLoad ? '100%' : `calc(100% - ${this.sidebarWidthPercent})`,
-                            pointerEvents: !active && !SnappingManager.GetIsDragging() ? 'none' : undefined,
-                            overflow: this.layoutDoc._singleLine ? 'hidden' : this.layoutDoc._autoHeight ? 'visible' : undefined,
-                        }}
-                        onScroll={this.onScroll}
-                        onDrop={this.ondrop}>
+                    {this.gptStatus === GPTStatus.NONE || this.gptStatus === GPTStatus.LOADING ? (
                         <div
-                            className={minimal ? 'formattedTextBox-minimal' : `formattedTextBox-inner${rounded}${selPaddingClass}`}
-                            ref={this.createDropTarget}
+                            className={`formattedTextBox-outer${selected ? '-selected' : ''}`}
+                            ref={this._scrollRef}
                             style={{
-                                padding: StrCast(this.layoutDoc._textBoxPadding),
-                                paddingLeft: StrCast(this.layoutDoc._textBoxPaddingX, `${paddingX - selPad}px`),
-                                paddingRight: StrCast(this.layoutDoc._textBoxPaddingX, `${paddingX - selPad}px`),
-                                paddingTop: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY - selPad}px`),
-                                paddingBottom: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY - selPad}px`),
-                                pointerEvents: !active && !SnappingManager.GetIsDragging() ? (this.layoutDoc.isLinkButton ? 'none' : undefined) : undefined,
+                                width: this.props.dontSelectOnLoad ? '100%' : `calc(100% - ${this.sidebarWidthPercent})`,
+                                pointerEvents: !active && !SnappingManager.GetIsDragging() ? 'none' : undefined,
+                                overflow: this.layoutDoc._singleLine ? 'hidden' : this.layoutDoc._autoHeight ? 'visible' : undefined,
                             }}
-                        />
-                    </div>
+                            onScroll={this.onScroll}
+                            onDrop={this.ondrop}>
+                            <div
+                                className={minimal ? 'formattedTextBox-minimal' : `formattedTextBox-inner${rounded}${selPaddingClass}`}
+                                ref={this.createDropTarget}
+                                style={{
+                                    padding: StrCast(this.layoutDoc._textBoxPadding),
+                                    paddingLeft: StrCast(this.layoutDoc._textBoxPaddingX, `${paddingX - selPad}px`),
+                                    paddingRight: StrCast(this.layoutDoc._textBoxPaddingX, `${paddingX - selPad}px`),
+                                    paddingTop: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY - selPad}px`),
+                                    paddingBottom: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY - selPad}px`),
+                                    pointerEvents: !active && !SnappingManager.GetIsDragging() ? (this.layoutDoc.isLinkButton ? 'none' : undefined) : undefined,
+                                }}
+                            />
+                        </div>
+                    ) : (
+                        <div className="gpt-typing-wrapper">
+                            <div>{this.gptPrompt}</div>
+                            <br />
+                            <Typist
+                                key={this.gptRes}
+                                avgTypingDelay={15}
+                                cursor={{ hideWhenDone: true }}
+                                onTypingDone={() => {
+                                    this.setGPTText();
+                                }}>
+                                <div>{this.gptRes}</div>
+                            </Typist>
+                        </div>
+                    )}
                     {this.noSidebar || this.props.dontSelectOnLoad || !this.SidebarShown || this.sidebarWidthPercent === '0%' ? null : this.sidebarCollection}
                     {this.noSidebar || this.Document._noSidebar || this.props.dontSelectOnLoad || this.Document._singleLine ? null : this.sidebarHandle}
                     {this.audioHandle}
diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx
index 8f9261614..04904b3b1 100644
--- a/src/client/views/pdf/AnchorMenu.tsx
+++ b/src/client/views/pdf/AnchorMenu.tsx
@@ -10,7 +10,7 @@ import { SelectionManager } from '../../util/SelectionManager';
 import { AntimodeMenu, AntimodeMenuProps } from '../AntimodeMenu';
 import { LinkPopup } from '../linking/LinkPopup';
 import { ButtonDropdown } from '../nodes/formattedText/RichTextMenu';
-import { gptSummarize } from '../../apis/gpt/Summarization';
+import { gptAPICall, GPTCallType } from '../../apis/gpt/Summarization';
 import { GPTPopup } from './GPTPopup';
 import './AnchorMenu.scss';
 
@@ -136,16 +136,17 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
      * Invokes the API with the selected text and stores it in the summarized text.
      * @param e pointer down event
      */
-    invokeGPT = async (e: React.PointerEvent) => {
+    gptSummarize = async (e: React.PointerEvent) => {
         this.setGPTPopupVis(true);
         this.setLoading(true);
-        const res = await gptSummarize(this.selectedText);
+        const res = await gptAPICall(this.selectedText, GPTCallType.SUMMARY);
         // const res = await this.mockSummarize();
         if (res) {
             this.setSummarizedText(res);
         } else {
             this.setSummarizedText('Something went wrong.');
         }
+
         this.setLoading(false);
     };
 
@@ -243,6 +244,19 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
         this.highlightColor = Utils.colorString(col);
     };
 
+    /**
+     * Returns whether the selected text can be summarized. The goal is to have
+     * all selected text available to summarize but its only supported for pdf and web ATM.
+     * @returns Whether the GPT icon for summarization should appear
+     */
+    canSummarize = (): boolean => {
+        const docs = SelectionManager.Docs();
+        if (docs.length > 0) {
+            return docs[0].type === 'pdf' || docs[0].type === 'web';
+        }
+        return false;
+    };
+
     render() {
         const buttons =
             this.Status === 'marquee' ? (
@@ -254,14 +268,14 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
                         </button>
                     </Tooltip>
                     {/* GPT Summarize icon only shows up when text is highlighted, not on marquee selection*/}
-                    {AnchorMenu.Instance.StartCropDrag === unimplementedFunction && (
+                    {AnchorMenu.Instance.StartCropDrag === unimplementedFunction && this.canSummarize() && (
                         <Tooltip key="gpt" title={<div className="dash-tooltip">Summarize with AI</div>}>
-                            <button className="antimodeMenu-button annotate" onPointerDown={this.invokeGPT} style={{ cursor: 'grab' }}>
+                            <button className="antimodeMenu-button annotate" onPointerDown={this.gptSummarize} style={{ cursor: 'grab' }}>
                                 <FontAwesomeIcon icon="comment-dots" size="lg" />
                             </button>
                         </Tooltip>
                     )}
-                    <GPTPopup key="gptpopup" visible={this.showGPTPopup} text={this.summarizedText} loadingSummary={this.loadingSummary} callApi={this.invokeGPT} />
+                    <GPTPopup key="gptpopup" visible={this.showGPTPopup} text={this.summarizedText} loadingSummary={this.loadingSummary} callApi={this.gptSummarize} />
                     {AnchorMenu.Instance.OnAudio === unimplementedFunction ? null : (
                         <Tooltip key="annoaudiotate" title={<div className="dash-tooltip">Click to Record Annotation</div>}>
                             <button className="antimodeMenu-button annotate" onPointerDown={this.audioDown} style={{ cursor: 'grab' }}>
diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx
index 6e268c561..88854debe 100644
--- a/src/client/views/pdf/PDFViewer.tsx
+++ b/src/client/views/pdf/PDFViewer.tsx
@@ -24,7 +24,6 @@ import { AnchorMenu } from './AnchorMenu';
 import { Annotation } from './Annotation';
 import './PDFViewer.scss';
 import React = require('react');
-import { gptSummarize } from '../../apis/gpt/Summarization';
 import { GPTPopup } from './GPTPopup';
 const PDFJSViewer = require('pdfjs-dist/web/pdf_viewer');
 const pdfjsLib = require('pdfjs-dist');
-- 
cgit v1.2.3-70-g09d2


From d34d212ea550a3c1ca16747fadeb9e69c936cb5d Mon Sep 17 00:00:00 2001
From: Sophie Zhang <sophie_zhang@brown.edu>
Date: Wed, 1 Mar 2023 23:49:11 -0500
Subject: feat: simple typing animation

---
 .../views/nodes/formattedText/FormattedTextBox.tsx | 97 +++++++---------------
 1 file changed, 31 insertions(+), 66 deletions(-)

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

diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 35c845deb..65b96c281 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -65,20 +65,12 @@ import applyDevTools = require('prosemirror-dev-tools');
 import React = require('react');
 import { Configuration, OpenAIApi } from 'openai';
 import { gptAPICall, GPTCallType } from '../../../apis/gpt/Summarization';
-import ReactLoading from 'react-loading';
-import Typist from 'react-typist';
 const translateGoogleApi = require('translate-google-api');
 
 export const GoogleRef = 'googleDocId';
 
 type PullHandler = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => void;
 
-enum GPTStatus {
-    LOADING,
-    TYPING,
-    NONE,
-}
-
 @observer
 export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
     public static LayoutString(fieldStr: string) {
@@ -181,19 +173,10 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
         };
     }
 
-    // State for GPT Typing animation
-    @observable
-    private gptStatus: GPTStatus = GPTStatus.NONE;
-    @observable
-    private gptPrompt: string = '';
+    // State for GPT
     @observable
     private gptRes: string = '';
 
-    @action
-    private setGPTStatus = (status: GPTStatus) => {
-        this.gptStatus = status;
-    };
-
     public static PasteOnLoad: ClipboardEvent | undefined;
     public static SelectOnLoad = '';
     public static DontSelectInitialText = false; // whether initial text should be selected or not
@@ -877,31 +860,29 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
         });
     };
 
+    animateRes = (resIndex: number) => {
+        if (resIndex < this.gptRes.length) {
+            this.dataDoc.text = (this.dataDoc.text as RichTextField)?.Text + this.gptRes[resIndex];
+            setTimeout(() => {
+                this.animateRes(resIndex + 1);
+            }, 20);
+        }
+    };
+
     askGPT = action(async () => {
         try {
-            this.gptPrompt = (this.dataDoc.text as RichTextField)?.Text;
-            this.setGPTStatus(GPTStatus.LOADING);
             let res = await gptAPICall((this.dataDoc.text as RichTextField)?.Text, GPTCallType.COMPLETION);
             // let res = await this.mockGPT();
             if (res) {
                 this.gptRes = res;
-                this.setGPTStatus(GPTStatus.TYPING);
-            } else {
-                this.setGPTStatus(GPTStatus.NONE);
+                this.animateRes(0);
             }
         } catch (err) {
             console.log(err);
             this.dataDoc.text = (this.dataDoc.text as RichTextField)?.Text + 'Something went wrong';
-            this.setGPTStatus(GPTStatus.NONE);
         }
     });
 
-    setGPTText = action(() => {
-        this.dataDoc.text = (this.dataDoc.text as RichTextField)?.Text + this.gptRes;
-        this.gptRes = '';
-        this.setGPTStatus(GPTStatus.NONE);
-    });
-
     generateImage = async () => {
         console.log('Generate image from text: ', (this.dataDoc.text as RichTextField)?.Text);
         try {
@@ -1997,45 +1978,29 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
                     onPointerUp={this.onPointerUp}
                     onPointerDown={this.onPointerDown}
                     onDoubleClick={this.onDoubleClick}>
-                    {this.gptStatus === GPTStatus.NONE || this.gptStatus === GPTStatus.LOADING ? (
+                    <div
+                        className={`formattedTextBox-outer${selected ? '-selected' : ''}`}
+                        ref={this._scrollRef}
+                        style={{
+                            width: this.props.dontSelectOnLoad ? '100%' : `calc(100% - ${this.sidebarWidthPercent})`,
+                            pointerEvents: !active && !SnappingManager.GetIsDragging() ? 'none' : undefined,
+                            overflow: this.layoutDoc._singleLine ? 'hidden' : this.layoutDoc._autoHeight ? 'visible' : undefined,
+                        }}
+                        onScroll={this.onScroll}
+                        onDrop={this.ondrop}>
                         <div
-                            className={`formattedTextBox-outer${selected ? '-selected' : ''}`}
-                            ref={this._scrollRef}
+                            className={minimal ? 'formattedTextBox-minimal' : `formattedTextBox-inner${rounded}${selPaddingClass}`}
+                            ref={this.createDropTarget}
                             style={{
-                                width: this.props.dontSelectOnLoad ? '100%' : `calc(100% - ${this.sidebarWidthPercent})`,
-                                pointerEvents: !active && !SnappingManager.GetIsDragging() ? 'none' : undefined,
-                                overflow: this.layoutDoc._singleLine ? 'hidden' : this.layoutDoc._autoHeight ? 'visible' : undefined,
+                                padding: StrCast(this.layoutDoc._textBoxPadding),
+                                paddingLeft: StrCast(this.layoutDoc._textBoxPaddingX, `${paddingX - selPad}px`),
+                                paddingRight: StrCast(this.layoutDoc._textBoxPaddingX, `${paddingX - selPad}px`),
+                                paddingTop: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY - selPad}px`),
+                                paddingBottom: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY - selPad}px`),
+                                pointerEvents: !active && !SnappingManager.GetIsDragging() ? (this.layoutDoc.isLinkButton ? 'none' : undefined) : undefined,
                             }}
-                            onScroll={this.onScroll}
-                            onDrop={this.ondrop}>
-                            <div
-                                className={minimal ? 'formattedTextBox-minimal' : `formattedTextBox-inner${rounded}${selPaddingClass}`}
-                                ref={this.createDropTarget}
-                                style={{
-                                    padding: StrCast(this.layoutDoc._textBoxPadding),
-                                    paddingLeft: StrCast(this.layoutDoc._textBoxPaddingX, `${paddingX - selPad}px`),
-                                    paddingRight: StrCast(this.layoutDoc._textBoxPaddingX, `${paddingX - selPad}px`),
-                                    paddingTop: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY - selPad}px`),
-                                    paddingBottom: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY - selPad}px`),
-                                    pointerEvents: !active && !SnappingManager.GetIsDragging() ? (this.layoutDoc.isLinkButton ? 'none' : undefined) : undefined,
-                                }}
-                            />
-                        </div>
-                    ) : (
-                        <div className="gpt-typing-wrapper">
-                            <div>{this.gptPrompt}</div>
-                            <br />
-                            <Typist
-                                key={this.gptRes}
-                                avgTypingDelay={15}
-                                cursor={{ hideWhenDone: true }}
-                                onTypingDone={() => {
-                                    this.setGPTText();
-                                }}>
-                                <div>{this.gptRes}</div>
-                            </Typist>
-                        </div>
-                    )}
+                        />
+                    </div>
                     {this.noSidebar || this.props.dontSelectOnLoad || !this.SidebarShown || this.sidebarWidthPercent === '0%' ? null : this.sidebarCollection}
                     {this.noSidebar || this.Document._noSidebar || this.props.dontSelectOnLoad || this.Document._singleLine ? null : this.sidebarHandle}
                     {this.audioHandle}
-- 
cgit v1.2.3-70-g09d2


From 4a60851bd4d3495b2605863e3070c73129c9bc57 Mon Sep 17 00:00:00 2001
From: geireann <geireann.lindfield@gmail.com>
Date: Thu, 2 Mar 2023 11:41:35 -0500
Subject: Image uploads use local path rather than web url

---
 src/client/views/nodes/ImageBox.tsx                |  2 +-
 .../views/nodes/formattedText/FormattedTextBox.tsx | 28 +++++++++++++---------
 2 files changed, 18 insertions(+), 12 deletions(-)

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

diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index e2ecca0b6..1eaf86c56 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -326,7 +326,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
     @computed get nativeSize() {
         TraceMobx();
         const nativeWidth = NumCast(this.dataDoc[this.fieldKey + '-nativeWidth'], NumCast(this.layoutDoc[this.fieldKey + '-nativeWidth'], 500));
-        const nativeHeight = NumCast(this.dataDoc[this.fieldKey + '-nativeHeight'], NumCast(this.layoutDoc[this.fieldKey + '-nativeHeight'], 1));
+        const nativeHeight = NumCast(this.dataDoc[this.fieldKey + '-nativeHeight'], NumCast(this.layoutDoc[this.fieldKey + '-nativeHeight'], 500));
         const nativeOrientation = NumCast(this.dataDoc[this.fieldKey + '-nativeOrientation'], 1);
         return { nativeWidth, nativeHeight, nativeOrientation };
     }
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 683ccb9c4..d4dffcb62 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -64,6 +64,8 @@ import { SummaryView } from './SummaryView';
 import applyDevTools = require('prosemirror-dev-tools');
 import React = require('react');
 import { Configuration, OpenAIApi } from 'openai';
+import { CollectionSubView, SlowLoadDocuments } from '../../collections/CollectionSubView';
+import { Networking } from '../../../Network';
 const translateGoogleApi = require('translate-google-api');
 
 export const GoogleRef = 'googleDocId';
@@ -861,12 +863,20 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
             let image_url = response.data.data[0].url;
             console.log(image_url);
             if (image_url) {
-                const newDoc = Docs.Create.ImageDocument(image_url, {
-                    x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10, 
-                    y: NumCast(this.rootDoc.y),
-                    _height: 200,
-                    _width: 200
+                const batch = UndoManager.StartBatch('generate openAI image');
+                // const loadingDoc = SlowLoadDocuments(image_url, , [], "", emptyFunction, NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10, NumCast(this.rootDoc.y), this.addDocument, this.props.Document._viewType === CollectionViewType.Freeform).then(batch.end);
+                const [{ accessPaths }] = await Networking.PostToServer('/uploadRemoteImage', { sources: [image_url] });
+                const source = Utils.prepend(accessPaths.agnostic.client);
+                const newDoc = Docs.Create.ImageDocument(source, {
+                        x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10, 
+                        y: NumCast(this.rootDoc.y),
+                        _height: 200,
+                        _width: 200,
+                        // _nativeWidth: 200,
+                        // _nativeHeight: 200
                 })
+                // Doc.GetProto(newDoc)["data-nativeHeight"] = 200;
+                // Doc.GetProto(newDoc)["data-nativeWidth"] = 200;
                 if (DocListCast(Doc.MyOverlayDocs?.data).includes(this.rootDoc)) {
                     newDoc.overlayX = this.rootDoc.x;
                     newDoc.overlayY = NumCast(this.rootDoc.y) + NumCast(this.rootDoc._height);
@@ -874,12 +884,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
                 } else {
                     this.props.addDocument?.(newDoc);
                 }
-                setTimeout(() => {
-                    newDoc.data = image_url;
-                    newDoc._height = 200;
-                    newDoc._width = 200;
-                    DocUtils.MakeLink({doc: this.rootDoc}, {doc: newDoc}, "Dall-E");
-                }, 500)
+
+                DocUtils.MakeLink({doc: this.rootDoc}, {doc: newDoc}, "Dall-E");
             }
         } catch (err) {
             console.log(err);
-- 
cgit v1.2.3-70-g09d2


From db582e135fceb6162d0c9cf00e2580fb1349fddb Mon Sep 17 00:00:00 2001
From: Sophie Zhang <sophie_zhang@brown.edu>
Date: Thu, 13 Apr 2023 01:13:33 -0400
Subject: added text edits

---
 src/client/apis/gpt/GPT.ts                         |  87 ++++++++++
 src/client/apis/gpt/Summarization.ts               |  48 ------
 src/client/views/nodes/WebBox.tsx                  |   2 +-
 .../views/nodes/formattedText/FormattedTextBox.tsx |  31 ++--
 src/client/views/pdf/AnchorMenu.tsx                | 147 +++++++++++++---
 src/client/views/pdf/GPTPopup.scss                 | 123 --------------
 src/client/views/pdf/GPTPopup.tsx                  | 131 ---------------
 src/client/views/pdf/GPTPopup/GPTPopup.scss        | 132 +++++++++++++++
 src/client/views/pdf/GPTPopup/GPTPopup.tsx         | 186 +++++++++++++++++++++
 src/client/views/pdf/PDFViewer.tsx                 |   2 +-
 10 files changed, 547 insertions(+), 342 deletions(-)
 create mode 100644 src/client/apis/gpt/GPT.ts
 delete mode 100644 src/client/apis/gpt/Summarization.ts
 delete mode 100644 src/client/views/pdf/GPTPopup.scss
 delete mode 100644 src/client/views/pdf/GPTPopup.tsx
 create mode 100644 src/client/views/pdf/GPTPopup/GPTPopup.scss
 create mode 100644 src/client/views/pdf/GPTPopup/GPTPopup.tsx

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

diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts
new file mode 100644
index 000000000..4b3960902
--- /dev/null
+++ b/src/client/apis/gpt/GPT.ts
@@ -0,0 +1,87 @@
+import { Configuration, OpenAIApi } from 'openai';
+
+enum GPTCallType {
+    SUMMARY = 'summary',
+    COMPLETION = 'completion',
+    EDIT = 'edit',
+}
+
+type GPTCallOpts = {
+    model: string;
+    maxTokens: number;
+    temp: number;
+    prompt: string;
+};
+
+const callTypeMap: { [type: string]: GPTCallOpts } = {
+    summary: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: 'Summarize this text briefly: ' },
+    edit: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: 'Reword this: ' },
+    completion: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: '' },
+};
+
+/**
+ * Calls the OpenAI API.
+ *
+ * @param inputText Text to process
+ * @returns AI Output
+ */
+const gptAPICall = async (inputText: string, callType: GPTCallType) => {
+    if (callType === GPTCallType.SUMMARY) inputText += '.';
+    const opts: GPTCallOpts = callTypeMap[callType];
+    try {
+        const configuration = new Configuration({
+            apiKey: process.env.OPENAI_KEY,
+        });
+        const openai = new OpenAIApi(configuration);
+        const response = await openai.createCompletion({
+            model: opts.model,
+            max_tokens: opts.maxTokens,
+            temperature: opts.temp,
+            prompt: `${opts.prompt}${inputText}`,
+        });
+        console.log(response.data.choices[0]);
+        return response.data.choices[0].text;
+    } catch (err) {
+        console.log(err);
+        return 'Error connecting with API.';
+    }
+};
+
+const gptImageCall = async (prompt: string) => {
+    try {
+        const configuration = new Configuration({
+            apiKey: process.env.OPENAI_KEY,
+        });
+        const openai = new OpenAIApi(configuration);
+        const response = await openai.createImage({
+            prompt: prompt,
+            n: 1,
+            size: '1024x1024',
+        });
+        return response.data.data[0].url;
+    } catch (err) {
+        console.error(err);
+        return;
+    }
+};
+
+// const gptEditCall = async (selectedText: string, fullText: string) => {
+//     try {
+//         const configuration = new Configuration({
+//             apiKey: process.env.OPENAI_KEY,
+//         });
+//         const openai = new OpenAIApi(configuration);
+//         const response = await openai.createCompletion({
+//             model: 'text-davinci-003',
+//             max_tokens: 256,
+//             temperature: 0.1,
+//             prompt: `Replace the phrase ${selectedText} inside of ${fullText}.`,
+//         });
+//         return response.data.choices[0].text.trim();
+//     } catch (err) {
+//         console.log(err);
+//         return 'Error connecting with API.';
+//     }
+// };
+
+export { gptAPICall, gptImageCall, GPTCallType };
diff --git a/src/client/apis/gpt/Summarization.ts b/src/client/apis/gpt/Summarization.ts
deleted file mode 100644
index 0d9b5dfcd..000000000
--- a/src/client/apis/gpt/Summarization.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Configuration, OpenAIApi } from 'openai';
-
-enum GPTCallType {
-    SUMMARY = 'summary',
-    COMPLETION = 'completion',
-}
-
-type GPTCallOpts = {
-    model: string;
-    maxTokens: number;
-    temp: number;
-    prompt: string;
-};
-
-const callTypeMap: { [type: string]: GPTCallOpts } = {
-    summary: { model: 'text-davinci-003', maxTokens: 100, temp: 0.5, prompt: 'Summarize this text: ' },
-    completion: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: '' },
-};
-
-/**
- * Calls the OpenAI API.
- *
- * @param inputText Text to process
- * @returns AI Output
- */
-const gptAPICall = async (inputText: string, callType: GPTCallType) => {
-    if (callType === GPTCallType.SUMMARY) inputText += '.';
-    const opts: GPTCallOpts = callTypeMap[callType];
-    try {
-        const configuration = new Configuration({
-            apiKey: process.env.OPENAI_KEY,
-        });
-        const openai = new OpenAIApi(configuration);
-        const response = await openai.createCompletion({
-            model: opts.model,
-            max_tokens: opts.maxTokens,
-            temperature: opts.temp,
-            prompt: `${opts.prompt}${inputText}`,
-        });
-        return response.data.choices[0].text;
-    } catch (err) {
-        console.log(err);
-        return 'Error connecting with API.';
-    }
-};
-
-
-export { gptAPICall, GPTCallType};
diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx
index e5ef85b5a..abde5a9ea 100644
--- a/src/client/views/nodes/WebBox.tsx
+++ b/src/client/views/nodes/WebBox.tsx
@@ -36,7 +36,7 @@ import { PinProps, PresBox } from './trails';
 import './WebBox.scss';
 import React = require('react');
 import { DragManager } from '../../util/DragManager';
-import { GPTPopup } from '../pdf/GPTPopup';
+import { GPTPopup } from '../pdf/GPTPopup/GPTPopup';
 const { CreateImage } = require('./WebBoxRenderer');
 const _global = (window /* browser */ || global) /* node */ as any;
 const htmlToText = require('html-to-text');
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 4ad1f73b0..3eae47f49 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -23,7 +23,7 @@ import { BoolCast, Cast, DocCast, FieldValue, NumCast, ScriptCast, StrCast } fro
 import { GetEffectiveAcl, TraceMobx } from '../../../../fields/util';
 import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, numberRange, OmitKeys, returnFalse, returnZero, setupMoveUpEvents, smoothScroll, unimplementedFunction, Utils } from '../../../../Utils';
 import { GoogleApiClientUtils, Pulls, Pushes } from '../../../apis/google_docs/GoogleApiClientUtils';
-import { gptAPICall, GPTCallType } from '../../../apis/gpt/Summarization';
+import { gptAPICall, GPTCallType, gptImageCall } from '../../../apis/gpt/GPT';
 import { DocServer } from '../../../DocServer';
 import { Docs, DocUtils } from '../../../documents/Documents';
 import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes';
@@ -891,26 +891,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
     generateImage = async () => {
         console.log('Generate image from text: ', (this.dataDoc.text as RichTextField)?.Text);
         try {
-            const configuration = new Configuration({
-                apiKey: process.env.OPENAI_KEY,
-            });
-            const openai = new OpenAIApi(configuration);
-            const response = await openai.createImage({
-                prompt: (this.dataDoc.text as RichTextField)?.Text,
-                n: 1,
-                size: '1024x1024',
-            });
-            let image_url = response.data.data[0].url;
-            console.log(image_url);
+            let image_url = await gptImageCall((this.dataDoc.text as RichTextField)?.Text);
             if (image_url) {
                 const [{ accessPaths }] = await Networking.PostToServer('/uploadRemoteImage', { sources: [image_url] });
                 const source = Utils.prepend(accessPaths.agnostic.client);
                 const newDoc = Docs.Create.ImageDocument(source, {
-                        x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10, 
-                        y: NumCast(this.rootDoc.y),
-                        _height: 200,
-                        _width: 200,
-                })
+                    x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10,
+                    y: NumCast(this.rootDoc.y),
+                    _height: 200,
+                    _width: 200,
+                });
                 if (DocListCast(Doc.MyOverlayDocs?.data).includes(this.rootDoc)) {
                     newDoc.overlayX = this.rootDoc.x;
                     newDoc.overlayY = NumCast(this.rootDoc.y) + NumCast(this.rootDoc._height);
@@ -919,7 +909,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
                     this.props.addDocument?.(newDoc);
                 }
                 // Create link between prompt and image
-                DocUtils.MakeLink({doc: this.rootDoc}, {doc: newDoc}, "Image Prompt");
+                DocUtils.MakeLink({ doc: this.rootDoc }, { doc: newDoc }, 'Image Prompt');
             }
         } catch (err) {
             console.log(err);
@@ -1189,6 +1179,11 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
                     RichTextMenu.Instance?.updateMenu(this._editorView, undefined, this.props);
                     this.autoLink();
                 }
+                // Accessing editor and text doc for gpt assisted text edits
+                if (this._editorView && selected) {
+                    AnchorMenu.Instance?.setEditorView(this._editorView);
+                    AnchorMenu.Instance?.setTextDoc(this.dataDoc);
+                }
             }),
             { fireImmediately: true }
         );
diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx
index 1b30e1f68..b66f294f4 100644
--- a/src/client/views/pdf/AnchorMenu.tsx
+++ b/src/client/views/pdf/AnchorMenu.tsx
@@ -10,10 +10,11 @@ import { SelectionManager } from '../../util/SelectionManager';
 import { AntimodeMenu, AntimodeMenuProps } from '../AntimodeMenu';
 import { LinkPopup } from '../linking/LinkPopup';
 import { ButtonDropdown } from '../nodes/formattedText/RichTextMenu';
-import { gptAPICall, GPTCallType } from '../../apis/gpt/Summarization';
-import { GPTPopup } from './GPTPopup';
-import './AnchorMenu.scss';
+import { gptAPICall, GPTCallType } from '../../apis/gpt/GPT';
+import { GPTPopup, GPTPopupMode } from './GPTPopup/GPTPopup';
 import { LightboxView } from '../LightboxView';
+import { EditorView } from 'prosemirror-view';
+import './AnchorMenu.scss';
 
 @observer
 export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
@@ -46,27 +47,55 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
     @observable public Status: 'marquee' | 'annotation' | '' = '';
 
     // GPT additions
-    @observable private summarizedText: string = '';
+    @observable private GPTpopupText: string = '';
     @observable private loadingSummary: boolean = false;
     @observable private showGPTPopup: boolean = false;
+    @observable private GPTMode: GPTPopupMode = GPTPopupMode.SUMMARY;
+    @observable private selectedText: string = '';
+    @observable private editorView?: EditorView;
+    @observable private textDoc?: Doc;
+    @observable private highlightRange: number[] | undefined;
+    private selectionRange: number[] | undefined;
+
     @action
     setGPTPopupVis = (vis: boolean) => {
         this.showGPTPopup = vis;
     };
     @action
-    setSummarizedText = (txt: string) => {
-        this.summarizedText = txt;
+    setGPTMode = (mode: GPTPopupMode) => {
+        this.GPTMode = mode;
+    };
+
+    @action
+    setGPTPopupText = (txt: string) => {
+        this.GPTpopupText = txt;
     };
+
     @action
     setLoading = (loading: boolean) => {
         this.loadingSummary = loading;
     };
 
-    private selectedText: string = '';
+    @action
+    setHighlightRange(r: number[] | undefined) {
+        this.highlightRange = r;
+    }
+
+    @action
     public setSelectedText = (txt: string) => {
         this.selectedText = txt;
     };
 
+    @action
+    public setEditorView = (editor: EditorView) => {
+        this.editorView = editor;
+    };
+
+    @action
+    public setTextDoc = (textDoc: Doc) => {
+        this.textDoc = textDoc;
+    };
+
     public onMakeAnchor: () => Opt<Doc> = () => undefined; // Method to get anchor from text search
 
     public OnCrop: (e: PointerEvent) => void = unimplementedFunction;
@@ -104,7 +133,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
                 if (!opacity) {
                     this._showLinkPopup = false;
                     this.setGPTPopupVis(false);
-                    this.setSummarizedText('');
+                    this.setGPTPopupText('');
                 }
             },
             { fireImmediately: true }
@@ -114,20 +143,20 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
             selected => {
                 this._showLinkPopup = false;
                 this.setGPTPopupVis(false);
-                this.setSummarizedText('');
+                this.setGPTPopupText('');
                 AnchorMenu.Instance.fadeOut(true);
             }
         );
     }
 
     /**
-     * Returns  a mock summary simulating an API call.
+     * Returns a mock api response.
      * @returns A Promise that resolves into a string
      */
-    mockSummarize = async (): Promise<string> => {
+    mockGPTCall = async (): Promise<string> => {
         return new Promise((resolve, reject) => {
             setTimeout(() => {
-                resolve('Mock summary. This is a summary of the highlighted text.');
+                resolve('test');
             }, 1000);
         });
     };
@@ -137,19 +166,68 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
      * @param e pointer down event
      */
     gptSummarize = async (e: React.PointerEvent) => {
+        this.setHighlightRange(undefined);
+        this.setGPTPopupVis(true);
+        this.setGPTMode(GPTPopupMode.SUMMARY);
+        this.setLoading(true);
+
+        try {
+            const res = await gptAPICall(this.selectedText, GPTCallType.SUMMARY);
+            if (res) {
+                this.setGPTPopupText(res);
+            } else {
+                this.setGPTPopupText('Something went wrong.');
+            }
+        } catch (err) {
+            console.error(err);
+        }
+
+        this.setLoading(false);
+    };
+
+    /**
+     * Makes a GPT call to edit selected text.
+     * @returns nothing
+     */
+    gptEdit = async () => {
+        if (!this.editorView) return;
+        this.setHighlightRange(undefined);
+        const state = this.editorView.state;
+        const sel = state.selection;
+        const fullText = state.doc.textBetween(0, this.editorView.state.doc.content.size, ' \n');
+        const selectedText = state.doc.textBetween(sel.from, sel.to);
+
         this.setGPTPopupVis(true);
+        this.setGPTMode(GPTPopupMode.EDIT);
         this.setLoading(true);
-        const res = await gptAPICall(this.selectedText, GPTCallType.SUMMARY);
-        // const res = await this.mockSummarize();
-        if (res) {
-            this.setSummarizedText(res);
-        } else {
-            this.setSummarizedText('Something went wrong.');
+
+        try {
+            let res = await gptAPICall(selectedText, GPTCallType.EDIT);
+            // let res = await this.mockGPTCall();
+            res = res.trim();
+            const resultText = fullText.slice(0, sel.from - 1) + res + fullText.slice(sel.to);
+
+            if (res) {
+                this.setGPTPopupText(resultText);
+                this.setHighlightRange([sel.from - 1, sel.from - 1 + res.length]);
+            } else {
+                this.setGPTPopupText('Something went wrong.');
+            }
+        } catch (err) {
+            console.error(err);
         }
 
         this.setLoading(false);
     };
 
+    /**
+     * Replaces text suggestions from GPT.
+     */
+    replaceText = (replacement: string) => {
+        if (!this.editorView || !this.textDoc) return;
+        this.textDoc.text = replacement;
+    };
+
     pointerDown = (e: React.PointerEvent) => {
         setupMoveUpEvents(
             this,
@@ -250,7 +328,19 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
     canSummarize = (): boolean => {
         const docs = SelectionManager.Docs();
         if (docs.length > 0) {
-            return docs[0].type === 'pdf' || docs[0].type === 'web';
+            return docs.some(doc => doc.type === 'pdf' || doc.type === 'web');
+        }
+        return false;
+    };
+
+    /**
+     * Returns whether the selected text can be edited.
+     * @returns Whether the GPT icon for summarization should appear
+     */
+    canEdit = (): boolean => {
+        const docs = SelectionManager.Docs();
+        if (docs.length > 0) {
+            return docs.some(doc => doc.type === 'rtf');
         }
         return false;
     };
@@ -273,7 +363,17 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
                             </button>
                         </Tooltip>
                     )}
-                    <GPTPopup key="gptpopup" visible={this.showGPTPopup} text={this.summarizedText} loadingSummary={this.loadingSummary} callApi={this.gptSummarize} />
+                    <GPTPopup
+                        key="gptpopup"
+                        visible={this.showGPTPopup}
+                        text={this.GPTpopupText}
+                        highlightRange={this.highlightRange}
+                        loading={this.loadingSummary}
+                        callSummaryApi={this.gptSummarize}
+                        callEditApi={this.gptEdit}
+                        replaceText={this.replaceText}
+                        mode={this.GPTMode}
+                    />
                     {AnchorMenu.Instance.OnAudio === unimplementedFunction ? null : (
                         <Tooltip key="annoaudiotate" title={<div className="dash-tooltip">Click to Record Annotation</div>}>
                             <button className="antimodeMenu-button annotate" onPointerDown={this.audioDown} style={{ cursor: 'grab' }}>
@@ -281,6 +381,13 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
                             </button>
                         </Tooltip>
                     )}
+                    {this.canEdit() && (
+                        <Tooltip key="gpttextedit" title={<div className="dash-tooltip">Edit text with AI</div>}>
+                            <button className="antimodeMenu-button annotate" onPointerDown={this.gptEdit} style={{ cursor: 'grab' }}>
+                                <FontAwesomeIcon icon="pencil-alt" size="lg" />
+                            </button>
+                        </Tooltip>
+                    )}
                     <Tooltip key="link" title={<div className="dash-tooltip">Find document to link to selected text</div>}>
                         <button className="antimodeMenu-button link" onPointerDown={this.toggleLinkPopup}>
                             <FontAwesomeIcon style={{ position: 'absolute', transform: 'scale(1.5)' }} icon={'search'} size="lg" />
diff --git a/src/client/views/pdf/GPTPopup.scss b/src/client/views/pdf/GPTPopup.scss
deleted file mode 100644
index 7b7d2e3f7..000000000
--- a/src/client/views/pdf/GPTPopup.scss
+++ /dev/null
@@ -1,123 +0,0 @@
-$textgrey: #707070;
-$lighttextgrey: #a3a3a3;
-$greyborder: #d3d3d3;
-$lightgrey: #ececec;
-$button: #5b97ff;
-
-.summary-box {
-    display: flex;
-    flex-direction: column;
-    background-color: #ffffff;
-    box-shadow: 0 2px 5px #7474748d;
-    color: $textgrey;
-    position: absolute;
-    bottom: 40px;
-    width: 250px;
-    border-radius: 15px;
-    padding: 15px;
-    padding-bottom: 0px;
-
-    .summary-heading {
-        display: flex;
-        align-items: center;
-        border-bottom: 1px solid $greyborder;
-        padding-bottom: 5px;
-
-        .summary-text {
-            font-size: 12px;
-            font-weight: 500;
-        }
-    }
-
-    label {
-        color: $textgrey;
-        font-size: 12px;
-        font-weight: 400;
-        letter-spacing: 1px;
-        margin: 0;
-        padding-right: 5px;
-    }
-
-    a {
-        cursor: pointer;
-    }
-
-    .content-wrapper {
-        padding-top: 10px;
-        min-height: 50px;
-        max-height: 150px;
-        overflow-y: auto;
-    }
-
-    .btns-wrapper {
-        height: 50px;
-        display: flex;
-        justify-content: space-between;
-        align-items: center;
-
-        .summarizing {
-            display: flex;
-            align-items: center;
-        }
-    }
-
-    button {
-        font-size: 9px;
-        padding: 10px;
-        color: #ffffff;
-        background-color: $button;
-        border-radius: 5px;
-    }
-
-    .text-btn {
-        &:hover {
-            background-color: $button;
-        }
-    }
-
-    .btn-secondary {
-        font-size: 8px;
-        padding: 10px 5px;
-        background-color: $lightgrey;
-        color: $textgrey;
-        &:hover {
-            background-color: $lightgrey;
-        }
-    }
-
-    .icon-btn {
-        background-color: #ffffff;
-        padding: 10px;
-        border-radius: 50%;
-        color: $button;
-        border: 1px solid $button;
-    }
-
-    .ai-warning {
-        padding: 10px 0;
-        font-size: 10px;
-        color: $lighttextgrey;
-        border-top: 1px solid $greyborder;
-    }
-}
-
-// Typist CSS
-.Typist .Cursor {
-    display: inline-block;
-}
-.Typist .Cursor--blinking {
-    opacity: 1;
-    animation: blink 1s linear infinite;
-}
-
-@keyframes blink {
-    0% {
-        opacity: 1;
-    }
-    50% {
-        opacity: 0;
-    }
-    100% {
-        opacity: 1;
-    }
-}
diff --git a/src/client/views/pdf/GPTPopup.tsx b/src/client/views/pdf/GPTPopup.tsx
deleted file mode 100644
index ec4fa58dc..000000000
--- a/src/client/views/pdf/GPTPopup.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { action, observable } from 'mobx';
-import { observer } from 'mobx-react';
-import React = require('react');
-import ReactLoading from 'react-loading';
-import Typist from 'react-typist';
-import { Doc } from '../../../fields/Doc';
-import { Docs } from '../../documents/Documents';
-import './GPTPopup.scss';
-
-interface GPTPopupProps {
-    visible: boolean;
-    text: string;
-    loadingSummary: boolean;
-    callApi: (e: React.PointerEvent) => Promise<void>;
-}
-
-@observer
-export class GPTPopup extends React.Component<GPTPopupProps> {
-    static Instance: GPTPopup;
-
-    @observable
-    private summaryDone: boolean = false;
-    @observable
-    private sidebarId: string = '';
-    @action
-    public setSummaryDone = (done: boolean) => {
-        this.summaryDone = done;
-    };
-    @action
-    public setSidebarId = (id: string) => {
-        this.sidebarId = id;
-    };
-
-    public addDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean = () => false;
-    
-    /**
-     * Transfers the summarization text to a sidebar annotation text document.
-     */
-    private transferToText = () => {
-        const newDoc = Docs.Create.TextDocument(this.props.text.trim(), {
-            _width: 200,
-            _height: 50,
-            _fitWidth: true,
-            _autoHeight: true,
-        });
-        this.addDoc(newDoc, this.sidebarId);
-    };
-
-    constructor(props: GPTPopupProps) {
-        super(props);
-        GPTPopup.Instance = this;
-    }
-
-    componentDidUpdate = () => {
-        if (this.props.loadingSummary) {
-            this.setSummaryDone(false);
-        }
-    };
-
-    render() {
-        return (
-            <div className="summary-box" style={{ display: this.props.visible ? 'flex' : 'none' }}>
-                <div className="summary-heading">
-                    <label className="summary-text">SUMMARY</label>
-                    {this.props.loadingSummary && <ReactLoading type="spin" color="#bcbcbc" width={14} height={14} />}
-                </div>
-                <div className="content-wrapper">
-                    {!this.props.loadingSummary &&
-                        (!this.summaryDone ? (
-                            <Typist
-                                key={this.props.text}
-                                avgTypingDelay={15}
-                                cursor={{ hideWhenDone: true }}
-                                onTypingDone={action(() => {
-                                    setTimeout(
-                                        action(() => {
-                                            this.summaryDone = true;
-                                        }),
-                                        500
-                                    );
-                                })}>
-                                {this.props.text}
-                            </Typist>
-                        ) : (
-                            this.props.text
-                        ))}
-                </div>
-                {!this.props.loadingSummary && (
-                    <div className="btns-wrapper">
-                        {this.summaryDone ? (
-                            <>
-                                <button className="icon-btn" onPointerDown={e => this.props.callApi(e)}>
-                                    <FontAwesomeIcon icon="redo-alt" size="lg" />
-                                </button>
-                                <button
-                                    className="text-btn"
-                                    onClick={e => {
-                                        this.transferToText();
-                                    }}>
-                                    Transfer to Text
-                                </button>
-                            </>
-                        ) : (
-                            <div className="summarizing">
-                                <label>Summarizing</label>
-                                <ReactLoading type="bubbles" color="#bcbcbc" width={20} height={20} />
-                                <button
-                                    className="btn-secondary"
-                                    onClick={e => {
-                                        this.setSummaryDone(true);
-                                    }}>
-                                    Stop Animation
-                                </button>
-                            </div>
-                        )}
-                    </div>
-                )}
-                {this.summaryDone && (
-                    <div className="ai-warning">
-                        <FontAwesomeIcon icon="exclamation-circle" size="sm" style={{ paddingRight: '5px' }} />
-                        AI generated responses can contain inaccurate or misleading content.{' '}
-                        <a target="_blank" href="https://www.nytimes.com/2023/02/08/technology/ai-chatbots-disinformation.html">
-                            Learn More
-                        </a>
-                    </div>
-                )}
-            </div>
-        );
-    }
-}
diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.scss b/src/client/views/pdf/GPTPopup/GPTPopup.scss
new file mode 100644
index 000000000..50fbe5211
--- /dev/null
+++ b/src/client/views/pdf/GPTPopup/GPTPopup.scss
@@ -0,0 +1,132 @@
+$textgrey: #707070;
+$lighttextgrey: #a3a3a3;
+$greyborder: #d3d3d3;
+$lightgrey: #ececec;
+$button: #5b97ff;
+$highlightedText: #82e0ff;
+
+.summary-box {
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    background-color: #ffffff;
+    box-shadow: 0 2px 5px #7474748d;
+    color: $textgrey;
+    position: fixed;
+    bottom: 5px;
+    right: 5px;
+    width: 250px;
+    min-height: 200px;
+    border-radius: 15px;
+    padding: 15px;
+    padding-bottom: 0;
+    z-index: 999;
+
+    .summary-heading {
+        display: flex;
+        align-items: center;
+        border-bottom: 1px solid $greyborder;
+        padding-bottom: 5px;
+
+        .summary-text {
+            font-size: 12px;
+            font-weight: 500;
+        }
+    }
+
+    label {
+        color: $textgrey;
+        font-size: 12px;
+        font-weight: 400;
+        letter-spacing: 1px;
+        margin: 0;
+        padding-right: 5px;
+    }
+
+    a {
+        cursor: pointer;
+    }
+
+    .content-wrapper {
+        padding-top: 10px;
+        min-height: 50px;
+        max-height: 150px;
+        overflow-y: auto;
+    }
+
+    .btns-wrapper {
+        height: 50px;
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+
+        .summarizing {
+            display: flex;
+            align-items: center;
+        }
+    }
+
+    button {
+        font-size: 9px;
+        padding: 10px;
+        color: #ffffff;
+        background-color: $button;
+        border-radius: 5px;
+    }
+
+    .text-btn {
+        &:hover {
+            background-color: $button;
+        }
+    }
+
+    .btn-secondary {
+        font-size: 8px;
+        padding: 10px 5px;
+        background-color: $lightgrey;
+        color: $textgrey;
+        &:hover {
+            background-color: $lightgrey;
+        }
+    }
+
+    .icon-btn {
+        background-color: #ffffff;
+        padding: 10px;
+        border-radius: 50%;
+        color: $button;
+        border: 1px solid $button;
+    }
+
+    .ai-warning {
+        padding: 10px 0;
+        font-size: 10px;
+        color: $lighttextgrey;
+        border-top: 1px solid $greyborder;
+    }
+
+    .highlighted-text {
+        background-color: $highlightedText;
+    }
+}
+
+// Typist CSS
+.Typist .Cursor {
+    display: inline-block;
+}
+.Typist .Cursor--blinking {
+    opacity: 1;
+    animation: blink 1s linear infinite;
+}
+
+@keyframes blink {
+    0% {
+        opacity: 1;
+    }
+    50% {
+        opacity: 0;
+    }
+    100% {
+        opacity: 1;
+    }
+}
diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.tsx b/src/client/views/pdf/GPTPopup/GPTPopup.tsx
new file mode 100644
index 000000000..91bc0a7ff
--- /dev/null
+++ b/src/client/views/pdf/GPTPopup/GPTPopup.tsx
@@ -0,0 +1,186 @@
+import React = require('react');
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { action, observable } from 'mobx';
+import { observer } from 'mobx-react';
+import ReactLoading from 'react-loading';
+import Typist from 'react-typist';
+import { Doc } from '../../../../fields/Doc';
+import { Docs } from '../../../documents/Documents';
+import './GPTPopup.scss';
+
+export enum GPTPopupMode {
+    SUMMARY,
+    EDIT,
+}
+
+interface GPTPopupProps {
+    visible: boolean;
+    text: string;
+    loading: boolean;
+    mode: GPTPopupMode;
+    callSummaryApi: (e: React.PointerEvent) => Promise<void>;
+    callEditApi: (e: React.PointerEvent) => Promise<void>;
+    replaceText: (replacement: string) => void;
+    highlightRange?: number[];
+}
+
+@observer
+export class GPTPopup extends React.Component<GPTPopupProps> {
+    static Instance: GPTPopup;
+
+    @observable
+    private done: boolean = false;
+    @observable
+    private sidebarId: string = '';
+
+    @action
+    public setDone = (done: boolean) => {
+        this.done = done;
+    };
+    @action
+    public setSidebarId = (id: string) => {
+        this.sidebarId = id;
+    };
+
+    public addDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean = () => false;
+
+    /**
+     * Transfers the summarization text to a sidebar annotation text document.
+     */
+    private transferToText = () => {
+        const newDoc = Docs.Create.TextDocument(this.props.text.trim(), {
+            _width: 200,
+            _height: 50,
+            _fitWidth: true,
+            _autoHeight: true,
+        });
+        this.addDoc(newDoc, this.sidebarId);
+    };
+
+    constructor(props: GPTPopupProps) {
+        super(props);
+        GPTPopup.Instance = this;
+    }
+
+    componentDidUpdate = () => {
+        if (this.props.loading) {
+            this.setDone(false);
+        }
+    };
+
+    summaryBox = () => (
+        <>
+            <div>
+                {this.heading('SUMMARY')}
+                <div className="content-wrapper">
+                    {!this.props.loading &&
+                        (!this.done ? (
+                            <Typist
+                                key={this.props.text}
+                                avgTypingDelay={15}
+                                cursor={{ hideWhenDone: true }}
+                                onTypingDone={() => {
+                                    setTimeout(() => {
+                                        this.setDone(true);
+                                    }, 500);
+                                }}>
+                                {this.props.text}
+                            </Typist>
+                        ) : (
+                            this.props.text
+                        ))}
+                </div>
+            </div>
+            {!this.props.loading && (
+                <div className="btns-wrapper">
+                    {this.done ? (
+                        <>
+                            <button className="icon-btn" onPointerDown={e => this.props.callSummaryApi(e)}>
+                                <FontAwesomeIcon icon="redo-alt" size="lg" />
+                            </button>
+                            <button
+                                className="text-btn"
+                                onClick={e => {
+                                    this.transferToText();
+                                }}>
+                                Transfer to Text
+                            </button>
+                        </>
+                    ) : (
+                        <div className="summarizing">
+                            <span>Summarizing</span>
+                            <ReactLoading type="bubbles" color="#bcbcbc" width={20} height={20} />
+                            <button
+                                className="btn-secondary"
+                                onClick={e => {
+                                    this.setDone(true);
+                                }}>
+                                Stop Animation
+                            </button>
+                        </div>
+                    )}
+                </div>
+            )}
+        </>
+    );
+
+    editBox = () => {
+        const hr = this.props.highlightRange;
+        return (
+            hr && (
+                <>
+                    <div>
+                        {this.heading('TEXT EDIT SUGGESTIONS')}
+                        <div className="content-wrapper">
+                            <div>
+                                {this.props.text.slice(0, hr[0])} <span className="highlighted-text">{this.props.text.slice(hr[0], hr[1])}</span> {this.props.text.slice(hr[1])}
+                            </div>
+                        </div>
+                    </div>
+                    {!this.props.loading && (
+                        <div className="btns-wrapper">
+                            <>
+                                <button className="icon-btn" onPointerDown={e => this.props.callEditApi(e)}>
+                                    <FontAwesomeIcon icon="redo-alt" size="lg" />
+                                </button>
+                                <button
+                                    className="text-btn"
+                                    onClick={e => {
+                                        this.props.replaceText(this.props.text);
+                                    }}>
+                                    Replace Text
+                                </button>
+                            </>
+                        </div>
+                    )}
+                    {this.aiWarning()}
+                </>
+            )
+        );
+    };
+
+    aiWarning = () =>
+        this.done ? (
+            <div className="ai-warning">
+                <FontAwesomeIcon icon="exclamation-circle" size="sm" style={{ paddingRight: '5px' }} />
+                AI generated responses can contain inaccurate or misleading content.
+            </div>
+        ) : (
+            <></>
+        );
+
+    heading = (headingText: string) => (
+        <div className="summary-heading">
+            <label className="summary-text">{headingText}</label>
+            {this.props.loading && <ReactLoading type="spin" color="#bcbcbc" width={14} height={14} />}
+        </div>
+    );
+
+    render() {
+        return (
+            <div className="summary-box" style={{ display: this.props.visible ? 'flex' : 'none' }}>
+                {this.props.mode === GPTPopupMode.SUMMARY ? this.summaryBox() : this.editBox()}
+            </div>
+        );
+    }
+}
diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx
index 3f891789a..d17d0e13c 100644
--- a/src/client/views/pdf/PDFViewer.tsx
+++ b/src/client/views/pdf/PDFViewer.tsx
@@ -24,7 +24,7 @@ import { AnchorMenu } from './AnchorMenu';
 import { Annotation } from './Annotation';
 import './PDFViewer.scss';
 import React = require('react');
-import { GPTPopup } from './GPTPopup';
+import { GPTPopup } from './GPTPopup/GPTPopup';
 const PDFJSViewer = require('pdfjs-dist/web/pdf_viewer');
 const pdfjsLib = require('pdfjs-dist');
 const _global = (window /* browser */ || global) /* node */ as any;
-- 
cgit v1.2.3-70-g09d2


From e45cf590cf20d3af2bc07e4af78d69470ebfe665 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 17 Apr 2023 11:40:45 -0400
Subject: updated to master

---
 package-lock.json                                  | 39 ----------------------
 .../views/nodes/formattedText/FormattedTextBox.tsx |  2 +-
 2 files changed, 1 insertion(+), 40 deletions(-)

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

diff --git a/package-lock.json b/package-lock.json
index 8ac3df61b..69d112d72 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5567,16 +5567,6 @@
             "integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=",
             "dev": true
         },
-        "d": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
-            "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
-            "dev": true,
-            "requires": {
-                "es5-ext": "^0.10.50",
-                "type": "^1.0.1"
-            }
-        },
         "d3": {
             "version": "7.8.4",
             "resolved": "https://registry.npmjs.org/d3/-/d3-7.8.4.tgz",
@@ -6957,28 +6947,6 @@
                 "is-symbol": "^1.0.2"
             }
         },
-        "es5-ext": {
-            "version": "0.10.62",
-            "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz",
-            "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==",
-            "dev": true,
-            "requires": {
-                "es6-iterator": "^2.0.3",
-                "es6-symbol": "^3.1.3",
-                "next-tick": "^1.1.0"
-            }
-        },
-        "es6-iterator": {
-            "version": "2.0.3",
-            "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
-            "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
-            "dev": true,
-            "requires": {
-                "d": "1",
-                "es5-ext": "^0.10.35",
-                "es6-symbol": "^3.1.1"
-            }
-        },
         "es6-promise": {
             "version": "3.2.1",
             "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz",
@@ -6990,7 +6958,6 @@
             "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
             "dev": true,
             "requires": {
-                "d": "^1.0.1",
                 "ext": "^1.1.2"
             }
         },
@@ -22663,12 +22630,6 @@
             "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
             "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
         },
-        "type": {
-            "version": "1.2.0",
-            "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
-            "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==",
-            "dev": true
-        },
         "type-check": {
             "version": "0.4.0",
             "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 8d6659f07..37ede931d 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -911,7 +911,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
                     this.props.addDocument?.(newDoc);
                 }
                 // Create link between prompt and image
-                DocUtils.MakeLink({ doc: this.rootDoc }, { doc: newDoc }, 'Image Prompt');
+                DocUtils.MakeLink(this.rootDoc, newDoc, { linkRelationship: 'Image Prompt' });
             }
         } catch (err) {
             console.log(err);
-- 
cgit v1.2.3-70-g09d2


From c2d3e35ff0ce5200776f31bc5030dc3041d19371 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 17 Apr 2023 15:15:46 -0400
Subject: minor cleanup

---
 src/client/views/nodes/WebBox.tsx                         |  4 ++--
 src/client/views/nodes/formattedText/FormattedTextBox.tsx |  9 ---------
 src/client/views/pdf/AnchorMenu.tsx                       | 14 +-------------
 3 files changed, 3 insertions(+), 24 deletions(-)

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

diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx
index 94724c006..e05b48c0b 100644
--- a/src/client/views/nodes/WebBox.tsx
+++ b/src/client/views/nodes/WebBox.tsx
@@ -14,6 +14,7 @@ import { TraceMobx } from '../../../fields/util';
 import { emptyFunction, getWordAtPoint, returnFalse, returnOne, returnZero, setupMoveUpEvents, smoothScroll, StopEvent, Utils } from '../../../Utils';
 import { Docs, DocUtils } from '../../documents/Documents';
 import { DocumentManager } from '../../util/DocumentManager';
+import { DragManager } from '../../util/DragManager';
 import { ScriptingGlobals } from '../../util/ScriptingGlobals';
 import { SnappingManager } from '../../util/SnappingManager';
 import { undoBatch, UndoManager } from '../../util/UndoManager';
@@ -27,6 +28,7 @@ import { LightboxView } from '../LightboxView';
 import { MarqueeAnnotator } from '../MarqueeAnnotator';
 import { AnchorMenu } from '../pdf/AnchorMenu';
 import { Annotation } from '../pdf/Annotation';
+import { GPTPopup } from '../pdf/GPTPopup/GPTPopup';
 import { SidebarAnnos } from '../SidebarAnnos';
 import { StyleProp } from '../StyleProvider';
 import { DocFocusOptions, DocumentView, DocumentViewProps, OpenWhere } from './DocumentView';
@@ -35,8 +37,6 @@ import { LinkDocPreview } from './LinkDocPreview';
 import { PinProps, PresBox } from './trails';
 import './WebBox.scss';
 import React = require('react');
-import { DragManager } from '../../util/DragManager';
-import { GPTPopup } from '../pdf/GPTPopup/GPTPopup';
 const { CreateImage } = require('./WebBoxRenderer');
 const _global = (window /* browser */ || global) /* node */ as any;
 const htmlToText = require('html-to-text');
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index ff4e725ed..3e60441aa 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -858,14 +858,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
         this._downX = this._downY = Number.NaN;
     };
 
-    mockGPT = async (): Promise<string> => {
-        return new Promise((resolve, reject) => {
-            setTimeout(() => {
-                resolve('Mock GPT Call.');
-            }, 2000);
-        });
-    };
-
     animateRes = (resIndex: number) => {
         if (resIndex < this.gptRes.length) {
             this.dataDoc.text = (this.dataDoc.text as RichTextField)?.Text + this.gptRes[resIndex];
@@ -878,7 +870,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
     askGPT = action(async () => {
         try {
             let res = await gptAPICall((this.dataDoc.text as RichTextField)?.Text, GPTCallType.COMPLETION);
-            // let res = await this.mockGPT();
             if (res) {
                 this.gptRes = res;
                 this.animateRes(0);
diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx
index 620750f36..d6dddf71a 100644
--- a/src/client/views/pdf/AnchorMenu.tsx
+++ b/src/client/views/pdf/AnchorMenu.tsx
@@ -149,18 +149,6 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
         );
     }
 
-    /**
-     * Returns a mock api response.
-     * @returns A Promise that resolves into a string
-     */
-    mockGPTCall = async (): Promise<string> => {
-        return new Promise((resolve, reject) => {
-            setTimeout(() => {
-                resolve('test');
-            }, 1000);
-        });
-    };
-
     /**
      * Invokes the API with the selected text and stores it in the summarized text.
      * @param e pointer down event
@@ -203,7 +191,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
 
         try {
             let res = await gptAPICall(selectedText, GPTCallType.EDIT);
-             // let res = await this.mockGPTCall();
+            // let res = await this.mockGPTCall();
             if (!res) return;
             res = res.trim();
             const resultText = fullText.slice(0, sel.from - 1) + res + fullText.slice(sel.to - 1);
-- 
cgit v1.2.3-70-g09d2


From 0a66b0f369a13d5f399bf125727aff73cd6fd1b4 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Wed, 19 Apr 2023 13:01:37 -0400
Subject: fixed image importing metadata for gpt. fixed showTitle UI to be able
 to turn off titles on templates (like icon views). added ui for choosing
 primary/alternate image and a drop target for setting alternate.

---
 src/client/documents/Documents.ts                  |  2 +
 src/client/views/MainView.tsx                      |  2 +
 src/client/views/PropertiesButtons.tsx             |  6 ++-
 src/client/views/nodes/ImageBox.scss               |  8 +++
 src/client/views/nodes/ImageBox.tsx                | 58 +++++++++++++++++++---
 .../views/nodes/formattedText/FormattedTextBox.tsx |  6 ++-
 src/client/views/nodes/trails/PresBox.tsx          |  4 +-
 7 files changed, 73 insertions(+), 13 deletions(-)

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

diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 09b45a481..a0149eadf 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -147,6 +147,8 @@ export class DocumentOptions {
     _panY?: NUMt = new NumInfo('vertical pan location of a freeform view');
     _width?: NUMt = new NumInfo('displayed width of a document');
     _height?: NUMt = new NumInfo('displayed height of document');
+    'data-nativeWidth'?: NUMt = new NumInfo('native width of data field contents (e.g., the pixel width of an image)');
+    'data-nativeHeight'?: NUMt = new NumInfo('native height of data field contents (e.g., the pixel height of an image)');
     _nativeWidth?: NUMt = new NumInfo('native width of document contents (e.g., the pixel width of an image)');
     _nativeHeight?: NUMt = new NumInfo('native height of document contents (e.g., the pixel height of an image)');
     _nativeDimModifiable?: BOOLt = new BoolInfo('native dimensions can be modified using document decoration reizers');
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index acbe0cbc3..4cbf8a811 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -314,6 +314,8 @@ export class MainView extends React.Component {
                 fa.faArrowUp,
                 fa.faBolt,
                 fa.faBullseye,
+                fa.faTurnUp,
+                fa.faTurnDown,
                 fa.faCaretUp,
                 fa.faCat,
                 fa.faCheck,
diff --git a/src/client/views/PropertiesButtons.tsx b/src/client/views/PropertiesButtons.tsx
index 031d501ad..98dcf4f21 100644
--- a/src/client/views/PropertiesButtons.tsx
+++ b/src/client/views/PropertiesButtons.tsx
@@ -177,7 +177,11 @@ export class PropertiesButtons extends React.Component<{}, {}> {
             '_showTitle',
             on => 'Switch between title styles',
             on => 'text-width',
-            (dv, doc) => ((dv?.rootDoc || doc)._showTitle = !(dv?.rootDoc || doc)._showTitle ? 'title' : (dv?.rootDoc || doc)._showTitle === 'title' ? 'title:hover' : undefined)
+            (dv, doc) => {
+                const tdoc = dv?.rootDoc || doc;
+                const newtitle = !tdoc._showTitle ? 'title' : tdoc._showTitle === 'title' ? 'title:hover' : '';
+                tdoc._showTitle = newtitle;
+            }
         );
     }
     @computed get autoHeightButton() {
diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss
index 6359a9491..22dbc1e80 100644
--- a/src/client/views/nodes/ImageBox.scss
+++ b/src/client/views/nodes/ImageBox.scss
@@ -119,6 +119,14 @@
         }
     }
 }
+.imageBox-alternateDropTarget {
+    position: absolute;
+    color: white;
+    background: black;
+    right: 0;
+    bottom: 0;
+    z-index: 2;
+}
 
 .imageBox-fader img {
     position: absolute;
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index e8d4be1fd..98df777cb 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -1,3 +1,5 @@
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { Tooltip } from '@material-ui/core';
 import { action, computed, IReactionDisposer, observable, ObservableMap, reaction, runInAction } from 'mobx';
 import { observer } from 'mobx-react';
 import { extname } from 'path';
@@ -8,7 +10,7 @@ import { List } from '../../../fields/List';
 import { ObjectField } from '../../../fields/ObjectField';
 import { createSchema } from '../../../fields/Schema';
 import { ComputedField } from '../../../fields/ScriptField';
-import { BoolCast, Cast, NumCast, StrCast } from '../../../fields/Types';
+import { Cast, NumCast, StrCast } from '../../../fields/Types';
 import { ImageField } from '../../../fields/URLField';
 import { TraceMobx } from '../../../fields/util';
 import { DashColor, emptyFunction, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents, Utils } from '../../../Utils';
@@ -19,6 +21,7 @@ import { DocumentType } from '../../documents/DocumentTypes';
 import { Networking } from '../../Network';
 import { DocumentManager } from '../../util/DocumentManager';
 import { DragManager } from '../../util/DragManager';
+import { SnappingManager } from '../../util/SnappingManager';
 import { undoBatch } from '../../util/UndoManager';
 import { ContextMenu } from '../../views/ContextMenu';
 import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView';
@@ -57,6 +60,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
     private _dropDisposer?: DragManager.DragDropDisposer;
     private _disposers: { [name: string]: IReactionDisposer } = {};
     private _getAnchor: (savedAnnotations: Opt<ObservableMap<number, HTMLDivElement[]>>, addAsAnnotation: boolean) => Opt<Doc> = () => undefined;
+    private _overlayIconRef = React.createRef<HTMLDivElement>();
     @observable _curSuffix = '';
     @observable _uploadIcon = uploadIcons.idle;
 
@@ -131,10 +135,16 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
     @action
     drop = (e: Event, de: DragManager.DropEvent) => {
         if (de.complete.docDragData) {
-            if (de.metaKey) {
+            const targetIsBullseye = (ele: HTMLElement): boolean => {
+                if (!ele) return false;
+                if (ele === this._overlayIconRef.current) return true;
+                return targetIsBullseye(ele.parentElement as HTMLElement);
+            };
+            if (de.metaKey || targetIsBullseye(e.target as HTMLElement)) {
                 de.complete.docDragData.droppedDocuments.forEach(
                     action((drop: Doc) => {
                         Doc.AddDocToList(this.dataDoc, this.fieldKey + '-alternates', drop);
+                        this.rootDoc[this.fieldKey + '-usePath'] = 'alternate:hover';
                         e.stopPropagation();
                     })
                 );
@@ -154,8 +164,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
 
     @undoBatch
     resolution = () => (this.layoutDoc._showFullRes = !this.layoutDoc._showFullRes);
-    @undoBatch
-    setUseAlt = () => (this.layoutDoc[this.fieldKey + '-useAlt'] = !this.layoutDoc[this.fieldKey + '-useAlt']);
 
     @undoBatch
     setNativeSize = action(() => {
@@ -239,7 +247,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
             funcs.push({ description: 'Rotate Clockwise 90', event: this.rotate, icon: 'redo-alt' });
             funcs.push({ description: `Show ${this.layoutDoc._showFullRes ? 'Dynamic Res' : 'Full Res'}`, event: this.resolution, icon: 'expand' });
             funcs.push({ description: 'Set Native Pixel Size', event: this.setNativeSize, icon: 'expand-arrows-alt' });
-            funcs.push({ description: `${this.layoutDoc[this.fieldKey + '-useAlt'] ? 'Show Alternate' : 'Show Primary'}`, event: this.setUseAlt, icon: 'image' });
             funcs.push({ description: 'Copy path', event: () => Utils.CopyText(this.choosePath(field.url)), icon: 'copy' });
             if (!Doc.noviceMode) {
                 funcs.push({ description: 'Export to Google Photos', event: () => GooglePhotos.Transactions.UploadImages([this.props.Document]), icon: 'caret-square-right' });
@@ -356,6 +363,41 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
         const nativeOrientation = NumCast(this.dataDoc[this.fieldKey + '-nativeOrientation'], 1);
         return { nativeWidth, nativeHeight, nativeOrientation };
     }
+    @computed get overlayImageIcon() {
+        const usePath = this.rootDoc[`_${this.fieldKey}-usePath`];
+        return (
+            <Tooltip
+                title={
+                    <div className="dash-tooltip">
+                        toggle between
+                        <span style={{ color: usePath === undefined ? 'black' : undefined }}>
+                            <em> primary, </em>
+                        </span>
+                        <span style={{ color: usePath === 'alternate' ? 'black' : undefined }}>
+                            <em>alternate, </em>
+                        </span>
+                        and show
+                        <span style={{ color: usePath === 'alternate:hover' ? 'black' : undefined }}>
+                            <em> alternate on hover</em>
+                        </span>
+                    </div>
+                }>
+                <div
+                    className="imageBox-alternateDropTarget"
+                    ref={this._overlayIconRef}
+                    onPointerDown={e => setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, e => (this.rootDoc[`_${this.fieldKey}-usePath`] = usePath === undefined ? 'alternate' : usePath === 'alternate' ? 'alternate:hover' : undefined))}
+                    style={{
+                        display: (SnappingManager.GetIsDragging() && DragManager.DocDragData?.canEmbed) || DocListCast(this.dataDoc[this.fieldKey + '-alternates']).length ? 'block' : 'none',
+                        width: 'min(10%, 25px)',
+                        height: 'min(10%, 25px)',
+                        background: usePath === undefined ? 'white' : usePath === 'alternate' ? 'black' : 'gray',
+                        color: usePath === undefined ? 'black' : 'white',
+                    }}>
+                    <FontAwesomeIcon icon="turn-up" size="lg" />
+                </div>
+            </Tooltip>
+        );
+    }
 
     @computed get paths() {
         const field = Cast(this.dataDoc[this.fieldKey], ImageField, null); // retrieve the primary image URL that is being rendered from the data doc
@@ -389,15 +431,14 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
             transformOrigin = 'right top';
             transform = `translate(-100%, 0%) rotate(${rotation}deg) scale(${aspect})`;
         }
+        const usePath = this.rootDoc[`_${this.fieldKey}-usePath`];
 
         return (
             <div className="imageBox-cont" onPointerEnter={action(() => (this._isHovering = true))} onPointerLeave={action(() => (this._isHovering = false))} key={this.layoutDoc[Id]} ref={this.createDropTarget} onPointerDown={this.marqueeDown}>
                 <div className="imageBox-fader" style={{ opacity: backAlpha }}>
                     <img key="paths" src={srcpath} style={{ transform, transformOrigin }} draggable={false} width={nativeWidth} />
                     {fadepath === srcpath ? null : (
-                        <div
-                            className={`imageBox-fadeBlocker${(this._isHovering && this.layoutDoc[this.fieldKey + '-useAlt'] === undefined) || BoolCast(this.layoutDoc[this.fieldKey + '-useAlt']) ? '-hover' : ''}`}
-                            style={{ transition: StrCast(this.layoutDoc.viewTransition, 'opacity 1000ms') }}>
+                        <div className={`imageBox-fadeBlocker${(this._isHovering && usePath === 'alternate:hover') || usePath === 'alternate' ? '-hover' : ''}`} style={{ transition: StrCast(this.layoutDoc.viewTransition, 'opacity 1000ms') }}>
                             <img className="imageBox-fadeaway" key="fadeaway" src={fadepath} style={{ transform, transformOrigin }} draggable={false} width={nativeWidth} />
                         </div>
                     )}
@@ -405,6 +446,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
                 {!Doc.noviceMode && this.considerDownloadIcon}
                 {this.considerGooglePhotosLink()}
                 <FaceRectangles document={this.dataDoc} color={'#0000FF'} backgroundColor={'#0000FF'} />
+                {this.overlayImageIcon}
             </div>
         );
     }
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 3e60441aa..bbe38cf99 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -885,13 +885,15 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
         try {
             let image_url = await gptImageCall((this.dataDoc.text as RichTextField)?.Text);
             if (image_url) {
-                const [{ accessPaths }] = await Networking.PostToServer('/uploadRemoteImage', { sources: [image_url] });
-                const source = Utils.prepend(accessPaths.agnostic.client);
+                const [result] = await Networking.PostToServer('/uploadRemoteImage', { sources: [image_url] });
+                const source = Utils.prepend(result.accessPaths.agnostic.client);
                 const newDoc = Docs.Create.ImageDocument(source, {
                     x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10,
                     y: NumCast(this.rootDoc.y),
                     _height: 200,
                     _width: 200,
+                    'data-nativeWidth': result.nativeWidth,
+                    'data-nativeHeight': result.nativeHeight,
                 });
                 if (DocListCast(Doc.MyOverlayDocs?.data).includes(this.rootDoc)) {
                     newDoc.overlayX = this.rootDoc.x;
diff --git a/src/client/views/nodes/trails/PresBox.tsx b/src/client/views/nodes/trails/PresBox.tsx
index 807a19771..f8c47aafe 100644
--- a/src/client/views/nodes/trails/PresBox.tsx
+++ b/src/client/views/nodes/trails/PresBox.tsx
@@ -527,7 +527,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() {
             bestTarget._dataTransition = `all ${transTime}ms`;
             const fkey = Doc.LayoutFieldKey(bestTarget);
             Doc.GetProto(bestTarget)[fkey] = activeItem.presData instanceof ObjectField ? activeItem.presData[Copy]() : activeItem.presData;
-            bestTarget[fkey + '-useAlt'] = activeItem.presUseAlt;
+            bestTarget[fkey + '-usePath'] = activeItem.presUsePath;
             setTimeout(() => (bestTarget._dataTransition = undefined), transTime + 10);
         }
         if ((pinDataTypes?.textview && activeItem.presData !== undefined) || (!pinDataTypes && activeItem.presData !== undefined)) {
@@ -613,7 +613,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() {
                 pinProps?.activeFrame !== undefined;
             const fkey = Doc.LayoutFieldKey(targetDoc);
             if (pinProps.pinData.dataview) {
-                pinDoc.presUseAlt = targetDoc[fkey + '-useAlt'];
+                pinDoc.presUsePath = targetDoc[fkey + '-usePath'];
                 pinDoc.presData = targetDoc[fkey] instanceof ObjectField ? (targetDoc[fkey] as ObjectField)[Copy]() : targetDoc.data;
             }
             if (pinProps.pinData.dataannos) {
-- 
cgit v1.2.3-70-g09d2


From 33299a19d86948051eef442825c0b3241d1ac619 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 24 Apr 2023 11:55:53 -0400
Subject: fixed isContentActive=false to apply to stacking collections. fixed
 pile view to be faster and to work in fit content panels. fixed issues with
 hidden docs and freeformviews that act as lightboxes - hidden docs can be
 shown as the lightbox doc without modifying the hidden flag to allow
 collection state to be restored.

---
 src/client/documents/Documents.ts                  |   2 +-
 src/client/util/CurrentUserUtils.ts                |  12 +--
 src/client/util/DocumentManager.ts                 |  15 ++-
 src/client/views/DocComponent.tsx                  | 104 ++++++++++----------
 src/client/views/LightboxView.tsx                  |   6 +-
 src/client/views/MainView.tsx                      |  18 ++--
 src/client/views/PropertiesButtons.tsx             |   7 +-
 src/client/views/StyleProvider.tsx                 |   5 +-
 .../views/collections/CollectionPileView.tsx       |  35 +++----
 .../views/collections/CollectionStackingView.tsx   |  15 ++-
 .../views/collections/CollectionTimeView.tsx       |   2 +-
 src/client/views/collections/CollectionView.tsx    |   1 +
 src/client/views/collections/TreeView.tsx          |   2 -
 .../CollectionFreeFormLayoutEngines.tsx            |  23 ++---
 .../collectionFreeForm/CollectionFreeFormView.tsx  | 107 ++++++++++++++-------
 .../collections/collectionFreeForm/MarqueeView.tsx |   8 +-
 src/client/views/nodes/DocumentView.tsx            |  57 ++++++-----
 src/client/views/nodes/ImageBox.scss               |   4 +-
 src/client/views/nodes/ImageBox.tsx                |   1 +
 src/client/views/nodes/WebBox.scss                 |   1 +
 .../views/nodes/formattedText/DashDocView.tsx      |   2 +
 .../nodes/formattedText/FormattedTextBox.scss      |  30 ++++++
 .../views/nodes/formattedText/FormattedTextBox.tsx |  47 ++++++++-
 src/fields/util.ts                                 |   3 +
 24 files changed, 315 insertions(+), 192 deletions(-)

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

diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index a0149eadf..dee5feebc 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -1066,7 +1066,7 @@ export namespace Docs {
         }
 
         export function PileDocument(documents: Array<Doc>, options: DocumentOptions, id?: string) {
-            return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { _overflow: 'visible', _forceActive: true, _noAutoscroll: true, ...options, _viewType: CollectionViewType.Pile }, id);
+            return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { _overflow: 'visible', enableDragWhenActive: true, _forceActive: true, _noAutoscroll: true, ...options, _viewType: CollectionViewType.Pile }, id);
         }
 
         export function LinearDocument(documents: Array<Doc>, options: DocumentOptions, id?: string) {
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index abf7313a4..cdb12624f 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -619,12 +619,12 @@ export class CurrentUserUtils {
     }
     static viewTools(): Button[] {
         return [
-            { title: "Grid",         icon: "border-all",  toolTip: "Show Grid",           btnType: ButtonType.ToggleButton, expertMode: false, toolType:"grid",       funcs: {}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform
-            { title: "Snap\xA0Lines",icon: "th",          toolTip: "Show Snap Lines",     btnType: ButtonType.ToggleButton, expertMode: false, toolType:"snap lines", funcs: {}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform
-            { title: "View\xA0All",  icon: "object-group",toolTip: "Fit all Docs to View",btnType: ButtonType.ToggleButton, expertMode: false, toolType:"viewAll",    funcs: {}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform
-            { title: "Clusters",     icon: "braille",     toolTip: "Show Doc Clusters",   btnType: ButtonType.ToggleButton, expertMode: false, toolType:"clusters",   funcs: {}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform
-            { title: "Arrange",      icon: "window",      toolTip: "Toggle Auto Arrange", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"arrange",    funcs: {hidden: 'IsNoviceMode()'}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform
-            { title: "Reset",        icon: "check",       toolTip: "Reset View",          btnType: ButtonType.ClickButton,  expertMode: false, backgroundColor:"transparent",    scripts: { onClick: 'resetView()'}}, // Only when floating document is selected in freeform
+            { title: "Grid",          icon: "border-all",   toolTip: "Show Grid",           btnType: ButtonType.ToggleButton, expertMode: false, toolType:"grid",       funcs: {}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform
+            { title: "Snap\xA0Lines", icon: "th",           toolTip: "Show Snap Lines",     btnType: ButtonType.ToggleButton, expertMode: false, toolType:"snap lines", funcs: {}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform
+            { title: "View\xA0All",   icon: "object-group", toolTip: "Fit all Docs to View",btnType: ButtonType.ToggleButton, expertMode: false, toolType:"viewAll",    funcs: {}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform
+            { title: "Clusters",      icon: "braille",      toolTip: "Show Doc Clusters",   btnType: ButtonType.ToggleButton, expertMode: false, toolType:"clusters",   funcs: {}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform
+            { title: "Arrange",icon: "arrow-down-short-wide",toolTip: "Toggle Auto Arrange",btnType: ButtonType.ToggleButton, expertMode: false, toolType:"arrange",    funcs: {hidden: 'IsNoviceMode()'}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform
+            { title: "Reset",         icon: "check",        toolTip: "Reset View",          btnType: ButtonType.ClickButton,  expertMode: false, backgroundColor:"transparent",    scripts: { onClick: 'resetView()'}}, // Only when floating document is selected in freeform
         ]
     }
     static textTools():Button[] {
diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts
index e01457b4f..4542c1c05 100644
--- a/src/client/util/DocumentManager.ts
+++ b/src/client/util/DocumentManager.ts
@@ -8,8 +8,7 @@ import { CollectionViewType } from '../documents/DocumentTypes';
 import { CollectionDockingView } from '../views/collections/CollectionDockingView';
 import { TabDocView } from '../views/collections/TabDocView';
 import { LightboxView } from '../views/LightboxView';
-import { MainView } from '../views/MainView';
-import { DocFocusOptions, DocumentView, OpenWhere, OpenWhereMod } from '../views/nodes/DocumentView';
+import { DocFocusOptions, DocumentView, DocumentViewInternal, OpenWhere, OpenWhereMod } from '../views/nodes/DocumentView';
 import { FormattedTextBox } from '../views/nodes/formattedText/FormattedTextBox';
 import { LinkAnchorBox } from '../views/nodes/LinkAnchorBox';
 import { PresBox } from '../views/nodes/trails';
@@ -227,7 +226,7 @@ export class DocumentManager {
         let rootContextView = docViewPath.shift();
         await (rootContextView && this.focusViewsInPath(rootContextView, options, async () => ({ childDocView: docViewPath.shift(), viewSpec: undefined })));
         if (options.toggleTarget && (!options.didMove || targetDocView.rootDoc.hidden)) targetDocView.rootDoc.hidden = !targetDocView.rootDoc.hidden;
-        else if (options.openLocation?.startsWith(OpenWhere.toggle) && !options.didMove && rootContextView) MainView.addDocTabFunc(rootContextView.rootDoc, options.openLocation);
+        else if (options.openLocation?.startsWith(OpenWhere.toggle) && !options.didMove && rootContextView) DocumentViewInternal.addDocTabFunc(rootContextView.rootDoc, options.openLocation);
     };
 
     // shows a document by first:
@@ -247,9 +246,17 @@ export class DocumentManager {
             const viewIndex = docContextPath.findIndex(doc => this.getDocumentView(doc));
             if (viewIndex !== -1) return res(this.getDocumentView(docContextPath[viewIndex])!);
             options.didMove = true;
-            docContextPath.some(doc => TabDocView.Activate(doc)) || MainView.addDocTabFunc(docContextPath[0], options.openLocation ?? OpenWhere.addRight);
+            docContextPath.some(doc => TabDocView.Activate(doc)) || DocumentViewInternal.addDocTabFunc(docContextPath[0], options.openLocation ?? OpenWhere.addRight);
             this.AddViewRenderedCb(docContextPath[0], dv => res(dv));
         });
+        if (options.openLocation === OpenWhere.lightbox) {
+            // even if we found the document view, if the target is a lightbox, we try to open it in the lightbox to preserve lightbox semantics (eg, there's only one active doc in the lightbox)
+            const target = DocCast(targetDoc.annotationOn, targetDoc);
+            const contextView = this.getDocumentView(DocCast(target.context));
+            if (contextView?.docView?._componentView?.addDocTab?.(target, OpenWhere.lightbox)) {
+                await new Promise<void>(waitres => setTimeout(() => waitres()));
+            }
+        }
         docContextPath.shift();
         const childViewIterator = async (docView: DocumentView) => {
             const innerDoc = docContextPath.shift();
diff --git a/src/client/views/DocComponent.tsx b/src/client/views/DocComponent.tsx
index a59189fd2..d60ad68c6 100644
--- a/src/client/views/DocComponent.tsx
+++ b/src/client/views/DocComponent.tsx
@@ -141,29 +141,25 @@ export function ViewBoxAnnotatableComponent<P extends ViewBoxAnnotatableProps>()
             const effectiveAcl = GetEffectiveAcl(this.dataDoc);
             const indocs = doc instanceof Doc ? [doc] : doc;
             const docs = indocs.filter(doc => [AclEdit, AclAdmin].includes(effectiveAcl) || GetEffectiveAcl(doc) === AclAdmin);
-            if (docs.length) {
-                docs.map(doc => {
-                    Doc.SetInPlace(doc, 'followLinkToggle', undefined, true);
-                    doc.annotationOn === this.props.Document && Doc.SetInPlace(doc, 'annotationOn', undefined, true);
+
+            docs.forEach(doc => doc.annotationOn === this.props.Document && Doc.SetInPlace(doc, 'annotationOn', undefined, true));
+            const targetDataDoc = this.dataDoc;
+            const value = DocListCast(targetDataDoc[annotationKey ?? this.annotationKey]);
+            const toRemove = value.filter(v => docs.includes(v));
+
+            if (toRemove.length !== 0) {
+                const recent = Doc.MyRecentlyClosed;
+                toRemove.forEach(doc => {
+                    leavePushpin && DocUtils.LeavePushpin(doc, annotationKey ?? this.annotationKey);
+                    Doc.RemoveDocFromList(targetDataDoc, annotationKey ?? this.annotationKey, doc);
+                    doc.context = undefined;
+                    if (recent) {
+                        Doc.RemoveDocFromList(recent, 'data', doc);
+                        doc.type !== DocumentType.LOADING && Doc.AddDocToList(recent, 'data', doc, undefined, true, true);
+                    }
                 });
-                const targetDataDoc = this.dataDoc;
-                const value = DocListCast(targetDataDoc[annotationKey ?? this.annotationKey]);
-                const toRemove = value.filter(v => docs.includes(v));
-
-                if (toRemove.length !== 0) {
-                    const recent = Doc.MyRecentlyClosed;
-                    toRemove.forEach(doc => {
-                        leavePushpin && DocUtils.LeavePushpin(doc, annotationKey ?? this.annotationKey);
-                        Doc.RemoveDocFromList(targetDataDoc, annotationKey ?? this.annotationKey, doc);
-                        doc.context = undefined;
-                        if (recent) {
-                            Doc.RemoveDocFromList(recent, 'data', doc);
-                            doc.type !== DocumentType.LOADING && Doc.AddDocToList(recent, 'data', doc, undefined, true, true);
-                        }
-                    });
-                    this.isAnyChildContentActive() && this.props.select(false);
-                    return true;
-                }
+                this.isAnyChildContentActive() && this.props.select(false);
+                return true;
             }
 
             return false;
@@ -190,46 +186,44 @@ export function ViewBoxAnnotatableComponent<P extends ViewBoxAnnotatableProps>()
                 return false;
             }
             const targetDataDoc = this.props.Document[DataSym];
-            const docList = DocListCast(targetDataDoc[annotationKey ?? this.annotationKey]);
-            const added = docs.filter(d => !docList.includes(d));
             const effectiveAcl = GetEffectiveAcl(targetDataDoc);
 
+            if (effectiveAcl === AclPrivate || effectiveAcl === AclReadonly) {
+                return false;
+            }
+            const added = docs;
             if (added.length) {
-                if (effectiveAcl === AclPrivate || effectiveAcl === AclReadonly) {
-                    return false;
-                } else {
-                    if (this.props.Document[AclSym] && Object.keys(this.props.Document[AclSym]).length) {
-                        added.forEach(d => {
-                            for (const [key, value] of Object.entries(this.props.Document[AclSym])) {
-                                if (d.author === denormalizeEmail(key.substring(4)) && !d.aliasOf) distributeAcls(key, SharingPermissions.Admin, d);
-                            }
-                        });
-                    }
+                if (this.props.Document[AclSym] && Object.keys(this.props.Document[AclSym]).length) {
+                    added.forEach(d => {
+                        for (const key of Object.keys(this.props.Document[AclSym])) {
+                            if (d.author === denormalizeEmail(key.substring(4)) && !d.aliasOf) distributeAcls(key, SharingPermissions.Admin, d);
+                        }
+                    });
+                }
 
-                    if (effectiveAcl === AclAugment) {
-                        added.map(doc => {
-                            if ([AclAdmin, AclEdit].includes(GetEffectiveAcl(doc)) && Doc.ActiveDashboard) inheritParentAcls(Doc.ActiveDashboard, doc);
+                if (effectiveAcl === AclAugment) {
+                    added.map(doc => {
+                        if ([AclAdmin, AclEdit].includes(GetEffectiveAcl(doc)) && Doc.ActiveDashboard) inheritParentAcls(Doc.ActiveDashboard, doc);
+                        doc.context = this.props.Document;
+                        if (annotationKey ?? this._annotationKeySuffix()) Doc.GetProto(doc).annotationOn = this.props.Document;
+                        Doc.AddDocToList(targetDataDoc, annotationKey ?? this.annotationKey, doc);
+                    });
+                } else {
+                    added
+                        .filter(doc => [AclAdmin, AclEdit].includes(GetEffectiveAcl(doc)))
+                        .map(doc => {
+                            // only make a pushpin if we have acl's to edit the document
+                            //DocUtils.LeavePushpin(doc);
+                            doc._stayInCollection = undefined;
                             doc.context = this.props.Document;
                             if (annotationKey ?? this._annotationKeySuffix()) Doc.GetProto(doc).annotationOn = this.props.Document;
-                            Doc.AddDocToList(targetDataDoc, annotationKey ?? this.annotationKey, doc);
+
+                            Doc.ActiveDashboard && inheritParentAcls(Doc.ActiveDashboard, doc);
                         });
-                    } else {
-                        added
-                            .filter(doc => [AclAdmin, AclEdit].includes(GetEffectiveAcl(doc)))
-                            .map(doc => {
-                                // only make a pushpin if we have acl's to edit the document
-                                //DocUtils.LeavePushpin(doc);
-                                doc._stayInCollection = undefined;
-                                doc.context = this.props.Document;
-                                if (annotationKey ?? this._annotationKeySuffix()) Doc.GetProto(doc).annotationOn = this.props.Document;
-
-                                Doc.ActiveDashboard && inheritParentAcls(Doc.ActiveDashboard, doc);
-                            });
-                        const annoDocs = targetDataDoc[annotationKey ?? this.annotationKey] as List<Doc>;
-                        if (annoDocs instanceof List) annoDocs.push(...added);
-                        else targetDataDoc[annotationKey ?? this.annotationKey] = new List<Doc>(added);
-                        targetDataDoc[(annotationKey ?? this.annotationKey) + '-lastModified'] = new DateField(new Date(Date.now()));
-                    }
+                    const annoDocs = targetDataDoc[annotationKey ?? this.annotationKey] as List<Doc>;
+                    if (annoDocs instanceof List) annoDocs.push(...added);
+                    else targetDataDoc[annotationKey ?? this.annotationKey] = new List<Doc>(added);
+                    targetDataDoc[(annotationKey ?? this.annotationKey) + '-lastModified'] = new DateField(new Date(Date.now()));
                 }
             }
             return true;
diff --git a/src/client/views/LightboxView.tsx b/src/client/views/LightboxView.tsx
index 69eec8456..c18a89481 100644
--- a/src/client/views/LightboxView.tsx
+++ b/src/client/views/LightboxView.tsx
@@ -63,10 +63,8 @@ export class LightboxView extends React.Component<LightboxViewProps> {
             Doc.ActiveTool = InkTool.None;
             MainView.Instance._exploreMode = false;
         } else {
-            if (doc) {
-                const l = DocUtils.MakeLinkToActiveAudio(() => doc).lastElement();
-                l && (Cast(l.anchor2, Doc, null).backgroundColor = 'lightgreen');
-            }
+            const l = DocUtils.MakeLinkToActiveAudio(() => doc).lastElement();
+            l && (Cast(l.anchor2, Doc, null).backgroundColor = 'lightgreen');
             CollectionStackedTimeline.CurrentlyPlaying?.forEach(dv => dv.ComponentView?.Pause?.());
             //TabDocView.PinDoc(doc, { hidePresBox: true });
             this._history ? this._history.push({ doc, target }) : (this._history = [{ doc, target }]);
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index 4cbf8a811..e4554c339 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -49,7 +49,7 @@ import { LinkMenu } from './linking/LinkMenu';
 import './MainView.scss';
 import { AudioBox } from './nodes/AudioBox';
 import { DocumentLinksButton } from './nodes/DocumentLinksButton';
-import { DocumentView, OpenWhere, OpenWhereMod } from './nodes/DocumentView';
+import { DocumentView, DocumentViewInternal, OpenWhere, OpenWhereMod } from './nodes/DocumentView';
 import { DashFieldViewMenu } from './nodes/formattedText/DashFieldView';
 import { FormattedTextBox } from './nodes/formattedText/FormattedTextBox';
 import { RichTextMenu } from './nodes/formattedText/RichTextMenu';
@@ -222,6 +222,7 @@ export class MainView extends React.Component {
 
     constructor(props: Readonly<{}>) {
         super(props);
+        DocumentViewInternal.addDocTabFunc = MainView.addDocTabFunc_impl;
         MainView.Instance = this;
         DashboardView._urlState = HistoryUtil.parseUrl(window.location) || ({} as any);
 
@@ -245,6 +246,7 @@ export class MainView extends React.Component {
             ...[
                 fa.faExclamationCircle,
                 fa.faEdit,
+                fa.faArrowDownShortWide,
                 fa.faTrash,
                 fa.faTrashAlt,
                 fa.faShare,
@@ -584,7 +586,7 @@ export class MainView extends React.Component {
                     Document={this.headerBarDoc}
                     DataDoc={undefined}
                     addDocument={undefined}
-                    addDocTab={MainView.addDocTabFunc}
+                    addDocTab={DocumentViewInternal.addDocTabFunc}
                     pinToPres={emptyFunction}
                     docViewPath={returnEmptyDoclist}
                     styleProvider={DefaultStyleProvider}
@@ -619,7 +621,7 @@ export class MainView extends React.Component {
                     Document={this.mainContainer!}
                     DataDoc={undefined}
                     addDocument={undefined}
-                    addDocTab={MainView.addDocTabFunc}
+                    addDocTab={DocumentViewInternal.addDocTabFunc}
                     pinToPres={emptyFunction}
                     docViewPath={returnEmptyDoclist}
                     styleProvider={this._hideUI ? DefaultStyleProvider : undefined}
@@ -688,7 +690,7 @@ export class MainView extends React.Component {
 
     sidebarScreenToLocal = () => new Transform(0, -this.topOfSidebarDoc, 1);
     mainContainerXf = () => this.sidebarScreenToLocal().translate(-this.leftScreenOffsetOfMainDocView, 0);
-    static addDocTabFunc = (doc: Doc, location: OpenWhere): boolean => {
+    static addDocTabFunc_impl = (doc: Doc, location: OpenWhere): boolean => {
         const whereFields = doc._viewType === CollectionViewType.Docking ? [OpenWhere.dashboard] : location.split(':');
         const keyValue = whereFields[1]?.includes('KeyValue');
         const whereMods: OpenWhereMod = whereFields.length > 1 ? (whereFields[1].replace('KeyValue', '') as OpenWhereMod) : OpenWhereMod.none;
@@ -716,7 +718,7 @@ export class MainView extends React.Component {
                         Document={this._sidebarContent.proto || this._sidebarContent}
                         DataDoc={undefined}
                         addDocument={undefined}
-                        addDocTab={MainView.addDocTabFunc}
+                        addDocTab={DocumentViewInternal.addDocTabFunc}
                         pinToPres={emptyFunction}
                         docViewPath={returnEmptyDoclist}
                         styleProvider={this._sidebarContent.proto === Doc.MyDashboards || this._sidebarContent.proto === Doc.MyFilesystem ? DashboardStyleProvider : DefaultStyleProvider}
@@ -748,7 +750,7 @@ export class MainView extends React.Component {
                     Document={Doc.MyLeftSidebarMenu}
                     DataDoc={undefined}
                     addDocument={undefined}
-                    addDocTab={MainView.addDocTabFunc}
+                    addDocTab={DocumentViewInternal.addDocTabFunc}
                     pinToPres={emptyFunction}
                     rootSelected={returnTrue}
                     removeDocument={returnFalse}
@@ -810,7 +812,7 @@ export class MainView extends React.Component {
                             </div>
                         )}
                         <div className="properties-container" style={{ width: this.propertiesWidth() }}>
-                            {this.propertiesWidth() < 10 ? null : <PropertiesView styleProvider={DefaultStyleProvider} addDocTab={MainView.addDocTabFunc} width={this.propertiesWidth()} height={this.propertiesHeight()} />}
+                            {this.propertiesWidth() < 10 ? null : <PropertiesView styleProvider={DefaultStyleProvider} addDocTab={DocumentViewInternal.addDocTabFunc} width={this.propertiesWidth()} height={this.propertiesHeight()} />}
                         </div>
                     </div>
                 </div>
@@ -889,7 +891,7 @@ export class MainView extends React.Component {
                     docViewPath={returnEmptyDoclist}
                     moveDocument={this.moveButtonDoc}
                     addDocument={this.addButtonDoc}
-                    addDocTab={MainView.addDocTabFunc}
+                    addDocTab={DocumentViewInternal.addDocTabFunc}
                     pinToPres={emptyFunction}
                     removeDocument={this.remButtonDoc}
                     ScreenToLocalTransform={this.buttonBarXf}
diff --git a/src/client/views/PropertiesButtons.tsx b/src/client/views/PropertiesButtons.tsx
index 98dcf4f21..cf808f801 100644
--- a/src/client/views/PropertiesButtons.tsx
+++ b/src/client/views/PropertiesButtons.tsx
@@ -136,10 +136,11 @@ export class PropertiesButtons extends React.Component<{}, {}> {
                     // containerDoc.noShadow =
                     // containerDoc.disableDocBrushing =
                     // containerDoc._forceActive =
-                    containerDoc._fitContentsToBox = containerDoc._isLightbox = !containerDoc._isLightbox;
-                    containerDoc._xPadding = containerDoc._yPadding = containerDoc._isLightbox ? 10 : undefined;
+                    //containerDoc._fitContentsToBox =
+                    containerDoc._isLightbox = !containerDoc._isLightbox;
+                    //containerDoc._xPadding = containerDoc._yPadding = containerDoc._isLightbox ? 10 : undefined;
                     const containerContents = DocListCast(dv.dataDoc[dv.props.fieldKey ?? Doc.LayoutFieldKey(containerDoc)]);
-                    dv.rootDoc.onClick = ScriptField.MakeScript('{self.data = undefined; documentView.select(false)}', { documentView: 'any' });
+                    //dv.rootDoc.onClick = ScriptField.MakeScript('{self.data = undefined; documentView.select(false)}', { documentView: 'any' });
                     containerContents.forEach(doc => LinkManager.Links(doc).forEach(link => (link.linkDisplay = false)));
                 });
             }
diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx
index 5f16e0ebd..d98e5aa80 100644
--- a/src/client/views/StyleProvider.tsx
+++ b/src/client/views/StyleProvider.tsx
@@ -30,7 +30,6 @@ export enum StyleProp {
     TreeViewSortings = 'treeViewSortings', // options for how to sort tree view items
     DocContents = 'docContents', // when specified, the JSX returned will replace the normal rendering of the document view
     Opacity = 'opacity', // opacity of the document view
-    Hidden = 'hidden', // whether the document view should not be isplayed
     BoxShadow = 'boxShadow', // box shadow - used for making collections standout and for showing clusters in free form views
     BorderRounding = 'borderRounding', // border radius of the document view
     Color = 'color', // foreground color of Document view items
@@ -172,8 +171,6 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps
             const backColor = backgroundCol() || docView?.props.styleProvider?.(docView.props.treeViewDoc, docView.props, StyleProp.BackgroundColor);
             if (!backColor) return undefined;
             return lightOrDark(backColor);
-        case StyleProp.Hidden:
-            return props?.LayoutTemplateString?.includes(KeyValueBox.name) ? false : BoolCast(doc?.hidden);
         case StyleProp.BorderRounding:
             return StrCast(doc?.[fieldKey + 'borderRounding'], StrCast(doc?.borderRounding, doc?._viewType === CollectionViewType.Pile ? '50%' : ''));
         case StyleProp.TitleHeight:
@@ -257,7 +254,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps
                     if (StrCast(Doc.LayoutField(doc)).includes(SliderBox.name)) break;
                     docColor =
                         docColor ||
-                        (doc?._viewType === CollectionViewType.Pile || Doc.IsSystem(doc)
+                        (Doc.IsSystem(doc)
                             ? darkScheme()
                                 ? Colors.DARK_GRAY
                                 : Colors.LIGHT_GRAY // system docs (seen in treeView) get a grayish background
diff --git a/src/client/views/collections/CollectionPileView.tsx b/src/client/views/collections/CollectionPileView.tsx
index fd9b0c0ce..e5d89865b 100644
--- a/src/client/views/collections/CollectionPileView.tsx
+++ b/src/client/views/collections/CollectionPileView.tsx
@@ -1,6 +1,6 @@
 import { action, computed, IReactionDisposer, reaction } from 'mobx';
 import { observer } from 'mobx-react';
-import { Doc, HeightSym, WidthSym } from '../../../fields/Doc';
+import { Doc, DocListCast, HeightSym, WidthSym } from '../../../fields/Doc';
 import { NumCast, StrCast } from '../../../fields/Types';
 import { emptyFunction, returnFalse, returnTrue, setupMoveUpEvents } from '../../../Utils';
 import { DocUtils } from '../../documents/Documents';
@@ -26,12 +26,6 @@ export class CollectionPileView extends CollectionSubView() {
         }
         this._originalChrome = this.layoutDoc._chromeHidden;
         this.layoutDoc._chromeHidden = true;
-
-        // pileups are designed to go away when they are empty.
-        this._disposers.selected = reaction(
-            () => this.childDocs.length,
-            num => !num && this.props.CollectionFreeFormDocumentView?.().props.removeDocument?.(this.props.Document)
-        );
     }
     componentWillUnmount() {
         this.layoutDoc._chromeHidden = this._originalChrome;
@@ -48,13 +42,15 @@ export class CollectionPileView extends CollectionSubView() {
 
     @undoBatch
     removePileDoc = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDoc: (doc: Doc | Doc[]) => boolean) => {
-        (doc instanceof Doc ? [doc] : doc).map(undoBatch(d => Doc.deiconifyView(d)));
-        return this.props.moveDocument?.(doc, targetCollection, addDoc) || false;
+        (doc instanceof Doc ? [doc] : doc).forEach(d => Doc.deiconifyView(d));
+        const ret = this.props.moveDocument?.(doc, targetCollection, addDoc) || false;
+        if (ret && !DocListCast(this.rootDoc[this.fieldKey ?? 'data']).length) this.props.DocumentView?.().props.removeDocument?.(this.rootDoc);
+        return ret;
     };
 
-    toggleIcon = () => {
+    @computed get toggleIcon() {
         return ScriptField.MakeScript('documentView.iconify()', { documentView: 'any' });
-    };
+    }
 
     // returns the contents of the pileup in a CollectionFreeFormView
     @computed get contents() {
@@ -63,11 +59,11 @@ export class CollectionPileView extends CollectionSubView() {
             <div className="collectionPileView-innards" style={{ pointerEvents: isStarburst || SnappingManager.GetIsDragging() ? undefined : 'none' }}>
                 <CollectionFreeFormView
                     {...this.props}
+                    childContentsActive={returnFalse}
                     layoutEngine={this.layoutEngine}
-                    childDocumentsActive={isStarburst ? returnTrue : undefined}
                     addDocument={this.addPileDoc}
                     childCanEmbedOnDrag={true}
-                    childClickScript={this.toggleIcon()}
+                    childClickScript={this.toggleIcon}
                     moveDocument={this.removePileDoc}
                 />
             </div>
@@ -87,15 +83,12 @@ export class CollectionPileView extends CollectionSubView() {
             this.layoutDoc._panY = -10;
             this.props.Document._pileLayoutEngine = computePassLayout.name;
         } else {
-            const defaultSize = 25;
-            !this.layoutDoc._starburstRadius && (this.layoutDoc._starburstRadius = 250);
+            const defaultSize = 500;
             !this.layoutDoc._starburstDocScale && (this.layoutDoc._starburstDocScale = 2.5);
-            if (this.layoutEngine() === computePassLayout.name) {
-                this.rootDoc.x = NumCast(this.rootDoc.x) + this.layoutDoc[WidthSym]() / 2 - defaultSize / 2;
-                this.rootDoc.y = NumCast(this.rootDoc.y) + this.layoutDoc[HeightSym]() / 2 - defaultSize / 2;
-                this.layoutDoc._starburstPileWidth = this.layoutDoc[WidthSym]();
-                this.layoutDoc._starburstPileHeight = this.layoutDoc[HeightSym]();
-            }
+            this.rootDoc.x = NumCast(this.rootDoc.x) + this.layoutDoc[WidthSym]() / 2 - defaultSize / 2;
+            this.rootDoc.y = NumCast(this.rootDoc.y) + this.layoutDoc[HeightSym]() / 2 - defaultSize / 2;
+            this.layoutDoc._starburstPileWidth = this.layoutDoc[WidthSym]();
+            this.layoutDoc._starburstPileHeight = this.layoutDoc[HeightSym]();
             this.layoutDoc._panX = this.layoutDoc._panY = 0;
             this.layoutDoc._width = this.layoutDoc._height = defaultSize;
             this.props.Document._pileLayoutEngine = computeStarburstLayout.name;
diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx
index bdad325d5..020fe1cb4 100644
--- a/src/client/views/collections/CollectionStackingView.tsx
+++ b/src/client/views/collections/CollectionStackingView.tsx
@@ -10,7 +10,7 @@ import { listSpec } from '../../../fields/Schema';
 import { SchemaHeaderField } from '../../../fields/SchemaHeaderField';
 import { BoolCast, Cast, NumCast, ScriptCast, StrCast } from '../../../fields/Types';
 import { TraceMobx } from '../../../fields/util';
-import { emptyFunction, returnEmptyDoclist, returnFalse, returnZero, setupMoveUpEvents, smoothScroll, Utils } from '../../../Utils';
+import { emptyFunction, returnEmptyDoclist, returnFalse, returnNone, returnZero, setupMoveUpEvents, smoothScroll, Utils } from '../../../Utils';
 import { Docs, DocUtils } from '../../documents/Documents';
 import { CollectionViewType } from '../../documents/DocumentTypes';
 import { DragManager, dropActionType } from '../../util/DragManager';
@@ -238,9 +238,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection
         this.createDashEventsTarget(ele!); //so the whole grid is the drop target?
     };
 
-    @computed get onChildClickHandler() {
-        return () => this.props.childClickScript || ScriptCast(this.Document.onChildClick);
-    }
+    onChildClickHandler = () => this.props.childClickScript || ScriptCast(this.Document.onChildClick);
     @computed get onChildDoubleClickHandler() {
         return () => this.props.childDoubleClickScript || ScriptCast(this.Document.onChildDoubleClick);
     }
@@ -300,7 +298,13 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection
 
     @observable _renderCount = 5;
     isChildContentActive = () =>
-        this.props.isDocumentActive?.() && (this.props.childDocumentsActive?.() || BoolCast(this.rootDoc.childDocumentsActive)) ? true : this.props.childDocumentsActive?.() === false || this.rootDoc.childDocumentsActive === false ? false : undefined;
+        this.props.isContentActive?.() === false
+            ? false
+            : this.props.isDocumentActive?.() && (this.props.childDocumentsActive?.() || BoolCast(this.rootDoc.childDocumentsActive))
+            ? true
+            : this.props.childDocumentsActive?.() === false || this.rootDoc.childDocumentsActive === false
+            ? false
+            : undefined;
     isChildButtonContentActive = () => (this.props.childDocumentsActive?.() === false || this.rootDoc.childDocumentsActive === false ? false : undefined);
     // this is what renders the document that you see on the screen
     // called in Children: this actually adds a document to our children list
@@ -320,6 +324,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection
                 renderDepth={this.props.renderDepth + 1}
                 PanelWidth={width}
                 PanelHeight={height}
+                pointerEvents={this.props.DocumentView?.().props.onClick ? returnNone : undefined} // if the stack has an onClick, then we don't want the contents to be interactive (see CollectionPileView)
                 styleProvider={this.styleProvider}
                 docViewPath={this.props.docViewPath}
                 fitWidth={this.props.childFitWidth}
diff --git a/src/client/views/collections/CollectionTimeView.tsx b/src/client/views/collections/CollectionTimeView.tsx
index 4d5978548..03c010703 100644
--- a/src/client/views/collections/CollectionTimeView.tsx
+++ b/src/client/views/collections/CollectionTimeView.tsx
@@ -277,7 +277,7 @@ export class CollectionTimeView extends CollectionSubView() {
 }
 
 ScriptingGlobals.add(function pivotColumnClick(pivotDoc: Doc, bounds: ViewDefBounds) {
-    const pivotField = StrCast(pivotDoc._pivotField) || 'author';
+    const pivotField = StrCast(pivotDoc._pivotField, 'author');
     let prevFilterIndex = NumCast(pivotDoc._prevFilterIndex);
     const originalFilter = StrListCast(ObjectField.MakeCopy(pivotDoc._docFilters as ObjectField));
     pivotDoc['_prevDocFilter' + prevFilterIndex] = ObjectField.MakeCopy(pivotDoc._docFilters as ObjectField);
diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx
index 790aa765d..b76033a0c 100644
--- a/src/client/views/collections/CollectionView.tsx
+++ b/src/client/views/collections/CollectionView.tsx
@@ -46,6 +46,7 @@ interface CollectionViewProps_ extends FieldViewProps {
     // property overrides for child documents
     childDocuments?: Doc[]; // used to override the documents shown by the sub collection to an explicit list (see LinkBox)
     childDocumentsActive?: () => boolean | undefined; // whether child documents can be dragged if collection can be dragged (eg., in a when a Pile document is in startburst mode)
+    childContentsActive?: () => boolean | undefined;
     childFitWidth?: (child: Doc) => boolean;
     childShowTitle?: () => string;
     childOpacity?: () => number;
diff --git a/src/client/views/collections/TreeView.tsx b/src/client/views/collections/TreeView.tsx
index 8fb610b87..4adf86683 100644
--- a/src/client/views/collections/TreeView.tsx
+++ b/src/client/views/collections/TreeView.tsx
@@ -801,7 +801,6 @@ export class TreeView extends React.Component<TreeViewProps> {
             case StyleProp.Opacity:         return this.props.treeView.outlineMode ? undefined : 1;
             case StyleProp.BackgroundColor: return this.selected ? '#7089bb' : StrCast(doc._backgroundColor, StrCast(doc.backgroundColor));
             case StyleProp.Highlighting:    if (this.props.treeView.outlineMode) return undefined;
-            case StyleProp.Hidden:          return false;
             case StyleProp.BoxShadow:       return undefined;
             case StyleProp.DocContents:
                 const highlightIndex = this.props.treeView.outlineMode ? Doc.DocBrushStatus.unbrushed : Doc.isBrushedHighlightedDegree(doc);
@@ -827,7 +826,6 @@ export class TreeView extends React.Component<TreeViewProps> {
     };
     embeddedStyleProvider = (doc: Doc | undefined, props: Opt<DocumentViewProps>, property: string): any => {
         if (property.startsWith(StyleProp.Decorations)) return null;
-        if (property.startsWith(StyleProp.Hidden)) return false;
         return this.props?.treeView?.props.styleProvider?.(doc, props, property); // properties are inherited from the CollectionTreeView, not the hierarchical parent in the treeView
     };
     onKeyDown = (e: React.KeyboardEvent, fieldProps: FieldViewProps) => {
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx
index 81b0c4d8a..2549ee0b3 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx
@@ -93,6 +93,7 @@ export function computePassLayout(poolData: Map<string, PoolData>, pivotDoc: Doc
             width: layout[WidthSym](),
             height: layout[HeightSym](),
             pair: { layout, data },
+            transition: 'all .3s',
             replica: '',
         });
     });
@@ -100,28 +101,27 @@ export function computePassLayout(poolData: Map<string, PoolData>, pivotDoc: Doc
 }
 
 export function computeStarburstLayout(poolData: Map<string, PoolData>, pivotDoc: Doc, childPairs: { layout: Doc; data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[], engineProps: any) {
-    const mustFit = pivotDoc[WidthSym]() !== panelDim[0]; // if a panel size is set that's not the same as the pivot doc's size, then assume this is in a panel for a content fitting view (like a grid) in which case everything must be scaled to stay within the panel
     const docMap = new Map<string, PoolData>();
-    const docSize = mustFit ? panelDim[0] * 0.33 : 75; // assume an icon sized at 75
-    const burstRadius = mustFit ? panelDim : [NumCast(pivotDoc._starburstRadius, panelDim[0]) - docSize, NumCast(pivotDoc._starburstRadius, panelDim[1]) - docSize];
-    const scaleDim = [burstRadius[0] * 2 + docSize, burstRadius[1] * 2 + docSize];
+    const burstDiam = [NumCast(pivotDoc._width), NumCast(pivotDoc._height)];
     childPairs.forEach(({ layout, data }, i) => {
-        const docSize = layout.layoutKey === 'layout_icon' ? (mustFit ? panelDim[0] * 0.33 : 75) : 400; // assume a icon sized at 75
+        const aspect = layout[HeightSym]() / layout[WidthSym]();
+        const docSize = Math.min(Math.min(400, layout[WidthSym]()), Math.min(400, layout[WidthSym]()) / aspect);
         const deg = (i / childPairs.length) * Math.PI * 2;
         docMap.set(layout[Id], {
-            x: Math.cos(deg) * burstRadius[0] - docSize / 2,
-            y: Math.sin(deg) * burstRadius[1] - (docSize * layout[HeightSym]()) / layout[WidthSym]() / 2,
-            width: docSize, //layout[WidthSym](),
-            height: (docSize * layout[HeightSym]()) / layout[WidthSym](),
+            x: Math.min(burstDiam[0] / 2 - docSize, Math.max(-burstDiam[0] / 2, (Math.cos(deg) * burstDiam[0]) / 2 - docSize / 2)),
+            y: Math.min(burstDiam[1] / 2 - docSize * aspect, Math.max(-burstDiam[1] / 2, (Math.sin(deg) * burstDiam[1]) / 2 - (docSize / 2) * aspect)),
+            width: docSize,
+            height: docSize * aspect,
             zIndex: NumCast(layout.zIndex),
             pair: { layout, data },
             replica: '',
             color: 'white',
             backgroundColor: 'white',
+            transition: 'all 0.3s',
         });
     });
-    const divider = { type: 'div', color: 'transparent', x: -burstRadius[0], y: 0, width: 15, height: 15, payload: undefined };
-    return normalizeResults(scaleDim, 12, docMap, poolData, viewDefsToJSX, [], 0, [divider]);
+    const divider = { type: 'div', color: 'transparent', x: -burstDiam[0] / 2, y: -burstDiam[1] / 2, width: 15, height: 15, payload: undefined };
+    return normalizeResults(burstDiam, 12, docMap, poolData, viewDefsToJSX, [], 0, [divider]);
 }
 
 export function computePivotLayout(poolData: Map<string, PoolData>, pivotDoc: Doc, childPairs: { layout: Doc; data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[], engineProps: any) {
@@ -424,6 +424,7 @@ function normalizeResults(
                     color: newPosRaw.color,
                     pair: ele[1].pair,
                 };
+                if (newPosRaw.transition) newPos.transition = newPosRaw.transition;
                 poolData.set(newPos.pair.layout[Id] + (newPos.replica || ''), { transition: 'all 1s', ...newPos });
             }
         });
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
index ff0d01f29..1fc4d9259 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
@@ -15,7 +15,7 @@ import { BoolCast, Cast, DocCast, FieldValue, NumCast, ScriptCast, StrCast } fro
 import { ImageField } from '../../../../fields/URLField';
 import { TraceMobx } from '../../../../fields/util';
 import { GestureUtils } from '../../../../pen-gestures/GestureUtils';
-import { aggregateBounds, emptyFunction, intersectRect, returnFalse, setupMoveUpEvents, Utils } from '../../../../Utils';
+import { aggregateBounds, emptyFunction, intersectRect, returnFalse, returnNone, returnTrue, returnZero, setupMoveUpEvents, Utils } from '../../../../Utils';
 import { CognitiveServices } from '../../../cognitive_services/CognitiveServices';
 import { Docs, DocUtils } from '../../../documents/Documents';
 import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes';
@@ -308,6 +308,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
     };
 
     focus = (anchor: Doc, options: DocFocusOptions) => {
+        if (this._lightboxDoc) return;
         const xfToCollection = options?.docTransform ?? Transform.Identity();
         const savedState = { panX: NumCast(this.Document[this.panXFieldKey]), panY: NumCast(this.Document[this.panYFieldKey]), scale: options?.willZoomCentered ? this.Document[this.scaleFieldKey] : undefined };
         const cantTransform = this.fitContentsToBox || ((this.rootDoc._isGroup || this.layoutDoc._lockedTransform) && !LightboxView.LightboxDoc);
@@ -327,7 +328,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
 
     getView = async (doc: Doc): Promise<Opt<DocumentView>> => {
         return new Promise<Opt<DocumentView>>(res => {
-            doc.hidden && (doc.hidden = false);
+            if (doc.hidden && this._lightboxDoc !== doc) doc.hidden = false;
             const findDoc = (finish: (dv: DocumentView) => void) => DocumentManager.Instance.AddViewRenderedCb(doc, dv => finish(dv));
             findDoc(dv => res(dv));
         });
@@ -778,7 +779,9 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
         this._batch?.end();
     };
 
+    @action
     onClick = (e: React.MouseEvent) => {
+        if (this._lightboxDoc) this._lightboxDoc = undefined;
         if (this.onBrowseClickHandler()) {
             if (this.props.DocumentView?.()) {
                 this.onBrowseClickHandler().script.run({ documentView: this.props.DocumentView(), clientX: e.clientX, clientY: e.clientY });
@@ -1274,7 +1277,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
                 docRangeFilters={this.childDocRangeFilters}
                 searchFilterDocs={this.searchFilterDocs}
                 isDocumentActive={this.props.childDocumentsActive?.() ? this.props.isDocumentActive : this.isContentActive}
-                isContentActive={emptyFunction}
+                isContentActive={this.props.childContentsActive ?? emptyFunction}
                 focus={this.Document._isGroup ? this.groupFocus : this.isAnnotationOverlay ? this.props.focus : this.focus}
                 addDocTab={this.addDocTab}
                 addDocument={this.props.addDocument}
@@ -1320,13 +1323,16 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
             case undefined:
             case OpenWhere.lightbox:
                 if (this.layoutDoc._isLightbox) {
-                    // _isLightbox docs have a script that will unset this overlay onClick
-                    this.layoutDoc[this.props.fieldKey] = new List<Doc>(doc instanceof Doc ? [doc] : doc);
+                    this._lightboxDoc = doc;
+                    return true;
+                } else if (this.childDocList?.includes(doc)) {
+                    if (doc.hidden) doc.hidden = false;
                     return true;
                 }
         }
         return this.props.addDocTab(doc, where);
     });
+    @observable _lightboxDoc: Opt<Doc>;
 
     getCalculatedPositions(params: { pair: { layout: Doc; data?: Doc }; index: number; collection: Doc }): PoolData {
         const childDoc = params.pair.layout;
@@ -1936,7 +1942,9 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
             1000
         );
     };
-
+    lightboxPanelWidth = () => Math.max(0, this.props.PanelWidth() - 30);
+    lightboxPanelHeight = () => Math.max(0, this.props.PanelHeight() - 30);
+    lightboxScreenToLocal = () => this.props.ScreenToLocalTransform().translate(-15, -15);
     render() {
         TraceMobx();
         return (
@@ -1965,36 +1973,65 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
                     width: `${100 / (this.nativeDimScaling || 1)}%`,
                     height: this.props.getScrollHeight?.() ?? `${100 / (this.nativeDimScaling || 1)}%`,
                 }}>
-                {this._firstRender ? this.placeholder : this.marqueeView}
-                {this.props.noOverlay ? null : <CollectionFreeFormOverlayView elements={this.elementFunc} />}
-
-                {/* // uncomment to show snap lines */}
-                <div className="snapLines" style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}>
-                    <svg style={{ width: '100%', height: '100%' }}>
-                        {this._hLines?.map(l => (
-                            <line x1="0" y1={l} x2="1000" y2={l} stroke="black" />
-                        ))}
-                        {this._vLines?.map(l => (
-                            <line y1="0" x1={l} y2="1000" x2={l} stroke="black" />
-                        ))}
-                    </svg>
-                </div>
+                {this._lightboxDoc ? (
+                    <div style={{ padding: 15, width: '100%', height: '100%' }}>
+                        <DocumentView
+                            {...this.props}
+                            Document={this._lightboxDoc}
+                            DataDoc={undefined}
+                            PanelWidth={this.lightboxPanelWidth}
+                            PanelHeight={this.lightboxPanelHeight}
+                            NativeWidth={returnZero}
+                            NativeHeight={returnZero}
+                            onClick={this.onChildClickHandler}
+                            onKey={this.onKeyDown}
+                            onDoubleClick={this.onChildDoubleClickHandler}
+                            onBrowseClick={this.onBrowseClickHandler}
+                            docFilters={this.childDocFilters}
+                            docRangeFilters={this.childDocRangeFilters}
+                            searchFilterDocs={this.searchFilterDocs}
+                            isDocumentActive={this.props.childDocumentsActive?.() ? this.props.isDocumentActive : this.isContentActive}
+                            isContentActive={this.props.childContentsActive ?? emptyFunction}
+                            addDocTab={this.addDocTab}
+                            ScreenToLocalTransform={this.lightboxScreenToLocal}
+                            fitContentsToBox={undefined}
+                            focus={this.focus}
+                        />
+                    </div>
+                ) : (
+                    <>
+                        {this._firstRender ? this.placeholder : this.marqueeView}
+                        {this.props.noOverlay ? null : <CollectionFreeFormOverlayView elements={this.elementFunc} />}
+
+                        {/* // uncomment to show snap lines */}
+                        <div className="snapLines" style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}>
+                            <svg style={{ width: '100%', height: '100%' }}>
+                                {this._hLines?.map(l => (
+                                    <line x1="0" y1={l} x2="1000" y2={l} stroke="black" />
+                                ))}
+                                {this._vLines?.map(l => (
+                                    <line y1="0" x1={l} y2="1000" x2={l} stroke="black" />
+                                ))}
+                            </svg>
+                        </div>
 
-                {this.props.Document._isGroup && SnappingManager.GetIsDragging() && this.ChildDrag ? (
-                    <div
-                        className="collectionFreeForm-groupDropper"
-                        ref={this.createGroupEventsTarget}
-                        style={{
-                            width: this.ChildDrag ? '10000' : '100%',
-                            height: this.ChildDrag ? '10000' : '100%',
-                            left: this.ChildDrag ? '-5000' : 0,
-                            top: this.ChildDrag ? '-5000' : 0,
-                            position: 'absolute',
-                            background: '#0009930',
-                            pointerEvents: 'all',
-                        }}
-                    />
-                ) : null}
+                        {this.props.Document._isGroup && SnappingManager.GetIsDragging() && this.ChildDrag ? (
+                            <div
+                                className="collectionFreeForm-groupDropper"
+                                ref={this.createGroupEventsTarget}
+                                style={{
+                                    width: this.ChildDrag ? '10000' : '100%',
+                                    height: this.ChildDrag ? '10000' : '100%',
+                                    left: this.ChildDrag ? '-5000' : 0,
+                                    top: this.ChildDrag ? '-5000' : 0,
+                                    position: 'absolute',
+                                    background: '#0009930',
+                                    pointerEvents: 'all',
+                                }}
+                            />
+                        ) : null}
+                    </>
+                )}
             </div>
         );
     }
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index d443df0f3..eaeb5f933 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -371,10 +371,10 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque
 
     @undoBatch
     @action
-    delete = () => {
+    delete = (e?: React.PointerEvent<Element> | KeyboardEvent | undefined, hide?: boolean) => {
         const selected = this.marqueeSelect(false);
         SelectionManager.DeselectAll();
-        selected.forEach(doc => this.props.removeDocument?.(doc));
+        selected.forEach(doc => (hide ? (doc.hidden = true) : this.props.removeDocument?.(doc)));
 
         this.cleanupInteractions(false);
         MarqueeOptionsMenu.Instance.fadeOut(true);
@@ -550,11 +550,11 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque
         if (this._commandExecuted || (e as any).propagationIsStopped) {
             return;
         }
-        if (e.key === 'Backspace' || e.key === 'Delete' || e.key === 'd') {
+        if (e.key === 'Backspace' || e.key === 'Delete' || e.key === 'd' || e.key === 'h') {
             this._commandExecuted = true;
             e.stopPropagation();
             (e as any).propagationIsStopped = true;
-            this.delete();
+            this.delete(e, e.key === 'h');
             e.stopPropagation();
         }
         if ('cbtsSpg'.indexOf(e.key) !== -1) {
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index dc508d95f..2ea0a6f19 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -262,7 +262,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
     @observable _animateScalingTo = 0;
 
     public get animateScaleTime() {
-        return this._animateScaleTime ?? 300;
+        return this._animateScaleTime ?? 100;
     }
     public get displayName() {
         return 'DocumentView(' + this.props.Document.title + ')';
@@ -295,8 +295,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
         return this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.Decorations + (this.props.isSelected() ? ':selected' : ''));
     }
     @computed get backgroundBoxColor() {
-        const thumb = ImageCast(this.layoutDoc['thumb-frozen'], ImageCast(this.layoutDoc.thumb));
-        return thumb ? undefined : this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BackgroundColor + ':box');
+        return this.thumbShown() ? undefined : this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BackgroundColor + ':box');
     }
     @computed get docContents() {
         return this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.DocContents);
@@ -413,6 +412,8 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
         setTimeout(() => this._titleRef.current?.setIsFocused(true)); // use timeout in case title wasn't shown to allow re-render so that titleref will be defined
     };
 
+    public static addDocTabFunc: (doc: Doc, location: OpenWhere) => boolean = returnFalse;
+
     onClick = action((e: React.MouseEvent | React.PointerEvent) => {
         if (!this.Document.ignoreClick && this.pointerEvents !== 'none' && this.props.renderDepth >= 0 && Utils.isClick(e.clientX, e.clientY, this._downX, this._downY, this._downTime)) {
             let stopPropagate = true;
@@ -446,24 +447,33 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
                 let clickFunc: undefined | (() => any);
                 if (!this.disableClickScriptFunc && this.onClickHandler?.script) {
                     const { clientX, clientY, shiftKey, altKey, metaKey } = e;
-                    const func = () =>
-                        this.onClickHandler?.script.run(
-                            {
-                                this: this.layoutDoc,
-                                self: this.rootDoc,
-                                _readOnly_: false,
-                                scriptContext: this.props.scriptContext,
-                                documentView: this.props.DocumentView(),
-                                clientX,
-                                clientY,
-                                shiftKey,
-                                altKey,
-                                metaKey,
-                            },
-                            console.log
-                        ).result?.select === true
-                            ? this.props.select(false)
-                            : '';
+                    const func = () => {
+                        // replace default add doc func with this view's add doc func.
+                        // to allow override behaviors for how to display links to undisplayed documents.
+                        // e.g., if this document is part of a labeled 'lightbox' container, then documents will be shown in place
+                        // instead of in the global lightbox
+                        const oldFunc = DocumentViewInternal.addDocTabFunc;
+                        DocumentViewInternal.addDocTabFunc = this.props.addDocTab;
+                        const res =
+                            this.onClickHandler?.script.run(
+                                {
+                                    this: this.layoutDoc,
+                                    self: this.rootDoc,
+                                    _readOnly_: false,
+                                    scriptContext: this.props.scriptContext,
+                                    documentView: this.props.DocumentView(),
+                                    clientX,
+                                    clientY,
+                                    shiftKey,
+                                    altKey,
+                                    metaKey,
+                                },
+                                console.log
+                            ).result?.select === true
+                                ? this.props.select(false)
+                                : '';
+                        DocumentViewInternal.addDocTabFunc = oldFunc;
+                    };
                     clickFunc = () => (this.props.Document.dontUndo ? func() : UndoManager.RunInBatch(func, 'on click'));
                 } else {
                     // onDragStart implies a button doc that we don't want to select when clicking. RootDocument & isTemplateForField implies we're clicking on part of a template instance and we want to select the whole template, not the part
@@ -1318,9 +1328,6 @@ export class DocumentView extends React.Component<DocumentViewProps> {
         const hideCount = this.props.renderDepth === -1 || SnappingManager.GetIsDragging() || (this.isSelected() && this.props.renderDepth) || !this._isHovering || this.hideLinkButton;
         return hideCount ? null : <DocumentLinksButton View={this} scaling={this.scaleToScreenSpace} OnHover={true} Bottom={this.topMost} ShowCount={true} />;
     }
-    @computed get hidden() {
-        return this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.Hidden);
-    }
     @computed get docViewPath(): DocumentView[] {
         return this.props.docViewPath ? [...this.props.docViewPath(), this] : [this];
     }
@@ -1497,7 +1504,7 @@ export class DocumentView extends React.Component<DocumentViewProps> {
         const xshift = Math.abs(this.Xshift) <= 0.001 ? this.props.PanelWidth() : undefined;
         const yshift = Math.abs(this.Yshift) <= 0.001 ? this.props.PanelHeight() : undefined;
 
-        return this.hidden ? null : (
+        return (
             <div className="contentFittingDocumentView" onPointerEnter={action(() => (this._isHovering = true))} onPointerLeave={action(() => (this._isHovering = false))}>
                 {!this.props.Document || !this.props.PanelWidth() ? null : (
                     <div
diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss
index 22dbc1e80..29943e156 100644
--- a/src/client/views/nodes/ImageBox.scss
+++ b/src/client/views/nodes/ImageBox.scss
@@ -2,7 +2,9 @@
     border-radius: inherit;
     width: 100%;
     height: 100%;
-    position: relative;
+    position: absolute;
+    top: 0;
+    left: 0;
     transform-origin: top left;
 
     .imageBox-annotationLayer {
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index 98df777cb..20e5f5eca 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -505,6 +505,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
                     }
                 })}
                 style={{
+                    display: !SnappingManager.GetIsDragging() && this.props.thumbShown?.() ? 'none' : undefined,
                     width: this.props.PanelWidth() ? undefined : `100%`,
                     height: this.props.PanelWidth() ? undefined : `100%`,
                     pointerEvents: this.layoutDoc._lockedPosition ? 'none' : undefined,
diff --git a/src/client/views/nodes/WebBox.scss b/src/client/views/nodes/WebBox.scss
index 6f578a9fc..75847c100 100644
--- a/src/client/views/nodes/WebBox.scss
+++ b/src/client/views/nodes/WebBox.scss
@@ -183,6 +183,7 @@
                 height: 100%;
                 position: absolute;
                 top: 0;
+                left: 0;
                 body {
                     ::selection {
                         color: white;
diff --git a/src/client/views/nodes/formattedText/DashDocView.tsx b/src/client/views/nodes/formattedText/DashDocView.tsx
index b31fc01ff..648c579d0 100644
--- a/src/client/views/nodes/formattedText/DashDocView.tsx
+++ b/src/client/views/nodes/formattedText/DashDocView.tsx
@@ -181,6 +181,8 @@ export class DashDocViewInternal extends React.Component<IDashDocViewInternal> {
                     height: this._height,
                     position: 'absolute',
                     display: 'inline-block',
+                    left: 0,
+                    top: 0,
                 }}
                 onPointerLeave={this.onPointerLeave}
                 onPointerEnter={this.onPointerEnter}
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss
index fd7fbb333..3b42c41a5 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.scss
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss
@@ -24,6 +24,27 @@ audiotag:hover {
     transform: scale(2);
     transform-origin: bottom center;
 }
+.formattedTextBox {
+    touch-action: none;
+    background: inherit;
+    padding: 0;
+    border-width: 0px;
+    border-radius: inherit;
+    border-color: $medium-gray;
+    box-sizing: border-box;
+    background-color: inherit;
+    border-style: solid;
+    overflow-y: auto;
+    overflow-x: hidden;
+    color: inherit;
+    display: flex;
+    flex-direction: row;
+    transition: opacity 1s;
+    width: 100%;
+    position: absolute;
+    top: 0;
+    left: 0;
+}
 
 .formattedTextBox-cont {
     touch-action: none;
@@ -51,6 +72,15 @@ audiotag:hover {
         position: absolute;
     }
 }
+.formattedTextBox-alternateButton {
+    position: absolute;
+    color: white;
+    background: black;
+    right: 0;
+    bottom: 0;
+    width: 15;
+    height: 15;
+}
 
 .formattedTextBox-outer-selected,
 .formattedTextBox-outer {
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index bbe38cf99..2755d5100 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -1,9 +1,9 @@
 import { IconProp } from '@fortawesome/fontawesome-svg-core';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { Tooltip } from '@material-ui/core';
 import { isEqual } from 'lodash';
 import { action, computed, IReactionDisposer, observable, ObservableSet, reaction, runInAction } from 'mobx';
 import { observer } from 'mobx-react';
-import { Configuration, OpenAIApi } from 'openai';
 import { baseKeymap, selectAll } from 'prosemirror-commands';
 import { history } from 'prosemirror-history';
 import { inputRules } from 'prosemirror-inputrules';
@@ -1928,6 +1928,45 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
             </div>
         );
     }
+    @computed get overlayAlternateIcon() {
+        const usePath = this.rootDoc[`${this.props.fieldKey}-usePath`];
+        return (
+            <Tooltip
+                title={
+                    <div className="dash-tooltip">
+                        toggle between
+                        <span style={{ color: usePath === undefined ? 'black' : undefined }}>
+                            <em> primary, </em>
+                        </span>
+                        <span style={{ color: usePath === 'alternate' ? 'black' : undefined }}>
+                            <em>alternate, </em>
+                        </span>
+                        and show
+                        <span style={{ color: usePath === 'alternate:hover' ? 'black' : undefined }}>
+                            <em> alternate on hover</em>
+                        </span>
+                    </div>
+                }>
+                <div
+                    className="formattedTextBox-alternateButton"
+                    onPointerDown={e =>
+                        setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, e => (this.rootDoc[`_${this.props.fieldKey}-usePath`] = usePath === undefined ? 'alternate' : usePath === 'alternate' ? 'alternate:hover' : undefined))
+                    }
+                    style={{
+                        display: this.props.isContentActive() ? 'block' : 'none',
+                        background: usePath === undefined ? 'white' : usePath === 'alternate' ? 'black' : 'gray',
+                        color: usePath === undefined ? 'black' : 'white',
+                    }}>
+                    <FontAwesomeIcon icon="turn-up" size="lg" />
+                </div>
+            </Tooltip>
+        );
+    }
+    @computed get fieldKey() {
+        const usePath = StrCast(this.rootDoc[`${this.props.fieldKey}-usePath`]);
+        return this.props.fieldKey + (usePath && (!usePath.includes(':hover') || this._isHovering) ? `-${usePath.replace(':hover', '')}` : '');
+    }
+    @observable _isHovering = false;
     render() {
         TraceMobx();
         const active = this.props.isContentActive() || this.props.isSelected();
@@ -1944,7 +1983,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
         const styleFromLayoutString = Doc.styleFromLayoutString(this.rootDoc, this.layoutDoc, this.props, scale); // this converts any expressions in the format string to style props.  e.g., <FormattedTextBox height='{this._headerHeight}px' >
         return styleFromLayoutString?.height === '0px' ? null : (
             <div
-                className="formattedTextBox-cont"
+                className="formattedTextBox"
+                onPointerEnter={action(() => (this._isHovering = true))}
+                onPointerLeave={action(() => (this._isHovering = false))}
                 ref={r =>
                     r?.addEventListener(
                         'wheel', // if scrollTop is 0, then don't let wheel trigger scroll on any container (which it would since onScroll won't be triggered on this)
@@ -1966,6 +2007,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
                               width: `${100 / scale}%`,
                               height: `${100 / scale}%`,
                           }),
+                    display: !SnappingManager.GetIsDragging() && this.props.thumbShown?.() ? 'none' : undefined,
                     transition: 'inherit',
                     // overflowY: this.layoutDoc._autoHeight ? "hidden" : undefined,
                     color: this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.Color),
@@ -2017,6 +2059,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
                     {this.noSidebar || this.props.dontSelectOnLoad || !this.SidebarShown || this.sidebarWidthPercent === '0%' ? null : this.sidebarCollection}
                     {this.noSidebar || this.Document._noSidebar || this.props.dontSelectOnLoad || this.Document._singleLine ? null : this.sidebarHandle}
                     {this.audioHandle}
+                    {this.overlayAlternateIcon}
                 </div>
             </div>
         );
diff --git a/src/fields/util.ts b/src/fields/util.ts
index 70d9ed61f..92f3a69eb 100644
--- a/src/fields/util.ts
+++ b/src/fields/util.ts
@@ -107,8 +107,11 @@ const _setterImpl = action(function (target: any, prop: string | symbol | number
                 redo: () => (receiver[prop] = value),
                 undo: () => {
                     const wasUpdate = receiver[UpdatingFromServer];
+                    const wasForce = receiver[ForceServerWrite];
+                    receiver[ForceServerWrite] = true; // needed since writes aren't propagated to server if UpdatingFromServerIsSet
                     receiver[UpdatingFromServer] = true; // needed if the event caused ACL's to change such that the doc is otherwise no longer editable.
                     receiver[prop] = curValue;
+                    receiver[ForceServerWrite] = wasForce;
                     receiver[UpdatingFromServer] = wasUpdate;
                 },
                 prop: prop?.toString(),
-- 
cgit v1.2.3-70-g09d2


From abddb2104fe8b23c4b3f86c2ed46c20bcd0777e6 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 24 Apr 2023 12:24:02 -0400
Subject: resized alternate text icon

---
 src/client/views/nodes/formattedText/FormattedTextBox.scss | 6 ++++--
 src/client/views/nodes/formattedText/FormattedTextBox.tsx  | 4 ++--
 2 files changed, 6 insertions(+), 4 deletions(-)

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

diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss
index 3b42c41a5..eb9dd1e66 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.scss
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss
@@ -73,13 +73,15 @@ audiotag:hover {
     }
 }
 .formattedTextBox-alternateButton {
+    align-items: center;
+    flex-direction: column;
     position: absolute;
     color: white;
     background: black;
     right: 0;
     bottom: 0;
-    width: 15;
-    height: 15;
+    width: 11;
+    height: 11;
 }
 
 .formattedTextBox-outer-selected,
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 2755d5100..82a26fa86 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -1953,11 +1953,11 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
                         setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, e => (this.rootDoc[`_${this.props.fieldKey}-usePath`] = usePath === undefined ? 'alternate' : usePath === 'alternate' ? 'alternate:hover' : undefined))
                     }
                     style={{
-                        display: this.props.isContentActive() ? 'block' : 'none',
+                        display: this.props.isContentActive() && !SnappingManager.GetIsDragging() ? 'flex' : 'none',
                         background: usePath === undefined ? 'white' : usePath === 'alternate' ? 'black' : 'gray',
                         color: usePath === undefined ? 'black' : 'white',
                     }}>
-                    <FontAwesomeIcon icon="turn-up" size="lg" />
+                    <FontAwesomeIcon icon="turn-up" size="sm" />
                 </div>
             </Tooltip>
         );
-- 
cgit v1.2.3-70-g09d2


From 4846ff09a02cce1da4f0b8984e387d7d204837f1 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Wed, 26 Apr 2023 10:58:58 -0400
Subject: fixed filters - checkboxes generated when options are less than 20,
 added -undefined- as distinct value option. fixed pointer evcents for docs on
 pdfs

---
 src/client/util/CurrentUserUtils.ts                |   7 +-
 src/client/util/DragManager.ts                     |  18 +-
 src/client/views/FilterPanel.scss                  |  12 +-
 src/client/views/FilterPanel.tsx                   |  41 +++--
 .../collectionFreeForm/CollectionFreeFormView.tsx  |   1 +
 src/client/views/nodes/DocumentView.tsx            |   2 +
 src/client/views/nodes/FilterBox.scss              | 189 ---------------------
 src/client/views/nodes/FilterBox.tsx               |   0
 .../views/nodes/formattedText/FormattedTextBox.tsx |   6 +-
 src/client/views/pdf/PDFViewer.tsx                 |   3 +-
 src/fields/Doc.ts                                  |   2 +-
 11 files changed, 57 insertions(+), 224 deletions(-)
 delete mode 100644 src/client/views/nodes/FilterBox.scss
 delete mode 100644 src/client/views/nodes/FilterBox.tsx

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

diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index 4a90edd49..5475873a9 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -49,6 +49,7 @@ interface Button {
     ignoreClick?: boolean;
     buttonText?: string;
     backgroundColor?: string;
+    waitForDoubleClickToClick?: boolean;
 
     // fields that do not correspond to DocumentOption fields
     scripts?: { script?: string; onClick?: string; onDoubleClick?: string }
@@ -613,8 +614,9 @@ export class CurrentUserUtils {
 
     static freeTools(): Button[] {
         return [
-            { title: "Bottom",    icon: "arrows-down-to-line",toolTip: "Make doc topmost",    btnType: ButtonType.ClickButton,  expertMode: false,                        funcs: {}, scripts: { onClick: 'sendToBack()'}}, // Only when floating document is selected in freeform
-            { title: "Top",       icon: "arrows-up-to-line",  toolTip: "Make doc bottommost", btnType: ButtonType.ClickButton,  expertMode: false,                        funcs: {}, scripts: { onClick: 'bringToFront()'}}, // Only when floating document is selected in freeform
+            { title: "Bottom",  icon: "arrows-down-to-line",toolTip: "Make doc topmost",                                   btnType: ButtonType.ClickButton,  expertMode: false, funcs: {}, scripts: { onClick: 'sendToBack()'}}, // Only when floating document is selected in freeform
+            { title: "Top",     icon: "arrows-up-to-line",  toolTip: "Make doc bottommost",                                btnType: ButtonType.ClickButton,  expertMode: false, funcs: {}, scripts: { onClick: 'bringToFront()'}}, // Only when floating document is selected in freeform
+            { title: "Z order", icon: "z",                  toolTip: "Bring Forward on Drag (double click to set for all)",waitForDoubleClickToClick:true, btnType: ButtonType.ToggleButton, expertMode: false, funcs: {}, scripts: { onClick: 'toggleRaiseOnDrag(false, _readOnly_)', onDoubleClick:`{ return toggleRaiseOnDrag(true, _readOnly_)`}}, // Only when floating document is selected in freeform
         ]
     }
     static viewTools(): Button[] {
@@ -689,7 +691,6 @@ export class CurrentUserUtils {
             { title: "Fill",    icon: "fill-drip",     toolTip: "Background Fill Color",btnType: ButtonType.ColorButton,         expertMode: false,                                                                                                          ignoreClick: true, width: 20,  scripts: { script:  'return setBackgroundColor(value, _readOnly_)'}}, // Only when a document is selected
             { title: "Header",  icon: "heading",       toolTip: "Header Color",         btnType: ButtonType.ColorButton,         expertMode: false,                                                                                                          ignoreClick: true,             scripts: { script:  'return setHeaderColor(value, _readOnly_)'}},
             { title: "Overlay", icon: "layer-group",   toolTip: "Overlay",              btnType: ButtonType.ToggleButton,        expertMode: false, toolType:CollectionViewType.Freeform, funcs: {hidden: '!SelectionManager_selectedDocType(self.toolType, self.expertMode, true)'},       scripts: { onClick: 'toggleOverlay(_readOnly_)'}}, // Only when floating document is selected in freeform
-            { title: "Z order", icon: "z",             toolTip: "Bring Forward on Drag",btnType: ButtonType.ToggleButton,        expertMode: false, toolType:CollectionViewType.Freeform, funcs: {},                                                                                        scripts: { onClick: 'toggleRaiseOnDrag(_readOnly_)'}}, // Only when floating document is selected in freeform
             { title: "Back",    icon: "chevron-left",  toolTip: "Prev Animation Frame", btnType: ButtonType.ClickButton,         expertMode: true,  toolType:CollectionViewType.Freeform, funcs: {hidden: '!SelectionManager_selectedDocType(self.toolType, self.expertMode)'}, width: 20,  scripts: { onClick: 'prevKeyFrame(_readOnly_)'}},
             { title: "Num", icon:"",toolTip: "Frame Number (click to toggle edit mode)",btnType: ButtonType.TextButton,          expertMode: true,  toolType:CollectionViewType.Freeform, funcs: {hidden: '!SelectionManager_selectedDocType(self.toolType, self.expertMode)', buttonText: 'selectedDocs()?.lastElement()?.currentFrame?.toString()'}, width: 20, scripts: { onClick: '{ return curKeyFrame(_readOnly_);}'}},
             { title: "Fwd",     icon: "chevron-right", toolTip: "Next Animation Frame", btnType: ButtonType.ClickButton,         expertMode: true,  toolType:CollectionViewType.Freeform, funcs: {hidden: '!SelectionManager_selectedDocType(self.toolType, self.expertMode)'}, width: 20,  scripts: { onClick: 'nextKeyFrame(_readOnly_)'}},
diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts
index 404c85eb2..b6de5604d 100644
--- a/src/client/util/DragManager.ts
+++ b/src/client/util/DragManager.ts
@@ -11,6 +11,7 @@ import * as globalCssVariables from '../views/global/globalCssVariables.scss';
 import { Colors } from '../views/global/globalEnums';
 import { DocumentView } from '../views/nodes/DocumentView';
 import { ScriptingGlobals } from './ScriptingGlobals';
+import { SelectionManager } from './SelectionManager';
 import { SnappingManager } from './SnappingManager';
 import { UndoManager } from './UndoManager';
 
@@ -597,7 +598,18 @@ export namespace DragManager {
     }
 }
 
-ScriptingGlobals.add(function toggleRaiseOnDrag(readOnly?: boolean) {
-    if (readOnly) return DragManager.GetRaiseWhenDragged() ? Colors.MEDIUM_BLUE : 'transparent';
-    DragManager.SetRaiseWhenDragged(!DragManager.GetRaiseWhenDragged());
+ScriptingGlobals.add(function toggleRaiseOnDrag(forAllDocs: boolean, readOnly?: boolean) {
+    if (readOnly) {
+        if (SelectionManager.Views().length)
+            return SelectionManager.Views().some(dv => dv.rootDoc.raiseWhenDragged)
+                ? Colors.MEDIUM_BLUE
+                : SelectionManager.Views().some(dv => dv.rootDoc.raiseWhenDragged === false)
+                ? 'transparent'
+                : DragManager.GetRaiseWhenDragged()
+                ? Colors.MEDIUM_BLUE_ALT
+                : Colors.PINK;
+        return DragManager.GetRaiseWhenDragged() ? Colors.PINK : 'transparent';
+    }
+    if (!forAllDocs) SelectionManager.Views().map(dv => (dv.rootDoc.raiseWhenDragged ? (dv.rootDoc.raiseWhenDragged = undefined) : dv.rootDoc.raiseWhenDragged === false ? (dv.rootDoc.raiseWhenDragged = true) : (dv.rootDoc.raiseWhenDragged = false)));
+    else DragManager.SetRaiseWhenDragged(!DragManager.GetRaiseWhenDragged());
 });
diff --git a/src/client/views/FilterPanel.scss b/src/client/views/FilterPanel.scss
index 7f907c8d4..c903f29ee 100644
--- a/src/client/views/FilterPanel.scss
+++ b/src/client/views/FilterPanel.scss
@@ -33,9 +33,10 @@
 // }
 
 .filterBox-select {
-    // width: 90%;
+    display: flex;
+    width: 100%;
     margin-top: 5px;
-    // margin-bottom: 15px;
+    background: white;
 }
 
 .filterBox-saveBookmark {
@@ -150,8 +151,8 @@
 .filterBox-treeView {
     display: flex;
     flex-direction: column;
-    width: 200px;
-    position: absolute;
+    width: 100%;
+    position: relative;
     right: 0;
     top: 0;
     z-index: 1;
@@ -184,6 +185,7 @@
         display: inline-block;
         width: 100%;
         margin-bottom: 10px;
-        //height: calc(100% - 30px);
+        margin-left: 5px;
+        overflow: auto;
     }
 }
diff --git a/src/client/views/FilterPanel.tsx b/src/client/views/FilterPanel.tsx
index d35494f26..a237249c1 100644
--- a/src/client/views/FilterPanel.tsx
+++ b/src/client/views/FilterPanel.tsx
@@ -51,10 +51,12 @@ export class FilterPanel extends React.Component<filterProps> {
 
         const keys = new Set<string>(noviceFields);
         this.allDocs.forEach(doc => SearchBox.documentKeys(doc).filter(key => keys.add(key)));
-        return Array.from(keys.keys())
+        const sortedKeys = Array.from(keys.keys())
             .filter(key => key[0])
             .filter(key => key[0] === '#' || key.indexOf('lastModified') !== -1 || (key[0] === key[0].toUpperCase() && !key.startsWith('_')) || noviceFields.includes(key) || !Doc.noviceMode)
             .sort();
+        noviceFields.forEach(key => sortedKeys.splice(sortedKeys.indexOf(key), 1));
+        return [...noviceFields, ...sortedKeys];
     }
 
     /**
@@ -129,7 +131,7 @@ export class FilterPanel extends React.Component<filterProps> {
                 maxVal = Math.max(num, maxVal);
             }
         });
-        if (facetHeader === 'text' || (facetValues.rtFields / allCollectionDocs.length > 0.1 && facetValues.rtFields > 20)) {
+        if (facetHeader === 'text' || (facetValues.rtFields / allCollectionDocs.length > 0.1 && facetValues.strings.length > 20)) {
             this._chosenFacets.set(facetHeader, 'text');
         } else if (facetHeader !== 'tags' && nonNumbers / facetValues.strings.length < 0.1) {
         } else {
@@ -140,7 +142,7 @@ export class FilterPanel extends React.Component<filterProps> {
     facetValues = (facetHeader: string) => {
         const allCollectionDocs = new Set<Doc>();
         SearchBox.foreachRecursiveDoc(this.targetDocChildren, (depth: number, doc: Doc) => allCollectionDocs.add(doc));
-        const set = new Set<string>();
+        const set = new Set<string>([String.fromCharCode(127) + '--undefined--']);
         if (facetHeader === 'tags')
             allCollectionDocs.forEach(child =>
                 Field.toString(child[facetHeader] as Field)
@@ -158,32 +160,29 @@ export class FilterPanel extends React.Component<filterProps> {
         let nonNumbers = 0;
 
         facetValues.map(val => Number.isNaN(Number(val)) && nonNumbers++);
-        const facetValueDocSet = (nonNumbers / facetValues.length > 0.1 ? facetValues.sort() : facetValues.sort((n1: string, n2: string) => Number(n1) - Number(n2))).map(facetValue => {
-            return facetValue;
-        });
-        return facetValueDocSet;
+        return nonNumbers / facetValues.length > 0.1 ? facetValues.sort() : facetValues.sort((n1: string, n2: string) => Number(n1) - Number(n2));
     };
 
     render() {
         const options = this._allFacets.filter(facet => this.currentFacets.indexOf(facet) === -1).map(facet => ({ value: facet, label: facet }));
         return (
-            <div className="filterBox-treeView" style={{ position: 'relative', width: '100%' }}>
-                <div className="filterBox-select-bool">
-                    <select className="filterBox-selection" onChange={action(e => this.targetDoc && (this.targetDoc._filterBoolean = (e.target as any).value))} defaultValue={StrCast(this.targetDoc?.filterBoolean)}>
-                        {['AND', 'OR'].map(bool => (
-                            <option value={bool} key={bool}>
-                                {bool}
-                            </option>
-                        ))}
-                    </select>
-                    <div className="filterBox-select-text">filters together</div>
-                </div>
-
+            <div className="filterBox-treeView">
                 <div className="filterBox-select">
-                    <Select placeholder="Add a filter..." options={options} isMulti={false} onChange={val => this.facetClick((val as UserOptions).value)} onKeyDown={e => e.stopPropagation()} value={null} closeMenuOnSelect={true} />
+                    <div style={{ width: '100%' }}>
+                        <Select placeholder="Add a filter..." options={options} isMulti={false} onChange={val => this.facetClick((val as UserOptions).value)} onKeyDown={e => e.stopPropagation()} value={null} closeMenuOnSelect={true} />
+                    </div>
+                    <div className="filterBox-select-bool">
+                        <select className="filterBox-selection" onChange={action(e => this.targetDoc && (this.targetDoc._filterBoolean = (e.target as any).value))} defaultValue={StrCast(this.targetDoc?.filterBoolean)}>
+                            {['AND', 'OR'].map(bool => (
+                                <option value={bool} key={bool}>
+                                    {bool}
+                                </option>
+                            ))}
+                        </select>
+                    </div>{' '}
                 </div>
 
-                <div className="filterBox-tree" key="tree" style={{ overflow: 'auto' }}>
+                <div className="filterBox-tree" key="tree">
                     {Array.from(this.activeFacets.keys()).map(facetHeader => (
                         <div>
                             {facetHeader}
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
index 3333befc6..719a39e8d 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
@@ -1972,6 +1972,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
                             : SnappingManager.GetIsDragging() && this.childDocs.includes(DragManager.docsBeingDragged.lastElement())
                             ? 'all'
                             : (this.props.pointerEvents?.() as any),
+                    textAlign: this.isAnnotationOverlay ? 'initial' : undefined,
                     transform: `scale(${this.nativeDimScaling || 1})`,
                     width: `${100 / (this.nativeDimScaling || 1)}%`,
                     height: this.props.getScrollHeight?.() ?? `${100 / (this.nativeDimScaling || 1)}%`,
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index 2ea0a6f19..38aa8db55 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -868,6 +868,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
             ? true
             : false;
     };
+    docFilters = () => [...this.props.docFilters(), ...StrListCast(this.layoutDoc.docFilters)];
     contentPointerEvents = () => (!this.disableClickScriptFunc && this.onClickHandler ? 'none' : this.pointerEvents);
     @computed get contents() {
         TraceMobx();
@@ -898,6 +899,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
                     docViewPath={this.props.viewPath}
                     thumbShown={this.thumbShown}
                     setContentView={this.setContentView}
+                    docFilters={this.docFilters}
                     NativeDimScaling={this.props.NativeDimScaling}
                     PanelHeight={this.panelHeight}
                     setHeight={!this.props.suppressSetHeight ? this.setHeight : undefined}
diff --git a/src/client/views/nodes/FilterBox.scss b/src/client/views/nodes/FilterBox.scss
deleted file mode 100644
index 7f907c8d4..000000000
--- a/src/client/views/nodes/FilterBox.scss
+++ /dev/null
@@ -1,189 +0,0 @@
-.filterBox-flyout {
-    display: block;
-    text-align: left;
-    font-weight: 100;
-
-    .filterBox-flyout-facet {
-        background-color: white;
-        text-align: left;
-        display: inline-block;
-        position: relative;
-        width: 100%;
-
-        .filterBox-flyout-facet-check {
-            margin-right: 6px;
-        }
-    }
-}
-
-.filter-bookmark {
-    //display: flex;
-
-    .filter-bookmark-icon {
-        float: right;
-        margin-right: 10px;
-        margin-top: 7px;
-    }
-}
-
-// .filterBox-bottom {
-//     // position: fixed;
-//     // bottom: 0;
-//     // width: 100%;
-// }
-
-.filterBox-select {
-    // width: 90%;
-    margin-top: 5px;
-    // margin-bottom: 15px;
-}
-
-.filterBox-saveBookmark {
-    background-color: #e9e9e9;
-    border-radius: 11px;
-    padding-left: 8px;
-    padding-right: 8px;
-    padding-top: 5px;
-    padding-bottom: 5px;
-    margin: 8px;
-    display: flex;
-    font-size: 11px;
-    cursor: pointer;
-
-    &:hover {
-        background-color: white;
-    }
-
-    .filterBox-saveBookmark-icon {
-        margin-right: 6px;
-        margin-top: 4px;
-        margin-left: 2px;
-    }
-}
-
-.filterBox-select-scope,
-.filterBox-select-bool,
-.filterBox-addWrapper,
-.filterBox-select-matched,
-.filterBox-saveWrapper {
-    font-size: 10px;
-    justify-content: center;
-    justify-items: center;
-    padding-bottom: 10px;
-    display: flex;
-}
-
-.filterBox-addWrapper {
-    font-size: 11px;
-    width: 100%;
-}
-
-.filterBox-saveWrapper {
-    width: 100%;
-}
-
-// .filterBox-top {
-//     padding-bottom: 20px;
-//     border-bottom: 2px solid black;
-//     position: fixed;
-//     top: 0;
-//     width: 100%;
-// }
-
-.filterBox-select-scope {
-    padding-bottom: 20px;
-    border-bottom: 2px solid black;
-}
-
-.filterBox-title {
-    font-size: 15;
-    // border: 2px solid black;
-    width: 100%;
-    align-self: center;
-    text-align: center;
-    background-color: #d3d3d3;
-}
-
-.filterBox-select-bool {
-    margin-top: 6px;
-}
-
-.filterBox-select-text {
-    margin-right: 8px;
-    margin-left: 8px;
-    margin-top: 3px;
-}
-
-.filterBox-select-box {
-    margin-right: 2px;
-    font-size: 30px;
-    border: 0;
-    background: transparent;
-}
-
-.filterBox-selection {
-    border-radius: 6px;
-    border: none;
-    background-color: #e9e9e9;
-    padding: 2px;
-
-    &:hover {
-        background-color: white;
-    }
-}
-
-.filterBox-addFilter {
-    width: 120px;
-    background-color: #e9e9e9;
-    border-radius: 12px;
-    padding: 5px;
-    margin: 5px;
-    display: flex;
-    text-align: center;
-    justify-content: center;
-
-    &:hover {
-        background-color: white;
-    }
-}
-
-.filterBox-treeView {
-    display: flex;
-    flex-direction: column;
-    width: 200px;
-    position: absolute;
-    right: 0;
-    top: 0;
-    z-index: 1;
-    background-color: #9f9f9f;
-
-    .filterBox-tree {
-        z-index: 0;
-    }
-
-    .filterBox-addfacet {
-        display: inline-block;
-        width: 200px;
-        height: 30px;
-        text-align: left;
-
-        .filterBox-addFacetButton {
-            display: flex;
-            margin: auto;
-            cursor: pointer;
-        }
-
-        > div,
-        > div > div {
-            width: 100%;
-            height: 100%;
-        }
-    }
-
-    .filterBox-tree {
-        display: inline-block;
-        width: 100%;
-        margin-bottom: 10px;
-        //height: calc(100% - 30px);
-    }
-}
diff --git a/src/client/views/nodes/FilterBox.tsx b/src/client/views/nodes/FilterBox.tsx
deleted file mode 100644
index e69de29bb..000000000
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 82a26fa86..399e6bfdf 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -852,7 +852,11 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
         const optionItems = options && 'subitems' in options ? options.subitems : [];
         optionItems.push({ description: `Generate Dall-E Image`, event: () => this.generateImage(), icon: 'star' });
         optionItems.push({ description: `Ask GPT-3`, event: () => this.askGPT(), icon: 'lightbulb' });
-        optionItems.push({ description: !this.Document._singleLine ? 'Make Single Line' : 'Make Multi Line', event: () => (this.layoutDoc._singleLine = !this.layoutDoc._singleLine), icon: !this.Document._singleLine ? 'grip-lines' : 'bars' });
+        optionItems.push({
+            description: !this.Document._singleLine ? 'Create New Doc on Carriage Return' : 'Allow Carriage Returns',
+            event: () => (this.layoutDoc._singleLine = !this.layoutDoc._singleLine),
+            icon: !this.Document._singleLine ? 'grip-lines' : 'bars',
+        });
         optionItems.push({ description: `${this.Document._autoHeight ? 'Lock' : 'Auto'} Height`, event: () => (this.layoutDoc._autoHeight = !this.layoutDoc._autoHeight), icon: this.Document._autoHeight ? 'lock' : 'unlock' });
         !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' });
         this._downX = this._downY = Number.NaN;
diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx
index 68241e61f..20803bba8 100644
--- a/src/client/views/pdf/PDFViewer.tsx
+++ b/src/client/views/pdf/PDFViewer.tsx
@@ -536,7 +536,8 @@ export class PDFViewer extends React.Component<IViewerProps> {
                 NativeWidth={returnZero}
                 NativeHeight={returnZero}
                 setContentView={emptyFunction} // override setContentView to do nothing
-                pointerEvents={SnappingManager.GetIsDragging() ? returnAll : returnNone}
+                pointerEvents={SnappingManager.GetIsDragging() ? returnAll : returnNone} // freeform view doesn't get events unless something is being dragged onto it.
+                childPointerEvents={'all'} // but freeform children need to get events to allow text editing, etc
                 renderDepth={this.props.renderDepth + 1}
                 isAnnotationOverlay={true}
                 fieldKey={this.props.fieldKey + '-annotations'}
diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts
index b0033b977..0c2ee35fd 100644
--- a/src/fields/Doc.ts
+++ b/src/fields/Doc.ts
@@ -1391,7 +1391,7 @@ export namespace Doc {
             return vals.some(v => v.includes(value)); // bcz: arghh: Todo: comparison should be parameterized as exact, or substring
         }
         const fieldStr = Field.toString(fieldVal as Field);
-        return fieldStr.includes(value); // bcz: arghh: Todo: comparison should be parameterized as exact, or substring
+        return fieldStr.includes(value) || (value === String.fromCharCode(127) + '--undefined--' && fieldVal === undefined); // bcz: arghh: Todo: comparison should be parameterized as exact, or substring
     }
 
     export function deiconifyView(doc: Doc) {
-- 
cgit v1.2.3-70-g09d2


From 1150296b546bb3628c3dac5704150dfb4295434b Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Wed, 26 Apr 2023 16:03:49 -0400
Subject: added cheatsheet and shortcut for viewing text shortcuts. changed
 menu organization slightly for viewability.

---
 src/client/util/RTFMarkup.tsx                      | 137 +++++++++++++++++++++
 src/client/util/ServerStats.tsx                    |   3 +-
 src/client/views/MainView.tsx                      |   8 +-
 src/client/views/MainViewModal.tsx                 |  37 +++---
 src/client/views/nodes/DocumentView.tsx            |   6 +-
 .../views/nodes/formattedText/FormattedTextBox.tsx |   2 +
 .../views/nodes/formattedText/RichTextRules.ts     |   7 ++
 7 files changed, 177 insertions(+), 23 deletions(-)
 create mode 100644 src/client/util/RTFMarkup.tsx

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

diff --git a/src/client/util/RTFMarkup.tsx b/src/client/util/RTFMarkup.tsx
new file mode 100644
index 000000000..306d151d7
--- /dev/null
+++ b/src/client/util/RTFMarkup.tsx
@@ -0,0 +1,137 @@
+import { action, computed, observable } from 'mobx';
+import { observer } from 'mobx-react';
+import * as React from 'react';
+import { MainViewModal } from '../views/MainViewModal';
+
+@observer
+export class RTFMarkup extends React.Component<{}> {
+    static Instance: RTFMarkup;
+    @observable private isOpen = false; // whether the SharingManager modal is open or not
+
+    // private get linkVisible() {
+    //     return this.targetDoc ? this.targetDoc["acl-" + PublicKey] !== SharingPermissions.None : false;
+    // }
+
+    @action
+    public open = () => (this.isOpen = true);
+
+    @action
+    public close = () => (this.isOpen = false);
+
+    constructor(props: {}) {
+        super(props);
+        RTFMarkup.Instance = this;
+    }
+
+    @observable _stats: { [key: string]: any } | undefined;
+
+    /**
+     * @returns the main interface of the SharingManager.
+     */
+    @computed get cheatSheet() {
+        return (
+            <div style={{ textAlign: 'initial', height: '100%' }}>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`wiki:phrase`}</b>
+                    {` display wikipedia page for entered text (terminate with carriage return)`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`#tag `}</b>
+                    {`  add hashtag metadata to document. e.g, #idea`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`#, ## ... ###### `}</b>
+                    {`  set heading style based on number of '#'s between 1 and 6`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`#tag `}</b>
+                    {`  add hashtag metadata to document. e.g, #idea`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`>> `}</b>
+                    {`  add a sidebar text document inline`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`\`\` `}</b>
+                    {`  create a code snippet block`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%% `}</b>
+                    {`  restore default styling`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%color `}</b>
+                    {`  changes text color styling. e.g., %green.`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%num `}</b>
+                    {`  set font size. e.g., %10 for 10pt font`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%eq `}</b>
+                    {`  creates an equation block for typeset math`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%alt `}</b>
+                    {`  switch between primary and alternate text (see bottom right Button for hover options).`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%f `}</b>
+                    {`  create an inline footnote`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%> `}</b>
+                    {`  create a bockquote section. Terminate with 2 carriage returns`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%( `}</b>
+                    {`  start a section of inline elidable text. Terminate the inline text with %)`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%q `}</b>
+                    {`  start a quoted block of text that’s indented on the left and right. Terminate with %q`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%d `}</b>
+                    {`  start a block text where the first line is indented`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%h `}</b>
+                    {`  start a block of text that begins with a hanging indent`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%[ `}</b>
+                    {`  left justify text`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%^ `}</b>
+                    {`  center text`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`%] `}</b>
+                    {`  right justify text`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`[:doctitle]] `}</b>
+                    {`  hyperlink to document specified by it’s title`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`[[fieldname]] `}</b>
+                    {`  display value of fieldname`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`[[fieldname=value]] `}</b>
+                    {`  assign value to fieldname of document and display it`}
+                </p>
+                <p>
+                    <b style={{ fontSize: 'larger' }}>{`[[fieldname:doctitle]] `}</b>
+                    {`  show value of fieldname from doc specified by it’s title`}
+                </p>
+            </div>
+        );
+    }
+
+    render() {
+        return <MainViewModal contents={this.cheatSheet} isDisplayed={this.isOpen} interactive={true} closeOnExternalClick={this.close} />;
+    }
+}
diff --git a/src/client/util/ServerStats.tsx b/src/client/util/ServerStats.tsx
index 0ab411bff..f84ad8598 100644
--- a/src/client/util/ServerStats.tsx
+++ b/src/client/util/ServerStats.tsx
@@ -1,8 +1,7 @@
-import { action, computed, observable, runInAction } from 'mobx';
+import { action, computed, observable } from 'mobx';
 import { observer } from 'mobx-react';
 import * as React from 'react';
 import { MainViewModal } from '../views/MainViewModal';
-import { DocumentView } from '../views/nodes/DocumentView';
 import './SharingManager.scss';
 
 @observer
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index 43f56dd8c..5af9a9f9a 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -14,7 +14,7 @@ import { StrCast } from '../../fields/Types';
 import { emptyFunction, returnEmptyDoclist, returnEmptyFilter, returnFalse, returnTrue, returnZero, setupMoveUpEvents, Utils } from '../../Utils';
 import { GoogleAuthenticationManager } from '../apis/GoogleAuthenticationManager';
 import { DocServer } from '../DocServer';
-import { Docs, DocUtils } from '../documents/Documents';
+import { Docs } from '../documents/Documents';
 import { CollectionViewType, DocumentType } from '../documents/DocumentTypes';
 import { CaptureManager } from '../util/CaptureManager';
 import { DocumentManager } from '../util/DocumentManager';
@@ -22,11 +22,12 @@ import { GroupManager } from '../util/GroupManager';
 import { HistoryUtil } from '../util/History';
 import { Hypothesis } from '../util/HypothesisUtils';
 import { ReportManager } from '../util/ReportManager';
+import { RTFMarkup } from '../util/RTFMarkup';
 import { ScriptingGlobals } from '../util/ScriptingGlobals';
 import { SelectionManager } from '../util/SelectionManager';
+import { ServerStats } from '../util/ServerStats';
 import { ColorScheme, SettingsManager } from '../util/SettingsManager';
 import { SharingManager } from '../util/SharingManager';
-import { ServerStats } from '../util/ServerStats';
 import { SnappingManager } from '../util/SnappingManager';
 import { Transform } from '../util/Transform';
 import { TimelineMenu } from './animationtimeline/TimelineMenu';
@@ -41,7 +42,7 @@ import { DashboardView } from './DashboardView';
 import { DictationOverlay } from './DictationOverlay';
 import { DocumentDecorations } from './DocumentDecorations';
 import { GestureOverlay } from './GestureOverlay';
-import { TOPBAR_HEIGHT, LEFT_MENU_WIDTH } from './global/globalCssVariables.scss';
+import { LEFT_MENU_WIDTH, TOPBAR_HEIGHT } from './global/globalCssVariables.scss';
 import { Colors } from './global/globalEnums';
 import { KeyManager } from './GlobalKeyHandler';
 import { InkTranscription } from './InkTranscription';
@@ -971,6 +972,7 @@ export class MainView extends React.Component {
                 <DictationOverlay />
                 <SharingManager />
                 <ServerStats />
+                <RTFMarkup />
                 <SettingsManager />
                 <ReportManager />
                 <CaptureManager />
diff --git a/src/client/views/MainViewModal.tsx b/src/client/views/MainViewModal.tsx
index 55dee005d..32997a944 100644
--- a/src/client/views/MainViewModal.tsx
+++ b/src/client/views/MainViewModal.tsx
@@ -1,5 +1,5 @@
 import * as React from 'react';
-import "./MainViewModal.scss";
+import './MainViewModal.scss';
 import { observer } from 'mobx-react';
 
 export interface MainViewOverlayProps {
@@ -15,31 +15,38 @@ export interface MainViewOverlayProps {
 
 @observer
 export class MainViewModal extends React.Component<MainViewOverlayProps> {
-
     render() {
         const p = this.props;
         const dialogueOpacity = p.dialogueBoxDisplayedOpacity || 1;
         const overlayOpacity = p.overlayDisplayedOpacity || 0.4;
-        return !p.isDisplayed ? (null) : (
-            <div className="mainViewModal-cont" style={{
-                pointerEvents: p.isDisplayed && p.interactive ? "all" : "none"
-            }}>
-                <div className="dialogue-box" style={{
-                    borderColor: "black",
-                    ...(p.dialogueBoxStyle || {}),
-                    opacity: p.isDisplayed ? dialogueOpacity : 0
-                }} >
+        return !p.isDisplayed ? null : (
+            <div
+                className="mainViewModal-cont"
+                style={{
+                    pointerEvents: p.isDisplayed && p.interactive ? 'all' : 'none',
+                }}>
+                <div
+                    className="dialogue-box"
+                    style={{
+                        borderColor: 'black',
+                        height: 'max-content',
+                        overflow: 'auto',
+                        maxHeight: '80%',
+                        ...(p.dialogueBoxStyle || {}),
+                        opacity: p.isDisplayed ? dialogueOpacity : 0,
+                    }}>
                     {p.contents}
                 </div>
-                <div className="overlay"
+                <div
+                    className="overlay"
                     onClick={this.props?.closeOnExternalClick}
                     style={{
-                        backgroundColor: "black",
+                        backgroundColor: 'black',
                         ...(p.overlayStyle || {}),
-                        opacity: p.isDisplayed ? overlayOpacity : 0
+                        opacity: p.isDisplayed ? overlayOpacity : 0,
                     }}
                 />
             </div>
         );
     }
-}
\ No newline at end of file
+}
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index 38aa8db55..d58a4c199 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -767,6 +767,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
         const constantItems: ContextMenuProps[] = [];
 
         if (!Doc.IsSystem(this.rootDoc)) {
+            constantItems.push({ description: 'Show Metadata', event: () => this.props.addDocTab(this.props.Document, (OpenWhere.addRight.toString() + 'KeyValue') as OpenWhere), icon: 'layer-group' });
             constantItems.push({ description: 'Export as Zip file', icon: 'download', event: async () => Doc.Zip(this.props.Document) });
             constantItems.push({ description: 'Import Zipped file', icon: 'upload', event: ({ x, y }) => this.importDocument() });
             (this.rootDoc._viewType !== CollectionViewType.Docking || !Doc.noviceMode) && constantItems.push({ description: 'Share', event: () => SharingManager.Instance.open(this.props.DocumentView()), icon: 'users' });
@@ -774,11 +775,10 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
                 // need option to gray out menu items ... preferably with a '?' that explains why they're grayed out (eg., no permissions)
                 constantItems.push({ description: 'Close', event: this.deleteClicked, icon: 'times' });
             }
-            cm.addItem({ description: 'General...', noexpand: false, subitems: constantItems, icon: 'question' });
+            cm.addItem({ description: 'General...', noexpand: true, subitems: constantItems, icon: 'question' });
         }
         const help = cm.findByDescription('Help...');
         const helpItems: ContextMenuProps[] = help && 'subitems' in help ? help.subitems : [];
-        helpItems.push({ description: 'Show Metadata', event: () => this.props.addDocTab(this.props.Document, (OpenWhere.addRight.toString() + 'KeyValue') as OpenWhere), icon: 'layer-group' });
         !Doc.noviceMode && helpItems.push({ description: 'Text Shortcuts Ctrl+/', event: () => this.props.addDocTab(Docs.Create.PdfDocument('/assets/cheat-sheet.pdf', { _width: 300, _height: 300 }), OpenWhere.addRight), icon: 'keyboard' });
         !Doc.noviceMode && helpItems.push({ description: 'Print Document in Console', event: () => console.log(this.props.Document), icon: 'hand-point-right' });
         !Doc.noviceMode && helpItems.push({ description: 'Print DataDoc in Console', event: () => console.log(this.props.Document[DataSym]), icon: 'hand-point-right' });
@@ -825,7 +825,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
                 icon: 'book',
             });
         }
-        if (!help) cm.addItem({ description: 'Help...', noexpand: true, subitems: helpItems, icon: 'question' });
+        if (!help) cm.addItem({ description: 'Help...', noexpand: !Doc.noviceMode, subitems: helpItems, icon: 'question' });
         else cm.moveAfter(help);
 
         e?.stopPropagation(); // DocumentViews should stop propagation of this event
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 399e6bfdf..0610d1e45 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -68,6 +68,7 @@ import { schema } from './schema_rts';
 import { SummaryView } from './SummaryView';
 import applyDevTools = require('prosemirror-dev-tools');
 import React = require('react');
+import { RTFMarkup } from '../../../util/RTFMarkup';
 const translateGoogleApi = require('translate-google-api');
 export const GoogleRef = 'googleDocId';
 type PullHandler = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => void;
@@ -858,6 +859,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
             icon: !this.Document._singleLine ? 'grip-lines' : 'bars',
         });
         optionItems.push({ description: `${this.Document._autoHeight ? 'Lock' : 'Auto'} Height`, event: () => (this.layoutDoc._autoHeight = !this.layoutDoc._autoHeight), icon: this.Document._autoHeight ? 'lock' : 'unlock' });
+        optionItems.push({ description: `show markdown options`, event: RTFMarkup.Instance.open, icon: 'text' });
         !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' });
         this._downX = this._downY = Number.NaN;
     };
diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts
index 599f96bca..20ce929ad 100644
--- a/src/client/views/nodes/formattedText/RichTextRules.ts
+++ b/src/client/views/nodes/formattedText/RichTextRules.ts
@@ -8,6 +8,7 @@ import { normalizeEmail } from '../../../../fields/util';
 import { Utils } from '../../../../Utils';
 import { DocServer } from '../../../DocServer';
 import { Docs, DocUtils } from '../../../documents/Documents';
+import { RTFMarkup } from '../../../util/RTFMarkup';
 import { FormattedTextBox } from './FormattedTextBox';
 import { wrappingInputRule } from './prosemirrorPatches';
 import { RichTextMenu } from './RichTextMenu';
@@ -228,6 +229,12 @@ export class RichTextRules {
                 return null;
             }),
 
+            // show markup help
+            new InputRule(new RegExp(/%\?$/), (state, match, start, end) => {
+                setTimeout(RTFMarkup.Instance.open);
+                return state.tr.deleteRange(start, end);
+            }),
+
             // stop using active style
             new InputRule(new RegExp(/%alt$/), (state, match, start, end) => {
                 setTimeout(() => (this.Document[this.TextBox.props.fieldKey + '-usePath'] = this.Document[this.TextBox.props.fieldKey + '-usePath'] ? undefined : 'alternate'));
-- 
cgit v1.2.3-70-g09d2


From 76f4456d652e124e733eb9b93272384d53ac3d28 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Tue, 2 May 2023 09:27:08 -0400
Subject: fixed ink stroke text box placement.  fixed opening metadata from
 OverlayView.   Made trail videos a novice feature.

---
 src/client/views/InkStroke.scss                     |  3 +++
 src/client/views/OverlayView.tsx                    |  6 +++---
 .../views/nodes/formattedText/FormattedTextBox.tsx  |  2 +-
 src/client/views/nodes/trails/PresElementBox.tsx    | 21 ++++++++-------------
 4 files changed, 15 insertions(+), 17 deletions(-)

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

diff --git a/src/client/views/InkStroke.scss b/src/client/views/InkStroke.scss
index bed7caf7f..f504890a5 100644
--- a/src/client/views/InkStroke.scss
+++ b/src/client/views/InkStroke.scss
@@ -39,5 +39,8 @@
         &:hover {
             background: #9f9f9f0a;
         }
+        > .formattedTextBox {
+            position: relative;
+        }
     }
 }
diff --git a/src/client/views/OverlayView.tsx b/src/client/views/OverlayView.tsx
index bdc48d03a..9baedae6d 100644
--- a/src/client/views/OverlayView.tsx
+++ b/src/client/views/OverlayView.tsx
@@ -7,14 +7,14 @@ import { Doc, DocListCast, HeightSym, WidthSym } from '../../fields/Doc';
 import { Id } from '../../fields/FieldSymbols';
 import { NumCast } from '../../fields/Types';
 import { emptyFunction, returnEmptyDoclist, returnEmptyFilter, returnFalse, returnTrue, setupMoveUpEvents, Utils } from '../../Utils';
-import { DocUtils } from '../documents/Documents';
 import { DocumentType } from '../documents/DocumentTypes';
+import { DocumentManager } from '../util/DocumentManager';
 import { DragManager } from '../util/DragManager';
 import { Transform } from '../util/Transform';
 import { CollectionFreeFormLinksView } from './collections/collectionFreeForm/CollectionFreeFormLinksView';
 import { LightboxView } from './LightboxView';
 import { MainView } from './MainView';
-import { DocumentView } from './nodes/DocumentView';
+import { DocumentView, DocumentViewInternal } from './nodes/DocumentView';
 import './OverlayView.scss';
 import { DefaultStyleProvider } from './StyleProvider';
 
@@ -224,7 +224,7 @@ export class OverlayView extends React.Component {
                             focus={emptyFunction}
                             styleProvider={DefaultStyleProvider}
                             docViewPath={returnEmptyDoclist}
-                            addDocTab={d.type === DocumentType.PRES ? MainView.addDocTabFunc : returnFalse}
+                            addDocTab={DocumentViewInternal.addDocTabFunc}
                             pinToPres={emptyFunction}
                             docFilters={returnEmptyFilter}
                             docRangeFilters={returnEmptyFilter}
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 0610d1e45..eb28ffbd7 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -2065,7 +2065,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps
                     {this.noSidebar || this.props.dontSelectOnLoad || !this.SidebarShown || this.sidebarWidthPercent === '0%' ? null : this.sidebarCollection}
                     {this.noSidebar || this.Document._noSidebar || this.props.dontSelectOnLoad || this.Document._singleLine ? null : this.sidebarHandle}
                     {this.audioHandle}
-                    {this.overlayAlternateIcon}
+                    {this.props.dontScale ? null : this.overlayAlternateIcon}
                 </div>
             </div>
         );
diff --git a/src/client/views/nodes/trails/PresElementBox.tsx b/src/client/views/nodes/trails/PresElementBox.tsx
index 502eef373..62e5314c3 100644
--- a/src/client/views/nodes/trails/PresElementBox.tsx
+++ b/src/client/views/nodes/trails/PresElementBox.tsx
@@ -353,14 +353,11 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() {
     @undoBatch
     @action
     showRecording = (activeItem: Doc, iconClick: boolean = false) => {
-        // if (iconClick) PresElementBox.showVideo = true;
-        // if (!PresElementBox.showVideo) return;
-
         // remove the overlays on switch *IF* not opened from the specific icon
         if (!iconClick) PresElementBox.removeEveryExistingRecordingInOverlay();
 
         if (activeItem.recording) {
-            Doc.AddDocToList(Doc.MyOverlayDocs, undefined, Cast(activeItem.recording, Doc, null));
+            Doc.AddDocToList(Doc.MyOverlayDocs, undefined, DocCast(activeItem.recording));
         }
     };
 
@@ -437,15 +434,13 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() {
                 </div>
             </Tooltip>
         );
-        if (!Doc.noviceMode) {
-            items.push(
-                <Tooltip key="slash" title={<div className="dash-tooltip">{this.recordingIsInOverlay ? 'Hide Recording' : `${PresElementBox.videoIsRecorded(activeItem) ? 'Show' : 'Start'} recording`}</div>}>
-                    <div className="slideButton" onClick={e => (this.recordingIsInOverlay ? this.hideRecording(e, true) : this.startRecording(e, activeItem))} style={{ fontWeight: 700 }}>
-                        <FontAwesomeIcon icon={`video${this.recordingIsInOverlay ? '-slash' : ''}`} onPointerDown={e => e.stopPropagation()} />
-                    </div>
-                </Tooltip>
-            );
-        }
+        items.push(
+            <Tooltip key="slash" title={<div className="dash-tooltip">{this.recordingIsInOverlay ? 'Hide Recording' : `${PresElementBox.videoIsRecorded(activeItem) ? 'Show' : 'Start'} recording`}</div>}>
+                <div className="slideButton" onClick={e => (this.recordingIsInOverlay ? this.hideRecording(e, true) : this.startRecording(e, activeItem))} style={{ fontWeight: 700 }}>
+                    <FontAwesomeIcon icon={`video${this.recordingIsInOverlay ? '-slash' : ''}`} onPointerDown={e => e.stopPropagation()} />
+                </div>
+            </Tooltip>
+        );
         if (this.indexInPres !== 0) {
             items.push(
                 <Tooltip key="arrow" title={<div className="dash-tooltip">{activeItem.groupWithUp ? 'Ungroup' : 'Group with up'}</div>}>
-- 
cgit v1.2.3-70-g09d2

