From 11244a698bce594982ee5dca55b9695bb774451c Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 7 Oct 2024 18:13:38 -0400
Subject: moved all quiz code out of LabelBox and ImageBox and into
 StyleProviderQuiz.  changed quizBoxes and quizMode to be stored as Doc
 metadata. Extended styles to cover contextMenuItems.   remove
 this.setListening() from comparisonBox until contextMenu selectedVal is
 fixed.

---
 src/client/views/StyleProviderQuiz.tsx | 391 +++++++++++++++++++++++++++++++++
 1 file changed, 391 insertions(+)
 create mode 100644 src/client/views/StyleProviderQuiz.tsx

(limited to 'src/client/views/StyleProviderQuiz.tsx')

diff --git a/src/client/views/StyleProviderQuiz.tsx b/src/client/views/StyleProviderQuiz.tsx
new file mode 100644
index 000000000..8973ada95
--- /dev/null
+++ b/src/client/views/StyleProviderQuiz.tsx
@@ -0,0 +1,391 @@
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { Tooltip } from '@mui/material';
+import axios from 'axios';
+import * as React from 'react';
+import { returnFalse, setupMoveUpEvents } from '../../ClientUtils';
+import { emptyFunction } from '../../Utils';
+import { Doc, DocListCast, Opt } from '../../fields/Doc';
+import { DocData } from '../../fields/DocSymbols';
+import { List } from '../../fields/List';
+import { NumCast, StrCast } from '../../fields/Types';
+import { GPTCallType, gptAPICall, gptImageLabel } from '../apis/gpt/GPT';
+import { Docs } from '../documents/Documents';
+import { ContextMenu } from './ContextMenu';
+import { ContextMenuProps } from './ContextMenuItem';
+import { StyleProp } from './StyleProp';
+import { AnchorMenu } from './pdf/AnchorMenu';
+import { DocumentViewProps } from './nodes/DocumentView';
+import { FieldViewProps } from './nodes/FieldView';
+import { ImageBox } from './nodes/ImageBox';
+import { ImageUtility } from './nodes/generativeFill/generativeFillUtils/ImageHandler';
+import './StyleProviderQuiz.scss';
+
+export namespace styleProviderQuiz {
+    enum quizMode {
+        SMART = 'smart',
+        NORMAL = 'normal',
+        NONE = 'none',
+    }
+
+    async function selectUrlToBase64(blob: Blob): Promise<string> {
+        try {
+            return new Promise((resolve, reject) => {
+                const reader = new FileReader();
+                reader.readAsDataURL(blob);
+                reader.onloadend = () => resolve(reader.result as string);
+                reader.onerror = error => reject(error);
+            });
+        } catch (error) {
+            console.error('Error:', error);
+            throw error;
+        }
+    }
+    /**
+     * Creates label boxes over text on the image to be filled in.
+     * @param boxes
+     * @param texts
+     */
+    async function createBoxes(img: ImageBox, boxes: [[[number, number]]], texts: [string]) {
+        img.Document._quizBoxes = new List<Doc>([]);
+        for (let i = 0; i < boxes.length; i++) {
+            const coords = boxes[i] ? boxes[i] : [];
+            const width = coords[1][0] - coords[0][0];
+            const height = coords[2][1] - coords[0][1];
+            const text = texts[i];
+
+            const newCol = Docs.Create.LabelDocument({
+                _width: width,
+                _height: height,
+                _layout_fitWidth: true,
+                title: '',
+            });
+            const scaling = 1 / (img._props.NativeDimScaling?.() || 1);
+            newCol.x = coords[0][0] + NumCast(img.marqueeref.current?.left) * scaling;
+            newCol.y = coords[0][1] + NumCast(img.marqueeref.current?.top) * scaling;
+
+            newCol.zIndex = 1000;
+            newCol.forceActive = true;
+            newCol.quiz = text;
+            newCol[DocData].textTransform = 'none';
+            Doc.AddDocToList(img.Document, '_quizBoxes', newCol);
+            img.addDocument(newCol);
+            //   img._loading = false;
+        }
+    }
+
+    /**
+     * Calls backend to find any text on an image. Gets the text and the
+     * coordinates of the text and creates label boxes at those locations.
+     * @param quiz
+     * @param i
+     */
+    async function pushInfo(imgBox: ImageBox, quiz: quizMode, i?: string) {
+        imgBox.Document._quizMode = quiz;
+        const quizBoxes = DocListCast(imgBox.Document.quizBoxes);
+        if (!quizBoxes.length) {
+            //    this._loading = true;
+
+            const img = {
+                file: i ? i : imgBox.paths[0],
+                drag: i ? 'drag' : 'full',
+                smart: quiz,
+            };
+            const response = await axios.post('http://localhost:105/labels/', img, {
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+            });
+            if (response.data['boxes'].length != 0) {
+                createBoxes(imgBox, response.data['boxes'], response.data['text']);
+            } else {
+                // this._loading = false;
+            }
+        } else quizBoxes.forEach(box => (box.hidden = false));
+    }
+
+    async function createCanvas(img: ImageBox) {
+        const canvas = document.createElement('canvas');
+        const scaling = 1 / (img._props.NativeDimScaling?.() || 1);
+        const w = AnchorMenu.Instance.marqueeWidth * scaling;
+        const h = AnchorMenu.Instance.marqueeHeight * scaling;
+        canvas.width = w;
+        canvas.height = h;
+        const ctx = canvas.getContext('2d'); // draw image to canvas. scale to target dimensions
+        if (ctx) {
+            img.imageRef && ctx.drawImage(img.imageRef, NumCast(img.marqueeref.current?.left) * scaling, NumCast(img.marqueeref.current?.top) * scaling, w, h, 0, 0, w, h);
+        }
+        const blob = await ImageUtility.canvasToBlob(canvas);
+        return selectUrlToBase64(blob);
+    }
+    /**
+     * Create flashcards from an image.
+     */
+    async function getImageDesc(img: ImageBox) {
+        // this._loading = true;
+        try {
+            const hrefBase64 = await createCanvas(img);
+            const response = await gptImageLabel(hrefBase64, 'Make flashcards out of this image with each question and answer labeled as "question" and "answer". Do not label each flashcard and do not include asterisks: ');
+            AnchorMenu.Instance.transferToFlashcard(response, NumCast(img.layoutDoc['x']), NumCast(img.layoutDoc['y']));
+        } catch (error) {
+            console.log('Error', error);
+        }
+        // this._loading = false;
+    }
+
+    /**
+     * Calls the createCanvas and pushInfo methods to convert the
+     * image to a form that can be passed to GPT and find the locations
+     * of the text.
+     */
+    async function makeLabels(img: ImageBox) {
+        try {
+            const hrefBase64 = await createCanvas(img);
+            pushInfo(img, quizMode.NORMAL, hrefBase64);
+        } catch (error) {
+            console.log('Error', error);
+        }
+    }
+
+    /**
+     * Determines whether two words should be considered
+     * the same, allowing minor typos.
+     * @param str1
+     * @param str2
+     * @returns
+     */
+    function levenshteinDistance(str1: string, str2: string) {
+        const len1 = str1.length;
+        const len2 = str2.length;
+        const dp = Array.from(Array(len1 + 1), () => Array(len2 + 1).fill(0));
+
+        if (len1 === 0) return len2;
+        if (len2 === 0) return len1;
+
+        for (let i = 0; i <= len1; i++) dp[i][0] = i;
+        for (let j = 0; j <= len2; j++) dp[0][j] = j;
+
+        for (let i = 1; i <= len1; i++) {
+            for (let j = 1; j <= len2; j++) {
+                const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
+                dp[i][j] = Math.min(
+                    dp[i - 1][j] + 1, // deletion
+                    dp[i][j - 1] + 1, // insertion
+                    dp[i - 1][j - 1] + cost // substitution
+                );
+            }
+        }
+
+        return dp[len1][len2];
+    }
+
+    /**
+     * Different algorithm for determining string similarity.
+     * @param str1
+     * @param str2
+     * @returns
+     */
+    function jaccardSimilarity(str1: string, str2: string) {
+        const set1 = new Set(str1.split(' '));
+        const set2 = new Set(str2.split(' '));
+
+        const intersection = new Set([...set1].filter(x => set2.has(x)));
+        const union = new Set([...set1, ...set2]);
+
+        return intersection.size / union.size;
+    }
+
+    /**
+     * Averages the jaccardSimilarity and levenshteinDistance scores
+     * to determine string similarity for the labelboxes answers and
+     * the users response.
+     * @param str1
+     * @param str2
+     * @returns
+     */
+    function stringSimilarity(str1: string, str2: string) {
+        const levenshteinDist = levenshteinDistance(str1, str2);
+        const levenshteinScore = 1 - levenshteinDist / Math.max(str1.length, str2.length);
+
+        const jaccardScore = jaccardSimilarity(str1, str2);
+
+        // Combine the scores with a higher weight on Jaccard similarity
+        return 0.5 * levenshteinScore + 0.5 * jaccardScore;
+    }
+    /**
+     * Returns whether two strings are similar
+     * @param input
+     * @param target
+     * @returns
+     */
+    function compareWords(input: string, target: string) {
+        const distance = stringSimilarity(input.toLowerCase(), target.toLowerCase());
+        return distance >= 0.7;
+    }
+
+    /**
+     * GPT returns a hex color for what color the label box should be based on
+     * the correctness of the users answer.
+     * @param inputString
+     * @returns
+     */
+    function extractHexAndSentences(inputString: string) {
+        // Regular expression to match a hexadecimal number at the beginning followed by a period and sentences
+        const regex = /^#([0-9A-Fa-f]+)\.\s*(.+)$/s;
+        const match = inputString.match(regex);
+
+        if (match) {
+            const hexNumber = match[1];
+            const sentences = match[2].trim();
+            return { hexNumber, sentences };
+        } else {
+            return { error: 'The input string does not match the expected format.' };
+        }
+    }
+    /**
+     * Check whether the contents of the label boxes on an image are correct.
+     */
+    function check(img: ImageBox) {
+        //this._loading = true;
+        img.quizBoxes.forEach(async doc => {
+            const input = StrCast(doc[DocData].title);
+            if (img.quizMode == quizMode.SMART && input) {
+                const questionText = 'Question: What was labeled in this image?';
+                const rubricText = ' Rubric: ' + StrCast(doc.quiz);
+                const queryText =
+                    questionText +
+                    ' UserAnswer: ' +
+                    input +
+                    '. ' +
+                    rubricText +
+                    '. One sentence and evaluate based on meaning, not wording. Provide a hex color at the beginning with a period after it on a scale of green (minor details missed) to red (big error) for how correct the answer is. Example: "#FFFFFF. Pasta is delicious."';
+                const response = await gptAPICall(queryText, GPTCallType.QUIZ);
+                const hexSent = extractHexAndSentences(response);
+                doc.quiz = hexSent.sentences?.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric');
+                doc.backgroundColor = '#' + hexSent.hexNumber;
+            } else {
+                const match = compareWords(input, StrCast(doc.quiz).trim());
+                doc.backgroundColor = match ? '#11c249' : '#eb2d2d';
+            }
+        });
+        //this._loading = false;
+    }
+
+    function redo(img: ImageBox) {
+        img.quizBoxes.forEach(doc => {
+            doc[DocData].title = '';
+            doc.backgroundColor = '#e4e4e4';
+        });
+    }
+
+    /**
+     * Get rid of all the label boxes on the images.
+     */
+    function exitQuizMode(img: ImageBox) {
+        img.Document._quizMode = quizMode.NONE;
+        DocListCast(img.Document._quizBoxes).forEach(box => {
+            box.hidden = true;
+        });
+    }
+
+    export function quizStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & DocumentViewProps>, property: string) {
+        const editLabelAnswer = (qdoc: Doc) => {
+            // when click the pencil, set the text to the quiz content. when click off, set the quiz text to that and set textbox to nothing.
+            if (!qdoc._editLabel) {
+                qdoc.title = StrCast(qdoc.quiz);
+            } else {
+                qdoc.quiz = StrCast(qdoc.title);
+                qdoc.title = '';
+            }
+            qdoc._editLabel = !qdoc._editLabel;
+        };
+        const editAnswer = (qdoc: Opt<Doc>) => {
+            return (
+                <Tooltip
+                    title={
+                        <div className="answer-tooltip" style={{ minWidth: '150px' }}>
+                            {qdoc?._editLabel ? 'save' : 'edit correct answer'}
+                        </div>
+                    }>
+                    <div className="answer-tool-tip" onPointerDown={e => setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => qdoc && editLabelAnswer(qdoc))}>
+                        <FontAwesomeIcon className="edit-icon" color={qdoc?._editLabel ? 'white' : 'black'} icon="pencil" size="sm" />
+                    </div>
+                </Tooltip>
+            );
+        };
+        const answerIcon = (qdoc: Opt<Doc>) => {
+            return (
+                <Tooltip
+                    title={
+                        <div className="answer-tooltip" style={{ minWidth: '150px' }}>
+                            {StrCast(qdoc?.quiz ?? '')}
+                        </div>
+                    }>
+                    <div className="answer-tool-tip">
+                        <FontAwesomeIcon className="q-icon" icon="circle" color="white" />
+                        <FontAwesomeIcon className="answer-icon" icon="question" />
+                    </div>
+                </Tooltip>
+            );
+        };
+        const checkIcon = (img: ImageBox) => (
+            <Tooltip title={<div className="dash-tooltip">Check</div>}>
+                <div className="check-icon" onPointerDown={() => check(img)}>
+                    <FontAwesomeIcon icon="circle-check" size="lg" />
+                </div>
+            </Tooltip>
+        );
+        const redoIcon = (img: ImageBox) => (
+            <Tooltip title={<div className="dash-tooltip">Redo</div>}>
+                <div className="redo-icon" onPointerDown={() => redo(img)}>
+                    <FontAwesomeIcon icon="redo-alt" size="lg" />
+                </div>
+            </Tooltip>
+        );
+
+        const imgBox = props?.DocumentView?.().ComponentView as ImageBox;
+        switch (property) {
+            case StyleProp.Decorations:
+                {
+                    if (doc?.quiz) {
+                        // this should only be set on Labels that are part of an image quiz
+                        return (
+                            <>
+                                {editAnswer(doc?.[DocData])}
+                                {answerIcon(doc)}
+                            </>
+                        );
+                    } else if (imgBox?.Document._quizMode && imgBox.Document._quizMode !== quizMode.NONE) {
+                        return (
+                            <>
+                                {checkIcon(imgBox)}
+                                {redoIcon(imgBox)}
+                            </>
+                        );
+                    }
+                }
+                break;
+            case StyleProp.ContextMenuItems:
+                if (imgBox) {
+                    const quizes: ContextMenuProps[] = [];
+                    quizes.push({
+                        description: 'Smart Check',
+                        event: doc?.quizMode == quizMode.NONE ? () => pushInfo(imgBox, quizMode.SMART) : () => exitQuizMode(imgBox),
+                        icon: 'pen-to-square',
+                    });
+                    quizes.push({
+                        description: 'Normal',
+                        event: doc?.quizMode == quizMode.NONE ? () => pushInfo(imgBox, quizMode.NORMAL) : () => exitQuizMode(imgBox),
+                        icon: 'pencil',
+                    });
+                    ContextMenu.Instance?.addItem({ description: 'Quiz Mode', subitems: quizes, icon: 'file-pen' });
+                }
+                break;
+            case StyleProp.AnchorMenuItems:
+                if (imgBox) {
+                    AnchorMenu.Instance.gptFlashcards = () => getImageDesc(imgBox);
+                    AnchorMenu.Instance.makeLabels = () => makeLabels(props?.DocumentView?.().ComponentView as ImageBox);
+                }
+        }
+        return undefined;
+    }
+}
-- 
cgit v1.2.3-70-g09d2


From 504b8059ff4e162e089177e366a312dd583d5cff Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 7 Oct 2024 21:47:05 -0400
Subject: moved some more quiz functions to styleProviderQuiz

---
 src/client/views/StyleProviderQuiz.tsx | 21 ++++++++++++++-------
 src/client/views/nodes/ImageBox.tsx    | 12 ++----------
 2 files changed, 16 insertions(+), 17 deletions(-)

(limited to 'src/client/views/StyleProviderQuiz.tsx')

diff --git a/src/client/views/StyleProviderQuiz.tsx b/src/client/views/StyleProviderQuiz.tsx
index 8973ada95..1f2ad1485 100644
--- a/src/client/views/StyleProviderQuiz.tsx
+++ b/src/client/views/StyleProviderQuiz.tsx
@@ -83,7 +83,7 @@ export namespace styleProviderQuiz {
         imgBox.Document._quizMode = quiz;
         const quizBoxes = DocListCast(imgBox.Document.quizBoxes);
         if (!quizBoxes.length) {
-            //    this._loading = true;
+            imgBox.Loading = true;
 
             const img = {
                 file: i ? i : imgBox.paths[0],
@@ -98,7 +98,7 @@ export namespace styleProviderQuiz {
             if (response.data['boxes'].length != 0) {
                 createBoxes(imgBox, response.data['boxes'], response.data['text']);
             } else {
-                // this._loading = false;
+                imgBox.Loading = false;
             }
         } else quizBoxes.forEach(box => (box.hidden = false));
     }
@@ -121,7 +121,7 @@ export namespace styleProviderQuiz {
      * Create flashcards from an image.
      */
     async function getImageDesc(img: ImageBox) {
-        // this._loading = true;
+        img.Loading = true;
         try {
             const hrefBase64 = await createCanvas(img);
             const response = await gptImageLabel(hrefBase64, 'Make flashcards out of this image with each question and answer labeled as "question" and "answer". Do not label each flashcard and do not include asterisks: ');
@@ -129,7 +129,7 @@ export namespace styleProviderQuiz {
         } catch (error) {
             console.log('Error', error);
         }
-        // this._loading = false;
+        img.Loading = false;
     }
 
     /**
@@ -241,14 +241,21 @@ export namespace styleProviderQuiz {
             return { error: 'The input string does not match the expected format.' };
         }
     }
+    function imgQuizBoxes(img: ImageBox) {
+        return DocListCast(img.Document.quizBoxes);
+    }
+    function imgQuizMode(img: ImageBox) {
+        return StrCast(img.Document._quizMode);
+    }
+
     /**
      * Check whether the contents of the label boxes on an image are correct.
      */
     function check(img: ImageBox) {
         //this._loading = true;
-        img.quizBoxes.forEach(async doc => {
+        imgQuizBoxes(img).forEach(async doc => {
             const input = StrCast(doc[DocData].title);
-            if (img.quizMode == quizMode.SMART && input) {
+            if (imgQuizMode(img) == quizMode.SMART && input) {
                 const questionText = 'Question: What was labeled in this image?';
                 const rubricText = ' Rubric: ' + StrCast(doc.quiz);
                 const queryText =
@@ -271,7 +278,7 @@ export namespace styleProviderQuiz {
     }
 
     function redo(img: ImageBox) {
-        img.quizBoxes.forEach(doc => {
+        imgQuizBoxes(img).forEach(doc => {
             doc[DocData].title = '';
             doc.backgroundColor = '#e4e4e4';
         });
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index 0b474076b..0827eb062 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -80,12 +80,12 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
     private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef();
     imageRef: HTMLImageElement | null = null; // <video> ref
     marqueeref = React.createRef<MarqueeAnnotator>();
+    @observable Loading = false; // bcz: this should be migrated into StylProviderQuiz since it's not fundamental to the imageBox
 
     @observable private _searchInput = '';
     @observable private _savedAnnotations = new ObservableMap<number, (HTMLDivElement & { marqueeing?: boolean })[]>();
     @observable private _curSuffix = '';
     @observable private _error = '';
-    @observable private _loading = false;
     @observable private _isHovering = false; // flag to switch between primary and alternate images on hover
 
     constructor(props: FieldViewProps) {
@@ -98,14 +98,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
         this._dropDisposer?.();
         ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.Document));
     };
-
-    @computed get quizBoxes() {
-        return DocListCast(this.Document.quizBoxes);
-    }
-    @computed get quizMode() {
-        return StrCast(this.Document._quizMode);
-    }
-
     getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => {
         const visibleAnchor = this._getAnchor?.(this._savedAnnotations, true); // use marquee anchor, otherwise, save zoom/pan as anchor
         const anchor =
@@ -562,7 +554,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
                     addDocument={this.addDocument}>
                     {this.content}
                 </CollectionFreeFormView>
-                {this._loading ? (
+                {this.Loading ? (
                     <div className="loading-spinner" style={{ position: 'absolute' }}>
                         <ReactLoading type="spin" height={50} width={50} color={'blue'} />
                     </div>
-- 
cgit v1.2.3-70-g09d2


From 3b17868560090756caf8b9b0f043ea163f2320e8 Mon Sep 17 00:00:00 2001
From: eleanor-park <eleanor_park@brown.edu>
Date: Sun, 20 Oct 2024 12:44:15 -0400
Subject: changes

---
 src/client/views/MainView.tsx                      |   5 +-
 src/client/views/StyleProviderQuiz.tsx             |   2 +-
 .../views/nodes/generativeFill/GenerativeFill.scss |  97 ---
 .../views/nodes/generativeFill/GenerativeFill.tsx  | 687 -------------------
 .../generativeFill/GenerativeFillButtons.scss      |   4 -
 .../nodes/generativeFill/GenerativeFillButtons.tsx |  72 --
 .../generativeFillUtils/BrushHandler.ts            |  35 -
 .../GenerativeFillMathHelpers.ts                   |   6 -
 .../generativeFillUtils/ImageHandler.ts            | 312 ---------
 .../generativeFillUtils/PointerHandler.ts          |  11 -
 .../generativeFillUtils/generativeFillConstants.ts |   9 -
 .../generativeFillInterfaces.ts                    |  20 -
 .../nodes/imageEditor/GenerativeFillButtons.scss   |   4 +
 .../nodes/imageEditor/GenerativeFillButtons.tsx    |  72 ++
 .../views/nodes/imageEditor/ImageEditor.scss       | 134 ++++
 src/client/views/nodes/imageEditor/ImageEditor.tsx | 758 +++++++++++++++++++++
 .../views/nodes/imageEditor/ImageEditorButtons.tsx |  69 ++
 .../imageEditor/imageEditingUtils/ImageHandler.ts  | 312 +++++++++
 .../imageEditor/imageEditorUtils/BrushHandler.ts   |  35 +
 .../imageEditorUtils/GenerativeFillMathHelpers.ts  |   6 +
 .../imageEditor/imageEditorUtils/ImageHandler.ts   | 312 +++++++++
 .../imageEditor/imageEditorUtils/PointerHandler.ts |  11 +
 .../imageEditorUtils/imageEditorConstants.ts       |   9 +
 .../imageEditorUtils/imageEditorInterfaces.ts      |  38 ++
 .../imageEditor/imageToolUtils/BrushHandler.ts     |  35 +
 .../imageEditor/imageToolUtils/ImageHandler.ts     | 312 +++++++++
 src/client/views/smartdraw/AnnotationPalette.tsx   |   2 +-
 27 files changed, 2112 insertions(+), 1257 deletions(-)
 delete mode 100644 src/client/views/nodes/generativeFill/GenerativeFill.scss
 delete mode 100644 src/client/views/nodes/generativeFill/GenerativeFill.tsx
 delete mode 100644 src/client/views/nodes/generativeFill/GenerativeFillButtons.scss
 delete mode 100644 src/client/views/nodes/generativeFill/GenerativeFillButtons.tsx
 delete mode 100644 src/client/views/nodes/generativeFill/generativeFillUtils/BrushHandler.ts
 delete mode 100644 src/client/views/nodes/generativeFill/generativeFillUtils/GenerativeFillMathHelpers.ts
 delete mode 100644 src/client/views/nodes/generativeFill/generativeFillUtils/ImageHandler.ts
 delete mode 100644 src/client/views/nodes/generativeFill/generativeFillUtils/PointerHandler.ts
 delete mode 100644 src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillConstants.ts
 delete mode 100644 src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillInterfaces.ts
 create mode 100644 src/client/views/nodes/imageEditor/GenerativeFillButtons.scss
 create mode 100644 src/client/views/nodes/imageEditor/GenerativeFillButtons.tsx
 create mode 100644 src/client/views/nodes/imageEditor/ImageEditor.scss
 create mode 100644 src/client/views/nodes/imageEditor/ImageEditor.tsx
 create mode 100644 src/client/views/nodes/imageEditor/ImageEditorButtons.tsx
 create mode 100644 src/client/views/nodes/imageEditor/imageEditingUtils/ImageHandler.ts
 create mode 100644 src/client/views/nodes/imageEditor/imageEditorUtils/BrushHandler.ts
 create mode 100644 src/client/views/nodes/imageEditor/imageEditorUtils/GenerativeFillMathHelpers.ts
 create mode 100644 src/client/views/nodes/imageEditor/imageEditorUtils/ImageHandler.ts
 create mode 100644 src/client/views/nodes/imageEditor/imageEditorUtils/PointerHandler.ts
 create mode 100644 src/client/views/nodes/imageEditor/imageEditorUtils/imageEditorConstants.ts
 create mode 100644 src/client/views/nodes/imageEditor/imageEditorUtils/imageEditorInterfaces.ts
 create mode 100644 src/client/views/nodes/imageEditor/imageToolUtils/BrushHandler.ts
 create mode 100644 src/client/views/nodes/imageEditor/imageToolUtils/ImageHandler.ts

(limited to 'src/client/views/StyleProviderQuiz.tsx')

diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index 7779d339f..7bc211036 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -73,7 +73,7 @@ import { OpenWhere, OpenWhereMod } from './nodes/OpenWhere';
 import { TaskCompletionBox } from './nodes/TaskCompletedBox';
 import { DashFieldViewMenu } from './nodes/formattedText/DashFieldView';
 import { RichTextMenu } from './nodes/formattedText/RichTextMenu';
-import GenerativeFill from './nodes/generativeFill/GenerativeFill';
+import ImageEditorBox from './nodes/imageEditor/ImageEditor';
 import { PresBox } from './nodes/trails';
 import { AnchorMenu } from './pdf/AnchorMenu';
 import { GPTPopup } from './pdf/GPTPopup/GPTPopup';
@@ -446,6 +446,7 @@ export class MainView extends ObservableReactComponent<object> {
                 fa.faAlignRight,
                 fa.faHeading,
                 fa.faRulerCombined,
+                fa.faFill,
                 fa.faFillDrip,
                 fa.faLink,
                 fa.faUnlink,
@@ -1146,7 +1147,7 @@ export class MainView extends ObservableReactComponent<object> {
                 <LightboxView key="lightbox" PanelWidth={this._windowWidth} addSplit={CollectionDockingView.AddSplit} PanelHeight={this._windowHeight} maxBorder={this.lightboxMaxBorder} />
                 <GPTPopup key="gptpopup" />
                 <SchemaCSVPopUp key="schemacsvpopup" />
-                <GenerativeFill imageEditorOpen={ImageEditor.Open} imageEditorSource={ImageEditor.Source} imageRootDoc={ImageEditor.RootDoc} addDoc={ImageEditor.AddDoc} />
+                <ImageEditorBox imageEditorOpen={ImageEditor.Open} imageEditorSource={ImageEditor.Source} imageRootDoc={ImageEditor.RootDoc} addDoc={ImageEditor.AddDoc} />
             </div>
         );
     }
diff --git a/src/client/views/StyleProviderQuiz.tsx b/src/client/views/StyleProviderQuiz.tsx
index 1f2ad1485..8a1515d21 100644
--- a/src/client/views/StyleProviderQuiz.tsx
+++ b/src/client/views/StyleProviderQuiz.tsx
@@ -17,7 +17,7 @@ import { AnchorMenu } from './pdf/AnchorMenu';
 import { DocumentViewProps } from './nodes/DocumentView';
 import { FieldViewProps } from './nodes/FieldView';
 import { ImageBox } from './nodes/ImageBox';
-import { ImageUtility } from './nodes/generativeFill/generativeFillUtils/ImageHandler';
+import { ImageUtility } from './nodes/imageEditor/imageEditorUtils/ImageHandler';
 import './StyleProviderQuiz.scss';
 
 export namespace styleProviderQuiz {
diff --git a/src/client/views/nodes/generativeFill/GenerativeFill.scss b/src/client/views/nodes/generativeFill/GenerativeFill.scss
deleted file mode 100644
index c2669a950..000000000
--- a/src/client/views/nodes/generativeFill/GenerativeFill.scss
+++ /dev/null
@@ -1,97 +0,0 @@
-$navHeight: 5rem;
-$canvasSize: 1024px;
-$scale: 0.5;
-
-.generativeFillContainer {
-    position: absolute;
-    top: 0;
-    left: 0;
-    z-index: 9999;
-    height: 100vh;
-    width: 100vw;
-    display: flex;
-    flex-direction: column;
-    overflow: hidden;
-
-    .generativeFillControls {
-        flex-shrink: 0;
-        height: $navHeight;
-        color: #000000;
-        background-color: #ffffff;
-        z-index: 999;
-        width: 100%;
-        display: flex;
-        gap: 3rem;
-        justify-content: space-between;
-        align-items: center;
-        border-bottom: 1px solid #c7cdd0;
-        padding: 0 2rem;
-
-        h1 {
-            font-size: 1.5rem;
-        }
-    }
-
-    .drawingArea {
-        cursor: none;
-        touch-action: none;
-        position: relative;
-        flex-grow: 1;
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        width: 100%;
-        background-color: #f0f4f6;
-
-        canvas {
-            display: block;
-            position: absolute;
-            transform-origin: 50% 50%;
-        }
-
-        .pointer {
-            pointer-events: none;
-            position: absolute;
-            border-radius: 50%;
-            width: 50px;
-            height: 50px;
-            border: 1px solid #ffffff;
-            transform: translate(-50%, -50%);
-            display: flex;
-            justify-content: center;
-            align-items: center;
-
-            .innerPointer {
-                width: 100%;
-                height: 100%;
-                border: 1px solid #000000;
-                border-radius: 50%;
-            }
-        }
-
-        .iconContainer {
-            position: absolute;
-            top: 2rem;
-            left: 2rem;
-            display: flex;
-            flex-direction: column;
-            gap: 2rem;
-        }
-
-        .editsBox {
-            position: absolute;
-            top: 2rem;
-            right: 2rem;
-            display: flex;
-            flex-direction: column;
-            gap: 1rem;
-
-            img {
-                transition: all 0.2s ease-in-out;
-                &:hover {
-                    opacity: 0.8;
-                }
-            }
-        }
-    }
-}
diff --git a/src/client/views/nodes/generativeFill/GenerativeFill.tsx b/src/client/views/nodes/generativeFill/GenerativeFill.tsx
deleted file mode 100644
index 261eb4bb4..000000000
--- a/src/client/views/nodes/generativeFill/GenerativeFill.tsx
+++ /dev/null
@@ -1,687 +0,0 @@
-/* eslint-disable jsx-a11y/label-has-associated-control */
-/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
-/* eslint-disable jsx-a11y/img-redundant-alt */
-/* eslint-disable jsx-a11y/click-events-have-key-events */
-/* eslint-disable react/function-component-definition */
-import { Checkbox, FormControlLabel, Slider, TextField } from '@mui/material';
-import { IconButton } from 'browndash-components';
-import * as React from 'react';
-import { useEffect, useRef, useState } from 'react';
-import { CgClose } from 'react-icons/cg';
-import { IoMdRedo, IoMdUndo } from 'react-icons/io';
-import { ClientUtils } from '../../../../ClientUtils';
-import { Doc, DocListCast } from '../../../../fields/Doc';
-import { List } from '../../../../fields/List';
-import { NumCast } from '../../../../fields/Types';
-import { Networking } from '../../../Network';
-import { DocUtils } from '../../../documents/DocUtils';
-import { Docs } from '../../../documents/Documents';
-import { CollectionDockingView } from '../../collections/CollectionDockingView';
-import { CollectionFreeFormView } from '../../collections/collectionFreeForm';
-import { ImageEditorData } from '../ImageBox';
-import { OpenWhereMod } from '../OpenWhere';
-import './GenerativeFill.scss';
-import { EditButtons, CutButtons } from './GenerativeFillButtons';
-import { BrushHandler, BrushType } from './generativeFillUtils/BrushHandler';
-import { APISuccess, ImageUtility } from './generativeFillUtils/ImageHandler';
-import { PointerHandler } from './generativeFillUtils/PointerHandler';
-import { activeColor, canvasSize, eraserColor, freeformRenderSize, newCollectionSize, offsetDistanceY, offsetX } from './generativeFillUtils/generativeFillConstants';
-import { CursorData, ImageDimensions, Point } from './generativeFillUtils/generativeFillInterfaces';
-import { DocumentView } from '../DocumentView';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { ImageField } from '../../../../fields/URLField';
-import { resolve } from 'url';
-
-interface GenerativeFillProps {
-    imageEditorOpen: boolean;
-    imageEditorSource: string;
-    imageRootDoc: Doc | undefined;
-    addDoc: ((doc: Doc | Doc[], annotationKey?: string) => boolean) | undefined;
-}
-
-// Added field on image doc: gen_fill_children: List of children Docs
-
-const GenerativeFill = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc }: GenerativeFillProps) => {
-    const canvasRef = useRef<HTMLCanvasElement>(null);
-    const canvasBackgroundRef = useRef<HTMLCanvasElement>(null);
-    const drawingAreaRef = useRef<HTMLDivElement>(null);
-    const [cursorData, setCursorData] = useState<CursorData>({
-        x: 0,
-        y: 0,
-        width: 150,
-    });
-    const [isBrushing, setIsBrushing] = useState(false);
-    const [canvasScale, setCanvasScale] = useState(0.5);
-    // format: array of [image source, corresponding image Doc]
-    const [edits, setEdits] = useState<{ url: string; saveRes: Doc | undefined }[]>([]);
-    const [edited, setEdited] = useState(false);
-    // const [brushStyle] = useState<BrushStyle>(BrushStyle.ADD);
-    const [input, setInput] = useState('');
-    const [loading, setLoading] = useState(false);
-    const [canvasDims, setCanvasDims] = useState<ImageDimensions>({
-        width: canvasSize,
-        height: canvasSize,
-    });
-    // whether to create a new collection or not
-    const [isNewCollection, setIsNewCollection] = useState(true);
-    // the current image in the main canvas
-    const currImg = useRef<HTMLImageElement | null>(null);
-    // the unedited version of each generation (parent)
-    const originalImg = useRef<HTMLImageElement | null>(null);
-    const originalDoc = useRef<Doc | null>(null);
-    // stores history of data urls
-    const undoStack = useRef<string[]>([]);
-    // stores redo stack
-    const redoStack = useRef<string[]>([]);
-
-    // references to keep track of tree structure
-    const newCollectionRef = useRef<Doc | null>(null);
-    const parentDoc = useRef<Doc | null>(null);
-    const childrenDocs = useRef<Doc[]>([]);
-
-    // constants for image cutting
-    const cutPts = useRef<Point[]>([]);
-
-    // Undo and Redo
-    const handleUndo = () => {
-        const ctx = ImageUtility.getCanvasContext(canvasRef);
-        if (!ctx || !currImg.current || !canvasRef.current) return;
-
-        const target = undoStack.current[undoStack.current.length - 1];
-        if (!target) {
-            ImageUtility.drawImgToCanvas(currImg.current, canvasRef, canvasDims.width, canvasDims.height);
-        } else {
-            redoStack.current = [...redoStack.current, canvasRef.current.toDataURL()];
-            const img = new Image();
-            img.src = target;
-            ImageUtility.drawImgToCanvas(img, canvasRef, canvasDims.width, canvasDims.height);
-            undoStack.current = undoStack.current.slice(0, -1);
-        }
-    };
-
-    const handleRedo = () => {
-        const ctx = ImageUtility.getCanvasContext(canvasRef);
-        if (!ctx || !currImg.current || !canvasRef.current) return;
-
-        const target = redoStack.current[redoStack.current.length - 1];
-        if (target) {
-            undoStack.current = [...undoStack.current, canvasRef.current?.toDataURL()];
-            const img = new Image();
-            img.src = target;
-            ImageUtility.drawImgToCanvas(img, canvasRef, canvasDims.width, canvasDims.height);
-            redoStack.current = redoStack.current.slice(0, -1);
-        }
-    };
-
-    // resets any erase strokes
-    const handleReset = () => {
-        if (!canvasRef.current || !currImg.current) return;
-        const ctx = ImageUtility.getCanvasContext(canvasRef);
-        if (!ctx) return;
-        ctx.clearRect(0, 0, canvasSize, canvasSize);
-        undoStack.current = [];
-        redoStack.current = [];
-        ImageUtility.drawImgToCanvas(currImg.current, canvasRef, canvasDims.width, canvasDims.height);
-    };
-
-    // initiate brushing
-    const handlePointerDown = (e: React.PointerEvent) => {
-        const canvas = canvasRef.current;
-        if (!canvas) return;
-        const ctx = ImageUtility.getCanvasContext(canvasRef);
-        if (!ctx) return;
-
-        undoStack.current = [...undoStack.current, canvasRef.current.toDataURL()];
-        redoStack.current = [];
-
-        setIsBrushing(true);
-        const { x, y } = PointerHandler.getPointRelativeToElement(canvas, e, canvasScale);
-        BrushHandler.brushCircleOverlay(x, y, cursorData.width / 2 / canvasScale, ctx, eraserColor /* , brushStyle === BrushStyle.SUBTRACT */);
-    };
-
-    // stop brushing, push to undo stack
-    const handlePointerUp = () => {
-        const ctx = ImageUtility.getCanvasContext(canvasBackgroundRef);
-        if (!ctx) return;
-        if (!isBrushing) return;
-        setIsBrushing(false);
-    };
-
-    // handles brushing on pointer movement
-    useEffect(() => {
-        if (!isBrushing) return undefined;
-        const canvas = canvasRef.current;
-        if (!canvas) return undefined;
-        const ctx = ImageUtility.getCanvasContext(canvasRef);
-        if (!ctx) return undefined;
-
-        const handlePointerMove = (e: PointerEvent) => {
-            const currPoint = PointerHandler.getPointRelativeToElement(canvas, e, canvasScale);
-            const lastPoint: Point = {
-                x: currPoint.x - e.movementX / canvasScale,
-                y: currPoint.y - e.movementY / canvasScale,
-            };
-            const pts = BrushHandler.createBrushPathOverlay(lastPoint, currPoint, cursorData.width / 2 / canvasScale, ctx, eraserColor, BrushType.CUT);
-            cutPts.current.push(...pts);
-        };
-
-        drawingAreaRef.current?.addEventListener('pointermove', handlePointerMove);
-        return () => {
-            drawingAreaRef.current?.removeEventListener('pointermove', handlePointerMove);
-        };
-    }, [isBrushing]);
-
-    // first load
-    useEffect(() => {
-        const loadInitial = async () => {
-            if (!imageEditorSource || imageEditorSource === '') return;
-            const img = new Image();
-            const res = await ImageUtility.urlToBase64(imageEditorSource);
-            if (!res) return;
-            img.src = `data:image/png;base64,${res}`;
-
-            img.onload = () => {
-                currImg.current = img;
-                originalImg.current = img;
-                const imgWidth = img.naturalWidth;
-                const imgHeight = img.naturalHeight;
-                const scale = Math.min(canvasSize / imgWidth, canvasSize / imgHeight);
-                const width = imgWidth * scale;
-                const height = imgHeight * scale;
-                setCanvasDims({ width, height });
-            };
-        };
-
-        loadInitial();
-
-        // cleanup
-        return () => {
-            setInput('');
-            setEdited(false);
-            newCollectionRef.current = null;
-            parentDoc.current = null;
-            childrenDocs.current = [];
-            currImg.current = null;
-            originalImg.current = null;
-            originalDoc.current = null;
-            undoStack.current = [];
-            redoStack.current = [];
-            ImageUtility.clearCanvas(canvasRef);
-        };
-    }, [canvasRef, imageEditorSource]);
-
-    // once the appropriate dimensions are set, draw the image to the canvas
-    useEffect(() => {
-        if (!currImg.current) return;
-        ImageUtility.drawImgToCanvas(currImg.current, canvasRef, canvasDims.width, canvasDims.height);
-    }, [canvasDims]);
-
-    // handles brush sizing
-    useEffect(() => {
-        const handleKeyPress = (e: KeyboardEvent) => {
-            if (e.key === 'ArrowUp') {
-                e.preventDefault();
-                e.stopPropagation();
-                setCursorData(data => ({ ...data, width: data.width + 5 }));
-            } else if (e.key === 'ArrowDown') {
-                e.preventDefault();
-                e.stopPropagation();
-                setCursorData(data => (data.width >= 20 ? { ...data, width: data.width - 5 } : data));
-            }
-        };
-        window.addEventListener('keydown', handleKeyPress);
-        return () => window.removeEventListener('keydown', handleKeyPress);
-    }, []);
-
-    // handle pinch zoom
-    useEffect(() => {
-        const handlePinch = (e: WheelEvent) => {
-            e.preventDefault();
-            e.stopPropagation();
-            const delta = e.deltaY;
-            const scaleFactor = delta > 0 ? 0.98 : 1.02;
-            setCanvasScale(prevScale => prevScale * scaleFactor);
-        };
-
-        drawingAreaRef.current?.addEventListener('wheel', handlePinch, {
-            passive: false,
-        });
-        return () => drawingAreaRef.current?.removeEventListener('wheel', handlePinch);
-    }, [drawingAreaRef]);
-
-    // updates the current position of the cursor
-    const updateCursorData = (e: React.PointerEvent) => {
-        const drawingArea = drawingAreaRef.current;
-        if (!drawingArea) return;
-        const { x, y } = PointerHandler.getPointRelativeToElement(drawingArea, e, 1);
-        setCursorData(data => ({
-            ...data,
-            x,
-            y,
-        }));
-    };
-
-    // Get AI Edit
-    const getEdit = async () => {
-        const img = currImg.current;
-        if (!img) return;
-        const canvas = canvasRef.current;
-        if (!canvas) return;
-        const ctx = ImageUtility.getCanvasContext(canvasRef);
-        if (!ctx) return;
-        setLoading(true);
-        setEdited(true);
-        try {
-            const canvasOriginalImg = ImageUtility.getCanvasImg(img);
-            if (!canvasOriginalImg) return;
-            const canvasMask = ImageUtility.getCanvasMask(canvas, canvasOriginalImg);
-            if (!canvasMask) return;
-            const maskBlob = await ImageUtility.canvasToBlob(canvasMask);
-            const imgBlob = await ImageUtility.canvasToBlob(canvasOriginalImg);
-            const res = await ImageUtility.getEdit(imgBlob, maskBlob, input !== '' ? input + ' in the same style' : 'Fill in the image in the same style', 2);
-
-            // create first image
-            if (!newCollectionRef.current) {
-                if (!isNewCollection && imageRootDoc) {
-                    // if the parent hasn't been set yet
-                    if (!parentDoc.current) parentDoc.current = imageRootDoc;
-                } else {
-                    if (!(originalImg.current && imageRootDoc)) return;
-                    // create new collection and add it to the view
-                    newCollectionRef.current = Docs.Create.FreeformDocument([], {
-                        x: NumCast(imageRootDoc.x) + NumCast(imageRootDoc._width) + offsetX,
-                        y: NumCast(imageRootDoc.y),
-                        _width: newCollectionSize,
-                        _height: newCollectionSize,
-                        title: 'Image edit collection',
-                    });
-                    DocUtils.MakeLink(imageRootDoc, newCollectionRef.current, { link_relationship: 'Image Edit Version History' });
-
-                    // opening new tab
-                    CollectionDockingView.AddSplit(newCollectionRef.current, OpenWhereMod.right);
-
-                    // add the doc to the main freeform
-                    // eslint-disable-next-line no-use-before-define
-                    await createNewImgDoc(originalImg.current, true);
-                }
-            } else {
-                childrenDocs.current = [];
-            }
-
-            originalImg.current = currImg.current;
-            originalDoc.current = parentDoc.current;
-            const { urls } = res as APISuccess;
-            if (res.status !== 'error') {
-                const imgUrls = await Promise.all(urls.map(url => ImageUtility.convertImgToCanvasUrl(url, canvasDims.width, canvasDims.height)));
-                const imgRes = await Promise.all(
-                    imgUrls.map(async url => {
-                        // eslint-disable-next-line no-use-before-define
-                        const saveRes = await onSave(url);
-                        return { url, saveRes };
-                    })
-                );
-                setEdits(imgRes);
-                const image = new Image();
-                // eslint-disable-next-line prefer-destructuring
-                image.src = imgUrls[0];
-                ImageUtility.drawImgToCanvas(image, canvasRef, canvasDims.width, canvasDims.height);
-                currImg.current = image;
-                parentDoc.current = imgRes[0].saveRes ?? null;
-            }
-        } catch (err) {
-            console.log(err);
-        }
-        setLoading(false);
-    };
-
-    const cutImage = async () => {
-        const img = currImg.current;
-        const canvas = canvasRef.current;
-        if (!canvas || !img) return;
-        canvas.width = img.naturalWidth;
-        canvas.height = img.naturalHeight;
-        const ctx = ImageUtility.getCanvasContext(canvasRef);
-        if (!ctx) return;
-        ctx.globalCompositeOperation = 'source-over';
-        setLoading(true);
-        setEdited(true);
-        // get the original image
-        const canvasOriginalImg = ImageUtility.getCanvasImg(img);
-        if (!canvasOriginalImg) return;
-        // draw the image onto the canvas
-        ctx.drawImage(img, 0, 0);
-        // get the mask which i assume is the thing the user draws on
-        // const canvasMask = ImageUtility.getCanvasMask(canvas, canvasOriginalImg);
-        // if (!canvasMask) return;
-        // canvasMask.width = canvas.width;
-        // canvasMask.height = canvas.height;
-        // now put the user's path around the mask
-        if (cutPts.current.length) {
-            ctx.beginPath();
-            ctx.moveTo(cutPts.current[0].x, cutPts.current[0].y); // later check edge case where cutPts is empty
-            for (let i = 0; i < cutPts.current.length; i++) {
-                ctx.lineTo(cutPts.current[i].x, cutPts.current[i].y);
-            }
-            ctx.closePath();
-            ctx.stroke();
-            ctx.fill();
-            // ctx.clip();
-        }
-        const url = canvas.toDataURL(); // this does the same thing as convert img to canvasurl
-        if (!newCollectionRef.current) {
-            if (!isNewCollection && imageRootDoc) {
-                // if the parent hasn't been set yet
-                if (!parentDoc.current) parentDoc.current = imageRootDoc;
-            } else {
-                if (!(originalImg.current && imageRootDoc)) return;
-                // create new collection and add it to the view
-                newCollectionRef.current = Docs.Create.FreeformDocument([], {
-                    x: NumCast(imageRootDoc.x) + NumCast(imageRootDoc._width) + offsetX,
-                    y: NumCast(imageRootDoc.y),
-                    _width: newCollectionSize,
-                    _height: newCollectionSize,
-                    title: 'Image edit collection',
-                });
-                DocUtils.MakeLink(imageRootDoc, newCollectionRef.current, { link_relationship: 'Image Edit Version History' });
-                // opening new tab
-                CollectionDockingView.AddSplit(newCollectionRef.current, OpenWhereMod.right);
-            }
-        }
-        const image = new Image();
-        image.src = url;
-        await createNewImgDoc(image, true);
-        // add the doc to the main freeform
-        // eslint-disable-next-line no-use-before-define
-        setLoading(false);
-        cutPts.current.length = 0;
-    };
-
-    // adjusts all the img positions to be aligned
-    const adjustImgPositions = () => {
-        if (!parentDoc.current) return;
-        const startY = NumCast(parentDoc.current.y);
-        const children = DocListCast(parentDoc.current.gen_fill_children);
-        const len = children.length;
-        const initialYPositions: number[] = [];
-        for (let i = 0; i < len; i++) {
-            initialYPositions.push(startY + i * offsetDistanceY);
-        }
-        children.forEach((doc, i) => {
-            if (len % 2 === 1) {
-                doc.y = initialYPositions[i] - Math.floor(len / 2) * offsetDistanceY;
-            } else {
-                doc.y = initialYPositions[i] - (len / 2 - 1 / 2) * offsetDistanceY;
-            }
-        });
-    };
-
-    // creates a new image document and returns its reference
-    const createNewImgDoc = async (img: HTMLImageElement, firstDoc: boolean): Promise<Doc | undefined> => {
-        if (!imageRootDoc) return undefined;
-        const { src } = img;
-        const [result] = await Networking.PostToServer('/uploadRemoteImage', { sources: [src] });
-        const source = ClientUtils.prepend(result.accessPaths.agnostic.client);
-
-        if (firstDoc) {
-            const x = 0;
-            const initialY = 0;
-            const newImg = Docs.Create.ImageDocument(source, {
-                x: x,
-                y: initialY,
-                _height: freeformRenderSize,
-                _width: freeformRenderSize,
-                data_nativeWidth: result.nativeWidth,
-                data_nativeHeight: result.nativeHeight,
-            });
-            if (isNewCollection && newCollectionRef.current) {
-                Doc.AddDocToList(newCollectionRef.current, undefined, newImg);
-            } else {
-                addDoc?.(newImg);
-            }
-            parentDoc.current = newImg;
-            return newImg;
-        }
-        if (!parentDoc.current) return undefined;
-        const x = NumCast(parentDoc.current.x) + freeformRenderSize + offsetX;
-        const initialY = 0;
-
-        const newImg = Docs.Create.ImageDocument(source, {
-            x: x,
-            y: initialY,
-            _height: freeformRenderSize,
-            _width: freeformRenderSize,
-            data_nativeWidth: result.nativeWidth,
-            data_nativeHeight: result.nativeHeight,
-        });
-
-        const parentList = DocListCast(parentDoc.current.gen_fill_children);
-        if (parentList.length > 0) {
-            parentList.push(newImg);
-            parentDoc.current.gen_fill_children = new List<Doc>(parentList);
-        } else {
-            parentDoc.current.gen_fill_children = new List<Doc>([newImg]);
-        }
-
-        DocUtils.MakeLink(parentDoc.current, newImg, { link_relationship: `Image edit; Prompt: ${input}` });
-        adjustImgPositions();
-
-        if (isNewCollection && newCollectionRef.current) {
-            Doc.AddDocToList(newCollectionRef.current, undefined, newImg);
-        } else {
-            addDoc?.(newImg);
-        }
-        return newImg;
-    };
-
-    // Saves an image to the collection
-    const onSave = async (src: string) => {
-        const img = new Image();
-        img.src = src;
-        if (!currImg.current || !originalImg.current || !imageRootDoc) return undefined;
-        try {
-            const res = await createNewImgDoc(img, false);
-            return res;
-        } catch (err) {
-            console.log(err);
-        }
-        return undefined;
-    };
-
-    // Closes the editor view
-    const handleViewClose = () => {
-        ImageEditorData.Open = false;
-        ImageEditorData.Source = '';
-        if (newCollectionRef.current) {
-            DocumentView.addViewRenderedCb(newCollectionRef.current, dv => (dv.ComponentView as CollectionFreeFormView)?.fitContentOnce());
-        }
-        setEdits([]);
-    };
-
-    return (
-        <div className="generativeFillContainer" style={{ display: imageEditorOpen ? 'flex' : 'none' }}>
-            <div className="generativeFillControls">
-                <h1>Image Editor</h1>
-                {/* <IconButton text="Cut out" icon={<FontAwesomeIcon icon="scissors" />} /> */}
-                <div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem' }}>
-                    <FormControlLabel
-                        control={
-                            <Checkbox
-                                // disable once edited has been clicked (doesn't make sense to change after first edit)
-                                disabled={edited}
-                                checked={isNewCollection}
-                                onChange={() => {
-                                    setIsNewCollection(prev => !prev);
-                                }}
-                            />
-                        }
-                        label="Create New Collection"
-                        labelPlacement="end"
-                        sx={{ whiteSpace: 'nowrap' }}
-                    />
-                    <EditButtons onClick={getEdit} loading={loading} onReset={handleReset} />
-                    <CutButtons onClick={cutImage} loading={loading} onReset={handleReset} />
-                    <IconButton color={activeColor} tooltip="close" icon={<CgClose size="16px" />} onClick={handleViewClose} />
-                </div>
-            </div>
-            {/* Main canvas for editing */}
-            <div
-                className="drawingArea" // this only works if pointerevents: none is set on the custom pointer
-                ref={drawingAreaRef}
-                onPointerOver={updateCursorData}
-                onPointerMove={updateCursorData}
-                onPointerDown={handlePointerDown}
-                onPointerUp={handlePointerUp}>
-                <canvas ref={canvasRef} width={canvasDims.width} height={canvasDims.height} style={{ transform: `scale(${canvasScale})` }} />
-                <canvas ref={canvasBackgroundRef} width={canvasDims.width} height={canvasDims.height} style={{ transform: `scale(${canvasScale})` }} />
-                <div
-                    className="pointer"
-                    style={{
-                        left: cursorData.x,
-                        top: cursorData.y,
-                        width: cursorData.width,
-                        height: cursorData.width,
-                    }}>
-                    <div className="innerPointer" />
-                </div>
-                {/* Icons */}
-                <div className="iconContainer">
-                    {/* Undo and Redo */}
-                    <IconButton
-                        style={{ cursor: 'pointer' }}
-                        onPointerDown={e => {
-                            e.stopPropagation();
-                            handleUndo();
-                        }}
-                        onPointerUp={e => {
-                            e.stopPropagation();
-                        }}
-                        color={activeColor}
-                        tooltip="Undo"
-                        icon={<IoMdUndo />}
-                    />
-                    <IconButton
-                        style={{ cursor: 'pointer' }}
-                        onPointerDown={e => {
-                            e.stopPropagation();
-                            handleRedo();
-                        }}
-                        onPointerUp={e => {
-                            e.stopPropagation();
-                        }}
-                        color={activeColor}
-                        tooltip="Redo"
-                        icon={<IoMdRedo />}
-                    />
-                    <div onPointerDown={e => e.stopPropagation()} style={{ height: 225, width: '100%', display: 'flex', justifyContent: 'center', cursor: 'pointer' }}>
-                        <Slider
-                            sx={{
-                                '& input[type="range"]': {
-                                    WebkitAppearance: 'slider-vertical',
-                                },
-                            }}
-                            orientation="vertical"
-                            min={25}
-                            max={500}
-                            defaultValue={150}
-                            size="small"
-                            valueLabelDisplay="auto"
-                            onChange={(e: any, val: any) => {
-                                setCursorData(prev => ({ ...prev, width: val as number }));
-                            }}
-                        />
-                    </div>
-                    <div onPointerDown={e => e.stopPropagation()} style={{ height: 225, width: '100%', display: 'flex', justifyContent: 'center', cursor: 'pointer' }}>
-                        <Slider
-                            sx={{
-                                '& input[type="range"]': {
-                                    WebkitAppearance: 'slider-vertical',
-                                },
-                            }}
-                            orientation="vertical"
-                            min={1}
-                            max={500}
-                            defaultValue={150}
-                            size="small"
-                            valueLabelDisplay="auto"
-                            onChange={(e: any, val: any) => {
-                                setCursorData(prev => ({ ...prev, width: val as number }));
-                            }}
-                        />
-                    </div>
-                </div>
-                {/* Edits thumbnails */}
-                <div className="editsBox">
-                    {edits.map((edit, i) => (
-                        <img
-                            // eslint-disable-next-line react/no-array-index-key
-                            key={i}
-                            alt="image edits"
-                            width={75}
-                            src={edit.url}
-                            style={{ cursor: 'pointer' }}
-                            onClick={async () => {
-                                const img = new Image();
-                                img.src = edit.url;
-                                ImageUtility.drawImgToCanvas(img, canvasRef, canvasDims.width, canvasDims.height);
-                                currImg.current = img;
-                                parentDoc.current = edit.saveRes ?? null;
-                            }}
-                        />
-                    ))}
-                    {/* Original img thumbnail */}
-                    {edits.length > 0 && (
-                        <div style={{ position: 'relative' }}>
-                            <label
-                                style={{
-                                    position: 'absolute',
-                                    bottom: 10,
-                                    left: 10,
-                                    color: '#ffffff',
-                                    fontSize: '0.8rem',
-                                    letterSpacing: '1px',
-                                    textTransform: 'uppercase',
-                                }}>
-                                Original
-                            </label>
-                            <img
-                                alt="image stuff"
-                                width={75}
-                                src={originalImg.current?.src}
-                                style={{ cursor: 'pointer' }}
-                                onClick={() => {
-                                    if (!originalImg.current) return;
-                                    const img = new Image();
-                                    img.src = originalImg.current.src;
-                                    ImageUtility.drawImgToCanvas(img, canvasRef, canvasDims.width, canvasDims.height);
-                                    currImg.current = img;
-                                    parentDoc.current = originalDoc.current;
-                                }}
-                            />
-                        </div>
-                    )}
-                </div>
-            </div>
-            <div>
-                <TextField
-                    value={input}
-                    onChange={(e: any) => setInput(e.target.value)}
-                    disabled={isBrushing}
-                    type="text"
-                    label="Prompt"
-                    placeholder="Prompt..."
-                    InputLabelProps={{ style: { fontSize: '16px' } }}
-                    inputProps={{ style: { fontSize: '16px' } }}
-                    sx={{
-                        backgroundColor: '#ffffff',
-                        position: 'absolute',
-                        bottom: '16px',
-                        transform: 'translateX(calc(50vw - 50%))',
-                        width: 'calc(100vw - 64px)',
-                    }}
-                />
-            </div>
-        </div>
-    );
-};
-
-export default GenerativeFill;
diff --git a/src/client/views/nodes/generativeFill/GenerativeFillButtons.scss b/src/client/views/nodes/generativeFill/GenerativeFillButtons.scss
deleted file mode 100644
index 0180ef904..000000000
--- a/src/client/views/nodes/generativeFill/GenerativeFillButtons.scss
+++ /dev/null
@@ -1,4 +0,0 @@
-.generativeFillBtnContainer {
-    display: flex;
-    gap: 1rem;
-}
diff --git a/src/client/views/nodes/generativeFill/GenerativeFillButtons.tsx b/src/client/views/nodes/generativeFill/GenerativeFillButtons.tsx
deleted file mode 100644
index fe22b273d..000000000
--- a/src/client/views/nodes/generativeFill/GenerativeFillButtons.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import './GenerativeFillButtons.scss';
-import * as React from 'react';
-import ReactLoading from 'react-loading';
-import { Button, IconButton, Type } from 'browndash-components';
-import { AiOutlineInfo } from 'react-icons/ai';
-import { activeColor } from './generativeFillUtils/generativeFillConstants';
-
-interface ButtonContainerProps {
-    onClick: () => Promise<void>;
-    loading: boolean;
-    onReset: () => void;
-}
-
-export function EditButtons({ loading, onClick: getEdit, onReset }: ButtonContainerProps) {
-    return (
-        <div className="generativeFillBtnContainer">
-            <Button text="RESET" type={Type.PRIM} color={activeColor} onClick={onReset} />
-            {loading ? (
-                <Button
-                    text="GET EDITS"
-                    type={Type.TERT}
-                    color={activeColor}
-                    icon={<ReactLoading type="spin" color="#ffffff" width={20} height={20} />}
-                    iconPlacement="right"
-                    onClick={() => {
-                        if (!loading) getEdit();
-                    }}
-                />
-            ) : (
-                <Button
-                    text="GET EDITS"
-                    type={Type.TERT}
-                    color={activeColor}
-                    onClick={() => {
-                        if (!loading) getEdit();
-                    }}
-                />
-            )}
-            <IconButton type={Type.SEC} color={activeColor} tooltip="Open Documentation" icon={<AiOutlineInfo size="16px" />} onClick={() => window.open('https://brown-dash.github.io/Dash-Documentation/features/generativeai/#editing', '_blank')} />
-        </div>
-    );
-}
-
-export function CutButtons({ loading, onClick: cutImage, onReset }: ButtonContainerProps) {
-    return (
-        <div className="generativeFillBtnContainer">
-            <Button text="RESET" type={Type.PRIM} color={activeColor} onClick={onReset} />
-            {loading ? (
-                <Button
-                    text="CUT IMAGE"
-                    type={Type.TERT}
-                    color={activeColor}
-                    icon={<ReactLoading type="spin" color="#ffffff" width={20} height={20} />}
-                    iconPlacement="right"
-                    onClick={() => {
-                        if (!loading) cutImage();
-                    }}
-                />
-            ) : (
-                <Button
-                    text="CUT IMAGE"
-                    type={Type.TERT}
-                    color={activeColor}
-                    onClick={() => {
-                        if (!loading) cutImage();
-                    }}
-                />
-            )}
-            <IconButton type={Type.SEC} color={activeColor} tooltip="Open Documentation" icon={<AiOutlineInfo size="16px" />} onClick={() => window.open('https://brown-dash.github.io/Dash-Documentation/features/generativeai/#editing', '_blank')} />
-        </div>
-    );
-}
diff --git a/src/client/views/nodes/generativeFill/generativeFillUtils/BrushHandler.ts b/src/client/views/nodes/generativeFill/generativeFillUtils/BrushHandler.ts
deleted file mode 100644
index 8a66d7347..000000000
--- a/src/client/views/nodes/generativeFill/generativeFillUtils/BrushHandler.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { GenerativeFillMathHelpers } from './GenerativeFillMathHelpers';
-import { eraserColor } from './generativeFillConstants';
-import { Point } from './generativeFillInterfaces';
-import { points } from '@turf/turf';
-
-export enum BrushType {
-    GEN_FILL,
-    CUT,
-}
-
-export class BrushHandler {
-    static brushCircleOverlay = (x: number, y: number, brushRadius: number, ctx: CanvasRenderingContext2D, fillColor: string /* , erase: boolean */) => {
-        ctx.globalCompositeOperation = 'destination-out';
-        ctx.fillStyle = fillColor;
-        ctx.shadowColor = eraserColor;
-        ctx.shadowBlur = 5;
-        ctx.beginPath();
-        ctx.arc(x, y, brushRadius, 0, 2 * Math.PI);
-        ctx.fill();
-        ctx.closePath();
-    };
-
-    static createBrushPathOverlay = (startPoint: Point, endPoint: Point, brushRadius: number, ctx: CanvasRenderingContext2D, fillColor: string, brushType: BrushType) => {
-        const dist = GenerativeFillMathHelpers.distanceBetween(startPoint, endPoint);
-        const pts: Point[] = [];
-        for (let i = 0; i < dist; i += 5) {
-            const s = i / dist;
-            const x = startPoint.x * (1 - s) + endPoint.x * s;
-            const y = startPoint.y * (1 - s) + endPoint.y * s;
-            pts.push({ x: startPoint.x, y: startPoint.y });
-            BrushHandler.brushCircleOverlay(x, y, brushRadius, ctx, fillColor);
-        }
-        return pts;
-    };
-}
diff --git a/src/client/views/nodes/generativeFill/generativeFillUtils/GenerativeFillMathHelpers.ts b/src/client/views/nodes/generativeFill/generativeFillUtils/GenerativeFillMathHelpers.ts
deleted file mode 100644
index 6da8c3da0..000000000
--- a/src/client/views/nodes/generativeFill/generativeFillUtils/GenerativeFillMathHelpers.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { Point } from './generativeFillInterfaces';
-
-export class GenerativeFillMathHelpers {
-    static distanceBetween = (p1: Point, p2: Point) => Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2);
-    static angleBetween = (p1: Point, p2: Point) => Math.atan2(p2.x - p1.x, p2.y - p1.y);
-}
diff --git a/src/client/views/nodes/generativeFill/generativeFillUtils/ImageHandler.ts b/src/client/views/nodes/generativeFill/generativeFillUtils/ImageHandler.ts
deleted file mode 100644
index 24dba1778..000000000
--- a/src/client/views/nodes/generativeFill/generativeFillUtils/ImageHandler.ts
+++ /dev/null
@@ -1,312 +0,0 @@
-import { RefObject } from 'react';
-import { bgColor, canvasSize } from './generativeFillConstants';
-
-export interface APISuccess {
-    status: 'success';
-    urls: string[];
-}
-
-export interface APIError {
-    status: 'error';
-    message: string;
-}
-
-export class ImageUtility {
-    /**
-     *
-     * @param canvas Canvas to convert
-     * @returns Blob of canvas
-     */
-    static canvasToBlob = (canvas: HTMLCanvasElement): Promise<Blob> =>
-        new Promise(resolve => {
-            canvas.toBlob(blob => {
-                if (blob) {
-                    resolve(blob);
-                }
-            }, 'image/png');
-        });
-
-    // given a square api image, get the cropped img
-    static getCroppedImg = (img: HTMLImageElement, width: number, height: number): HTMLCanvasElement | undefined => {
-        // Create a new canvas element
-        const canvas = document.createElement('canvas');
-        canvas.width = width;
-        canvas.height = height;
-        const ctx = canvas.getContext('2d');
-        if (ctx) {
-            // Clear the canvas
-            ctx.clearRect(0, 0, canvas.width, canvas.height);
-            if (width < height) {
-                // horizontal padding, x offset
-                const xOffset = (canvasSize - width) / 2;
-                ctx.drawImage(img, xOffset, 0, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
-            } else {
-                // vertical padding, y offset
-                const yOffset = (canvasSize - height) / 2;
-                ctx.drawImage(img, 0, yOffset, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
-            }
-            return canvas;
-        }
-        return undefined;
-    };
-
-    // converts an image to a canvas data url
-    static convertImgToCanvasUrl = async (imageSrc: string, width: number, height: number): Promise<string> =>
-        new Promise<string>((resolve, reject) => {
-            const img = new Image();
-            img.onload = () => {
-                const canvas = this.getCroppedImg(img, width, height);
-                if (canvas) {
-                    const dataUrl = canvas.toDataURL();
-                    resolve(dataUrl);
-                }
-            };
-            img.onerror = error => {
-                reject(error);
-            };
-            img.src = imageSrc;
-        });
-
-    // calls the openai api to get image edits
-    static getEdit = async (imgBlob: Blob, maskBlob: Blob, prompt: string, n?: number): Promise<APISuccess | APIError> => {
-        const apiUrl = 'https://api.openai.com/v1/images/edits';
-        const fd = new FormData();
-        fd.append('image', imgBlob, 'image.png');
-        fd.append('mask', maskBlob, 'mask.png');
-        fd.append('prompt', prompt);
-        fd.append('size', '1024x1024');
-        fd.append('n', n ? JSON.stringify(n) : '1');
-        fd.append('response_format', 'b64_json');
-
-        try {
-            const res = await fetch(apiUrl, {
-                method: 'POST',
-                headers: {
-                    Authorization: `Bearer ${process.env.OPENAI_KEY}`,
-                },
-                body: fd,
-            });
-            const data = await res.json();
-            console.log(data.data);
-            return {
-                status: 'success',
-                urls: (data.data as { b64_json: string }[]).map(urlData => `data:image/png;base64,${urlData.b64_json}`),
-            };
-        } catch (err) {
-            console.log(err);
-            return { status: 'error', message: 'API error.' };
-        }
-    };
-
-    // mock api call
-    static mockGetEdit = async (mockSrc: string): Promise<APISuccess | APIError> => ({
-        status: 'success',
-        urls: [mockSrc, mockSrc, mockSrc],
-    });
-
-    // Gets the canvas rendering context of a canvas
-    static getCanvasContext = (canvasRef: RefObject<HTMLCanvasElement>): CanvasRenderingContext2D | null => {
-        if (!canvasRef.current) return null;
-        const ctx = canvasRef.current.getContext('2d');
-        if (!ctx) return null;
-        return ctx;
-    };
-
-    // Helper for downloading the canvas (for debugging)
-    static downloadCanvas = (canvas: HTMLCanvasElement) => {
-        const url = canvas.toDataURL();
-        const downloadLink = document.createElement('a');
-        downloadLink.href = url;
-        downloadLink.download = 'canvas';
-
-        downloadLink.click();
-        downloadLink.remove();
-    };
-
-    // Download the canvas (for debugging)
-    static downloadImageCanvas = (imgUrl: string) => {
-        const img = new Image();
-        img.src = imgUrl;
-        img.onload = () => {
-            const canvas = document.createElement('canvas');
-            canvas.width = canvasSize;
-            canvas.height = canvasSize;
-            const ctx = canvas.getContext('2d');
-            ctx?.drawImage(img, 0, 0, canvasSize, canvasSize);
-
-            this.downloadCanvas(canvas);
-        };
-    };
-
-    // Clears the canvas
-    static clearCanvas = (canvasRef: React.RefObject<HTMLCanvasElement>) => {
-        const ctx = this.getCanvasContext(canvasRef);
-        if (!ctx || !canvasRef.current) return;
-        ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
-    };
-
-    // Draws the image to the current canvas
-    static drawImgToCanvas = (img: HTMLImageElement, canvasRef: React.RefObject<HTMLCanvasElement>, width: number, height: number) => {
-        const drawImg = (htmlImg: HTMLImageElement) => {
-            const ctx = this.getCanvasContext(canvasRef);
-            if (!ctx) return;
-            ctx.globalCompositeOperation = 'source-over';
-            ctx.clearRect(0, 0, width, height);
-            ctx.drawImage(htmlImg, 0, 0, width, height);
-        };
-
-        if (img.complete) {
-            drawImg(img);
-        } else {
-            img.onload = () => {
-                drawImg(img);
-            };
-        }
-    };
-
-    // Gets the image mask for the openai endpoint
-    static getCanvasMask = (srcCanvas: HTMLCanvasElement, paddedCanvas: HTMLCanvasElement): HTMLCanvasElement | undefined => {
-        const canvas = document.createElement('canvas');
-        canvas.width = canvasSize;
-        canvas.height = canvasSize;
-        const ctx = canvas.getContext('2d');
-        if (!ctx) return undefined;
-        ctx?.clearRect(0, 0, canvasSize, canvasSize);
-        ctx.drawImage(paddedCanvas, 0, 0);
-
-        // extract and set padding data
-        if (srcCanvas.height > srcCanvas.width) {
-            // horizontal padding, x offset
-            const xOffset = (canvasSize - srcCanvas.width) / 2;
-            ctx?.clearRect(xOffset, 0, srcCanvas.width, srcCanvas.height);
-            ctx.drawImage(srcCanvas, xOffset, 0, srcCanvas.width, srcCanvas.height);
-        } else {
-            // vertical padding, y offset
-            const yOffset = (canvasSize - srcCanvas.height) / 2;
-            ctx?.clearRect(0, yOffset, srcCanvas.width, srcCanvas.height);
-            ctx.drawImage(srcCanvas, 0, yOffset, srcCanvas.width, srcCanvas.height);
-        }
-        return canvas;
-    };
-
-    // Fills in the blank areas of the image with an image reflection (to fill in a square-shaped canvas)
-    static drawHorizontalReflection = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, xOffset: number) => {
-        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
-        const { data } = imageData;
-        for (let i = 0; i < canvas.height; i++) {
-            for (let j = 0; j < xOffset; j++) {
-                const targetIdx = 4 * (i * canvas.width + j);
-                const sourceI = i;
-                const sourceJ = xOffset + (xOffset - j);
-                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
-                data[targetIdx] = data[sourceIdx];
-                data[targetIdx + 1] = data[sourceIdx + 1];
-                data[targetIdx + 2] = data[sourceIdx + 2];
-            }
-        }
-        for (let i = 0; i < canvas.height; i++) {
-            for (let j = canvas.width - 1; j >= canvas.width - 1 - xOffset; j--) {
-                const targetIdx = 4 * (i * canvas.width + j);
-                const sourceI = i;
-                const sourceJ = canvas.width - 1 - xOffset - (xOffset - (canvas.width - j));
-                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
-                data[targetIdx] = data[sourceIdx];
-                data[targetIdx + 1] = data[sourceIdx + 1];
-                data[targetIdx + 2] = data[sourceIdx + 2];
-            }
-        }
-        ctx.putImageData(imageData, 0, 0);
-    };
-
-    // Fills in the blank areas of the image with an image reflection (to fill in a square-shaped canvas)
-    static drawVerticalReflection = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, yOffset: number) => {
-        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
-        const { data } = imageData;
-        for (let j = 0; j < canvas.width; j++) {
-            for (let i = 0; i < yOffset; i++) {
-                const targetIdx = 4 * (i * canvas.width + j);
-                const sourceJ = j;
-                const sourceI = yOffset + (yOffset - i);
-                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
-                data[targetIdx] = data[sourceIdx];
-                data[targetIdx + 1] = data[sourceIdx + 1];
-                data[targetIdx + 2] = data[sourceIdx + 2];
-            }
-        }
-        for (let j = 0; j < canvas.width; j++) {
-            for (let i = canvas.height - 1; i >= canvas.height - 1 - yOffset; i--) {
-                const targetIdx = 4 * (i * canvas.width + j);
-                const sourceJ = j;
-                const sourceI = canvas.height - 1 - yOffset - (yOffset - (canvas.height - i));
-                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
-                data[targetIdx] = data[sourceIdx];
-                data[targetIdx + 1] = data[sourceIdx + 1];
-                data[targetIdx + 2] = data[sourceIdx + 2];
-            }
-        }
-        ctx.putImageData(imageData, 0, 0);
-    };
-
-    // Gets the unaltered (besides filling in padding) version of the image for the api call
-    static getCanvasImg = (img: HTMLImageElement): HTMLCanvasElement | undefined => {
-        const canvas = document.createElement('canvas');
-        canvas.width = canvasSize;
-        canvas.height = canvasSize;
-        const ctx = canvas.getContext('2d');
-        if (!ctx) return undefined;
-        // fix scaling
-        const scale = Math.min(canvasSize / img.width, canvasSize / img.height);
-        const width = Math.floor(img.width * scale);
-        const height = Math.floor(img.height * scale);
-        ctx?.clearRect(0, 0, canvasSize, canvasSize);
-        ctx.fillStyle = bgColor;
-        ctx.fillRect(0, 0, canvasSize, canvasSize);
-
-        // extract and set padding data
-        if (img.naturalHeight > img.naturalWidth) {
-            // horizontal padding, x offset
-            const xOffset = Math.floor((canvasSize - width) / 2);
-            ctx.drawImage(img, xOffset, 0, width, height);
-
-            // draw reflected image padding
-            this.drawHorizontalReflection(ctx, canvas, xOffset);
-        } else {
-            // vertical padding, y offset
-            const yOffset = Math.floor((canvasSize - height) / 2);
-            ctx.drawImage(img, 0, yOffset, width, height);
-
-            // draw reflected image padding
-            this.drawVerticalReflection(ctx, canvas, yOffset);
-        }
-        return canvas;
-    };
-
-    /**
-     * Converts a url to base64 (tainted canvas workaround)
-     */
-    static urlToBase64 = async (imageUrl: string): Promise<string | undefined> => {
-        try {
-            const res = await fetch(imageUrl);
-            const blob = await res.blob();
-
-            return new Promise<string>((resolve, reject) => {
-                const reader = new FileReader();
-                reader.onload = () => {
-                    const base64Data = reader.result?.toString().split(',')[1];
-                    if (base64Data) {
-                        resolve(base64Data);
-                    } else {
-                        reject(new Error('Failed to convert.'));
-                    }
-                };
-                reader.onerror = () => {
-                    reject(new Error('Error reading image data'));
-                };
-                reader.readAsDataURL(blob);
-            });
-        } catch (err) {
-            console.error(err);
-        }
-        return undefined;
-    };
-}
diff --git a/src/client/views/nodes/generativeFill/generativeFillUtils/PointerHandler.ts b/src/client/views/nodes/generativeFill/generativeFillUtils/PointerHandler.ts
deleted file mode 100644
index 260923a64..000000000
--- a/src/client/views/nodes/generativeFill/generativeFillUtils/PointerHandler.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { Point } from './generativeFillInterfaces';
-
-export class PointerHandler {
-    static getPointRelativeToElement = (element: HTMLElement, e: React.PointerEvent | PointerEvent, scale: number): Point => {
-        const boundingBox = element.getBoundingClientRect();
-        return {
-            x: (e.clientX - boundingBox.x) / scale,
-            y: (e.clientY - boundingBox.y) / scale,
-        };
-    };
-}
diff --git a/src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillConstants.ts b/src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillConstants.ts
deleted file mode 100644
index 4772304bc..000000000
--- a/src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillConstants.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export const canvasSize = 1024;
-export const freeformRenderSize = 300;
-export const offsetDistanceY = freeformRenderSize + 400;
-export const offsetX = 200;
-export const newCollectionSize = 500;
-
-export const activeColor = '#1976d2';
-export const eraserColor = '#e1e9ec';
-export const bgColor = '#f0f4f6';
diff --git a/src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillInterfaces.ts b/src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillInterfaces.ts
deleted file mode 100644
index 1e7801056..000000000
--- a/src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillInterfaces.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-export interface CursorData {
-    x: number;
-    y: number;
-    width: number;
-}
-
-export interface Point {
-    x: number;
-    y: number;
-}
-
-export enum BrushMode {
-    ADD,
-    SUBTRACT,
-}
-
-export interface ImageDimensions {
-    width: number;
-    height: number;
-}
diff --git a/src/client/views/nodes/imageEditor/GenerativeFillButtons.scss b/src/client/views/nodes/imageEditor/GenerativeFillButtons.scss
new file mode 100644
index 000000000..0180ef904
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/GenerativeFillButtons.scss
@@ -0,0 +1,4 @@
+.generativeFillBtnContainer {
+    display: flex;
+    gap: 1rem;
+}
diff --git a/src/client/views/nodes/imageEditor/GenerativeFillButtons.tsx b/src/client/views/nodes/imageEditor/GenerativeFillButtons.tsx
new file mode 100644
index 000000000..32ed6b307
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/GenerativeFillButtons.tsx
@@ -0,0 +1,72 @@
+import './GenerativeFillButtons.scss';
+import * as React from 'react';
+import ReactLoading from 'react-loading';
+import { Button, IconButton, Type } from 'browndash-components';
+import { AiOutlineInfo } from 'react-icons/ai';
+import { activeColor } from './imageEditorUtils/imageEditorConstants';
+
+interface ButtonContainerProps {
+    onClick: () => Promise<void>;
+    loading: boolean;
+    onReset: () => void;
+}
+
+export function EditButtons({ loading, onClick: getEdit, onReset }: ButtonContainerProps) {
+    return (
+        <div className="generativeFillBtnContainer">
+            <Button text="RESET" type={Type.PRIM} color={activeColor} onClick={onReset} />
+            {loading ? (
+                <Button
+                    text="GET EDITS"
+                    type={Type.TERT}
+                    color={activeColor}
+                    icon={<ReactLoading type="spin" color="#ffffff" width={20} height={20} />}
+                    iconPlacement="right"
+                    onClick={() => {
+                        if (!loading) getEdit();
+                    }}
+                />
+            ) : (
+                <Button
+                    text="GET EDITS"
+                    type={Type.TERT}
+                    color={activeColor}
+                    onClick={() => {
+                        if (!loading) getEdit();
+                    }}
+                />
+            )}
+            <IconButton type={Type.SEC} color={activeColor} tooltip="Open Documentation" icon={<AiOutlineInfo size="16px" />} onClick={() => window.open('https://brown-dash.github.io/Dash-Documentation/features/generativeai/#editing', '_blank')} />
+        </div>
+    );
+}
+
+export function CutButtons({ loading, onClick: cutImage, onReset }: ButtonContainerProps) {
+    return (
+        <div className="generativeFillBtnContainer">
+            <Button text="RESET" type={Type.PRIM} color={activeColor} onClick={onReset} />
+            {loading ? (
+                <Button
+                    text="CUT IMAGE"
+                    type={Type.TERT}
+                    color={activeColor}
+                    icon={<ReactLoading type="spin" color="#ffffff" width={20} height={20} />}
+                    iconPlacement="right"
+                    onClick={() => {
+                        if (!loading) cutImage();
+                    }}
+                />
+            ) : (
+                <Button
+                    text="CUT IMAGE"
+                    type={Type.TERT}
+                    color={activeColor}
+                    onClick={() => {
+                        if (!loading) cutImage();
+                    }}
+                />
+            )}
+            <IconButton type={Type.SEC} color={activeColor} tooltip="Open Documentation" icon={<AiOutlineInfo size="16px" />} onClick={() => window.open('https://brown-dash.github.io/Dash-Documentation/features/generativeai/#editing', '_blank')} />
+        </div>
+    );
+}
diff --git a/src/client/views/nodes/imageEditor/ImageEditor.scss b/src/client/views/nodes/imageEditor/ImageEditor.scss
new file mode 100644
index 000000000..21c28f6da
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/ImageEditor.scss
@@ -0,0 +1,134 @@
+$navHeight: 5rem;
+$canvasSize: 1024px;
+$scale: 0.5;
+
+.imageEditorContainer {
+    position: absolute;
+    top: 0;
+    left: 0;
+    z-index: 9999;
+    height: 100vh;
+    width: 100vw;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+
+    .imageEditorTopBar {
+        flex-shrink: 0;
+        height: $navHeight;
+        color: #000000;
+        background-color: #ffffff;
+        z-index: 999;
+        width: 100%;
+        display: flex;
+        gap: 3rem;
+        justify-content: space-between;
+        align-items: center;
+        border-bottom: 1px solid #c7cdd0;
+        padding: 0 2rem;
+
+        .imageEditorControls {
+            display: flex;
+            align-items: center;
+            gap: 1.5rem;
+        }
+
+        h1 {
+            font-size: 1.5rem;
+        }
+    }
+
+    .drawingArea {
+        cursor: none;
+        touch-action: none;
+        position: relative;
+        flex-grow: 1;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        width: 100%;
+        background-color: #f0f4f6;
+
+        canvas {
+            display: block;
+            position: absolute;
+            transform-origin: 50% 50%;
+        }
+
+        .pointer {
+            pointer-events: none;
+            position: absolute;
+            border-radius: 50%;
+            width: 50px;
+            height: 50px;
+            border: 1px solid #ffffff;
+            transform: translate(-50%, -50%);
+            display: flex;
+            justify-content: center;
+            align-items: center;
+
+            .innerPointer {
+                width: 100%;
+                height: 100%;
+                border: 1px solid #000000;
+                border-radius: 50%;
+            }
+        }
+
+        .iconContainer {
+            position: absolute;
+            top: 3rem;
+            left: 2rem;
+            display: flex;
+            flex-direction: column;
+            gap: 4rem;
+
+            .imageToolsContainer {
+                display: flex;
+                flex-direction: column;
+                gap: 10px;
+            }
+
+            .undoRedoContainer {
+                justify-content: center;
+                display: flex;
+                flex-direction: row;
+            }
+
+            .sliderContainer {
+                height: 225px;
+                width: 100%;
+                display: flex;
+                justify-content: center;
+                cursor: pointer;
+            }
+        }
+
+        .editsBox {
+            position: absolute;
+            top: 2rem;
+            right: 2rem;
+            display: flex;
+            flex-direction: column;
+            gap: 1rem;
+
+            .originalImageLabel {
+                position: absolute;
+                bottom: 10;
+                left: 10;
+                color: #ffffff;
+                font-size: 0.8rem;
+                letter-spacing: 1px;
+                text-transform: uppercase;
+            }
+
+            img {
+                cursor: pointer;
+                transition: all 0.2s ease-in-out;
+                &:hover {
+                    opacity: 0.8;
+                }
+            }
+        }
+    }
+}
diff --git a/src/client/views/nodes/imageEditor/ImageEditor.tsx b/src/client/views/nodes/imageEditor/ImageEditor.tsx
new file mode 100644
index 000000000..86f7d8d29
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/ImageEditor.tsx
@@ -0,0 +1,758 @@
+/* eslint-disable jsx-a11y/label-has-associated-control */
+/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
+/* eslint-disable jsx-a11y/img-redundant-alt */
+/* eslint-disable jsx-a11y/click-events-have-key-events */
+/* eslint-disable react/function-component-definition */
+import { Checkbox, FormControlLabel, Slider, TextField } from '@mui/material';
+import { IconButton } from 'browndash-components';
+import * as React from 'react';
+import { useEffect, useRef, useState } from 'react';
+import { CgClose } from 'react-icons/cg';
+import { IoMdRedo, IoMdUndo } from 'react-icons/io';
+import { ClientUtils } from '../../../../ClientUtils';
+import { Doc, DocListCast } from '../../../../fields/Doc';
+import { List } from '../../../../fields/List';
+import { NumCast } from '../../../../fields/Types';
+import { Networking } from '../../../Network';
+import { DocUtils } from '../../../documents/DocUtils';
+import { Docs } from '../../../documents/Documents';
+import { CollectionDockingView } from '../../collections/CollectionDockingView';
+import { CollectionFreeFormView } from '../../collections/collectionFreeForm';
+import { ImageEditorData } from '../ImageBox';
+import { OpenWhereMod } from '../OpenWhere';
+import './ImageEditor.scss';
+import { ApplyFuncButtons, ImageToolButton } from './ImageEditorButtons';
+import { BrushHandler, BrushType } from './imageEditorUtils/BrushHandler';
+import { APISuccess, ImageUtility } from './imageEditorUtils/ImageHandler';
+import { PointerHandler } from './imageEditorUtils/PointerHandler';
+import { activeColor, canvasSize, eraserColor, freeformRenderSize, newCollectionSize, offsetDistanceY, offsetX } from './imageEditorUtils/imageEditorConstants';
+import { CursorData, ImageDimensions, ImageEditTool, ImageToolType, Point } from './imageEditorUtils/imageEditorInterfaces';
+import { DocumentView } from '../DocumentView';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { ImageField } from '../../../../fields/URLField';
+import { resolve } from 'url';
+import { DocData } from '../../../../fields/DocSymbols';
+
+interface GenerativeFillProps {
+    imageEditorOpen: boolean;
+    imageEditorSource: string;
+    imageRootDoc: Doc | undefined;
+    addDoc: ((doc: Doc | Doc[], annotationKey?: string) => boolean) | undefined;
+}
+
+// Added field on image doc: gen_fill_children: List of children Docs
+
+const ImageEditor = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc }: GenerativeFillProps) => {
+    const canvasRef = useRef<HTMLCanvasElement>(null);
+    const canvasBackgroundRef = useRef<HTMLCanvasElement>(null);
+    const drawingAreaRef = useRef<HTMLDivElement>(null);
+    const [cursorData, setCursorData] = useState<CursorData>({
+        x: 0,
+        y: 0,
+        width: 150,
+    });
+    const [isBrushing, setIsBrushing] = useState(false);
+    const [canvasScale, setCanvasScale] = useState(0.5);
+    // format: array of [image source, corresponding image Doc]
+    const [edits, setEdits] = useState<{ url: string; saveRes: Doc | undefined }[]>([]);
+    const [edited, setEdited] = useState(false);
+    const isFirstDoc = useRef(true);
+    // const [brushStyle] = useState<BrushStyle>(BrushStyle.ADD);
+    const [input, setInput] = useState('');
+    const [loading, setLoading] = useState(false);
+    const [canvasDims, setCanvasDims] = useState<ImageDimensions>({
+        width: canvasSize,
+        height: canvasSize,
+    });
+    // whether to create a new collection or not
+    const [isNewCollection, setIsNewCollection] = useState(true);
+    // the current image in the main canvas
+    const currImg = useRef<HTMLImageElement | null>(null);
+    // the unedited version of each generation (parent)
+    const originalImg = useRef<HTMLImageElement | null>(null);
+    const originalDoc = useRef<Doc | null>(null);
+    // stores history of data urls
+    const undoStack = useRef<string[]>([]);
+    // stores redo stack
+    const redoStack = useRef<string[]>([]);
+
+    // references to keep track of tree structure
+    const newCollectionRef = useRef<Doc | null>(null);
+    const parentDoc = useRef<Doc | null>(null);
+    const childrenDocs = useRef<Doc[]>([]);
+
+    // constants for image cutting
+    const cutPts = useRef<Point[]>([]);
+
+    /**
+     *
+     * @param type The new tool type we are changing to
+     */
+    const changeTool = (type: ImageToolType) => {
+        switch (type) {
+            case ImageToolType.GenerativeFill:
+                setCurrTool(genFillTool);
+                setCursorData(prev => ({ ...prev, width: genFillTool.sliderDefault as number }));
+                break;
+            case ImageToolType.Cut:
+                setCurrTool(cutTool);
+                setCursorData(prev => ({ ...prev, width: cutTool.sliderDefault as number }));
+                break;
+            default:
+                break;
+        }
+    };
+    // Undo and Redo
+    const handleUndo = () => {
+        const ctx = ImageUtility.getCanvasContext(canvasRef);
+        if (!ctx || !currImg.current || !canvasRef.current) return;
+
+        const target = undoStack.current[undoStack.current.length - 1];
+        if (!target) {
+            ImageUtility.drawImgToCanvas(currImg.current, canvasRef, canvasDims.width, canvasDims.height);
+        } else {
+            redoStack.current = [...redoStack.current, canvasRef.current.toDataURL()];
+            const img = new Image();
+            img.src = target;
+            ImageUtility.drawImgToCanvas(img, canvasRef, canvasDims.width, canvasDims.height);
+            undoStack.current = undoStack.current.slice(0, -1);
+        }
+    };
+
+    const handleRedo = () => {
+        const ctx = ImageUtility.getCanvasContext(canvasRef);
+        if (!ctx || !currImg.current || !canvasRef.current) return;
+
+        const target = redoStack.current[redoStack.current.length - 1];
+        if (target) {
+            undoStack.current = [...undoStack.current, canvasRef.current?.toDataURL()];
+            const img = new Image();
+            img.src = target;
+            ImageUtility.drawImgToCanvas(img, canvasRef, canvasDims.width, canvasDims.height);
+            redoStack.current = redoStack.current.slice(0, -1);
+        }
+    };
+
+    // resets any erase strokes
+    const handleReset = () => {
+        if (!canvasRef.current || !currImg.current) return;
+        const ctx = ImageUtility.getCanvasContext(canvasRef);
+        if (!ctx) return;
+        ctx.clearRect(0, 0, canvasSize, canvasSize);
+        undoStack.current = [];
+        redoStack.current = [];
+        ImageUtility.drawImgToCanvas(currImg.current, canvasRef, canvasDims.width, canvasDims.height);
+    };
+
+    // initiate brushing
+    const handlePointerDown = (e: React.PointerEvent) => {
+        const canvas = canvasRef.current;
+        if (!canvas) return;
+        const ctx = ImageUtility.getCanvasContext(canvasRef);
+        if (!ctx) return;
+
+        undoStack.current = [...undoStack.current, canvasRef.current.toDataURL()];
+        redoStack.current = [];
+
+        setIsBrushing(true);
+        const { x, y } = PointerHandler.getPointRelativeToElement(canvas, e, canvasScale);
+        BrushHandler.brushCircleOverlay(x, y, cursorData.width / 2 / canvasScale, ctx, eraserColor /* , brushStyle === BrushStyle.SUBTRACT */);
+    };
+
+    // stop brushing, push to undo stack
+    const handlePointerUp = () => {
+        const ctx = ImageUtility.getCanvasContext(canvasBackgroundRef);
+        if (!ctx) return;
+        if (!isBrushing) return;
+        setIsBrushing(false);
+    };
+
+    // handles brushing on pointer movement
+    useEffect(() => {
+        if (!isBrushing) return undefined;
+        const canvas = canvasRef.current;
+        if (!canvas) return undefined;
+        const ctx = ImageUtility.getCanvasContext(canvasRef);
+        if (!ctx) return undefined;
+
+        const handlePointerMove = (e: PointerEvent) => {
+            const currPoint = PointerHandler.getPointRelativeToElement(canvas, e, canvasScale);
+            const lastPoint: Point = {
+                x: currPoint.x - e.movementX / canvasScale,
+                y: currPoint.y - e.movementY / canvasScale,
+            };
+            const pts = BrushHandler.createBrushPathOverlay(lastPoint, currPoint, cursorData.width / 2 / canvasScale, ctx, eraserColor, BrushType.CUT);
+            cutPts.current.push(...pts);
+        };
+
+        drawingAreaRef.current?.addEventListener('pointermove', handlePointerMove);
+        return () => {
+            drawingAreaRef.current?.removeEventListener('pointermove', handlePointerMove);
+        };
+    }, [isBrushing]);
+
+    // first load
+    useEffect(() => {
+        const loadInitial = async () => {
+            if (!imageEditorSource || imageEditorSource === '') return;
+            const img = new Image();
+            const res = await ImageUtility.urlToBase64(imageEditorSource);
+            if (!res) return;
+            img.src = `data:image/png;base64,${res}`;
+
+            img.onload = () => {
+                currImg.current = img;
+                originalImg.current = img;
+                const imgWidth = img.naturalWidth;
+                const imgHeight = img.naturalHeight;
+                const scale = Math.min(canvasSize / imgWidth, canvasSize / imgHeight);
+                const width = imgWidth * scale;
+                const height = imgHeight * scale;
+                setCanvasDims({ width, height });
+            };
+        };
+
+        loadInitial();
+
+        // cleanup
+        return () => {
+            setInput('');
+            setEdited(false);
+            newCollectionRef.current = null;
+            parentDoc.current = null;
+            childrenDocs.current = [];
+            currImg.current = null;
+            originalImg.current = null;
+            originalDoc.current = null;
+            undoStack.current = [];
+            redoStack.current = [];
+            ImageUtility.clearCanvas(canvasRef);
+        };
+    }, [canvasRef, imageEditorSource]);
+
+    // once the appropriate dimensions are set, draw the image to the canvas
+    useEffect(() => {
+        if (!currImg.current) return;
+        ImageUtility.drawImgToCanvas(currImg.current, canvasRef, canvasDims.width, canvasDims.height);
+    }, [canvasDims]);
+
+    // handles brush sizing
+    useEffect(() => {
+        const handleKeyPress = (e: KeyboardEvent) => {
+            if (e.key === 'ArrowUp') {
+                e.preventDefault();
+                e.stopPropagation();
+                setCursorData(data => ({ ...data, width: data.width + 5 }));
+            } else if (e.key === 'ArrowDown') {
+                e.preventDefault();
+                e.stopPropagation();
+                setCursorData(data => (data.width >= 20 ? { ...data, width: data.width - 5 } : data));
+            }
+        };
+        window.addEventListener('keydown', handleKeyPress);
+        return () => window.removeEventListener('keydown', handleKeyPress);
+    }, []);
+
+    // handle pinch zoom
+    useEffect(() => {
+        const handlePinch = (e: WheelEvent) => {
+            e.preventDefault();
+            e.stopPropagation();
+            const delta = e.deltaY;
+            const scaleFactor = delta > 0 ? 0.98 : 1.02;
+            setCanvasScale(prevScale => prevScale * scaleFactor);
+        };
+
+        drawingAreaRef.current?.addEventListener('wheel', handlePinch, {
+            passive: false,
+        });
+        return () => drawingAreaRef.current?.removeEventListener('wheel', handlePinch);
+    }, [drawingAreaRef]);
+
+    // updates the current position of the cursor
+    const updateCursorData = (e: React.PointerEvent) => {
+        const drawingArea = drawingAreaRef.current;
+        if (!drawingArea) return;
+        const { x, y } = PointerHandler.getPointRelativeToElement(drawingArea, e, 1);
+        setCursorData(data => ({
+            ...data,
+            x,
+            y,
+        }));
+    };
+
+    // Get AI Edit
+    const getEdit = async () => {
+        const img = currImg.current;
+        if (!img) return;
+        const canvas = canvasRef.current;
+        if (!canvas) return;
+        const ctx = ImageUtility.getCanvasContext(canvasRef);
+        if (!ctx) return;
+        setLoading(true);
+        setEdited(true);
+        try {
+            const canvasOriginalImg = ImageUtility.getCanvasImg(img);
+            if (!canvasOriginalImg) return;
+            const canvasMask = ImageUtility.getCanvasMask(canvas, canvasOriginalImg);
+            if (!canvasMask) return;
+            const maskBlob = await ImageUtility.canvasToBlob(canvasMask);
+            const imgBlob = await ImageUtility.canvasToBlob(canvasOriginalImg);
+            const res = await ImageUtility.getEdit(imgBlob, maskBlob, input !== '' ? input + ' in the same style' : 'Fill in the image in the same style', 2);
+
+            // create first image
+            if (!newCollectionRef.current) {
+                if (!isNewCollection && imageRootDoc) {
+                    // if the parent hasn't been set yet
+                    if (!parentDoc.current) parentDoc.current = imageRootDoc;
+                } else {
+                    if (!(originalImg.current && imageRootDoc)) return;
+                    // create new collection and add it to the view
+                    newCollectionRef.current = Docs.Create.FreeformDocument([], {
+                        x: NumCast(imageRootDoc.x) + NumCast(imageRootDoc._width) + offsetX,
+                        y: NumCast(imageRootDoc.y),
+                        _width: newCollectionSize,
+                        _height: newCollectionSize,
+                        title: 'Image edit collection',
+                    });
+                    DocUtils.MakeLink(imageRootDoc, newCollectionRef.current, { link_relationship: 'Image Edit Version History' });
+
+                    // opening new tab
+                    CollectionDockingView.AddSplit(newCollectionRef.current, OpenWhereMod.right);
+
+                    // add the doc to the main freeform
+                    // eslint-disable-next-line no-use-before-define
+                    await createNewImgDoc(originalImg.current, true);
+                }
+            } else {
+                childrenDocs.current = [];
+            }
+
+            originalImg.current = currImg.current;
+            originalDoc.current = parentDoc.current;
+            const { urls } = res as APISuccess;
+            if (res.status !== 'error') {
+                const imgUrls = await Promise.all(urls.map(url => ImageUtility.convertImgToCanvasUrl(url, canvasDims.width, canvasDims.height)));
+                const imgRes = await Promise.all(
+                    imgUrls.map(async url => {
+                        // eslint-disable-next-line no-use-before-define
+                        const saveRes = await onSave(url);
+                        return { url, saveRes };
+                    })
+                );
+                setEdits(imgRes);
+                const image = new Image();
+                // eslint-disable-next-line prefer-destructuring
+                image.src = imgUrls[0];
+                ImageUtility.drawImgToCanvas(image, canvasRef, canvasDims.width, canvasDims.height);
+                currImg.current = image;
+                parentDoc.current = imgRes[0].saveRes ?? null;
+            }
+        } catch (err) {
+            console.log(err);
+        }
+        setLoading(false);
+    };
+
+    const cutImage = async () => {
+        const img = currImg.current;
+        const canvas = canvasRef.current;
+        if (!canvas || !img) return;
+        const ctx = ImageUtility.getCanvasContext(canvasRef);
+        if (!ctx) return;
+        setLoading(true);
+        // get the original image
+        const canvasOriginalImg = ImageUtility.getCanvasImg(img);
+        if (!canvasOriginalImg) return;
+        // NOTE: cutting two diff shapes can be made possible by having the user press a button to set a new shape!
+        let minX = img.width;
+        let maxX = 0;
+        let minY = img.height;
+        let maxY = 0;
+        if (cutPts.current.length) {
+            ctx.beginPath();
+            ctx.moveTo(cutPts.current[0].x, cutPts.current[0].y); // later check edge case where cutPts is empty
+            for (let i = 0; i < cutPts.current.length; i++) {
+                ctx.lineTo(cutPts.current[i].x, cutPts.current[i].y);
+                minX = Math.min(cutPts.current[i].x, minX);
+                minY = Math.min(cutPts.current[i].y, minY);
+                maxX = Math.max(cutPts.current[i].x, maxX);
+                maxY = Math.max(cutPts.current[i].y, maxY);
+            }
+            ctx.closePath();
+            ctx.globalCompositeOperation = 'destination-in';
+            ctx.fill();
+        }
+
+        const url = canvas.toDataURL(); // this does the same thing as convert img to canvasurl
+        if (!newCollectionRef.current) {
+            if (!isNewCollection && imageRootDoc) {
+                // if the parent hasn't been set yet
+                if (!parentDoc.current) parentDoc.current = imageRootDoc;
+            } else {
+                if (!(originalImg.current && imageRootDoc)) return;
+                // create new collection and add it to the view
+                newCollectionRef.current = Docs.Create.FreeformDocument([], {
+                    x: NumCast(imageRootDoc.x) + NumCast(imageRootDoc._width) + offsetX,
+                    y: NumCast(imageRootDoc.y),
+                    _width: newCollectionSize,
+                    _height: newCollectionSize,
+                    title: 'Image edit collection',
+                });
+                DocUtils.MakeLink(imageRootDoc, newCollectionRef.current, { link_relationship: 'Image Edit Version History' });
+                // opening new tab
+                CollectionDockingView.AddSplit(newCollectionRef.current, OpenWhereMod.right);
+            }
+        }
+
+        const image = new Image();
+        image.src = url;
+        image.onload = async () => {
+            const croppedCanvas = document.createElement('canvas');
+            const croppedCtx = croppedCanvas.getContext('2d');
+            if (!croppedCtx) return;
+            croppedCanvas.width = maxX - minX;
+            croppedCanvas.height = maxY - minY;
+            croppedCtx.globalCompositeOperation = 'source-over';
+            croppedCtx.clearRect(0, 0, croppedCanvas.width, croppedCanvas.height);
+            croppedCtx.drawImage(image, -minX, -minY);
+            const croppedURL = croppedCanvas.toDataURL();
+            const croppedImage = new Image();
+            croppedImage.src = croppedURL;
+
+            currImg.current = croppedImage;
+            const newImgDoc = await createNewImgDoc(croppedImage, isFirstDoc.current);
+            if (isFirstDoc.current) isFirstDoc.current = false;
+            if (newImgDoc) {
+                const docData = newImgDoc[DocData];
+                docData.backgroundColor = 'transparent';
+            }
+            setEdits(prevEdits => [...prevEdits, { url: croppedURL, saveRes: undefined }]);
+            setLoading(false);
+            cutPts.current.length = 0;
+        };
+    };
+
+    // adjusts all the img positions to be aligned
+    const adjustImgPositions = () => {
+        if (!parentDoc.current) return;
+        const startY = NumCast(parentDoc.current.y);
+        const children = DocListCast(parentDoc.current.gen_fill_children);
+        const len = children.length;
+        const initialYPositions: number[] = [];
+        for (let i = 0; i < len; i++) {
+            initialYPositions.push(startY + i * offsetDistanceY);
+        }
+        children.forEach((doc, i) => {
+            if (len % 2 === 1) {
+                doc.y = initialYPositions[i] - Math.floor(len / 2) * offsetDistanceY;
+            } else {
+                doc.y = initialYPositions[i] - (len / 2 - 1 / 2) * offsetDistanceY;
+            }
+        });
+    };
+
+    // creates a new image document and returns its reference
+    const createNewImgDoc = async (img: HTMLImageElement, firstDoc: boolean, parent?: Doc): Promise<Doc | undefined> => {
+        if (!imageRootDoc) return undefined;
+        const { src } = img;
+        const [result] = await Networking.PostToServer('/uploadRemoteImage', { sources: [src] });
+        const source = ClientUtils.prepend(result.accessPaths.agnostic.client);
+
+        if (firstDoc) {
+            const x = 0;
+            const initialY = 0;
+            const newImg = Docs.Create.ImageDocument(source, {
+                x: x,
+                y: initialY,
+                _height: freeformRenderSize,
+                _width: freeformRenderSize,
+                data_nativeWidth: result.nativeWidth,
+                data_nativeHeight: result.nativeHeight,
+            });
+            if (isNewCollection && newCollectionRef.current) {
+                Doc.AddDocToList(newCollectionRef.current, undefined, newImg);
+            } else {
+                addDoc?.(newImg);
+            }
+            parentDoc.current = newImg;
+            return newImg;
+        }
+        if (!parentDoc.current) return undefined;
+        const x = NumCast(parentDoc.current.x) + freeformRenderSize + offsetX;
+        const initialY = 0;
+
+        const newImg = Docs.Create.ImageDocument(source, {
+            x: x,
+            y: initialY,
+            _height: freeformRenderSize,
+            _width: freeformRenderSize,
+            data_nativeWidth: result.nativeWidth,
+            data_nativeHeight: result.nativeHeight,
+        });
+
+        const parentList = DocListCast(parentDoc.current.gen_fill_children);
+        if (parentList.length > 0) {
+            parentList.push(newImg);
+            parentDoc.current.gen_fill_children = new List<Doc>(parentList);
+        } else {
+            parentDoc.current.gen_fill_children = new List<Doc>([newImg]);
+        }
+
+        DocUtils.MakeLink(parentDoc.current, newImg, { link_relationship: `Image edit; Prompt: ${input}` });
+        adjustImgPositions();
+
+        if (isNewCollection && newCollectionRef.current) {
+            Doc.AddDocToList(newCollectionRef.current, undefined, newImg);
+        } else {
+            addDoc?.(newImg);
+        }
+        return newImg;
+    };
+
+    // Saves an image to the collection
+    const onSave = async (src: string) => {
+        const img = new Image();
+        img.src = src;
+        if (!currImg.current || !originalImg.current || !imageRootDoc) return undefined;
+        try {
+            const res = await createNewImgDoc(img, false);
+            return res;
+        } catch (err) {
+            console.log(err);
+        }
+        return undefined;
+    };
+
+    // Closes the editor view
+    const handleViewClose = () => {
+        ImageEditorData.Open = false;
+        ImageEditorData.Source = '';
+        if (newCollectionRef.current) {
+            DocumentView.addViewRenderedCb(newCollectionRef.current, dv => (dv.ComponentView as CollectionFreeFormView)?.fitContentOnce());
+        }
+        setEdits([]);
+    };
+
+    // defines the tools and sets current tool
+    const genFillTool: ImageEditTool = { type: ImageToolType.GenerativeFill, name: 'Generative Fill', btnText: 'GET EDITS', icon: 'fill', applyFunc: getEdit, sliderMin: 25, sliderMax: 500, sliderDefault: 150 };
+    const cutTool: ImageEditTool = { type: ImageToolType.Cut, name: 'Cut', btnText: 'CUT IMAGE', icon: 'scissors', applyFunc: cutImage, sliderMin: 1, sliderMax: 50, sliderDefault: 5 };
+    const imageEditTools: ImageEditTool[] = [genFillTool, cutTool];
+    const [currTool, setCurrTool] = useState<ImageEditTool>(genFillTool);
+
+    // the top controls for making a new collection, resetting, and applying edits,
+    function renderControls() {
+        return (
+            <div className="imageEditorTopBar">
+                <h1>Image Editor</h1>
+                {/* <IconButton text="Cut out" icon={<FontAwesomeIcon icon="scissors" />} /> */}
+                <div className="imageEditorControls">
+                    <FormControlLabel
+                        control={
+                            <Checkbox
+                                // disable once edited has been clicked (doesn't make sense to change after first edit)
+                                disabled={edited}
+                                checked={isNewCollection}
+                                onChange={() => {
+                                    setIsNewCollection(prev => !prev);
+                                }}
+                            />
+                        }
+                        label="Create New Collection"
+                        labelPlacement="end"
+                        sx={{ whiteSpace: 'nowrap' }}
+                    />
+                    <ApplyFuncButtons onClick={currTool.applyFunc} loading={loading} onReset={handleReset} btnText={currTool.btnText} />
+                    <IconButton color={activeColor} tooltip="close" icon={<CgClose size="16px" />} onClick={handleViewClose} />
+                </div>
+            </div>
+        );
+    }
+
+    // the side icons including tool type, the slider, and undo/redo
+    function renderSideIcons() {
+        return (
+            <div className="iconContainer">
+                <div className="imageToolsContainer">
+                    {imageEditTools.map(tool => {
+                        return ImageToolButton(tool, tool.type === currTool.type, changeTool);
+                    })}
+                </div>
+                <div className="sliderContainer" onPointerDown={e => e.stopPropagation()}>
+                    {currTool.type === ImageToolType.GenerativeFill && (
+                        <Slider
+                            sx={{
+                                '& input[type="range"]': {
+                                    WebkitAppearance: 'slider-vertical',
+                                },
+                            }}
+                            orientation="vertical"
+                            min={genFillTool.sliderMin}
+                            max={genFillTool.sliderMax}
+                            defaultValue={genFillTool.sliderDefault}
+                            size="small"
+                            valueLabelDisplay="auto"
+                            onChange={(e, val) => {
+                                setCursorData(prev => ({ ...prev, width: val as number }));
+                            }}
+                        />
+                    )}
+                    {currTool.type === ImageToolType.Cut && (
+                        <Slider
+                            sx={{
+                                '& input[type="range"]': {
+                                    WebkitAppearance: 'slider-vertical',
+                                },
+                            }}
+                            orientation="vertical"
+                            min={cutTool.sliderMin}
+                            max={cutTool.sliderMax}
+                            defaultValue={cutTool.sliderDefault}
+                            size="small"
+                            valueLabelDisplay="auto"
+                            onChange={(e, val) => {
+                                setCursorData(prev => ({ ...prev, width: val as number }));
+                            }}
+                        />
+                    )}
+                </div>
+                {/* Undo and Redo */}
+                <div className="undoRedoContainer">
+                    <IconButton
+                        style={{ cursor: 'pointer' }}
+                        onPointerDown={e => {
+                            e.stopPropagation();
+                            handleUndo();
+                        }}
+                        onPointerUp={e => {
+                            e.stopPropagation();
+                        }}
+                        color={activeColor}
+                        tooltip="Undo"
+                        icon={<IoMdUndo />}
+                    />
+                    <IconButton
+                        style={{ cursor: 'pointer' }}
+                        onPointerDown={e => {
+                            e.stopPropagation();
+                            handleRedo();
+                        }}
+                        onPointerUp={e => {
+                            e.stopPropagation();
+                        }}
+                        color={activeColor}
+                        tooltip="Redo"
+                        icon={<IoMdRedo />}
+                    />
+                </div>
+            </div>
+        );
+    }
+
+    // circular pointer for drawing/erasing
+    function renderPointer() {
+        return (
+            <div
+                className="pointer"
+                style={{
+                    left: cursorData.x,
+                    top: cursorData.y,
+                    width: cursorData.width,
+                    height: cursorData.width,
+                }}>
+                <div className="innerPointer" />
+            </div>
+        );
+    }
+
+    // the previews for each edit
+    function renderEditThumbnails() {
+        return (
+            <div className="editsBox">
+                {edits.map((edit, i) => (
+                    <img
+                        // eslint-disable-next-line react/no-array-index-key
+                        key={i}
+                        alt="image edits"
+                        width={75}
+                        src={edit.url}
+                        onClick={async () => {
+                            const img = new Image();
+                            img.src = edit.url;
+                            ImageUtility.drawImgToCanvas(img, canvasRef, img.width, img.height);
+                            currImg.current = img;
+                            parentDoc.current = edit.saveRes ?? null;
+                        }}
+                    />
+                ))}
+                {/* Original img thumbnail */}
+                {edits.length > 0 && (
+                    <div style={{ position: 'relative' }}>
+                        <label className="originalImageLabel">Original</label>
+                        <img
+                            alt="image stuff"
+                            width={75}
+                            src={originalImg.current?.src}
+                            onClick={() => {
+                                if (!originalImg.current) return;
+                                const img = new Image();
+                                img.src = originalImg.current.src;
+                                ImageUtility.drawImgToCanvas(img, canvasRef, canvasDims.width, canvasDims.height);
+                                currImg.current = img;
+                                if (!parentDoc.current) parentDoc.current = originalDoc.current;
+                            }}
+                        />
+                    </div>
+                )}
+            </div>
+        );
+    }
+
+    // the prompt box for generative fill
+    function renderPromptBox() {
+        return (
+            <div>
+                <TextField
+                    value={input}
+                    onChange={e => setInput(e.target.value)}
+                    disabled={isBrushing}
+                    type="text"
+                    label="Prompt"
+                    placeholder="Prompt..."
+                    InputLabelProps={{ style: { fontSize: '16px' } }}
+                    inputProps={{ style: { fontSize: '16px' } }}
+                    sx={{
+                        backgroundColor: '#ffffff',
+                        position: 'absolute',
+                        bottom: '16px',
+                        transform: 'translateX(calc(50vw - 50%))',
+                        width: 'calc(100vw - 64px)',
+                    }}
+                />
+            </div>
+        );
+    }
+
+    return (
+        <div className="imageEditorContainer" style={{ display: imageEditorOpen ? 'flex' : 'none' }}>
+            {renderControls()}
+            {/* Main canvas for editing */}
+            <div
+                className="drawingArea" // this only works if pointerevents: none is set on the custom pointer
+                ref={drawingAreaRef}
+                onPointerOver={updateCursorData}
+                onPointerMove={updateCursorData}
+                onPointerDown={handlePointerDown}
+                onPointerUp={handlePointerUp}>
+                <canvas ref={canvasRef} width={canvasDims.width} height={canvasDims.height} style={{ transform: `scale(${canvasScale})` }} />
+                <canvas ref={canvasBackgroundRef} width={canvasDims.width} height={canvasDims.height} style={{ transform: `scale(${canvasScale})` }} />
+                {renderPointer()}
+                {renderSideIcons()}
+                {renderEditThumbnails()}
+            </div>
+            {currTool.type === ImageToolType.GenerativeFill && renderPromptBox()}
+        </div>
+    );
+};
+
+export default ImageEditor;
diff --git a/src/client/views/nodes/imageEditor/ImageEditorButtons.tsx b/src/client/views/nodes/imageEditor/ImageEditorButtons.tsx
new file mode 100644
index 000000000..e90babf9b
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/ImageEditorButtons.tsx
@@ -0,0 +1,69 @@
+import './GenerativeFillButtons.scss';
+import * as React from 'react';
+import ReactLoading from 'react-loading';
+import { Button, IconButton, Type } from 'browndash-components';
+import { AiOutlineInfo } from 'react-icons/ai';
+import { bgColor } from './imageEditorUtils/imageEditorConstants';
+import { ImageEditTool, ImageToolType } from './imageEditorUtils/imageEditorInterfaces';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { SettingsManager } from '../../../util/SettingsManager';
+
+interface ButtonContainerProps {
+    onClick: () => Promise<void>;
+    loading: boolean;
+    onReset: () => void;
+    btnText: string;
+}
+
+export function ApplyFuncButtons({ loading, onClick: getEdit, onReset, btnText }: ButtonContainerProps) {
+    return (
+        <div className="generativeFillBtnContainer">
+            <Button text="RESET" type={Type.PRIM} color={SettingsManager.userVariantColor} onClick={onReset} />
+            {loading ? (
+                <Button
+                    text={btnText}
+                    type={Type.TERT}
+                    color={SettingsManager.userVariantColor}
+                    icon={<ReactLoading type="spin" color="#ffffff" width={20} height={20} />}
+                    iconPlacement="right"
+                    onClick={() => {
+                        if (!loading) getEdit();
+                    }}
+                />
+            ) : (
+                <Button
+                    text={btnText}
+                    type={Type.TERT}
+                    color={SettingsManager.userVariantColor}
+                    onClick={() => {
+                        if (!loading) getEdit();
+                    }}
+                />
+            )}
+            <IconButton
+                type={Type.SEC}
+                color={SettingsManager.userVariantColor}
+                tooltip="Open Documentation"
+                icon={<AiOutlineInfo size="16px" />}
+                onClick={() => window.open('https://brown-dash.github.io/Dash-Documentation/features/generativeai/#editing', '_blank')}
+            />
+        </div>
+    );
+}
+
+export function ImageToolButton(tool: ImageEditTool, isActive: boolean, selectTool: (type: ImageToolType) => void) {
+    return (
+        <div className="imageEditorButtonContainer">
+            <Button
+                style={{ width: '100%', border: '1px' }}
+                text={tool.name}
+                type={Type.TERT}
+                color={isActive ? SettingsManager.userVariantColor : bgColor}
+                icon={<FontAwesomeIcon icon={tool.icon} />}
+                onClick={() => {
+                    selectTool(tool.type);
+                }}
+            />
+        </div>
+    );
+}
diff --git a/src/client/views/nodes/imageEditor/imageEditingUtils/ImageHandler.ts b/src/client/views/nodes/imageEditor/imageEditingUtils/ImageHandler.ts
new file mode 100644
index 000000000..514e8a94f
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/imageEditingUtils/ImageHandler.ts
@@ -0,0 +1,312 @@
+import { RefObject } from 'react';
+import { bgColor, canvasSize } from '../ImageEditorUtils/imageEditorConstants';
+
+export interface APISuccess {
+    status: 'success';
+    urls: string[];
+}
+
+export interface APIError {
+    status: 'error';
+    message: string;
+}
+
+export class ImageUtility {
+    /**
+     *
+     * @param canvas Canvas to convert
+     * @returns Blob of canvas
+     */
+    static canvasToBlob = (canvas: HTMLCanvasElement): Promise<Blob> =>
+        new Promise(resolve => {
+            canvas.toBlob(blob => {
+                if (blob) {
+                    resolve(blob);
+                }
+            }, 'image/png');
+        });
+
+    // given a square api image, get the cropped img
+    static getCroppedImg = (img: HTMLImageElement, width: number, height: number): HTMLCanvasElement | undefined => {
+        // Create a new canvas element
+        const canvas = document.createElement('canvas');
+        canvas.width = width;
+        canvas.height = height;
+        const ctx = canvas.getContext('2d');
+        if (ctx) {
+            // Clear the canvas
+            ctx.clearRect(0, 0, canvas.width, canvas.height);
+            if (width < height) {
+                // horizontal padding, x offset
+                const xOffset = (canvasSize - width) / 2;
+                ctx.drawImage(img, xOffset, 0, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
+            } else {
+                // vertical padding, y offset
+                const yOffset = (canvasSize - height) / 2;
+                ctx.drawImage(img, 0, yOffset, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
+            }
+            return canvas;
+        }
+        return undefined;
+    };
+
+    // converts an image to a canvas data url
+    static convertImgToCanvasUrl = async (imageSrc: string, width: number, height: number): Promise<string> =>
+        new Promise<string>((resolve, reject) => {
+            const img = new Image();
+            img.onload = () => {
+                const canvas = this.getCroppedImg(img, width, height);
+                if (canvas) {
+                    const dataUrl = canvas.toDataURL();
+                    resolve(dataUrl);
+                }
+            };
+            img.onerror = error => {
+                reject(error);
+            };
+            img.src = imageSrc;
+        });
+
+    // calls the openai api to get image edits
+    static getEdit = async (imgBlob: Blob, maskBlob: Blob, prompt: string, n?: number): Promise<APISuccess | APIError> => {
+        const apiUrl = 'https://api.openai.com/v1/images/edits';
+        const fd = new FormData();
+        fd.append('image', imgBlob, 'image.png');
+        fd.append('mask', maskBlob, 'mask.png');
+        fd.append('prompt', prompt);
+        fd.append('size', '1024x1024');
+        fd.append('n', n ? JSON.stringify(n) : '1');
+        fd.append('response_format', 'b64_json');
+
+        try {
+            const res = await fetch(apiUrl, {
+                method: 'POST',
+                headers: {
+                    Authorization: `Bearer ${process.env.OPENAI_KEY}`,
+                },
+                body: fd,
+            });
+            const data = await res.json();
+            console.log(data.data);
+            return {
+                status: 'success',
+                urls: (data.data as { b64_json: string }[]).map(urlData => `data:image/png;base64,${urlData.b64_json}`),
+            };
+        } catch (err) {
+            console.log(err);
+            return { status: 'error', message: 'API error.' };
+        }
+    };
+
+    // mock api call
+    static mockGetEdit = async (mockSrc: string): Promise<APISuccess | APIError> => ({
+        status: 'success',
+        urls: [mockSrc, mockSrc, mockSrc],
+    });
+
+    // Gets the canvas rendering context of a canvas
+    static getCanvasContext = (canvasRef: RefObject<HTMLCanvasElement>): CanvasRenderingContext2D | null => {
+        if (!canvasRef.current) return null;
+        const ctx = canvasRef.current.getContext('2d');
+        if (!ctx) return null;
+        return ctx;
+    };
+
+    // Helper for downloading the canvas (for debugging)
+    static downloadCanvas = (canvas: HTMLCanvasElement) => {
+        const url = canvas.toDataURL();
+        const downloadLink = document.createElement('a');
+        downloadLink.href = url;
+        downloadLink.download = 'canvas';
+
+        downloadLink.click();
+        downloadLink.remove();
+    };
+
+    // Download the canvas (for debugging)
+    static downloadImageCanvas = (imgUrl: string) => {
+        const img = new Image();
+        img.src = imgUrl;
+        img.onload = () => {
+            const canvas = document.createElement('canvas');
+            canvas.width = canvasSize;
+            canvas.height = canvasSize;
+            const ctx = canvas.getContext('2d');
+            ctx?.drawImage(img, 0, 0, canvasSize, canvasSize);
+
+            this.downloadCanvas(canvas);
+        };
+    };
+
+    // Clears the canvas
+    static clearCanvas = (canvasRef: React.RefObject<HTMLCanvasElement>) => {
+        const ctx = this.getCanvasContext(canvasRef);
+        if (!ctx || !canvasRef.current) return;
+        ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
+    };
+
+    // Draws the image to the current canvas
+    static drawImgToCanvas = (img: HTMLImageElement, canvasRef: React.RefObject<HTMLCanvasElement>, width: number, height: number) => {
+        const drawImg = (htmlImg: HTMLImageElement) => {
+            const ctx = this.getCanvasContext(canvasRef);
+            if (!ctx) return;
+            ctx.globalCompositeOperation = 'source-over';
+            ctx.clearRect(0, 0, width, height);
+            ctx.drawImage(htmlImg, 0, 0, width, height);
+        };
+
+        if (img.complete) {
+            drawImg(img);
+        } else {
+            img.onload = () => {
+                drawImg(img);
+            };
+        }
+    };
+
+    // Gets the image mask for the openai endpoint
+    static getCanvasMask = (srcCanvas: HTMLCanvasElement, paddedCanvas: HTMLCanvasElement): HTMLCanvasElement | undefined => {
+        const canvas = document.createElement('canvas');
+        canvas.width = canvasSize;
+        canvas.height = canvasSize;
+        const ctx = canvas.getContext('2d');
+        if (!ctx) return undefined;
+        ctx?.clearRect(0, 0, canvasSize, canvasSize);
+        ctx.drawImage(paddedCanvas, 0, 0);
+
+        // extract and set padding data
+        if (srcCanvas.height > srcCanvas.width) {
+            // horizontal padding, x offset
+            const xOffset = (canvasSize - srcCanvas.width) / 2;
+            ctx?.clearRect(xOffset, 0, srcCanvas.width, srcCanvas.height);
+            ctx.drawImage(srcCanvas, xOffset, 0, srcCanvas.width, srcCanvas.height);
+        } else {
+            // vertical padding, y offset
+            const yOffset = (canvasSize - srcCanvas.height) / 2;
+            ctx?.clearRect(0, yOffset, srcCanvas.width, srcCanvas.height);
+            ctx.drawImage(srcCanvas, 0, yOffset, srcCanvas.width, srcCanvas.height);
+        }
+        return canvas;
+    };
+
+    // Fills in the blank areas of the image with an image reflection (to fill in a square-shaped canvas)
+    static drawHorizontalReflection = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, xOffset: number) => {
+        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+        const { data } = imageData;
+        for (let i = 0; i < canvas.height; i++) {
+            for (let j = 0; j < xOffset; j++) {
+                const targetIdx = 4 * (i * canvas.width + j);
+                const sourceI = i;
+                const sourceJ = xOffset + (xOffset - j);
+                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
+                data[targetIdx] = data[sourceIdx];
+                data[targetIdx + 1] = data[sourceIdx + 1];
+                data[targetIdx + 2] = data[sourceIdx + 2];
+            }
+        }
+        for (let i = 0; i < canvas.height; i++) {
+            for (let j = canvas.width - 1; j >= canvas.width - 1 - xOffset; j--) {
+                const targetIdx = 4 * (i * canvas.width + j);
+                const sourceI = i;
+                const sourceJ = canvas.width - 1 - xOffset - (xOffset - (canvas.width - j));
+                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
+                data[targetIdx] = data[sourceIdx];
+                data[targetIdx + 1] = data[sourceIdx + 1];
+                data[targetIdx + 2] = data[sourceIdx + 2];
+            }
+        }
+        ctx.putImageData(imageData, 0, 0);
+    };
+
+    // Fills in the blank areas of the image with an image reflection (to fill in a square-shaped canvas)
+    static drawVerticalReflection = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, yOffset: number) => {
+        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+        const { data } = imageData;
+        for (let j = 0; j < canvas.width; j++) {
+            for (let i = 0; i < yOffset; i++) {
+                const targetIdx = 4 * (i * canvas.width + j);
+                const sourceJ = j;
+                const sourceI = yOffset + (yOffset - i);
+                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
+                data[targetIdx] = data[sourceIdx];
+                data[targetIdx + 1] = data[sourceIdx + 1];
+                data[targetIdx + 2] = data[sourceIdx + 2];
+            }
+        }
+        for (let j = 0; j < canvas.width; j++) {
+            for (let i = canvas.height - 1; i >= canvas.height - 1 - yOffset; i--) {
+                const targetIdx = 4 * (i * canvas.width + j);
+                const sourceJ = j;
+                const sourceI = canvas.height - 1 - yOffset - (yOffset - (canvas.height - i));
+                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
+                data[targetIdx] = data[sourceIdx];
+                data[targetIdx + 1] = data[sourceIdx + 1];
+                data[targetIdx + 2] = data[sourceIdx + 2];
+            }
+        }
+        ctx.putImageData(imageData, 0, 0);
+    };
+
+    // Gets the unaltered (besides filling in padding) version of the image for the api call
+    static getCanvasImg = (img: HTMLImageElement): HTMLCanvasElement | undefined => {
+        const canvas = document.createElement('canvas');
+        canvas.width = canvasSize;
+        canvas.height = canvasSize;
+        const ctx = canvas.getContext('2d');
+        if (!ctx) return undefined;
+        // fix scaling
+        const scale = Math.min(canvasSize / img.width, canvasSize / img.height);
+        const width = Math.floor(img.width * scale);
+        const height = Math.floor(img.height * scale);
+        ctx?.clearRect(0, 0, canvasSize, canvasSize);
+        ctx.fillStyle = bgColor;
+        ctx.fillRect(0, 0, canvasSize, canvasSize);
+
+        // extract and set padding data
+        if (img.naturalHeight > img.naturalWidth) {
+            // horizontal padding, x offset
+            const xOffset = Math.floor((canvasSize - width) / 2);
+            ctx.drawImage(img, xOffset, 0, width, height);
+
+            // draw reflected image padding
+            this.drawHorizontalReflection(ctx, canvas, xOffset);
+        } else {
+            // vertical padding, y offset
+            const yOffset = Math.floor((canvasSize - height) / 2);
+            ctx.drawImage(img, 0, yOffset, width, height);
+
+            // draw reflected image padding
+            this.drawVerticalReflection(ctx, canvas, yOffset);
+        }
+        return canvas;
+    };
+
+    /**
+     * Converts a url to base64 (tainted canvas workaround)
+     */
+    static urlToBase64 = async (imageUrl: string): Promise<string | undefined> => {
+        try {
+            const res = await fetch(imageUrl);
+            const blob = await res.blob();
+
+            return new Promise<string>((resolve, reject) => {
+                const reader = new FileReader();
+                reader.onload = () => {
+                    const base64Data = reader.result?.toString().split(',')[1];
+                    if (base64Data) {
+                        resolve(base64Data);
+                    } else {
+                        reject(new Error('Failed to convert.'));
+                    }
+                };
+                reader.onerror = () => {
+                    reject(new Error('Error reading image data'));
+                };
+                reader.readAsDataURL(blob);
+            });
+        } catch (err) {
+            console.error(err);
+        }
+        return undefined;
+    };
+}
diff --git a/src/client/views/nodes/imageEditor/imageEditorUtils/BrushHandler.ts b/src/client/views/nodes/imageEditor/imageEditorUtils/BrushHandler.ts
new file mode 100644
index 000000000..a9fe02d4f
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/imageEditorUtils/BrushHandler.ts
@@ -0,0 +1,35 @@
+import { GenerativeFillMathHelpers } from './GenerativeFillMathHelpers';
+import { eraserColor } from './imageEditorConstants';
+import { Point } from './imageEditorInterfaces';
+import { points } from '@turf/turf';
+
+export enum BrushType {
+    GEN_FILL,
+    CUT,
+}
+
+export class BrushHandler {
+    static brushCircleOverlay = (x: number, y: number, brushRadius: number, ctx: CanvasRenderingContext2D, fillColor: string /* , erase: boolean */) => {
+        ctx.globalCompositeOperation = 'destination-out';
+        ctx.fillStyle = fillColor;
+        ctx.shadowColor = eraserColor;
+        ctx.shadowBlur = 5;
+        ctx.beginPath();
+        ctx.arc(x, y, brushRadius, 0, 2 * Math.PI);
+        ctx.fill();
+        ctx.closePath();
+    };
+
+    static createBrushPathOverlay = (startPoint: Point, endPoint: Point, brushRadius: number, ctx: CanvasRenderingContext2D, fillColor: string, brushType: BrushType) => {
+        const dist = GenerativeFillMathHelpers.distanceBetween(startPoint, endPoint);
+        const pts: Point[] = [];
+        for (let i = 0; i < dist; i += 5) {
+            const s = i / dist;
+            const x = startPoint.x * (1 - s) + endPoint.x * s;
+            const y = startPoint.y * (1 - s) + endPoint.y * s;
+            pts.push({ x: startPoint.x, y: startPoint.y });
+            BrushHandler.brushCircleOverlay(x, y, brushRadius, ctx, fillColor);
+        }
+        return pts;
+    };
+}
diff --git a/src/client/views/nodes/imageEditor/imageEditorUtils/GenerativeFillMathHelpers.ts b/src/client/views/nodes/imageEditor/imageEditorUtils/GenerativeFillMathHelpers.ts
new file mode 100644
index 000000000..f820300f3
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/imageEditorUtils/GenerativeFillMathHelpers.ts
@@ -0,0 +1,6 @@
+import { Point } from './imageEditorInterfaces';
+
+export class GenerativeFillMathHelpers {
+    static distanceBetween = (p1: Point, p2: Point) => Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2);
+    static angleBetween = (p1: Point, p2: Point) => Math.atan2(p2.x - p1.x, p2.y - p1.y);
+}
diff --git a/src/client/views/nodes/imageEditor/imageEditorUtils/ImageHandler.ts b/src/client/views/nodes/imageEditor/imageEditorUtils/ImageHandler.ts
new file mode 100644
index 000000000..ece0f4d7f
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/imageEditorUtils/ImageHandler.ts
@@ -0,0 +1,312 @@
+import { RefObject } from 'react';
+import { bgColor, canvasSize } from './imageEditorConstants';
+
+export interface APISuccess {
+    status: 'success';
+    urls: string[];
+}
+
+export interface APIError {
+    status: 'error';
+    message: string;
+}
+
+export class ImageUtility {
+    /**
+     *
+     * @param canvas Canvas to convert
+     * @returns Blob of canvas
+     */
+    static canvasToBlob = (canvas: HTMLCanvasElement): Promise<Blob> =>
+        new Promise(resolve => {
+            canvas.toBlob(blob => {
+                if (blob) {
+                    resolve(blob);
+                }
+            }, 'image/png');
+        });
+
+    // given a square api image, get the cropped img
+    static getCroppedImg = (img: HTMLImageElement, width: number, height: number): HTMLCanvasElement | undefined => {
+        // Create a new canvas element
+        const canvas = document.createElement('canvas');
+        canvas.width = width;
+        canvas.height = height;
+        const ctx = canvas.getContext('2d');
+        if (ctx) {
+            // Clear the canvas
+            ctx.clearRect(0, 0, canvas.width, canvas.height);
+            if (width < height) {
+                // horizontal padding, x offset
+                const xOffset = (canvasSize - width) / 2;
+                ctx.drawImage(img, xOffset, 0, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
+            } else {
+                // vertical padding, y offset
+                const yOffset = (canvasSize - height) / 2;
+                ctx.drawImage(img, 0, yOffset, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
+            }
+            return canvas;
+        }
+        return undefined;
+    };
+
+    // converts an image to a canvas data url
+    static convertImgToCanvasUrl = async (imageSrc: string, width: number, height: number): Promise<string> =>
+        new Promise<string>((resolve, reject) => {
+            const img = new Image();
+            img.onload = () => {
+                const canvas = this.getCroppedImg(img, width, height);
+                if (canvas) {
+                    const dataUrl = canvas.toDataURL();
+                    resolve(dataUrl);
+                }
+            };
+            img.onerror = error => {
+                reject(error);
+            };
+            img.src = imageSrc;
+        });
+
+    // calls the openai api to get image edits
+    static getEdit = async (imgBlob: Blob, maskBlob: Blob, prompt: string, n?: number): Promise<APISuccess | APIError> => {
+        const apiUrl = 'https://api.openai.com/v1/images/edits';
+        const fd = new FormData();
+        fd.append('image', imgBlob, 'image.png');
+        fd.append('mask', maskBlob, 'mask.png');
+        fd.append('prompt', prompt);
+        fd.append('size', '1024x1024');
+        fd.append('n', n ? JSON.stringify(n) : '1');
+        fd.append('response_format', 'b64_json');
+
+        try {
+            const res = await fetch(apiUrl, {
+                method: 'POST',
+                headers: {
+                    Authorization: `Bearer ${process.env.OPENAI_KEY}`,
+                },
+                body: fd,
+            });
+            const data = await res.json();
+            console.log(data.data);
+            return {
+                status: 'success',
+                urls: (data.data as { b64_json: string }[]).map(urlData => `data:image/png;base64,${urlData.b64_json}`),
+            };
+        } catch (err) {
+            console.log(err);
+            return { status: 'error', message: 'API error.' };
+        }
+    };
+
+    // mock api call
+    static mockGetEdit = async (mockSrc: string): Promise<APISuccess | APIError> => ({
+        status: 'success',
+        urls: [mockSrc, mockSrc, mockSrc],
+    });
+
+    // Gets the canvas rendering context of a canvas
+    static getCanvasContext = (canvasRef: RefObject<HTMLCanvasElement>): CanvasRenderingContext2D | null => {
+        if (!canvasRef.current) return null;
+        const ctx = canvasRef.current.getContext('2d');
+        if (!ctx) return null;
+        return ctx;
+    };
+
+    // Helper for downloading the canvas (for debugging)
+    static downloadCanvas = (canvas: HTMLCanvasElement) => {
+        const url = canvas.toDataURL();
+        const downloadLink = document.createElement('a');
+        downloadLink.href = url;
+        downloadLink.download = 'canvas';
+
+        downloadLink.click();
+        downloadLink.remove();
+    };
+
+    // Download the canvas (for debugging)
+    static downloadImageCanvas = (imgUrl: string) => {
+        const img = new Image();
+        img.src = imgUrl;
+        img.onload = () => {
+            const canvas = document.createElement('canvas');
+            canvas.width = canvasSize;
+            canvas.height = canvasSize;
+            const ctx = canvas.getContext('2d');
+            ctx?.drawImage(img, 0, 0, canvasSize, canvasSize);
+
+            this.downloadCanvas(canvas);
+        };
+    };
+
+    // Clears the canvas
+    static clearCanvas = (canvasRef: React.RefObject<HTMLCanvasElement>) => {
+        const ctx = this.getCanvasContext(canvasRef);
+        if (!ctx || !canvasRef.current) return;
+        ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
+    };
+
+    // Draws the image to the current canvas
+    static drawImgToCanvas = (img: HTMLImageElement, canvasRef: React.RefObject<HTMLCanvasElement>, width: number, height: number) => {
+        const drawImg = (htmlImg: HTMLImageElement) => {
+            const ctx = this.getCanvasContext(canvasRef);
+            if (!ctx) return;
+            ctx.globalCompositeOperation = 'source-over';
+            ctx.clearRect(0, 0, canvasRef.current?.width || width, canvasRef.current?.height || height);
+            ctx.drawImage(htmlImg, 0, 0, width, height);
+        };
+
+        if (img.complete) {
+            drawImg(img);
+        } else {
+            img.onload = () => {
+                drawImg(img);
+            };
+        }
+    };
+
+    // Gets the image mask for the openai endpoint
+    static getCanvasMask = (srcCanvas: HTMLCanvasElement, paddedCanvas: HTMLCanvasElement): HTMLCanvasElement | undefined => {
+        const canvas = document.createElement('canvas');
+        canvas.width = canvasSize;
+        canvas.height = canvasSize;
+        const ctx = canvas.getContext('2d');
+        if (!ctx) return undefined;
+        ctx?.clearRect(0, 0, canvasSize, canvasSize);
+        ctx.drawImage(paddedCanvas, 0, 0);
+
+        // extract and set padding data
+        if (srcCanvas.height > srcCanvas.width) {
+            // horizontal padding, x offset
+            const xOffset = (canvasSize - srcCanvas.width) / 2;
+            ctx?.clearRect(xOffset, 0, srcCanvas.width, srcCanvas.height);
+            ctx.drawImage(srcCanvas, xOffset, 0, srcCanvas.width, srcCanvas.height);
+        } else {
+            // vertical padding, y offset
+            const yOffset = (canvasSize - srcCanvas.height) / 2;
+            ctx?.clearRect(0, yOffset, srcCanvas.width, srcCanvas.height);
+            ctx.drawImage(srcCanvas, 0, yOffset, srcCanvas.width, srcCanvas.height);
+        }
+        return canvas;
+    };
+
+    // Fills in the blank areas of the image with an image reflection (to fill in a square-shaped canvas)
+    static drawHorizontalReflection = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, xOffset: number) => {
+        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+        const { data } = imageData;
+        for (let i = 0; i < canvas.height; i++) {
+            for (let j = 0; j < xOffset; j++) {
+                const targetIdx = 4 * (i * canvas.width + j);
+                const sourceI = i;
+                const sourceJ = xOffset + (xOffset - j);
+                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
+                data[targetIdx] = data[sourceIdx];
+                data[targetIdx + 1] = data[sourceIdx + 1];
+                data[targetIdx + 2] = data[sourceIdx + 2];
+            }
+        }
+        for (let i = 0; i < canvas.height; i++) {
+            for (let j = canvas.width - 1; j >= canvas.width - 1 - xOffset; j--) {
+                const targetIdx = 4 * (i * canvas.width + j);
+                const sourceI = i;
+                const sourceJ = canvas.width - 1 - xOffset - (xOffset - (canvas.width - j));
+                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
+                data[targetIdx] = data[sourceIdx];
+                data[targetIdx + 1] = data[sourceIdx + 1];
+                data[targetIdx + 2] = data[sourceIdx + 2];
+            }
+        }
+        ctx.putImageData(imageData, 0, 0);
+    };
+
+    // Fills in the blank areas of the image with an image reflection (to fill in a square-shaped canvas)
+    static drawVerticalReflection = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, yOffset: number) => {
+        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+        const { data } = imageData;
+        for (let j = 0; j < canvas.width; j++) {
+            for (let i = 0; i < yOffset; i++) {
+                const targetIdx = 4 * (i * canvas.width + j);
+                const sourceJ = j;
+                const sourceI = yOffset + (yOffset - i);
+                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
+                data[targetIdx] = data[sourceIdx];
+                data[targetIdx + 1] = data[sourceIdx + 1];
+                data[targetIdx + 2] = data[sourceIdx + 2];
+            }
+        }
+        for (let j = 0; j < canvas.width; j++) {
+            for (let i = canvas.height - 1; i >= canvas.height - 1 - yOffset; i--) {
+                const targetIdx = 4 * (i * canvas.width + j);
+                const sourceJ = j;
+                const sourceI = canvas.height - 1 - yOffset - (yOffset - (canvas.height - i));
+                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
+                data[targetIdx] = data[sourceIdx];
+                data[targetIdx + 1] = data[sourceIdx + 1];
+                data[targetIdx + 2] = data[sourceIdx + 2];
+            }
+        }
+        ctx.putImageData(imageData, 0, 0);
+    };
+
+    // Gets the unaltered (besides filling in padding) version of the image for the api call
+    static getCanvasImg = (img: HTMLImageElement): HTMLCanvasElement | undefined => {
+        const canvas = document.createElement('canvas');
+        canvas.width = canvasSize;
+        canvas.height = canvasSize;
+        const ctx = canvas.getContext('2d');
+        if (!ctx) return undefined;
+        // fix scaling
+        const scale = Math.min(canvasSize / img.width, canvasSize / img.height);
+        const width = Math.floor(img.width * scale);
+        const height = Math.floor(img.height * scale);
+        ctx?.clearRect(0, 0, canvasSize, canvasSize);
+        ctx.fillStyle = bgColor;
+        ctx.fillRect(0, 0, canvasSize, canvasSize);
+
+        // extract and set padding data
+        if (img.naturalHeight > img.naturalWidth) {
+            // horizontal padding, x offset
+            const xOffset = Math.floor((canvasSize - width) / 2);
+            ctx.drawImage(img, xOffset, 0, width, height);
+
+            // draw reflected image padding
+            this.drawHorizontalReflection(ctx, canvas, xOffset);
+        } else {
+            // vertical padding, y offset
+            const yOffset = Math.floor((canvasSize - height) / 2);
+            ctx.drawImage(img, 0, yOffset, width, height);
+
+            // draw reflected image padding
+            this.drawVerticalReflection(ctx, canvas, yOffset);
+        }
+        return canvas;
+    };
+
+    /**
+     * Converts a url to base64 (tainted canvas workaround)
+     */
+    static urlToBase64 = async (imageUrl: string): Promise<string | undefined> => {
+        try {
+            const res = await fetch(imageUrl);
+            const blob = await res.blob();
+
+            return new Promise<string>((resolve, reject) => {
+                const reader = new FileReader();
+                reader.onload = () => {
+                    const base64Data = reader.result?.toString().split(',')[1];
+                    if (base64Data) {
+                        resolve(base64Data);
+                    } else {
+                        reject(new Error('Failed to convert.'));
+                    }
+                };
+                reader.onerror = () => {
+                    reject(new Error('Error reading image data'));
+                };
+                reader.readAsDataURL(blob);
+            });
+        } catch (err) {
+            console.error(err);
+        }
+        return undefined;
+    };
+}
diff --git a/src/client/views/nodes/imageEditor/imageEditorUtils/PointerHandler.ts b/src/client/views/nodes/imageEditor/imageEditorUtils/PointerHandler.ts
new file mode 100644
index 000000000..e86f46636
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/imageEditorUtils/PointerHandler.ts
@@ -0,0 +1,11 @@
+import { Point } from './imageEditorInterfaces';
+
+export class PointerHandler {
+    static getPointRelativeToElement = (element: HTMLElement, e: React.PointerEvent | PointerEvent, scale: number): Point => {
+        const boundingBox = element.getBoundingClientRect();
+        return {
+            x: (e.clientX - boundingBox.x) / scale,
+            y: (e.clientY - boundingBox.y) / scale,
+        };
+    };
+}
diff --git a/src/client/views/nodes/imageEditor/imageEditorUtils/imageEditorConstants.ts b/src/client/views/nodes/imageEditor/imageEditorUtils/imageEditorConstants.ts
new file mode 100644
index 000000000..4772304bc
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/imageEditorUtils/imageEditorConstants.ts
@@ -0,0 +1,9 @@
+export const canvasSize = 1024;
+export const freeformRenderSize = 300;
+export const offsetDistanceY = freeformRenderSize + 400;
+export const offsetX = 200;
+export const newCollectionSize = 500;
+
+export const activeColor = '#1976d2';
+export const eraserColor = '#e1e9ec';
+export const bgColor = '#f0f4f6';
diff --git a/src/client/views/nodes/imageEditor/imageEditorUtils/imageEditorInterfaces.ts b/src/client/views/nodes/imageEditor/imageEditorUtils/imageEditorInterfaces.ts
new file mode 100644
index 000000000..f4ae7d9c4
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/imageEditorUtils/imageEditorInterfaces.ts
@@ -0,0 +1,38 @@
+import { IconProp } from '@fortawesome/fontawesome-svg-core';
+
+export interface CursorData {
+    x: number;
+    y: number;
+    width: number;
+}
+
+export interface Point {
+    x: number;
+    y: number;
+}
+
+export enum ImageToolType {
+    GenerativeFill = 'genFill',
+    Cut = 'cut',
+}
+
+export interface ImageEditTool {
+    type: ImageToolType;
+    name: string;
+    btnText: string;
+    icon: IconProp;
+    applyFunc: () => Promise<void>;
+    sliderMin?: number;
+    sliderMax?: number;
+    sliderDefault?: number;
+}
+
+export enum BrushMode {
+    ADD,
+    SUBTRACT,
+}
+
+export interface ImageDimensions {
+    width: number;
+    height: number;
+}
diff --git a/src/client/views/nodes/imageEditor/imageToolUtils/BrushHandler.ts b/src/client/views/nodes/imageEditor/imageToolUtils/BrushHandler.ts
new file mode 100644
index 000000000..7139bebc3
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/imageToolUtils/BrushHandler.ts
@@ -0,0 +1,35 @@
+import { GenerativeFillMathHelpers } from '../imageEditorUtils/GenerativeFillMathHelpers';
+import { eraserColor } from '../imageEditorUtils/imageEditorConstants';
+import { Point } from '../imageEditorUtils/imageEditorInterfaces';
+import { points } from '@turf/turf';
+
+export enum BrushType {
+    GEN_FILL,
+    CUT,
+}
+
+export class BrushHandler {
+    static brushCircleOverlay = (x: number, y: number, brushRadius: number, ctx: CanvasRenderingContext2D, fillColor: string /* , erase: boolean */) => {
+        ctx.globalCompositeOperation = 'destination-out';
+        ctx.fillStyle = fillColor;
+        ctx.shadowColor = eraserColor;
+        ctx.shadowBlur = 5;
+        ctx.beginPath();
+        ctx.arc(x, y, brushRadius, 0, 2 * Math.PI);
+        ctx.fill();
+        ctx.closePath();
+    };
+
+    static createBrushPathOverlay = (startPoint: Point, endPoint: Point, brushRadius: number, ctx: CanvasRenderingContext2D, fillColor: string, brushType: BrushType) => {
+        const dist = GenerativeFillMathHelpers.distanceBetween(startPoint, endPoint);
+        const pts: Point[] = [];
+        for (let i = 0; i < dist; i += 5) {
+            const s = i / dist;
+            const x = startPoint.x * (1 - s) + endPoint.x * s;
+            const y = startPoint.y * (1 - s) + endPoint.y * s;
+            pts.push({ x: startPoint.x, y: startPoint.y });
+            BrushHandler.brushCircleOverlay(x, y, brushRadius, ctx, fillColor);
+        }
+        return pts;
+    };
+}
diff --git a/src/client/views/nodes/imageEditor/imageToolUtils/ImageHandler.ts b/src/client/views/nodes/imageEditor/imageToolUtils/ImageHandler.ts
new file mode 100644
index 000000000..b9723b5be
--- /dev/null
+++ b/src/client/views/nodes/imageEditor/imageToolUtils/ImageHandler.ts
@@ -0,0 +1,312 @@
+import { RefObject } from 'react';
+import { bgColor, canvasSize } from '../imageEditorUtils/imageEditorConstants';
+
+export interface APISuccess {
+    status: 'success';
+    urls: string[];
+}
+
+export interface APIError {
+    status: 'error';
+    message: string;
+}
+
+export class ImageUtility {
+    /**
+     *
+     * @param canvas Canvas to convert
+     * @returns Blob of canvas
+     */
+    static canvasToBlob = (canvas: HTMLCanvasElement): Promise<Blob> =>
+        new Promise(resolve => {
+            canvas.toBlob(blob => {
+                if (blob) {
+                    resolve(blob);
+                }
+            }, 'image/png');
+        });
+
+    // given a square api image, get the cropped img
+    static getCroppedImg = (img: HTMLImageElement, width: number, height: number): HTMLCanvasElement | undefined => {
+        // Create a new canvas element
+        const canvas = document.createElement('canvas');
+        canvas.width = width;
+        canvas.height = height;
+        const ctx = canvas.getContext('2d');
+        if (ctx) {
+            // Clear the canvas
+            ctx.clearRect(0, 0, canvas.width, canvas.height);
+            if (width < height) {
+                // horizontal padding, x offset
+                const xOffset = (canvasSize - width) / 2;
+                ctx.drawImage(img, xOffset, 0, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
+            } else {
+                // vertical padding, y offset
+                const yOffset = (canvasSize - height) / 2;
+                ctx.drawImage(img, 0, yOffset, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
+            }
+            return canvas;
+        }
+        return undefined;
+    };
+
+    // converts an image to a canvas data url
+    static convertImgToCanvasUrl = async (imageSrc: string, width: number, height: number): Promise<string> =>
+        new Promise<string>((resolve, reject) => {
+            const img = new Image();
+            img.onload = () => {
+                const canvas = this.getCroppedImg(img, width, height);
+                if (canvas) {
+                    const dataUrl = canvas.toDataURL();
+                    resolve(dataUrl);
+                }
+            };
+            img.onerror = error => {
+                reject(error);
+            };
+            img.src = imageSrc;
+        });
+
+    // calls the openai api to get image edits
+    static getEdit = async (imgBlob: Blob, maskBlob: Blob, prompt: string, n?: number): Promise<APISuccess | APIError> => {
+        const apiUrl = 'https://api.openai.com/v1/images/edits';
+        const fd = new FormData();
+        fd.append('image', imgBlob, 'image.png');
+        fd.append('mask', maskBlob, 'mask.png');
+        fd.append('prompt', prompt);
+        fd.append('size', '1024x1024');
+        fd.append('n', n ? JSON.stringify(n) : '1');
+        fd.append('response_format', 'b64_json');
+
+        try {
+            const res = await fetch(apiUrl, {
+                method: 'POST',
+                headers: {
+                    Authorization: `Bearer ${process.env.OPENAI_KEY}`,
+                },
+                body: fd,
+            });
+            const data = await res.json();
+            console.log(data.data);
+            return {
+                status: 'success',
+                urls: (data.data as { b64_json: string }[]).map(urlData => `data:image/png;base64,${urlData.b64_json}`),
+            };
+        } catch (err) {
+            console.log(err);
+            return { status: 'error', message: 'API error.' };
+        }
+    };
+
+    // mock api call
+    static mockGetEdit = async (mockSrc: string): Promise<APISuccess | APIError> => ({
+        status: 'success',
+        urls: [mockSrc, mockSrc, mockSrc],
+    });
+
+    // Gets the canvas rendering context of a canvas
+    static getCanvasContext = (canvasRef: RefObject<HTMLCanvasElement>): CanvasRenderingContext2D | null => {
+        if (!canvasRef.current) return null;
+        const ctx = canvasRef.current.getContext('2d');
+        if (!ctx) return null;
+        return ctx;
+    };
+
+    // Helper for downloading the canvas (for debugging)
+    static downloadCanvas = (canvas: HTMLCanvasElement) => {
+        const url = canvas.toDataURL();
+        const downloadLink = document.createElement('a');
+        downloadLink.href = url;
+        downloadLink.download = 'canvas';
+
+        downloadLink.click();
+        downloadLink.remove();
+    };
+
+    // Download the canvas (for debugging)
+    static downloadImageCanvas = (imgUrl: string) => {
+        const img = new Image();
+        img.src = imgUrl;
+        img.onload = () => {
+            const canvas = document.createElement('canvas');
+            canvas.width = canvasSize;
+            canvas.height = canvasSize;
+            const ctx = canvas.getContext('2d');
+            ctx?.drawImage(img, 0, 0, canvasSize, canvasSize);
+
+            this.downloadCanvas(canvas);
+        };
+    };
+
+    // Clears the canvas
+    static clearCanvas = (canvasRef: React.RefObject<HTMLCanvasElement>) => {
+        const ctx = this.getCanvasContext(canvasRef);
+        if (!ctx || !canvasRef.current) return;
+        ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
+    };
+
+    // Draws the image to the current canvas
+    static drawImgToCanvas = (img: HTMLImageElement, canvasRef: React.RefObject<HTMLCanvasElement>, width: number, height: number) => {
+        const drawImg = (htmlImg: HTMLImageElement) => {
+            const ctx = this.getCanvasContext(canvasRef);
+            if (!ctx) return;
+            ctx.globalCompositeOperation = 'source-over';
+            ctx.clearRect(0, 0, width, height);
+            ctx.drawImage(htmlImg, 0, 0, width, height);
+        };
+
+        if (img.complete) {
+            drawImg(img);
+        } else {
+            img.onload = () => {
+                drawImg(img);
+            };
+        }
+    };
+
+    // Gets the image mask for the openai endpoint
+    static getCanvasMask = (srcCanvas: HTMLCanvasElement, paddedCanvas: HTMLCanvasElement): HTMLCanvasElement | undefined => {
+        const canvas = document.createElement('canvas');
+        canvas.width = canvasSize;
+        canvas.height = canvasSize;
+        const ctx = canvas.getContext('2d');
+        if (!ctx) return undefined;
+        ctx?.clearRect(0, 0, canvasSize, canvasSize);
+        ctx.drawImage(paddedCanvas, 0, 0);
+
+        // extract and set padding data
+        if (srcCanvas.height > srcCanvas.width) {
+            // horizontal padding, x offset
+            const xOffset = (canvasSize - srcCanvas.width) / 2;
+            ctx?.clearRect(xOffset, 0, srcCanvas.width, srcCanvas.height);
+            ctx.drawImage(srcCanvas, xOffset, 0, srcCanvas.width, srcCanvas.height);
+        } else {
+            // vertical padding, y offset
+            const yOffset = (canvasSize - srcCanvas.height) / 2;
+            ctx?.clearRect(0, yOffset, srcCanvas.width, srcCanvas.height);
+            ctx.drawImage(srcCanvas, 0, yOffset, srcCanvas.width, srcCanvas.height);
+        }
+        return canvas;
+    };
+
+    // Fills in the blank areas of the image with an image reflection (to fill in a square-shaped canvas)
+    static drawHorizontalReflection = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, xOffset: number) => {
+        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+        const { data } = imageData;
+        for (let i = 0; i < canvas.height; i++) {
+            for (let j = 0; j < xOffset; j++) {
+                const targetIdx = 4 * (i * canvas.width + j);
+                const sourceI = i;
+                const sourceJ = xOffset + (xOffset - j);
+                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
+                data[targetIdx] = data[sourceIdx];
+                data[targetIdx + 1] = data[sourceIdx + 1];
+                data[targetIdx + 2] = data[sourceIdx + 2];
+            }
+        }
+        for (let i = 0; i < canvas.height; i++) {
+            for (let j = canvas.width - 1; j >= canvas.width - 1 - xOffset; j--) {
+                const targetIdx = 4 * (i * canvas.width + j);
+                const sourceI = i;
+                const sourceJ = canvas.width - 1 - xOffset - (xOffset - (canvas.width - j));
+                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
+                data[targetIdx] = data[sourceIdx];
+                data[targetIdx + 1] = data[sourceIdx + 1];
+                data[targetIdx + 2] = data[sourceIdx + 2];
+            }
+        }
+        ctx.putImageData(imageData, 0, 0);
+    };
+
+    // Fills in the blank areas of the image with an image reflection (to fill in a square-shaped canvas)
+    static drawVerticalReflection = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, yOffset: number) => {
+        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+        const { data } = imageData;
+        for (let j = 0; j < canvas.width; j++) {
+            for (let i = 0; i < yOffset; i++) {
+                const targetIdx = 4 * (i * canvas.width + j);
+                const sourceJ = j;
+                const sourceI = yOffset + (yOffset - i);
+                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
+                data[targetIdx] = data[sourceIdx];
+                data[targetIdx + 1] = data[sourceIdx + 1];
+                data[targetIdx + 2] = data[sourceIdx + 2];
+            }
+        }
+        for (let j = 0; j < canvas.width; j++) {
+            for (let i = canvas.height - 1; i >= canvas.height - 1 - yOffset; i--) {
+                const targetIdx = 4 * (i * canvas.width + j);
+                const sourceJ = j;
+                const sourceI = canvas.height - 1 - yOffset - (yOffset - (canvas.height - i));
+                const sourceIdx = 4 * (sourceI * canvas.width + sourceJ);
+                data[targetIdx] = data[sourceIdx];
+                data[targetIdx + 1] = data[sourceIdx + 1];
+                data[targetIdx + 2] = data[sourceIdx + 2];
+            }
+        }
+        ctx.putImageData(imageData, 0, 0);
+    };
+
+    // Gets the unaltered (besides filling in padding) version of the image for the api call
+    static getCanvasImg = (img: HTMLImageElement): HTMLCanvasElement | undefined => {
+        const canvas = document.createElement('canvas');
+        canvas.width = canvasSize;
+        canvas.height = canvasSize;
+        const ctx = canvas.getContext('2d');
+        if (!ctx) return undefined;
+        // fix scaling
+        const scale = Math.min(canvasSize / img.width, canvasSize / img.height);
+        const width = Math.floor(img.width * scale);
+        const height = Math.floor(img.height * scale);
+        ctx?.clearRect(0, 0, canvasSize, canvasSize);
+        ctx.fillStyle = bgColor;
+        ctx.fillRect(0, 0, canvasSize, canvasSize);
+
+        // extract and set padding data
+        if (img.naturalHeight > img.naturalWidth) {
+            // horizontal padding, x offset
+            const xOffset = Math.floor((canvasSize - width) / 2);
+            ctx.drawImage(img, xOffset, 0, width, height);
+
+            // draw reflected image padding
+            this.drawHorizontalReflection(ctx, canvas, xOffset);
+        } else {
+            // vertical padding, y offset
+            const yOffset = Math.floor((canvasSize - height) / 2);
+            ctx.drawImage(img, 0, yOffset, width, height);
+
+            // draw reflected image padding
+            this.drawVerticalReflection(ctx, canvas, yOffset);
+        }
+        return canvas;
+    };
+
+    /**
+     * Converts a url to base64 (tainted canvas workaround)
+     */
+    static urlToBase64 = async (imageUrl: string): Promise<string | undefined> => {
+        try {
+            const res = await fetch(imageUrl);
+            const blob = await res.blob();
+
+            return new Promise<string>((resolve, reject) => {
+                const reader = new FileReader();
+                reader.onload = () => {
+                    const base64Data = reader.result?.toString().split(',')[1];
+                    if (base64Data) {
+                        resolve(base64Data);
+                    } else {
+                        reject(new Error('Failed to convert.'));
+                    }
+                };
+                reader.onerror = () => {
+                    reject(new Error('Error reading image data'));
+                };
+                reader.readAsDataURL(blob);
+            });
+        } catch (err) {
+            console.error(err);
+        }
+        return undefined;
+    };
+}
diff --git a/src/client/views/smartdraw/AnnotationPalette.tsx b/src/client/views/smartdraw/AnnotationPalette.tsx
index f1e2e4f41..dc0c01d36 100644
--- a/src/client/views/smartdraw/AnnotationPalette.tsx
+++ b/src/client/views/smartdraw/AnnotationPalette.tsx
@@ -185,7 +185,7 @@ export class AnnotationPalette extends ObservableReactComponent<AnnotationPalett
         docData.drawingColored = this._opts.autoColor;
         docData.drawingSize = this._opts.size;
         docData.drawingData = this._gptRes[cIndex];
-        docData.width = this._opts.size;
+        focusedDrawing.width = this._opts.size;
         docData.x = this._opts.x;
         docData.y = this._opts.y;
         await AnnotationPalette.addToPalette(focusedDrawing);
-- 
cgit v1.2.3-70-g09d2


From 63f9f9573dab9745d2f570e1917b325a474fdd93 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Mon, 28 Oct 2024 20:07:51 -0400
Subject: reworked LabelBox's to be fully editable.  gor rid of singleLine for
 label boxes. made a text_fitBox field to render text with a labelBox. fixed
 styleprovider in freeformview clusters to be untracked() to avoid
 invalidations.  added a default text color to Settings panel.   fixed
 clicking text buttons to not lose focus in labelBox.

---
 package-lock.json                                  |   8 +-
 package.json                                       |   2 +-
 src/client/documents/DocUtils.ts                   |   5 +-
 src/client/documents/Documents.ts                  |  14 +-
 src/client/util/CurrentUserUtils.ts                |   9 +-
 src/client/util/SettingsManager.tsx                |  24 +-
 src/client/views/MarqueeAnnotator.tsx              |   3 +-
 src/client/views/PinFuncs.ts                       |   4 +-
 src/client/views/SidebarAnnos.tsx                  |   4 +-
 src/client/views/StyleProvider.tsx                 |   4 +-
 src/client/views/StyleProviderQuiz.tsx             |   2 +-
 .../views/collections/CollectionCardDeckView.tsx   |   1 -
 .../CollectionFreeFormClusters.ts                  |   6 +-
 .../collections/collectionSchema/SchemaRowBox.tsx  |   2 +-
 src/client/views/global/globalScripts.ts           |  24 +-
 .../views/nodes/DataVizBox/DocCreatorMenu.tsx      |  35 +--
 src/client/views/nodes/DocumentView.tsx            |  12 +-
 src/client/views/nodes/EquationBox.tsx             |   2 +-
 .../views/nodes/FontIconBox/FontIconBox.scss       |   4 +
 src/client/views/nodes/FontIconBox/FontIconBox.tsx |   4 +-
 src/client/views/nodes/LabelBox.scss               |   1 -
 src/client/views/nodes/LabelBox.tsx                | 168 +++++++++----
 .../views/nodes/formattedText/FormattedTextBox.tsx | 259 +++++++++++----------
 .../formattedText/ProsemirrorExampleTransfer.ts    |   4 +-
 .../views/nodes/formattedText/RichTextMenu.tsx     |  71 +++---
 .../views/nodes/formattedText/RichTextRules.ts     |   2 +-
 src/client/views/nodes/trails/PresBox.tsx          |  14 +-
 src/client/views/pdf/GPTPopup/GPTPopup.tsx         |  23 +-
 src/fields/documentSchemas.ts                      |   7 +-
 29 files changed, 426 insertions(+), 292 deletions(-)

(limited to 'src/client/views/StyleProviderQuiz.tsx')

diff --git a/package-lock.json b/package-lock.json
index 4e95fcee0..43dcb1c3f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -72,7 +72,7 @@
                 "body-parser": "^1.20.2",
                 "bootstrap": "^5.3.2",
                 "brotli": "^1.3.3",
-                "browndash-components": "^0.1.50",
+                "browndash-components": "0.1.53",
                 "browser-assert": "^1.2.1",
                 "bson": "^6.2.0",
                 "canvas": "^2.11.2",
@@ -13575,9 +13575,9 @@
             }
         },
         "node_modules/browndash-components": {
-            "version": "0.1.50",
-            "resolved": "https://registry.npmjs.org/browndash-components/-/browndash-components-0.1.50.tgz",
-            "integrity": "sha512-8EgU82os8/Tg3gayh+nYXCQZ8P7GGQWnibz0U2RM0sXTbc3BWTSYiC1Sj1VpL7HPkZ1KMGHzWynCo9QKIOWZBg==",
+            "version": "0.1.53",
+            "resolved": "https://registry.npmjs.org/browndash-components/-/browndash-components-0.1.53.tgz",
+            "integrity": "sha512-lBZlXe2u/6d9NlrR+fH+9ncCFGRUxGqJRejpfhbhVmQ4Q4uvP9LlmTuaVQElqys5wGUyK0SuqrLYPGhJthdQLQ==",
             "dependencies": {
                 "@emotion/react": "^11.11.1",
                 "@emotion/styled": "^11.11.0",
diff --git a/package.json b/package.json
index 06179fd7d..63d5e387d 100644
--- a/package.json
+++ b/package.json
@@ -155,7 +155,7 @@
         "body-parser": "^1.20.2",
         "bootstrap": "^5.3.2",
         "brotli": "^1.3.3",
-        "browndash-components": "^0.1.50",
+        "browndash-components": "0.1.53",
         "browser-assert": "^1.2.1",
         "bson": "^6.2.0",
         "canvas": "^2.11.2",
diff --git a/src/client/documents/DocUtils.ts b/src/client/documents/DocUtils.ts
index 19f3c89ef..e3cb5e72c 100644
--- a/src/client/documents/DocUtils.ts
+++ b/src/client/documents/DocUtils.ts
@@ -679,10 +679,13 @@ export namespace DocUtils {
                 ? {} // if the new doc will inherit from a template, don't set any layout fields since that would block the inheritance
                 : {
                       _width: width || 200,
-                      _height: 35,
+                      _height: BoolCast(Doc.UserDoc().fitBox) ? 70 : 35,
                       _layout_centered: BoolCast(Doc.UserDoc()._layout_centered),
                       _layout_fitWidth: true,
                       _layout_autoHeight: true,
+                      text_fitBox: BoolCast(Doc.UserDoc().fitBox),
+                      text_align: StrCast(Doc.UserDoc().textAlign),
+                      text_fontColor: StrCast(Doc.UserDoc().fontColor),
                   }),
         });
 
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 98e8bc932..b0e1a7545 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -294,7 +294,6 @@ export class DocumentOptions {
     _yMargin?: NUMt = new NumInfo('gap between top edge of dcoument and start of masonry/stacking layouts', false);
     _xPadding?: NUMt = new NumInfo('x padding', false);
     _yPadding?: NUMt = new NumInfo('y padding', false);
-    _singleLine?: boolean; // whether label box is restricted to one line of text
     _createDocOnCR?: boolean; // whether carriage returns and tabs create new text documents
     _columnWidth?: NUMt = new NumInfo('width of table column', false);
     _columnsHideIfEmpty?: BOOLt = new BoolInfo('whether stacking view column headings should be hidden');
@@ -302,10 +301,14 @@ export class DocumentOptions {
     _caption_yMargin?: NUMt = new NumInfo('y margin of caption inside of a carousel collection', false, true);
     icon_nativeWidth?: NUMt = new NumInfo('native width of icon view', false, true);
     icon_nativeHeight?: NUMt = new NumInfo('native height of icon view', false, true);
-    _text_fontSize?: string;
-    _text_fontFamily?: string;
-    _text_fontWeight?: string;
-    text_align?: STRt = new StrInfo('horizontal text alignment default');
+    text_fontSize?: string;
+    text_fontFamily?: string;
+    text_fontWeight?: string;
+    text_fitBox?: BOOLt = new BoolInfo("whether text box should be scaled to fit it's containing render box");
+    text_align?: STRt = new StrInfo('horizontal text alignment default', undefined, undefined, ['left', 'center', 'right']);
+    title_align?: STRt = new StrInfo('horizontal title alignment in label box', undefined, undefined, ['left', 'center', 'right']);
+    title_transform?: STRt = new StrInfo('transformation to apply to title in label box (eg., uppercase)', undefined, undefined, ['uppercase', 'lowercase', 'capitalize']);
+    text_transform?: STRt = new StrInfo('transformation to apply to text in text box (eg., uppercase)', undefined, undefined, ['uppercase', 'lowercase', 'capitalize']);
     text_placeholder?: BOOLt = new BoolInfo('makes the text act like a placeholder and automatically select when the text box is selected');
     fontSize?: string;
     _pivotField?: string; // field key used to determine headings for sections in stacking, masonry, pivot views
@@ -496,7 +499,6 @@ export class DocumentOptions {
     sidebar_type_collection?: string; // collection type of text sidebar
 
     data_dashboards?: List<FieldType>; // list of dashboards used in shareddocs;
-    textTransform?: string;
     letterSpacing?: string;
     iconTemplate?: string; // name of icon template style
     icon_fieldKey?: string; // specifies the icon template to use (e.g., icon_fieldKey='george', then the icon template's name is icon_george; otherwise, the template's name would be icon_<type> where type is the Doc's type(pdf,rich text, etc))
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index 30c75c659..b3aa5e145 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -186,7 +186,7 @@ export class CurrentUserUtils {
         const templateIconsDoc = DocUtils.AssignOpts(DocCast(doc[field]), reqdOpts) ?? (doc[field] = Docs.Create.TreeDocument([], reqdOpts));
 
         const labelBox = (opts: DocumentOptions, fieldKey:string) => Docs.Create.LabelDocument({
-            layout: LabelBox.LayoutString(fieldKey), textTransform: "unset", letterSpacing: "unset", _singleLine: false, _label_minFontSize: 14, _label_maxFontSize: 14, layout_borderRounding: "5px",  _width: 150, _height: 70, _xPadding: 10, _yPadding: 10, ...opts
+            layout: LabelBox.LayoutString(fieldKey), letterSpacing: "unset", _label_minFontSize: 14, _label_maxFontSize: 14, layout_borderRounding: "5px",  _width: 150, _height: 70, _xPadding: 10, _yPadding: 10, ...opts
         });
         const imageBox = (opts: DocumentOptions, fieldKey:string) => Docs.Create.ImageDocument( "http://www.cs.brown.edu/~bcz/noImage.png", { layout:ImageBox.LayoutString(fieldKey), "icon_nativeWidth": 360 / 4, "icon_nativeHeight": 270 / 4, iconTemplate:DocumentType.IMG,  _width: 360 / 4, _height: 270 / 4,  _layout_showTitle: "title", ...opts });
         const fontBox = (opts:DocumentOptions, fieldKey:string) => Docs.Create.FontIconDocument({ layout:FontIconBox.LayoutString(fieldKey), _nativeHeight: 30, _nativeWidth: 30, _width: 30, _height: 30, ...opts  });
@@ -268,7 +268,7 @@ export class CurrentUserUtils {
             MakeTemplate(Docs.Create.MultirowDocument(
                     [
                         Docs.Create.MulticolumnDocument([], { title: "hero", _height: 200,  isSystem: true }),
-                        Docs.Create.TextDocument("", { title: "text", _layout_fitWidth:true,  _height: 100, isSystem: true, _text_fontFamily: StrCast(Doc.UserDoc().fontFamily), _text_fontSize: StrCast(Doc.UserDoc().fontSize) })
+                        Docs.Create.TextDocument("", { title: "text", _layout_fitWidth:true,  _height: 100, isSystem: true, text_fontFamily: StrCast(Doc.UserDoc().fontFamily), text_fontSize: StrCast(Doc.UserDoc().fontSize) })
                     ], {...opts, title: "Slide View Template"}));
         const plotlyApi = () => {
             let plotly = Doc.MyPublishedDocs.find(fdoc => fdoc.title === "@plotly");
@@ -392,7 +392,7 @@ pie title Minerals in my tap water
             {key: "Trail",      creator: Docs.Create.PresDocument,                          opts: { _width: 400, _height: 30, _type_collection: CollectionViewType.Stacking, _layout_dontCenter:'xy', dropAction: dropActionType.embed, treeView_HideTitle: true, _layout_fitWidth:true, layout_boxShadow: "0 0" }},
             {key: "Tab",        creator: opts => Docs.Create.FreeformDocument([], opts),    opts: { _width: 500, _height: 800, _layout_fitWidth: true, _freeform_backgroundGrid: true, }},
             {key: "Slide",      creator: opts => Docs.Create.TreeDocument([], opts),        opts: { _width: 300, _height: 200, _type_collection: CollectionViewType.Tree, 
-                                                                                                treeView_HasOverlay: true, _text_fontSize: "20px", _layout_autoHeight: true, 
+                                                                                                treeView_HasOverlay: true, text_fontSize: "20px", _layout_autoHeight: true, 
                                                                                                 dropAction:dropActionType.move, treeView_Type: TreeViewType.outline, 
                                                                                                 backgroundColor: "white", _xMargin: 0, _yMargin: 0, _createDocOnCR: true 
                                                                                             }, funcs: {title: 'this.text?.Text'}},
@@ -731,7 +731,7 @@ pie title Minerals in my tap water
         return [
             { title: "Font",     toolTip: "Font", width: 100,     btnType: ButtonType.DropdownList,                      toolType:"font",     ignoreClick: true,   scripts: {script:  '{ return setFontAttr(this.toolType, value, _readOnly_);}'},
               btnList: new List<string>(["Roboto", "Roboto Mono", "Nunito", "Times New Roman", "Arial", "Georgia", "Comic Sans MS", "Tahoma", "Impact", "Crimson Text"]) },
-            { title: "Font Size",toolTip: "Font size (%size)",    btnType: ButtonType.NumberDropdownButton,              toolType:"fontSize", ignoreClick: true,   scripts: {script:  '{ return setFontAttr(this.toolType, value, _readOnly_);}'}, numBtnMax: 200, numBtnMin: 6 },
+            { title: "Font Size",toolTip: "Font size (%size)",    btnType: ButtonType.NumberDropdownButton,              toolType:"fontSize", ignoreClick: true,   scripts: {script:  '{ return setFontAttr(this.toolType, value, _readOnly_);}'}, numBtnMax: 200, numBtnMin: 9 },
             { title: "Color",    toolTip: "Font color (%color)",  btnType: ButtonType.ColorButton,  icon: "font",        toolType:"fontColor",ignoreClick: true,   scripts: {script:  '{ return setFontAttr(this.toolType, value, _readOnly_);}'} },
             { title: "Highlight",toolTip: "Font highlight",       btnType: ButtonType.ColorButton,  icon: "highlighter", toolType:"highlight",ignoreClick: true,   scripts: {script:  '{ return setFontAttr(this.toolType, value, _readOnly_);}'} },
             { title: "Bold",     toolTip: "Bold (Ctrl+B)",        btnType: ButtonType.ToggleButton, icon: "bold",        toolType:"bold",     ignoreClick: true,   scripts: {onClick: '{ return toggleCharStyle(this.toolType, _readOnly_);}'} },
@@ -746,6 +746,7 @@ pie title Minerals in my tap water
                 { title: "Center",   toolTip: "Center align (Cmd-\\)",btnType: ButtonType.ToggleButton, icon: "align-center",toolType:"center",ignoreClick: true,  scripts: {onClick: '{ return toggleCharStyle(this.toolType, _readOnly_);}'} },
                 { title: "Right",    toolTip: "Right align (Cmd-])",  btnType: ButtonType.ToggleButton, icon: "align-right", toolType:"right", ignoreClick: true,  scripts: {onClick: '{ return toggleCharStyle(this.toolType, _readOnly_);}'} },
             ]},
+            { title: "Fit Box",  toolTip: "Fit text to box",      btnType: ButtonType.ToggleButton, icon: "object-group",toolType:"fitBox",    ignoreClick: true,  scripts: {onClick: '{ return toggleCharStyle(this.toolType, _readOnly_);}'} },
             { title: "Elide",    toolTip: "Elide selection",      btnType: ButtonType.ToggleButton, icon: "eye",         toolType:"elide",     ignoreClick: true,  scripts: {onClick: '{ return toggleCharStyle(this.toolType, _readOnly_);}'} },
             { title: "Dictate",  toolTip: "Dictate",              btnType: ButtonType.ToggleButton, icon: "microphone",  toolType:"dictation", ignoreClick: true,  scripts: {onClick: '{ return toggleCharStyle(this.toolType, _readOnly_);}'} },
             { title: "NoLink",   toolTip: "Auto Link",            btnType: ButtonType.ToggleButton, icon: "link",        toolType:"noAutoLink", expertMode:true,   scripts: {onClick: '{ return toggleCharStyle(this.toolType, _readOnly_);}'}, funcs: {hidden: 'IsNoviceMode()'}},
diff --git a/src/client/util/SettingsManager.tsx b/src/client/util/SettingsManager.tsx
index 9200d68db..33d7d90a8 100644
--- a/src/client/util/SettingsManager.tsx
+++ b/src/client/util/SettingsManager.tsx
@@ -1,5 +1,5 @@
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { Button, ColorPicker, Dropdown, DropdownType, EditableText, Group, NumberDropdown, Size, Toggle, ToggleType, Type } from 'browndash-components';
+import { Button, ColorPicker, Colors, Dropdown, DropdownType, EditableText, Group, NumberDropdown, Size, Toggle, ToggleType, Type } from 'browndash-components';
 import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx';
 import { observer } from 'mobx-react';
 import * as React from 'react';
@@ -45,7 +45,6 @@ export class SettingsManager extends React.Component<object> {
     public closeMgr = action(() => {
         this._isOpen = false;
     });
-    // eslint-disable-next-line react/no-unused-class-component-methods
     public openMgr = action(() => {
         this._isOpen = true;
     });
@@ -354,6 +353,27 @@ export class SettingsManager extends React.Component<object> {
                                     Doc.UserDoc().fontSize = val + 'px';
                                 }}
                             />
+                            <ColorPicker
+                                color={SettingsManager.userColor}
+                                type={Type.PRIM}
+                                defaultPickerType="Classic"
+                                selectedColor={StrCast(Doc.UserDoc().textBackgroundColor, Colors.LIGHT_GRAY)}
+                                background={SnappingManager.userBackgroundColor}
+                                icon={<FontAwesomeIcon icon="palette" size="lg" />}
+                                tooltip="default text background color"
+                                label="background"
+                                setSelectedColor={value => {
+                                    Doc.UserDoc().textBackgroundColor = value;
+                                    // if (!this.colorBatch) this.colorBatch = UndoManager.StartBatch(`Set ${tooltip} color`);
+                                    // this.colorScript?.script.run({ this: this.Document, value: value, _readOnly_: false });
+                                }}
+                                setFinalColor={value => {
+                                    Doc.UserDoc().textBackgroundColor = value;
+                                    // this.colorScript?.script.run({ this: this.Document, value: value, _readOnly_: false });
+                                    // this.colorBatch?.end();
+                                    // this.colorBatch = undefined;
+                                }}
+                            />
                             <Dropdown
                                 items={fontFamilies.map(val => ({
                                     text: val,
diff --git a/src/client/views/MarqueeAnnotator.tsx b/src/client/views/MarqueeAnnotator.tsx
index 7266875c5..fa1123a2d 100644
--- a/src/client/views/MarqueeAnnotator.tsx
+++ b/src/client/views/MarqueeAnnotator.tsx
@@ -34,7 +34,7 @@ export interface MarqueeAnnotatorProps {
     getPageFromScroll?: (top: number) => number;
     finishMarquee: (x?: number, y?: number) => void;
     anchorMenuClick?: () => undefined | ((anchor: Doc) => void);
-    anchorMenuFlashcard?: () => Promise<String>;
+    anchorMenuFlashcard?: () => Promise<string>;
     anchorMenuCrop?: (anchor: Doc | undefined, addCrop: boolean) => Doc | undefined;
     highlightDragSrcColor?: string;
 }
@@ -220,7 +220,6 @@ export class MarqueeAnnotator extends ObservableReactComponent<MarqueeAnnotatorP
                   e.preventDefault();
                   e.stopPropagation();
                   let cropRegion: Doc | undefined;
-                  // eslint-disable-next-line no-return-assign
                   const sourceAnchorCreator = () => (cropRegion = this.highlight('', true, undefined, true)); // hyperlink color
                   const targetCreator = (/* annotationOn: Doc | undefined */) => this.props.anchorMenuCrop!(cropRegion, false)!;
                   DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(this.props.docView(), sourceAnchorCreator, targetCreator), e.pageX, e.pageY, {
diff --git a/src/client/views/PinFuncs.ts b/src/client/views/PinFuncs.ts
index bed473eec..ab02c2d07 100644
--- a/src/client/views/PinFuncs.ts
+++ b/src/client/views/PinFuncs.ts
@@ -1,4 +1,4 @@
-import { Doc, DocListCast } from '../../fields/Doc';
+import { Doc, DocListCast, Field } from '../../fields/Doc';
 import { DocData } from '../../fields/DocSymbols';
 import { Copy, Id } from '../../fields/FieldSymbols';
 import { List } from '../../fields/List';
@@ -74,7 +74,7 @@ export function PinDocView(pinDocIn: Doc, pinProps: PinProps, targetDoc: Doc) {
         const fkey = Doc.LayoutFieldKey(targetDoc);
         if (pinProps.pinData.dataview) {
             pinDoc.config_usePath = targetDoc[fkey + '_usePath'];
-            pinDoc.config_data = targetDoc[fkey] instanceof ObjectField ? (targetDoc[fkey] as ObjectField)[Copy]() : targetDoc.data;
+            pinDoc.config_data = Field.Copy(targetDoc[fkey]);
         }
         if (pinProps.pinData.dataannos) {
             const fieldKey = Doc.LayoutFieldKey(targetDoc);
diff --git a/src/client/views/SidebarAnnos.tsx b/src/client/views/SidebarAnnos.tsx
index 1f3ad8444..87076bf65 100644
--- a/src/client/views/SidebarAnnos.tsx
+++ b/src/client/views/SidebarAnnos.tsx
@@ -78,8 +78,8 @@ export class SidebarAnnos extends ObservableReactComponent<FieldViewProps & Extr
             _height: 50,
             _layout_fitWidth: true,
             _layout_autoHeight: true,
-            _text_fontSize: StrCast(Doc.UserDoc().fontSize),
-            _text_fontFamily: StrCast(Doc.UserDoc().fontFamily),
+            text_fontSize: StrCast(Doc.UserDoc().fontSize),
+            text_fontFamily: StrCast(Doc.UserDoc().fontFamily),
         });
         Doc.SetSelectOnLoad(target);
         FormattedTextBox.DontSelectInitialText = true;
diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx
index 8859f6464..12c032964 100644
--- a/src/client/views/StyleProvider.tsx
+++ b/src/client/views/StyleProvider.tsx
@@ -251,7 +251,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps &
                 case DocumentType.PRESELEMENT: docColor = docColor || ""; break;
                 case DocumentType.PRES:        docColor = docColor || 'transparent'; break;
                 case DocumentType.FONTICON:    docColor = boxBackground ? undefined : docColor || Colors.DARK_GRAY; break;
-                case DocumentType.RTF:         docColor = docColor || Colors.LIGHT_GRAY; break;
+                case DocumentType.RTF:         docColor = docColor || StrCast(Doc.UserDoc().textBackgroundColor, Colors.LIGHT_GRAY); break;
                 case DocumentType.LINK:        docColor = (isAnchor ? docColor : undefined); break;
                 case DocumentType.INK:         docColor = doc?.stroke_isInkMask ? 'rgba(0,0,0,0.7)' : undefined; break;
                 case DocumentType.EQUATION:    docColor = docColor || 'transparent'; break;
@@ -313,7 +313,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps &
                         ? undefined // if it's a background & has a cluster color, make the shadow spread really big
                         : fieldKey.includes('_inline')  // if doc is an inline document in a text box
                         ? `${Colors.DARK_GRAY} ${StrCast(doc.layout_boxShadow, '0vw 0vw 0.1vw')}`
-                        : DocCast(doc.embedContainer)?.type === DocumentType.RTF && !isInk() // if doc is embedded in a text document (but not an inline)
+                        :doc.rootDocument !== doc.embedContainer && DocCast(doc.embedContainer)?.type === DocumentType.RTF && !isInk() // if doc is embedded in a text document (but not an inline) and this isn't a simple text template (where the layoutDoc's rootDocument is its embed container)
                         ? `${Colors.DARK_GRAY} ${StrCast(doc.layout_boxShadow, '0.2vw 0.2vw 0.8vw')}`
                         : StrCast(doc.layout_boxShadow, '');
             }
diff --git a/src/client/views/StyleProviderQuiz.tsx b/src/client/views/StyleProviderQuiz.tsx
index 1f2ad1485..f07a2c803 100644
--- a/src/client/views/StyleProviderQuiz.tsx
+++ b/src/client/views/StyleProviderQuiz.tsx
@@ -66,7 +66,7 @@ export namespace styleProviderQuiz {
             newCol.zIndex = 1000;
             newCol.forceActive = true;
             newCol.quiz = text;
-            newCol[DocData].textTransform = 'none';
+            newCol[DocData][Doc.LayoutFieldKey(newCol) + '_transform'] = 'none';
             Doc.AddDocToList(img.Document, '_quizBoxes', newCol);
             img.addDocument(newCol);
             //   img._loading = false;
diff --git a/src/client/views/collections/CollectionCardDeckView.tsx b/src/client/views/collections/CollectionCardDeckView.tsx
index beb45f326..0ba6b679c 100644
--- a/src/client/views/collections/CollectionCardDeckView.tsx
+++ b/src/client/views/collections/CollectionCardDeckView.tsx
@@ -620,7 +620,6 @@ export class CollectionCardView extends CollectionSubView() {
      * Actually renders all the cards
      */
     @computed get renderCards() {
-        console.log('CHILDPw = ' + this.childPanelWidth());
         // Map sorted documents to their rendered components
         return this.sortedDocs.map((doc, index) => {
             const cardsInRow = this.cardsInRowThatIncludesCardIndex(index);
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts b/src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts
index 6ad67a864..8530f7a23 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts
@@ -1,4 +1,4 @@
-import { action, observable } from 'mobx';
+import { action, observable, untracked } from 'mobx';
 import { CollectionFreeFormView } from '.';
 import { intersectRect } from '../../../../Utils';
 import { Doc, Opt } from '../../../../fields/Doc';
@@ -179,7 +179,9 @@ export class CollectionFreeFormClusters {
     };
 
     styleProvider = (doc: Opt<Doc>, props: Opt<FieldViewProps>, property: string) => {
-        if (doc && this.childDocs?.includes(doc))
+        // without untracked, every inquired style property for any Doc will be invalidated if a change is made to the collection's childDocs.
+        // this prevents that by assuming that a Doc is generally always (or never) a member of childDocs - if it's removed or added, then all of its properties get updated anyway.
+        if (doc && untracked(() => this.childDocs)?.includes(doc))
             switch (property.split(':')[0]) {
                 case StyleProp.BackgroundColor:
                     {
diff --git a/src/client/views/collections/collectionSchema/SchemaRowBox.tsx b/src/client/views/collections/collectionSchema/SchemaRowBox.tsx
index 6ffb0865a..7f519b065 100644
--- a/src/client/views/collections/collectionSchema/SchemaRowBox.tsx
+++ b/src/client/views/collections/collectionSchema/SchemaRowBox.tsx
@@ -188,7 +188,7 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() {
                             selectedCells={this.selectedCells}
                             selectedCol={this.selectedCol}
                             setColumnValues={this.setColumnValues}
-                            oneLine={BoolCast(this.schemaDoc?._singleLine)}
+                            oneLine={BoolCast(this.schemaDoc?._schema_singleLine)}
                             menuTarget={this.schemaView.MenuTarget}
                             transform={() => {
                                 const ind = index === this.schemaView.columnKeys.length - 1 ? this.schemaView.columnKeys.length - 3 : index;
diff --git a/src/client/views/global/globalScripts.ts b/src/client/views/global/globalScripts.ts
index 954c79f7d..013b18a97 100644
--- a/src/client/views/global/globalScripts.ts
+++ b/src/client/views/global/globalScripts.ts
@@ -295,10 +295,10 @@ ScriptingGlobals.add(function setTagFilter(tag: string, added: boolean, checkRes
 }, '');
 
 // eslint-disable-next-line prefer-arrow-callback
-ScriptingGlobals.add(function setFontAttr(attr: 'font' | 'fontColor' | 'highlight' | 'fontSize' | 'alignment', value: string | number, checkResult?: boolean) {
+ScriptingGlobals.add(function setFontAttr(attr: 'font' | 'fontColor' | 'highlight' | 'fontSize', value: string | number, checkResult?: boolean) {
     const editorView = RichTextMenu.Instance?.TextView?.EditorView;
     // prettier-ignore
-    const map: Map<'font'|'fontColor'|'highlight'|'fontSize'|'alignment', { checkResult: () => string | undefined; setDoc: () => void;}> = new Map([
+    const map: Map<'font'|'fontColor'|'highlight'|'fontSize', { checkResult: () => string | undefined; setDoc: () => void;}> = new Map([
         ['font', {
             checkResult: () => RichTextMenu.Instance?.fontFamily,
             setDoc: () => value && RichTextMenu.Instance?.setFontField(value.toString(), 'fontFamily'),
@@ -311,10 +311,6 @@ ScriptingGlobals.add(function setFontAttr(attr: 'font' | 'fontColor' | 'highligh
             checkResult: () => RichTextMenu.Instance?.fontColor,
             setDoc: () => value && RichTextMenu.Instance?.setFontField(value.toString(), 'fontColor'),
         }],
-        ['alignment', {
-            checkResult: () => RichTextMenu.Instance?.textAlign,
-            setDoc: () => { value && editorView?.state ? RichTextMenu.Instance?.align(editorView, editorView.dispatch, value.toString() as "center"|"left"|"right"):(Doc.UserDoc().textAlign = value); },
-        }],
         ['fontSize', {
             checkResult: () => RichTextMenu.Instance?.fontSize.replace('px', ''),
             setDoc: () => {
@@ -334,7 +330,7 @@ ScriptingGlobals.add(function setFontAttr(attr: 'font' | 'fontColor' | 'highligh
     return undefined;
 });
 
-type attrname = 'noAutoLink' | 'dictation' | 'bold' | 'italics' | 'elide' | 'underline' | 'left' | 'center' | 'right' | 'vcent' | 'bullet' | 'decimal';
+type attrname = 'noAutoLink' | 'dictation' | 'fitBox' | 'bold' | 'italics' | 'elide' | 'underline' | 'left' | 'center' | 'right' | 'vcent' | 'bullet' | 'decimal';
 type attrfuncs = [attrname, { checkResult: () => boolean; toggle?: () => unknown }];
 
 // eslint-disable-next-line prefer-arrow-callback
@@ -343,14 +339,10 @@ ScriptingGlobals.add(function toggleCharStyle(charStyle: attrname, checkResult?:
     const editorView = textView?.EditorView;
     // prettier-ignore
     const alignments:attrfuncs[] = (['left','right','center','vcent'] as ("left"|"center"|"right"|"vcent")[]).map((where) =>
-        [ where, {      checkResult: () => editorView ? (where === 'vcent' ?  RichTextMenu.Instance?.textVcenter ?? false:
-                                                                             (RichTextMenu.Instance?.textAlign === where)): 
-                                                         where === 'vcent' ?  BoolCast(Doc.UserDoc()._layout_centered):
-                                                                            (Doc.UserDoc().textAlign === where),
-                        toggle: () => { editorView?.state ? (where === 'vcent' ? RichTextMenu.Instance?.vcenterToggle():
-                                                                                 RichTextMenu.Instance?.align(editorView, editorView.dispatch, where)):
-                                                            where === 'vcent' ?  Doc.UserDoc()._layout_centered = !Doc.UserDoc()._layout_centered:
-                                                                                (Doc.UserDoc().textAlign = where); }
+        [ where, {      checkResult: () => (where === 'vcent' ?  RichTextMenu.Instance?.textVcenter ?? false:
+                                                             (RichTextMenu.Instance?.textAlign === where)),
+                        toggle: () => { (where === 'vcent' ? RichTextMenu.Instance?.vcenterToggle():
+                                                            RichTextMenu.Instance?.align(editorView, editorView?.dispatch, where)); }
         }]); // prettier-ignore
     // prettier-ignore
     const listings:attrfuncs[] = (['bullet','decimal'] as attrname[]).map(list =>
@@ -360,6 +352,8 @@ ScriptingGlobals.add(function toggleCharStyle(charStyle: attrname, checkResult?:
     const attrs:attrfuncs[] = [
         ['dictation', { checkResult: () => !!textView?._recordingDictation,
                         toggle: () => textView && runInAction(() => { textView._recordingDictation = !textView._recordingDictation;} ) }],
+        ['fitBox',    { checkResult: () => RichTextMenu.Instance?.fitBox ?? false,
+                        toggle: () => RichTextMenu.Instance?.toggleFitBox()}],
         ['elide',      { checkResult: () => false,
                         toggle: () => editorView ? RichTextMenu.Instance?.elideSelection(): 0}],
         ['noAutoLink',{ checkResult: () => ((editorView && RichTextMenu.Instance?.noAutoLink) ?? false),
diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx
index 16c016d6c..7c601185e 100644
--- a/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx
+++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx
@@ -245,7 +245,7 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> {
     }
 
     updateIcons = (docs: Doc[]) => {
-        console.log('called')
+        console.log('called');
         docs.map(this.getIcon);
     };
 
@@ -416,7 +416,7 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> {
                 const height = bottom - top;
                 const width = right - left;
                 const doc = !field.title.includes('$$')
-                    ? Docs.Create.TextDocument('', { _height: height, _width: width, title: field.title, x: left, y: top, _text_fontSize: `${height / 2}` })
+                    ? Docs.Create.TextDocument('', { _height: height, _width: width, title: field.title, x: left, y: top, text_fontSize: `${height / 2}` })
                     : Docs.Create.ImageDocument('', { _height: height, _width: width, title: field.title.replace(/\$\$/g, ''), x: left, y: top });
                 return doc;
             });
@@ -919,17 +919,17 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> {
     @action setExpandedView = (info: { icon: ImageField; doc: Doc } | undefined) => {
         if (info) {
             const doc = info.doc;
-            const wrapper: Doc = Docs.Create.FreeformDocument([info.doc], { _height: NumListCast(doc._height)[0], _width: NumListCast(doc._width)[0], title: ''});
-            const newInfo = {icon: new ImageField(''), doc: wrapper}
+            const wrapper: Doc = Docs.Create.FreeformDocument([info.doc], { _height: NumListCast(doc._height)[0], _width: NumListCast(doc._width)[0], title: '' });
+            const newInfo = { icon: new ImageField(''), doc: wrapper };
             this._expandedPreview = newInfo;
         } else {
             this._expandedPreview = info;
         }
     };
 
-    get editingWindow(){
+    get editingWindow() {
         const doc = this._expandedPreview?.doc ?? new Doc();
-        const rendered = 
+        const rendered = (
             <div className="docCreatorMenu-expanded-template-preview">
                 <CollectionFreeFormView
                     Document={this._expandedPreview!.doc}
@@ -945,7 +945,7 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> {
                     removeDocument={returnFalse}
                     PanelWidth={() => this._menuDimensions.width - 10}
                     PanelHeight={() => this._menuDimensions.height - 60}
-                    ScreenToLocalTransform={() => new Transform(-this._pageX,-this._pageY, 1)}
+                    ScreenToLocalTransform={() => new Transform(-this._pageX, -this._pageY, 1)}
                     renderDepth={5}
                     whenChildContentsActiveChanged={emptyFunction}
                     focus={emptyFunction}
@@ -961,14 +961,21 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> {
                     yPadding={0}
                 />
             </div>
-
+        );
 
         return (
             <div className="docCreatorMenu-expanded-template-preview">
-                <div className="top-panel"/>
+                <div className="top-panel" />
                 {rendered}
                 <div className="right-buttons-panel">
-                    <button className="docCreatorMenu-menu-button section-reveal-options top-right" onPointerDown={e => this.setUpButtonClick(e, () => {this._expandedPreview && this.updateIcons(this._suggestedTemplates.slice()); this.setExpandedView(undefined)})}>
+                    <button
+                        className="docCreatorMenu-menu-button section-reveal-options top-right"
+                        onPointerDown={e =>
+                            this.setUpButtonClick(e, () => {
+                                this._expandedPreview && this.updateIcons(this._suggestedTemplates.slice());
+                                this.setExpandedView(undefined);
+                            })
+                        }>
                         <FontAwesomeIcon icon="minimize" />
                     </button>
                     <button className="docCreatorMenu-menu-button section-reveal-options top-right-lower" onPointerDown={e => this.setUpButtonClick(e, () => this._expandedPreview && this._templateDocs.push(this._expandedPreview.doc))}>
@@ -976,7 +983,6 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> {
                     </button>
                 </div>
             </div>
-
         );
     }
 
@@ -2260,11 +2266,11 @@ export class FieldUtils {
             title: title,
             x: coord.x,
             y: coord.y,
-            _text_fontSize: `${FieldUtils.calculateFontSize(width, height, content, true)}`,
+            text_fontSize: `${FieldUtils.calculateFontSize(width, height, content, true)}`,
             backgroundColor: opts.backgroundColor ?? '',
             text_fontColor: opts.color,
             contentBold: opts.fontBold,
-            textTransform: opts.fontTransform,
+            text_transform: opts.fontTransform,
             color: opts.color,
             _layout_borderRounding: `${opts.cornerRounding ?? 0}px`,
             borderColor: opts.borderColor,
@@ -2306,7 +2312,7 @@ export class FieldUtils {
     public static CarouselField = (coords: { tl: [number, number]; br: [number, number] }, parentWidth: number, parentHeight: number, title: string, fields: Doc[]) => {
         const { width, height, coord } = FieldUtils.getDimensions(coords, parentWidth, parentHeight);
 
-        const doc = Docs.Create.Carousel3DDocument(fields, { _height: height, _width: width, title: title, x: coord.x, y: coord.y, _text_fontSize: `${height / 2}` });
+        const doc = Docs.Create.Carousel3DDocument(fields, { _height: height, _width: width, title: title, x: coord.x, y: coord.y, text_fontSize: `${height / 2}` });
 
         return doc;
     };
@@ -2359,4 +2365,3 @@ export class FieldUtils {
 //         }]
 //     };
 //  }
-
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index a343b9a39..eb7f333b9 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -880,7 +880,7 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
                           background: this.backgroundBoxColor,
                           opacity: this.opacity,
                           cursor: Doc.ActiveTool === InkTool.None ? 'grab' : 'crosshair',
-                          color: StrCast(this.layoutDoc.color, 'inherit'),
+                          color: StrCast(this.Document._color, 'inherit'),
                           fontFamily: StrCast(this.Document._text_fontFamily, 'inherit'),
                           fontSize: Cast(this.Document._text_fontSize, 'string', null),
                           transform: this._animateScalingTo ? `scale(${this._animateScalingTo})` : undefined,
@@ -981,12 +981,10 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
             type: SpringType.GENTLE,
             ...springMappings.gentle,
         };
-        switch (StrCast(presEffectDoc?.presentation_effect, StrCast(presEffectDoc?.followLinkAnimEffect))) {
-            case PresEffect.Expand:     return <SpringAnimation doc={root} startOpacity={0} dir={dir} presEffect={PresEffect.Expand} springSettings={timingConfig}>{renderDoc}</SpringAnimation>
-            case PresEffect.Flip:       return <SpringAnimation doc={root} startOpacity={0} dir={dir} presEffect={PresEffect.Flip}   springSettings={timingConfig}>{renderDoc}</SpringAnimation>
-            case PresEffect.Rotate:     return <SpringAnimation doc={root} startOpacity={0} dir={dir} presEffect={PresEffect.Rotate} springSettings={timingConfig}>{renderDoc}</SpringAnimation>
-            case PresEffect.Bounce:     return <SpringAnimation doc={root} startOpacity={0} dir={dir} presEffect={PresEffect.Bounce} springSettings={timingConfig}>{renderDoc}</SpringAnimation>
-            case PresEffect.Roll:       return <SpringAnimation doc={root} startOpacity={0} dir={dir} presEffect={PresEffect.Roll}   springSettings={timingConfig}>{renderDoc}</SpringAnimation>
+        const presEffect = StrCast(presEffectDoc?.presentation_effect, StrCast(presEffectDoc?.followLinkAnimEffect));
+        switch (presEffect) {
+            case PresEffect.Expand: case PresEffect.Flip: case PresEffect.Rotate: case PresEffect.Bounce:    
+            case PresEffect.Roll:       return <SpringAnimation doc={root} startOpacity={0} dir={dir} presEffect={presEffect}   springSettings={timingConfig}>{renderDoc}</SpringAnimation>
             // case PresEffect.Fade:    return <SlideEffect doc={root} dir={dir} presEffect={PresEffect.Fade} tension={timingConfig.stiffness} friction={timingConfig.damping} mass={timingConfig.mass}>{renderDoc}</SlideEffect>
             case PresEffect.Fade:       return <Fade {...effectProps}>{renderDoc}</Fade>
             // keep as preset, doesn't really make sense with spring config
diff --git a/src/client/views/nodes/EquationBox.tsx b/src/client/views/nodes/EquationBox.tsx
index fefe25764..290c90d6e 100644
--- a/src/client/views/nodes/EquationBox.tsx
+++ b/src/client/views/nodes/EquationBox.tsx
@@ -122,7 +122,7 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() {
                     width: 'fit-content', // `${100 / scale}%`,
                     height: `${100 / scale}%`,
                     pointerEvents: !this._props.isSelected() ? 'none' : undefined,
-                    fontSize: StrCast(this.layoutDoc._text_fontSize),
+                    fontSize: StrCast(this.Document._text_fontSize),
                 }}
                 onKeyDown={e => e.stopPropagation()}>
                 <EquationEditor ref={this._ref} value={StrCast(this.dataDoc.text, 'x')} spaceBehavesLikeTab onChange={this.onChange} autoCommands="pi theta sqrt sum prod alpha beta gamma rho" autoOperatorNames="sin cos tan" />
diff --git a/src/client/views/nodes/FontIconBox/FontIconBox.scss b/src/client/views/nodes/FontIconBox/FontIconBox.scss
index 2db285910..ab03a2318 100644
--- a/src/client/views/nodes/FontIconBox/FontIconBox.scss
+++ b/src/client/views/nodes/FontIconBox/FontIconBox.scss
@@ -10,6 +10,10 @@
         height: 3px !important;
     }
 }
+.fonticonbox {
+    margin: auto;
+    width: 100%;
+}
 .menuButton {
     height: 100%;
     display: flex;
diff --git a/src/client/views/nodes/FontIconBox/FontIconBox.tsx b/src/client/views/nodes/FontIconBox/FontIconBox.tsx
index d4898eb3c..b45774a75 100644
--- a/src/client/views/nodes/FontIconBox/FontIconBox.tsx
+++ b/src/client/views/nodes/FontIconBox/FontIconBox.tsx
@@ -262,11 +262,13 @@ export class FontIconBox extends ViewBoxBaseComponent<ButtonProps>() {
 
         const script = ScriptCast(this.Document.onClick)?.script;
         const toggleStatus = script?.run({ this: this.Document, value: undefined, _readOnly_: true }).result as boolean;
+
         // Colors
         const color = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string;
         const background = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor) as string;
         const items = DocListCast(this.dataDoc.data);
         const selectedItems = items.filter(itemDoc => ScriptCast(itemDoc.onClick).script.run({ this: itemDoc, value: undefined, _readOnly_: true }).result).map(item => StrCast(item.toolType));
+
         return (
             <MultiToggle
                 tooltip={`Toggle ${tooltip}`}
@@ -392,7 +394,7 @@ export class FontIconBox extends ViewBoxBaseComponent<ButtonProps>() {
 
     render() {
         return (
-            <div style={{ margin: 'auto', width: '100%' }} onContextMenu={this.specificContextMenu}>
+            <div className="fonticonbox" onContextMenu={this.specificContextMenu}>
                 {this.renderButton()}
             </div>
         );
diff --git a/src/client/views/nodes/LabelBox.scss b/src/client/views/nodes/LabelBox.scss
index ca4b3d467..889cdc0ca 100644
--- a/src/client/views/nodes/LabelBox.scss
+++ b/src/client/views/nodes/LabelBox.scss
@@ -13,7 +13,6 @@
     height: 100%;
     border-radius: inherit;
     //letter-spacing: 2px; // bcz: doesn't work with LabelBigText
-    text-transform: uppercase;
     overflow: hidden;
     display: inline-block;
     margin: auto;
diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx
index 94a9541f2..dcf9e1fed 100644
--- a/src/client/views/nodes/LabelBox.tsx
+++ b/src/client/views/nodes/LabelBox.tsx
@@ -1,19 +1,22 @@
 import { Property } from 'csstype';
-import { action, computed, makeObservable } from 'mobx';
+import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx';
 import { observer } from 'mobx-react';
 import * as React from 'react';
 import * as textfit from 'textfit';
-import { Field, FieldType } from '../../../fields/Doc';
-import { BoolCast, NumCast, StrCast } from '../../../fields/Types';
+import { Doc, Field } from '../../../fields/Doc';
+import { NumCast, StrCast } from '../../../fields/Types';
 import { TraceMobx } from '../../../fields/util';
 import { DocumentType } from '../../documents/DocumentTypes';
 import { Docs } from '../../documents/Documents';
 import { DragManager } from '../../util/DragManager';
+import { undoable } from '../../util/UndoManager';
 import { ViewBoxBaseComponent } from '../DocComponent';
 import { PinDocView, PinProps } from '../PinFuncs';
 import { StyleProp } from '../StyleProp';
 import { FieldView, FieldViewProps } from './FieldView';
 import './LabelBox.scss';
+import { FormattedTextBox } from './formattedText/FormattedTextBox';
+import { RichTextMenu } from './formattedText/RichTextMenu';
 
 @observer
 export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
@@ -22,7 +25,8 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
     }
     private dropDisposer?: DragManager.DragDropDisposer;
     private _timeout: NodeJS.Timeout | undefined;
-    _divRef: HTMLDivElement | null = null;
+    private _divRef: HTMLDivElement | null = null;
+    private _reaction: IReactionDisposer | undefined;
 
     constructor(props: FieldViewProps) {
         super(props);
@@ -36,21 +40,29 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
         }
     };
 
-    @computed get Title() {
-        return Field.toString(this.dataDoc[this.fieldKey] as FieldType) || StrCast(this.Document.title);
-    }
-
-    @computed get backgroundColor() {
-        return this._props.styleProvider?.(this.Document, this._props, StyleProp.BackgroundColor) as string;
-    }
-
     componentDidMount() {
         this._props.setContentViewBox?.(this);
+        this._reaction = reaction(
+            () => this.Title,
+            () => document.activeElement !== this._divRef && this._forceRerender++
+        );
     }
     componentWillUnMount() {
         this._timeout && clearTimeout(this._timeout);
+        this.setText(this._divRef?.innerText ?? '');
+        this._reaction?.();
     }
 
+    @observable _forceRerender = 0;
+
+    @computed get Title()           { return Field.toString(this.dataDoc[this.fieldKey]); } // prettier-ignore
+    @computed get backgroundColor() { return this._props.styleProvider?.(this.Document, this._props, StyleProp.BackgroundColor) as string; } // prettier-ignore
+    @computed get boxShadow()       { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BoxShadow) as string; } // prettier-ignore
+
+    setText = undoable((text: string) => {
+        this.dataDoc[this.fieldKey] = text;
+    }, 'set label text');
+
     drop = (/* e: Event, de: DragManager.DropEvent */) => {
         return false;
     };
@@ -82,10 +94,11 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
         const textfitParams = {
             minFontSize: NumCast(this.layoutDoc._label_minFontSize, 1),
             maxFontSize: NumCast(this.layoutDoc._label_maxFontSize, 100),
-            multiLine: BoolCast(this.layoutDoc._singleLine, true) ? false : true,
-            alignHoriz: true,
+            multiLine: r?.textContent?.includes('\n') ? true : false,
+            // hack because tetFit doesn't support align 'right', but we need mobx to invalidate, so treat null as false and set to right inline
+            alignHoriz: StrCast(this.layoutDoc[this.fieldKey + '_align']) === 'center' ? true : StrCast(this.layoutDoc[this.fieldKey + '_align']) === 'right' ? (null as unknown as boolean) : false,
             alignVert: true,
-            detectMultiLine: true,
+            detectMultiLine: false,
         };
         if (r) {
             if (!r.offsetHeight || !r.offsetWidth) {
@@ -94,65 +107,140 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
                 this._timeout = setTimeout(() => this.fitTextToBox(r));
                 return textfitParams;
             }
+            r.style.whiteSpace = ''; // textfit sets to nowrap if not multiline, but doesn't reeset if it becomes multiline
+            r.style.textAlign = StrCast(this.layoutDoc[this.fieldKey + '_align']); // textfit doesn't reset textAlign if it has been set to center, so we just set it to what we want
+            r.firstChild instanceof HTMLElement && (r.firstChild.style.textAlign = StrCast(this.layoutDoc[this.fieldKey + '_align']));
             textfit(r, textfitParams);
         }
         return textfitParams;
     };
+    resetCursor = (cranchor?: number) => {
+        if (this._divRef && (cranchor || this._divRef === document.activeElement)) {
+            const range = document.createRange();
+            const anchor = cranchor ?? this._divRef.childNodes.length;
+            const container = cranchor === undefined ? this._divRef : (this._divRef.firstChild?.firstChild ?? this._divRef);
+            range.setStart(container, anchor);
+            range.setEnd(container, anchor);
+            const sel = window.getSelection();
+            sel?.removeAllRanges();
+            sel?.addRange(range);
+        }
+    };
+
+    beforeInput = action((event: InputEvent) => {
+        const spanChild = this._divRef?.firstChild?.firstChild;
+        if (spanChild?.nodeName === '#text' && ['insertLineBreak', 'insertParagraph'].includes(event.inputType)) {
+            event.preventDefault();
+            event.stopPropagation();
+
+            const selection = document.getSelection();
+            if (selection && document.activeElement === event.target) {
+                const text = spanChild.textContent ?? '';
+                const cranchor = selection.anchorNode === this._divRef ? (selection.anchorOffset ? text.length : 0) : selection.anchorOffset;
+                const addReturnHack = text.length <= cranchor && text[text.length - 1] !== '\n' ? '\n\n' : '\n'; // not sure why, but need to add a second carriage return if typing enter at the end of the text
+                const splitText = text.substring(0, cranchor) + addReturnHack + text.substring(cranchor);
+                spanChild.textContent = splitText;
+                this.resetCursor(cranchor + addReturnHack.length);
+            }
+            // const span = document.createElement('span');
+            // span.innerHTML = '&#8203;';
+            // this._divRef!.append(span);
+        }
+    });
+    // .labelBox-mainButton > div > span:nth-child(2) {
+
+    /**
+     * When an IconButton is clicked, it will receive focus.  However, we don't want that since we want or need that since we really want
+     * to maintain focus in the label's editing div (and cursor position).  so this relies on IconButton's having a tabindex set to -1 so that
+     * we can march up the tree from the 'relatedTarget' to determine if the loss of focus was caused by a fonticonbox.   If it is, we then
+     * restore focus
+     * @param e focusout event on the editing div
+     */
+    keepFocus = (e: FocusEvent) => {
+        if (e.relatedTarget instanceof HTMLElement && e.relatedTarget.tabIndex === -1) {
+            for (let ele: HTMLElement | null = e.relatedTarget; ele; ele = (ele as HTMLElement)?.parentElement) {
+                if ((ele as HTMLElement)?.className === 'fonticonbox') {
+                    setTimeout(() => this._divRef?.focus());
+                    break;
+                }
+            }
+        }
+    };
+
     render() {
         TraceMobx();
         const boxParams = this.fitTextToBox(undefined); // this causes mobx to trigger re-render when data changes
-        const label = this.Title.startsWith('#') ? null : this.Title;
         return (
-            <div key={label?.length} className="labelBox-outerDiv" ref={this.createDropTarget} style={{ boxShadow: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BoxShadow) as string }}>
+            <div className="labelBox-outerDiv" ref={this.createDropTarget} style={{ boxShadow: this.boxShadow }}>
                 <div
                     className="labelBox-mainButton"
                     style={{
                         backgroundColor: this.backgroundColor,
-                        // fontSize: StrCast(this.layoutDoc._text_fontSize),
-                        color: StrCast(this.layoutDoc._color),
-                        fontFamily: StrCast(this.layoutDoc._text_fontFamily) || 'inherit',
+                        color: StrCast(this.layoutDoc._text_fontColor, StrCast(this.layoutDoc._color)),
+                        fontFamily: StrCast(this.layoutDoc._text_fontFamily, StrCast(Doc.UserDoc().fontFamily)) || 'inherit',
                         letterSpacing: StrCast(this.layoutDoc.letterSpacing),
-                        textTransform: StrCast(this.layoutDoc.textTransform) as Property.TextTransform,
+                        textTransform: StrCast(this.layoutDoc[this.fieldKey + '_transform']) as Property.TextTransform,
                         paddingLeft: NumCast(this.layoutDoc._xPadding),
                         paddingRight: NumCast(this.layoutDoc._xPadding),
                         paddingTop: NumCast(this.layoutDoc._yPadding),
                         paddingBottom: NumCast(this.layoutDoc._yPadding),
                         width: this._props.PanelWidth(),
                         height: this._props.PanelHeight(),
-                        whiteSpace: 'multiLine' in boxParams && boxParams.multiLine ? 'pre-wrap' : 'pre',
+                        whiteSpace: boxParams.multiLine ? 'pre-wrap' : 'pre',
                     }}>
                     <div
+                        key={this._forceRerender}
                         style={{
                             width: this._props.PanelWidth() - 2 * NumCast(this.layoutDoc._xPadding),
                             height: this._props.PanelHeight() - 2 * NumCast(this.layoutDoc._yPadding),
                             outline: 'unset !important',
                         }}
-                        onKeyDown={action(e => {
+                        onKeyDown={e => {
                             e.stopPropagation();
-                        })}
+                        }}
                         onKeyUp={action(e => {
                             e.stopPropagation();
-                            this.dataDoc[this.fieldKey] = this._divRef?.innerText ?? '';
-                            setTimeout(() => this._props.select(false));
+                            const text = this._divRef?.firstChild;
+                            if (text && (text as HTMLElement)?.nodeType === 3) {
+                                this._divRef?.removeChild(text);
+                                this._divRef?.firstChild?.appendChild(text);
+                                this.resetCursor();
+                            }
+                            this.fitTextToBox(this._divRef);
                         })}
+                        onFocus={() => {
+                            RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, this.dataDoc);
+                            this._divRef?.removeEventListener('focusout', this.keepFocus);
+                            this._divRef?.addEventListener('focusout', this.keepFocus);
+                        }}
                         onBlur={() => {
-                            this.dataDoc[this.fieldKey] = this._divRef?.innerText ?? '';
+                            this._divRef?.removeEventListener('focusout', this.keepFocus);
+                            this.setText(this._divRef?.innerText ?? '');
+                            RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined);
+                            FormattedTextBox.LiveTextUndo?.end();
+                            FormattedTextBox.LiveTextUndo = undefined;
+                        }}
+                        dangerouslySetInnerHTML={{
+                            __html: `<span class="textFitted textFitAlignVert" style="display: inline-block; text-align: center; font-size: 100px; height: 0px;">${this.Title.startsWith('#') ? null : (this.Title ?? '')}</span>`,
                         }}
                         contentEditable={this._props.onClickScript?.() ? undefined : true}
                         ref={r => {
+                            this._divRef?.removeEventListener('beforeinput', this.beforeInput);
                             this._divRef = r;
-                            this.fitTextToBox(r);
-                            if (this._props.isSelected() && this._divRef) {
-                                const range = document.createRange();
-                                range.setStart(this._divRef, this._divRef.childNodes.length);
-                                range.setEnd(this._divRef, this._divRef.childNodes.length);
-                                const sel = window.getSelection();
-                                sel?.removeAllRanges();
-                                sel?.addRange(range);
+                            if (this._divRef) {
+                                this._divRef.addEventListener('beforeinput', this.beforeInput);
+
+                                if (Doc.SelectOnLoad === this.Document) {
+                                    Doc.SelectOnLoad = undefined;
+                                    this._divRef.focus();
+                                }
+                                this.fitTextToBox(this._divRef);
+                                if (this.Title) {
+                                    this.resetCursor();
+                                }
                             }
-                        }}>
-                        {label}
-                    </div>
+                        }}
+                    />
                 </div>
             </div>
         );
@@ -161,9 +249,9 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
 
 Docs.Prototypes.TemplateMap.set(DocumentType.LABEL, {
     layout: { view: LabelBox, dataField: 'title' },
-    options: { acl: '', _singleLine: true, _layout_nativeDimEditable: true, _layout_reflowHorizontal: true, _layout_reflowVertical: true },
+    options: { acl: '', _layout_nativeDimEditable: true, _layout_reflowHorizontal: true, _layout_reflowVertical: true, title_align: 'center', title_transform: 'uppercase' },
 });
 Docs.Prototypes.TemplateMap.set(DocumentType.BUTTON, {
     layout: { view: LabelBox, dataField: 'title' },
-    options: { acl: '', _layout_nativeDimEditable: true, _layout_reflowHorizontal: true, _layout_reflowVertical: true },
+    options: { acl: '', _layout_nativeDimEditable: true, _layout_reflowHorizontal: true, _layout_reflowVertical: true, title_align: 'center', title_transform: 'uppercase' },
 });
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 2aee7b6b3..659cda9dd 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -65,6 +65,7 @@ import { RichTextMenu, RichTextMenuPlugin } from './RichTextMenu';
 import { RichTextRules } from './RichTextRules';
 import { schema } from './schema_rts';
 import { Property } from 'csstype';
+import { LabelBox } from '../LabelBox';
 // import * as applyDevTools from 'prosemirror-dev-tools';
 
 export interface FormattedTextBoxProps extends FieldViewProps {
@@ -158,6 +159,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     @computed get titleHeight()                { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.HeaderMargin) as number || 0; } // prettier-ignore
     @computed get layout_autoHeightMargins()   { return this.titleHeight + NumCast(this.layoutDoc._layout_autoHeightMargins); } // prettier-ignore
     @computed get sidebarKey()                 { return this.fieldKey + '_sidebar'; } // prettier-ignore
+    @computed get isLabel()                    { return this.dataDoc[this.fieldKey+"_fitBox"]; } // prettier-ignore
 
     constructor(props: FormattedTextBoxProps) {
         super(props);
@@ -165,7 +167,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         this._recordingStart = Date.now();
     }
 
-    public get EditorView() { return this._editorView; } // prettier-ignore
+    public get EditorView() { return this.isLabel ? undefined : this._editorView; } // prettier-ignore
 
     // public makeAIFlashcards: () => void = unimplementedFunction;
     public addToCollection: ((doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) | undefined;
@@ -175,10 +177,10 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     // but since removing one anchor from the list of attr anchors isn't implemented, this will end up removing nothing.
     public RemoveLinkFromDoc(linkDoc?: Doc) {
         this.unhighlightSearchTerms();
-        const state = this._editorView?.state;
+        const state = this.EditorView?.state;
         const a1 = DocCast(linkDoc?.link_anchor_1);
         const a2 = DocCast(linkDoc?.link_anchor_2);
-        if (state && a1 && a2 && this._editorView) {
+        if (state && a1 && a2 && this.EditorView) {
             this.removeDocument(a1);
             this.removeDocument(a2);
             let allFoundLinkAnchors: { href: string; title: string; anchorId: string }[] = [];
@@ -188,7 +190,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                 return true;
             });
             if (allFoundLinkAnchors.length) {
-                this._editorView.dispatch(removeMarkWithAttrs(state.tr, 0, state.doc.nodeSize - 2, state.schema.marks.linkAnchor, { allAnchors: allFoundLinkAnchors }));
+                this.EditorView.dispatch(removeMarkWithAttrs(state.tr, 0, state.doc.nodeSize - 2, state.schema.marks.linkAnchor, { allAnchors: allFoundLinkAnchors }));
 
                 this.setupEditor(this.config, this.fieldKey);
             }
@@ -197,16 +199,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     // removes all the specified link references from the selection.
     // NOTE: as above, this won't work correctly if there are marks with overlapping but not exact sets of link references.
     public RemoveAnchorFromSelection(allAnchors: { href: string; title: string; linkId: string; targetId: string }[]) {
-        const state = this._editorView?.state;
-        if (state && this._editorView) {
-            this._editorView.dispatch(removeMarkWithAttrs(state.tr, state.selection.from, state.selection.to, state.schema.marks.link, { allAnchors }));
+        const state = this.EditorView?.state;
+        if (state && this.EditorView) {
+            this.EditorView.dispatch(removeMarkWithAttrs(state.tr, state.selection.from, state.selection.to, state.schema.marks.link, { allAnchors }));
             this.setupEditor(this.config, this.fieldKey);
         }
     }
 
     getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => {
         const rootDoc: Doc = Doc.isTemplateDoc(this._props.docViewPath().lastElement()?.Document) ? this.Document : DocCast(this.Document.rootDocument, this.Document);
-        if (!pinProps && this._editorView?.state.selection.empty) return rootDoc;
+        if (!pinProps && this.EditorView?.state.selection.empty) return rootDoc;
         const anchor = Docs.Create.ConfigDocument({ title: StrCast(rootDoc.title), annotationOn: rootDoc });
         this.addDocument(anchor);
         this._finishingLink = true;
@@ -263,7 +265,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                 }
             });
         };
-        AnchorMenu.Instance.Highlight = undoable((color: string) => this._editorView?.state && RichTextMenu.Instance?.setFontField(color, 'fontHighlight'), 'highlght text');
+        AnchorMenu.Instance.Highlight = undoable((color: string) => this.EditorView?.state && RichTextMenu.Instance?.setFontField(color, 'fontHighlight'), 'highlght text');
         AnchorMenu.Instance.onMakeAnchor = () => this.getAnchor(true);
         AnchorMenu.Instance.StartCropDrag = unimplementedFunction;
         /**
@@ -292,7 +294,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         };
 
         AnchorMenu.Instance.setSelectedText(window.getSelection()?.toString() ?? '');
-        const coordsB = this._editorView!.coordsAtPos(this._editorView!.state.selection.to);
+        const coordsB = this.EditorView!.coordsAtPos(this.EditorView!.state.selection.to);
         this._props.rootSelected?.() && AnchorMenu.Instance.jumpTo(coordsB.left, coordsB.bottom);
         let ele: Opt<HTMLDivElement>;
         try {
@@ -309,7 +311,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     };
 
     leafText = (node: Node) => {
-        if (node.type === this._editorView?.state.schema.nodes.dashField) {
+        if (node.type === this.EditorView?.state.schema.nodes.dashField) {
             const refDoc = !node.attrs.docId ? DocCast(this.Document.rootDocument, this.Document) : (DocServer.GetCachedRefField(node.attrs.docId as string) as Doc);
             const fieldKey = StrCast(node.attrs.fieldKey);
             return (
@@ -320,16 +322,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         return '';
     };
     dispatchTransaction = (tx: Transaction) => {
-        if (this._editorView && !this._editorView.isDestroyed) {
-            const state = this._editorView.state.apply(tx);
-            this._editorView.updateState(state);
+        if (this.EditorView && !this.EditorView.isDestroyed) {
+            const state = this.EditorView.state.apply(tx);
+            this.EditorView.updateState(state);
             this.tryUpdateDoc(false);
         }
     };
 
     tryUpdateDoc = (force: boolean) => {
-        if (this._editorView) {
-            const { state } = this._editorView;
+        if (this.EditorView) {
+            const { state } = this.EditorView;
             const { dataDoc } = this;
             const newText = state.doc.textBetween(0, state.doc.content.size, ' \n', this.leafText);
             const newJson = JSON.stringify(state.toJSON());
@@ -372,7 +374,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                         textChange && (dataDoc[this.fieldKey + '_modificationDate'] = new DateField(new Date(Date.now())));
                         // if we've deleted all the text in a note driven by a template, then restore the template data
                         dataDoc[this.fieldKey] = undefined;
-                        this._editorView.updateState(EditorState.fromJSON(this.config, JSON.parse(rtField.Data)));
+                        this.EditorView.updateState(EditorState.fromJSON(this.config, JSON.parse(rtField.Data)));
                         ScriptCast(this.layoutDoc.onTextChanged, null)?.script.run({ this: this.layoutDoc, text: newText });
                         unchanged = false;
                     }
@@ -387,7 +389,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                 if (jsonstring) {
                     const json = JSON.parse(jsonstring);
                     json.selection = state.toJSON().selection;
-                    this._editorView.updateState(EditorState.fromJSON(this.config, json));
+                    this.EditorView.updateState(EditorState.fromJSON(this.config, json));
                 }
             }
             if (window.getSelection()?.isCollapsed && this._props.rootSelected?.()) {
@@ -407,8 +409,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                 linkAnchor = anchor;
             }
         });
-        if (this._editorView && linkTime) {
-            const { state } = this._editorView;
+        if (this.EditorView && linkTime) {
+            const { state } = this.EditorView;
             const node = state.selection.$from.node();
             if (linkAnchor && node.type !== state.schema.nodes.code_block) {
                 const time = linkTime + Date.now() / 1000 - this._recordingStart / 1000;
@@ -416,7 +418,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                 const { from } = state.selection;
                 const value = state.schema.nodes.audiotag.create({ timeCode: time, audioId: linkAnchor[Id] });
                 const replaced = state.tr.insert(from - 1, value);
-                this._editorView.dispatch(replaced.setSelection(new TextSelection(replaced.doc.resolve(from + 1))));
+                this.EditorView.dispatch(replaced.setSelection(new TextSelection(replaced.doc.resolve(from + 1))));
             }
         }
     };
@@ -431,16 +433,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                  (Doc.isTemplateForField(this.Document) && (link.link_anchor_1 === this.Document || link.link_anchor_2 === this.Document))) &&
                 link.link_relationship === LinkManager.AutoKeywords
         ); // prettier-ignore
-        if (this._editorView?.state.doc.textContent) {
-            let { tr } = this._editorView.state;
-            const { from, to } = this._editorView.state.selection;
-            const { autoLinkAnchor } = this._editorView.state.schema.marks;
+        if (this.EditorView?.state.doc.textContent) {
+            let { tr } = this.EditorView.state;
+            const { from, to } = this.EditorView.state.selection;
+            const { autoLinkAnchor } = this.EditorView.state.schema.marks;
             tr = tr.removeMark(0, tr.doc.content.size, autoLinkAnchor);
             Doc.MyPublishedDocs.filter(term => term.title).forEach(term => {
                 tr = this.hyperlinkTerm(tr, term, newAutoLinks);
             });
             tr = tr.setSelection(new TextSelection(tr.doc.resolve(from), tr.doc.resolve(to)));
-            this._editorView?.dispatch(tr);
+            this.EditorView?.dispatch(tr);
         }
         oldAutoLinks.filter(oldLink => !newAutoLinks.has(oldLink) && oldLink.link_anchor_2 !== this.Document).forEach(doc => Doc.DeleteLink?.(doc));
     };
@@ -450,11 +452,11 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         if (
             !this._props.dontRegisterView && // (this.Document.isTemplateForField === "text" || !this.Document.isTemplateForField) && // only update the title if the data document's data field is changing
             title.startsWith('-') &&
-            this._editorView &&
+            this.EditorView &&
             !this.dataDoc.title_custom &&
             (Doc.LayoutFieldKey(this.Document) === this.fieldKey || this.fieldKey === 'text')
         ) {
-            let node = this._editorView.state.doc;
+            let node = this.EditorView.state.doc;
             while (node.firstChild && node.firstChild.type.name !== 'text') node = node.firstChild;
             const str = node.textContent;
             const prefix = '-';
@@ -477,7 +479,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
      */
     hyperlinkTerm = (trIn: Transaction, target: Doc, newAutoLinks: Set<Doc>) => {
         let tr = trIn;
-        const editorView = this._editorView;
+        const editorView = this.EditorView;
         if (editorView && !Doc.AreProtosEqual(target, this.Document)) {
             const autoLinkTerm = Field.toString(target.title as FieldType).replace(/^@/, '');
             let alink: Doc | undefined;
@@ -553,18 +555,18 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     };
 
     unhighlightSearchTerms = () => {
-        if (this._editorView) {
-            const { state } = this._editorView;
+        if (this.EditorView) {
+            const { state } = this.EditorView;
             if (state) {
                 const mark = state.schema.mark(state.schema.marks.search_highlight);
                 const activeMark = state.schema.mark(state.schema.marks.search_highlight, { selected: true });
                 const end = state.doc.nodeSize - 2;
-                this._editorView.dispatch(state.tr.removeMark(0, end, mark).removeMark(0, end, activeMark));
+                this.EditorView.dispatch(state.tr.removeMark(0, end, mark).removeMark(0, end, activeMark));
             }
         }
     };
     adoptAnnotation = (start: number, end: number, mark: Mark) => {
-        const view = this._editorView!;
+        const view = this.EditorView!;
         const nmark = view.state.schema.marks.user_mark.create({ ...mark.attrs, userid: ClientUtils.CurrentUserEmail() });
         view.dispatch(view.state.tr.removeMark(start, end, nmark).addMark(start, end, nmark));
     };
@@ -616,7 +618,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                     if (added) {
                         draggedDoc._freeform_fitContentsToBox = true;
                         Doc.SetContainer(draggedDoc, this.Document);
-                        const view = this._editorView!;
+                        const view = this.EditorView!;
                         try {
                             this._inDrop = true;
                             const pos = view.posAtCoords({ left: de.x, top: de.y })?.pos;
@@ -810,7 +812,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
 
         let target: Element | HTMLElement | null = e.target as HTMLElement; // hrefs are stored on the database of the <a> node that wraps the hyerlink <span>
         while (target && (!(target instanceof HTMLElement) || !target.dataset?.targethrefs)) target = target.parentElement;
-        const editor = this._editorView;
+        const editor = this.EditorView;
         if (editor && target && !(e.nativeEvent instanceof simMouseEvent ? e.nativeEvent.dash : false)) {
             const hrefs = (target.dataset?.targethrefs as string)
                 ?.trim()
@@ -996,8 +998,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         const c = this.ProseRef?.getElementsByTagName('img');
         if (c) {
             for (const i of c) {
-                console.log(i);
-
                 // console.log(canvas.toDataURL());
                 // canvas.style.zIndex = '2000000';
                 // document.body.appendChild(canvas);
@@ -1022,8 +1022,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
 
     animateRes = (resIndex: number, newText: string) => {
         if (resIndex < newText.length) {
-            const marks = this._editorView?.state.storedMarks ?? [];
-            this._editorView?.dispatch(this._editorView?.state.tr.insertText(newText[resIndex]).setStoredMarks(marks));
+            const marks = this.EditorView?.state.storedMarks ?? [];
+            this.EditorView?.dispatch(this.EditorView?.state.tr.insertText(newText[resIndex]).setStoredMarks(marks));
             setTimeout(() => this.animateRes(resIndex + 1, newText), 20);
         }
     };
@@ -1035,8 +1035,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
             const res = await gptAPICall((this.dataDoc.text as RichTextField)?.Text, GPTCallType.COMPLETION);
             if (!res) {
                 this.animateRes(0, 'Something went wrong.');
-            } else if (this._editorView) {
-                const { dispatch, state } = this._editorView;
+            } else if (this.EditorView) {
+                const { dispatch, state } = this.EditorView;
                 // for no animation, use: dispatch(state.tr.insertText(res));
                 // for animted response starting at end of text, use:
                 dispatch(state.tr.setSelection(Selection.atEnd(state.doc)));
@@ -1057,13 +1057,13 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     };
 
     breakupDictation = () => {
-        if (this._editorView && this._recordingDictation) {
+        if (this.EditorView && this._recordingDictation) {
             this.stopDictation(/* true */);
             this._break = true;
-            const { state } = this._editorView;
+            const { state } = this.EditorView;
             const { to } = state.selection;
             const updated = TextSelection.create(state.doc, to, to);
-            this._editorView.dispatch(state.tr.setSelection(updated).insert(to, state.schema.nodes.paragraph.create({})));
+            this.EditorView.dispatch(state.tr.setSelection(updated).insert(to, state.schema.nodes.paragraph.create({})));
             if (this._recordingDictation) {
                 this.recordDictation();
             }
@@ -1082,7 +1082,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     stopDictation = (/* abort: boolean */) => DictationManager.Controls.stop(/* !abort */);
 
     setDictationContent = (value: string) => {
-        if (this._editorView && this._recordingStart) {
+        if (this.EditorView && this._recordingStart) {
             if (this._break) {
                 const textanchorFunc = () => {
                     const tanch = Docs.Create.ConfigDocument({ title: 'dictation anchor' });
@@ -1095,22 +1095,22 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                     const textanchor = Cast(link.link_anchor_1, Doc, null);
                     if (audioanchor) {
                         audioanchor.backgroundColor = 'tan';
-                        const audiotag = this._editorView.state.schema.nodes.audiotag.create({
+                        const audiotag = this.EditorView.state.schema.nodes.audiotag.create({
                             timeCode: NumCast(audioanchor._timecodeToShow),
                             audioId: audioanchor[Id],
                             textId: textanchor[Id],
                         });
                         textanchor[DocData].title = 'dictation:' + audiotag.attrs.timeCode;
-                        const tr = this._editorView.state.tr.insert(this._editorView.state.doc.content.size, audiotag);
+                        const tr = this.EditorView.state.tr.insert(this.EditorView.state.doc.content.size, audiotag);
                         const tr2 = tr.setSelection(TextSelection.create(tr.doc, tr.doc.content.size));
-                        this._editorView.dispatch(tr.setSelection(TextSelection.create(tr2.doc, tr2.doc.content.size)));
+                        this.EditorView.dispatch(tr.setSelection(TextSelection.create(tr2.doc, tr2.doc.content.size)));
                     }
                 }
             }
-            const { from } = this._editorView.state.selection;
+            const { from } = this.EditorView.state.selection;
             this._break = false;
-            const tr = this._editorView.state.tr.insertText(value);
-            this._editorView.dispatch(tr.setSelection(TextSelection.create(tr.doc, from, tr.doc.content.size)).scrollIntoView());
+            const tr = this.EditorView.state.tr.insertText(value);
+            this.EditorView.dispatch(tr.setSelection(TextSelection.create(tr.doc, from, tr.doc.content.size)).scrollIntoView());
         }
     };
 
@@ -1143,7 +1143,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                     }
                 });
                 this.dataDoc[ForceServerWrite] = this.dataDoc[UpdatingFromServer] = true; // need to allow permissions for adding links to readonly/augment only documents
-                this._editorView!.dispatch(tr.removeMark(selection.from, selection.to, splitter));
+                this.EditorView!.dispatch(tr.removeMark(selection.from, selection.to, splitter));
                 this.dataDoc[UpdatingFromServer] = this.dataDoc[ForceServerWrite] = false;
                 anchor.text = selectedText;
                 anchor.text_html = this._selectionHTML ?? selectedText;
@@ -1177,7 +1177,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
             let hadStart = start !== 0;
             frag.forEach((node, index) => {
                 const examinedNode = findAnchorNode(node, editor);
-                if (examinedNode?.node && (examinedNode.node.textContent || examinedNode.node.type === this._editorView?.state.schema.nodes.dashDoc || examinedNode.node.type === this._editorView?.state.schema.nodes.audiotag)) {
+                if (examinedNode?.node && (examinedNode.node.textContent || examinedNode.node.type === this.EditorView?.state.schema.nodes.dashDoc || examinedNode.node.type === this.EditorView?.state.schema.nodes.audiotag)) {
                     nodes.push(examinedNode.node);
                     !hadStart && (start = index + examinedNode.start);
                     hadStart = true;
@@ -1186,13 +1186,13 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
             return { frag: Fragment.fromArray(nodes), start };
         };
         const findAnchorNode = (node: Node, editor: EditorView) => {
-            if (node.type === this._editorView?.state.schema.nodes.audiotag) {
+            if (node.type === this.EditorView?.state.schema.nodes.audiotag) {
                 if (node.attrs.textId === textAnchorId) {
                     return { node, start: 0 };
                 }
                 return undefined;
             }
-            if (node.type === this._editorView?.state.schema.nodes.dashDoc) {
+            if (node.type === this.EditorView?.state.schema.nodes.dashDoc) {
                 if (node.attrs.docId === textAnchorId) {
                     return { node, start: 0 };
                 }
@@ -1208,9 +1208,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         };
 
         this._didScroll = false; // assume we don't need to scroll.  if we do, this will get set to true in handleScrollToSelextion when we dispatch the setSelection below
-        if (this._editorView && textAnchorId) {
-            const { state } = this._editorView;
-            const ret = findAnchorFrag(state.doc.content, this._editorView);
+        if (this.EditorView && textAnchorId) {
+            const { state } = this.EditorView;
+            const ret = findAnchorFrag(state.doc.content, this.EditorView);
 
             const firstChild = ret.frag.childCount ? ret.frag.child(0) : undefined;
             if (ret.start >= 0 && (ret.frag.size || (firstChild && [state.schema.nodes.dashDoc, state.schema.nodes.audioTag].includes(firstChild.type)))) {
@@ -1219,7 +1219,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                 if (ret.frag.firstChild) {
                     selection = TextSelection.between(state.doc.resolve(ret.start), state.doc.resolve(ret.start + ret.frag.firstChild.nodeSize)); // bcz: looks better to not have the target selected
                 }
-                this._editorView.dispatch(state.tr.setSelection(new TextSelection(selection.$from, selection.$from)).scrollIntoView());
+                this.EditorView.dispatch(state.tr.setSelection(new TextSelection(selection.$from, selection.$from)).scrollIntoView());
                 const escAnchorId = textAnchorId[0] >= '0' && textAnchorId[0] <= '9' ? `\\3${textAnchorId[0]} ${textAnchorId.substr(1)}` : textAnchorId;
                 addStyleSheetRule(FormattedTextBox._highlightStyleSheet, `${escAnchorId}`, { background: 'yellow', transform: 'scale(3)', 'transform-origin': 'left bottom' });
                 setTimeout(() => {
@@ -1307,15 +1307,15 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                 return !whichData ? undefined : { data: RTFCast(whichData), str: Field.toString(DocCast(whichData) ?? StrCast(whichData)) };
             },
             incomingValue => {
-                if (this._editorView && this._applyingChange !== this.fieldKey) {
+                if (this.EditorView && this._applyingChange !== this.fieldKey) {
                     if (incomingValue?.data) {
                         const updatedState = JSON.parse(incomingValue.data.Data);
-                        if (JSON.stringify(this._editorView.state.toJSON()) !== JSON.stringify(updatedState)) {
-                            this._editorView.updateState(EditorState.fromJSON(this.config, updatedState));
+                        if (JSON.stringify(this.EditorView.state.toJSON()) !== JSON.stringify(updatedState)) {
+                            this.EditorView.updateState(EditorState.fromJSON(this.config, updatedState));
                             this.tryUpdateScrollHeight();
                         }
-                    } else if (this._editorView.state.doc.textContent !== incomingValue?.str) {
-                        selectAll(this._editorView.state, tx => this._editorView?.dispatch(tx.insertText(incomingValue?.str ?? '')));
+                    } else if (this.EditorView.state.doc.textContent !== (incomingValue?.str ?? '')) {
+                        selectAll(this.EditorView.state, tx => this.EditorView?.dispatch(tx.insertText(incomingValue?.str ?? '')));
                     }
                 }
             },
@@ -1333,9 +1333,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
             action(selected => {
                 if (selected && this.dataDoc[this.fieldKey + '_placeholder']) {
                     setTimeout(() => {
-                        selectAll(this._editorView!.state, (tx: Transaction) => {
-                            this._editorView?.dispatch(tx);
-                            this._editorView!.focus();
+                        selectAll(this.EditorView!.state, (tx: Transaction) => {
+                            this.EditorView?.dispatch(tx);
+                            this.EditorView!.focus();
                         });
                     });
                 }
@@ -1343,12 +1343,12 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                 if (FormattedTextBox._globalHighlights.has('Bold Text')) {
                     this.layoutDoc[DocCss] = this.layoutDoc[DocCss] + 1; // css change happens outside of mobx/react, so this will notify anyone interested in the layout that it has changed
                 }
-                if (RichTextMenu.Instance?.view === this._editorView && !selected) {
+                if (((RichTextMenu.Instance?.view === this.EditorView && this.EditorView) || this.isLabel) && !selected) {
                     RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined);
                 }
-                if (this._editorView && selected) {
-                    RichTextMenu.Instance?.updateMenu(this._editorView, undefined, this._props, this.layoutDoc);
-                    setTimeout(this.autoLink, 20);
+                if (selected) {
+                    RichTextMenu.Instance?.updateMenu(this.EditorView, undefined, this._props, this.dataDoc);
+                    this.EditorView && setTimeout(this.autoLink, 20);
                 }
             }),
             { fireImmediately: true }
@@ -1389,7 +1389,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
 
             // DocCast(this.Document.image)._freeform_fitContentsToBox = true;
             // Doc.SetContainer(DocCast(this.Document.image), this.Document);
-            // const view = this._editorView!;
+            // const view = this.EditorView!;
             // try {
             //     this._inDrop = true;
             //     const pos = view.posAtCoords({ left: 0, top: 0 })?.pos;
@@ -1436,7 +1436,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     };
 
     addPdfReference = (pdfAnchorId: string) => {
-        const view = this._editorView!;
+        const view = this.EditorView!;
         if (pdfAnchorId) {
             DocServer.GetRefField(pdfAnchorId).then(pdfAnchor => {
                 if (pdfAnchor instanceof Doc) {
@@ -1487,7 +1487,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         const curText = Cast(this.dataDoc[this.fieldKey], RichTextField, null) || StrCast(this.dataDoc[this.fieldKey]);
         const rtfField = Cast((!curText && this.layoutDoc[this.fieldKey]) || this.dataDoc[fieldKey], RichTextField);
         if (this.ProseRef) {
-            this._editorView?.destroy();
+            this.EditorView?.destroy();
             this._editorView = new EditorView(this.ProseRef, {
                 state: rtfField?.Data ? EditorState.fromJSON(config, JSON.parse(rtfField.Data)) : EditorState.create(config),
                 handleScrollToSelection: editorView => {
@@ -1519,14 +1519,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
             if (!rtfField) {
                 const dataDoc = Doc.IsDelegateField(DocCast(this.layoutDoc.proto), this.fieldKey) ? DocCast(this.layoutDoc.proto) : this.dataDoc;
                 const startupText = Field.toString(dataDoc[fieldKey] as FieldType);
-                const textAlign = StrCast(this.dataDoc.text_align, StrCast(Doc.UserDoc().textAlign)) || 'left';
+                const textAlign = StrCast(this.dataDoc[this.fieldKey + '_align'], StrCast(Doc.UserDoc().textAlign)) || 'left';
                 if (textAlign !== 'left') {
                     selectAll(this._editorView.state, tr => {
-                        this._editorView?.dispatch(tr.replaceSelectionWith(state.schema.nodes.paragraph.create({ align: textAlign })));
+                        this.EditorView?.dispatch(tr.replaceSelectionWith(state.schema.nodes.paragraph.create({ align: textAlign })));
                     });
                 }
                 if (startupText) {
-                    this._editorView?.dispatch(this._editorView.state.tr.insertText(startupText));
+                    this.EditorView?.dispatch(this.EditorView.state.tr.insertText(startupText));
                 }
                 this.tryUpdateDoc(true);
             }
@@ -1539,56 +1539,57 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
             Doc.SetSelectOnLoad(undefined);
             FormattedTextBox.SelectOnLoadChar = '';
         }
-        if (this._editorView && selectOnLoad && !this._props.dontRegisterView && !this._props.dontSelectOnLoad && this.isActiveTab(this.ProseRef)) {
+        if (this.EditorView && selectOnLoad && !this._props.dontRegisterView && !this._props.dontSelectOnLoad && this.isActiveTab(this.ProseRef)) {
             this._props.select(false);
             if (selLoadChar) {
-                const $from = this._editorView.state.selection.anchor ? this._editorView.state.doc.resolve(this._editorView.state.selection.anchor - 1) : undefined;
+                const $from = this.EditorView.state.selection.anchor ? this.EditorView.state.doc.resolve(this.EditorView.state.selection.anchor - 1) : undefined;
                 const mark = schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) });
-                const curMarks = this._editorView.state.storedMarks ?? $from?.marksAcross(this._editorView.state.selection.$head) ?? [];
+                const curMarks = this.EditorView.state.storedMarks ?? $from?.marksAcross(this.EditorView.state.selection.$head) ?? [];
                 const storedMarks = [...curMarks.filter(m => m.type !== mark.type), mark];
-                const tr1 = this._editorView.state.tr.setStoredMarks(storedMarks);
-                const tr2 = selLoadChar === 'Enter' ? tr1.insert(this._editorView.state.doc.content.size - 1, schema.nodes.paragraph.create()) : tr1.insertText(selLoadChar, this._editorView.state.doc.content.size - 1);
+                const tr1 = this.EditorView.state.tr.setStoredMarks(storedMarks);
+                const tr2 = selLoadChar === 'Enter' ? tr1.insert(this.EditorView.state.doc.content.size - 1, schema.nodes.paragraph.create()) : tr1.insertText(selLoadChar, this.EditorView.state.doc.content.size - 1);
                 const tr = tr2.setStoredMarks(storedMarks);
 
-                this._editorView.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(tr.doc.content.size))));
+                this.EditorView.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(tr.doc.content.size))));
                 this.tryUpdateDoc(true); // calling select() above will make isContentActive() true only after a render .. which means the selectAll() above won't write to the Document and the incomingValue will overwrite the selection with the non-updated data
             } else if (!FormattedTextBox.DontSelectInitialText) {
                 const mark = schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) });
-                selectAll(this._editorView.state, (tx: Transaction) => {
-                    this._editorView?.dispatch(tx.addStoredMark(mark));
+                selectAll(this.EditorView.state, (tx: Transaction) => {
+                    this.EditorView?.dispatch(tx.addStoredMark(mark));
                 });
+                this.EditorView?.dispatch(this.EditorView.state.tr.setSelection(new TextSelection(this.EditorView.state.doc.resolve(1))));
                 this.tryUpdateDoc(true); // calling select() above will make isContentActive() true only after a render .. which means the selectAll() above won't write to the Document and the incomingValue will overwrite the selection with the non-updated data
             } else {
-                const $from = this._editorView.state.selection.anchor ? this._editorView.state.doc.resolve(this._editorView.state.selection.anchor - 1) : undefined;
+                const $from = this.EditorView.state.selection.anchor ? this.EditorView.state.doc.resolve(this.EditorView.state.selection.anchor - 1) : undefined;
                 const mark = schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) });
-                const curMarks = this._editorView.state.storedMarks ?? $from?.marksAcross(this._editorView.state.selection.$head) ?? [];
+                const curMarks = this.EditorView.state.storedMarks ?? $from?.marksAcross(this.EditorView.state.selection.$head) ?? [];
                 const storedMarks = [...curMarks.filter(m => m.type !== mark.type), mark];
-                const { tr } = this._editorView.state;
-                this._editorView.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(tr.doc.content.size))).setStoredMarks(storedMarks));
+                const { tr } = this.EditorView.state;
+                this.EditorView.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(tr.doc.content.size))).setStoredMarks(storedMarks));
                 this.tryUpdateDoc(true); // calling select() above will make isContentActive() true only after a render .. which means the selectAll() above won't write to the Document and the incomingValue will overwrite the selection with the non-updated data
             }
         }
         if (selectOnLoad) {
             FormattedTextBox.DontSelectInitialText = false;
-            this._editorView!.focus();
+            this.EditorView!.focus();
         }
         if (this._props.isContentActive()) this.prepareForTyping();
-        if (this._editorView && FormattedTextBox.PasteOnLoad) {
+        if (this.EditorView && FormattedTextBox.PasteOnLoad) {
             const pdfAnchorId = FormattedTextBox.PasteOnLoad.clipboardData?.getData('dash/pdfAnchor');
             FormattedTextBox.PasteOnLoad = undefined;
             pdfAnchorId && this.addPdfReference(pdfAnchorId);
         }
-        if (this._props.autoFocus) setTimeout(() => this._editorView!.focus()); // not sure why setTimeout is needed but editing dashFieldView's doesn't work without it.
+        if (this._props.autoFocus) setTimeout(() => this.EditorView!.focus()); // not sure why setTimeout is needed but editing dashFieldView's doesn't work without it.
     }
 
     // add user mark for any first character that was typed since the user mark that gets set in KeyPress won't have been called yet.
     prepareForTyping = () => {
-        if (this._editorView) {
+        if (this.EditorView) {
             const { text, paragraph } = schema.nodes;
-            const selNode = this._editorView.state.selection.$anchor.node();
-            if (this._editorView.state.selection.from === 1 && this._editorView.state.selection.empty && [undefined, text, paragraph].includes(selNode?.type)) {
+            const selNode = this.EditorView.state.selection.$anchor.node();
+            if (this.EditorView.state.selection.from === 1 && this.EditorView.state.selection.empty && [undefined, text, paragraph].includes(selNode?.type)) {
                 const docDefaultMarks = [schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) })];
-                this._editorView.state.selection.empty && this._editorView.state.selection.from === 1 && this._editorView?.dispatch(this._editorView?.state.tr.setStoredMarks(docDefaultMarks).removeStoredMark(schema.marks.pFontColor));
+                this.EditorView.state.selection.empty && this.EditorView.state.selection.from === 1 && this.EditorView?.dispatch(this.EditorView?.state.tr.setStoredMarks(docDefaultMarks).removeStoredMark(schema.marks.pFontColor));
             }
         }
     };
@@ -1602,7 +1603,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         FormattedTextBox.LiveTextUndo?.end();
         FormattedTextBox.LiveTextUndo = undefined;
         this.unhighlightSearchTerms();
-        this._editorView?.destroy();
+        this.EditorView?.destroy();
         RichTextMenu.Instance?.TextView === this && RichTextMenu.Instance.updateMenu(undefined, undefined, undefined, undefined);
         FormattedTextBoxComment.tooltip && (FormattedTextBoxComment.tooltip.style.display = 'none');
     }
@@ -1681,26 +1682,26 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         }
     };
     setFocus = (ipos?: number) => {
-        const pos = ipos ?? (this._editorView?.state.selection.$from.pos || 1);
-        setTimeout(() => this._editorView?.dispatch(this._editorView.state.tr.setSelection(TextSelection.near(this._editorView.state.doc.resolve(pos)))), 100);
+        const pos = ipos ?? (this.EditorView?.state.selection.$from.pos || 1);
+        setTimeout(() => this.EditorView?.dispatch(this.EditorView.state.tr.setSelection(TextSelection.near(this.EditorView.state.doc.resolve(pos)))), 100);
         setTimeout(() => (this.ProseRef?.children?.[0] as HTMLElement).focus(), 200);
     };
     @action
     onFocused = (e: React.FocusEvent): void => {
-        // applyDevTools.applyDevTools(this._editorView);
+        // applyDevTools.applyDevTools(this.EditorView);
         e.stopPropagation();
     };
 
     onClick = (e: React.MouseEvent): void => {
         if (!this._props.isContentActive()) return;
-        const editorView = this._editorView;
+        const editorView = this.EditorView;
         const editorRoot = editorView?.root instanceof Document ? editorView.root : undefined;
         if (editorView && (!this._forceUncollapse || editorRoot?.getSelection()?.isCollapsed)) {
             // this is a hack to allow the cursor to be placed at the end of a document when the document ends in an inline dash comment.  Apparently Chrome on Windows has a bug/feature which breaks this when clicking after the end of the text.
             const pcords = editorView.posAtCoords({ left: e.clientX, top: e.clientY });
             const node = pcords && editorView.state.doc.nodeAt(pcords.pos); // get what prosemirror thinks the clicked node is (if it's null, then we didn't click on any text)
             if (pcords && node?.type === editorView.state.schema.nodes.dashComment) {
-                this._editorView!.dispatch(editorView.state.tr.setSelection(TextSelection.create(editorView.state.doc, pcords.pos + 2)));
+                this.EditorView!.dispatch(editorView.state.tr.setSelection(TextSelection.create(editorView.state.doc, pcords.pos + 2)));
                 e.preventDefault();
             }
             if (!node && this.ProseRef) {
@@ -1726,33 +1727,33 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     hitBulletTargets(x: number, y: number, collapse: boolean, highlightOnly: boolean, selectOrderedList: boolean = false) {
         this._forceUncollapse = false;
         clearStyleSheetRules(FormattedTextBox._bulletStyleSheet);
-        const clickPos = this._editorView!.posAtCoords({ left: x, top: y });
+        const clickPos = this.EditorView!.posAtCoords({ left: x, top: y });
         const clickPosVal = clickPos?.pos || 1;
         let olistPos = clickPosVal;
         if (clickPos && olistPos && this._props.rootSelected?.()) {
-            const clickNode = this._editorView?.state.doc.resolve(olistPos).node();
-            const nodeBef = this._editorView?.state.doc.resolve(Math.max(0, olistPos - 1)).node();
-            olistPos = nodeBef?.type === this._editorView?.state.schema.nodes.ordered_list ? olistPos - 1 : olistPos;
-            let $olistPos = this._editorView?.state.doc.resolve(olistPos);
-            let olistNode = (nodeBef !== null || clickNode?.type === this._editorView?.state.schema.nodes.list_item) && olistPos === clickPos?.pos ? clickNode : nodeBef;
-            if (olistNode?.type === this._editorView?.state.schema.nodes.list_item) {
+            const clickNode = this.EditorView?.state.doc.resolve(olistPos).node();
+            const nodeBef = this.EditorView?.state.doc.resolve(Math.max(0, olistPos - 1)).node();
+            olistPos = nodeBef?.type === this.EditorView?.state.schema.nodes.ordered_list ? olistPos - 1 : olistPos;
+            let $olistPos = this.EditorView?.state.doc.resolve(olistPos);
+            let olistNode = (nodeBef !== null || clickNode?.type === this.EditorView?.state.schema.nodes.list_item) && olistPos === clickPos?.pos ? clickNode : nodeBef;
+            if (olistNode?.type === this.EditorView?.state.schema.nodes.list_item) {
                 if ($olistPos && $olistPos.depth) {
                     olistNode = $olistPos.parent;
-                    $olistPos = this._editorView?.state.doc.resolve($olistPos.start($olistPos.depth - 1));
+                    $olistPos = this.EditorView?.state.doc.resolve($olistPos.start($olistPos.depth - 1));
                 }
             }
-            const maxSize = this._editorView?.state.doc.content.size ?? 0;
-            const listPos = this._editorView?.state.doc.resolve(Math.min(maxSize, clickPosVal === olistPos ? clickPosVal + 1 : clickPosVal));
+            const maxSize = this.EditorView?.state.doc.content.size ?? 0;
+            const listPos = this.EditorView?.state.doc.resolve(Math.min(maxSize, clickPosVal === olistPos ? clickPosVal + 1 : clickPosVal));
             const listNode = listPos?.node();
-            if (olistNode && olistNode.type === this._editorView?.state.schema.nodes.ordered_list && listNode) {
+            if (olistNode && olistNode.type === this.EditorView?.state.schema.nodes.ordered_list && listNode) {
                 if (!highlightOnly) {
                     if (selectOrderedList) {
-                        this._editorView.dispatch(this._editorView.state.tr.setSelection(new NodeSelection(selectOrderedList ? $olistPos! : listPos!)));
+                        this.EditorView.dispatch(this.EditorView.state.tr.setSelection(new NodeSelection(selectOrderedList ? $olistPos! : listPos!)));
                     } else {
                         const nodePos = clickPosVal - (olistPos === clickPosVal ? 0 : 1);
-                        if (this._editorView.state.doc.nodeAt(nodePos)) {
-                            const tr = this._editorView.state.tr.setNodeMarkup(nodePos, listNode.type, { ...listNode.attrs, visibility: !listNode.attrs.visibility });
-                            this._editorView.dispatch(tr.setSelection(TextSelection.create(tr.doc, nodePos)));
+                        if (this.EditorView.state.doc.nodeAt(nodePos)) {
+                            const tr = this.EditorView.state.tr.setNodeMarkup(nodePos, listNode.type, { ...listNode.attrs, visibility: !listNode.attrs.visibility });
+                            this.EditorView.dispatch(tr.setSelection(TextSelection.create(tr.doc, nodePos)));
                         }
                     }
                 }
@@ -1772,19 +1773,19 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     onBlur = (e: React.FocusEvent) => {
         if (this.ProseRef?.children[0] !== e.nativeEvent.target) return;
         if (!(this.EditorView?.state.selection instanceof NodeSelection) || this.EditorView.state.selection.node.type !== this.EditorView.state.schema.nodes.footnote) {
-            const stordMarks = this._editorView?.state.storedMarks?.slice();
+            const stordMarks = this.EditorView?.state.storedMarks?.slice();
             if (!(this.EditorView?.state.selection instanceof NodeSelection)) {
                 this.autoLink();
-                if (this._editorView?.state.tr) {
+                if (this.EditorView?.state.tr) {
                     const tr = stordMarks?.reduce((tr2, m) => {
                         tr2.addStoredMark(m);
                         return tr2;
-                    }, this._editorView.state.tr);
-                    tr && this._editorView.dispatch(tr);
+                    }, this.EditorView.state.tr);
+                    tr && this.EditorView.dispatch(tr);
                 }
             }
         }
-        if (RichTextMenu.Instance?.view === this._editorView && !(this._props.isContentActive() || this._props.rootSelected?.())) {
+        if (RichTextMenu.Instance?.view === this.EditorView && !(this._props.isContentActive() || this._props.rootSelected?.())) {
             RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined);
         }
 
@@ -1831,7 +1832,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         }
         switch (e.key) {
             case 'Escape':
-                this._editorView!.dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from)));
+                this.EditorView!.dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from)));
                 (document.activeElement as HTMLElement).blur?.();
                 DocumentView.DeselectAll();
                 RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined);
@@ -1857,7 +1858,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         this.startUndoTypingBatch();
     };
     ondrop = (e: React.DragEvent) => {
-        this._editorView!.dispatch(updateBullets(this._editorView!.state.tr, this._editorView!.state.schema));
+        this.EditorView?.dispatch(updateBullets(this.EditorView.state.tr, this.EditorView.state.schema));
         e.stopPropagation(); // drag n drop of text within text note will generate a new note if not caughst, as will dragging in from outside of Dash.
     };
     onScroll = (e: React.UIEvent) => {
@@ -1872,7 +1873,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
     tryUpdateScrollHeight = () => {
         const margins = 2 * NumCast(this.layoutDoc._yMargin, this._props.yPadding || 0);
         const children = this.ProseRef?.children.length ? Array.from(this.ProseRef.children[0].children) : undefined;
-        if (children && !SnappingManager.IsDragging) {
+        if (this.EditorView && children && !SnappingManager.IsDragging) {
             const getChildrenHeights = (kids: Element[] | undefined) => kids?.reduce((p, child) => p + toHgt(child), margins) ?? 0;
             const toNum = (val: string) => Number(val.replace('px', ''));
             const toHgt = (node: Element): number => {
@@ -2093,7 +2094,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         const paddingX = Math.max(NumCast(this.layoutDoc._xMargin), this._props.xPadding ?? 0, 0, ((this._props.screenXPadding?.() ?? 0) - scrMargin[0]) * this.ScreenToLocalBoxXf().Scale);
         const paddingY = Math.max(NumCast(this.layoutDoc._yMargin), 0, ((this._props.yPadding ?? 0) - scrMargin[1]) * this.ScreenToLocalBoxXf().Scale);
         const styleFromLayout = styleFromLayoutString(this.Document, this._props, scale); // this converts any expressions in the format string to style props.  e.g., <FormattedTextBox height='{this._header_height}px' >
-        return styleFromLayout?.height === '0px' ? null : (
+        return this.isLabel ? (
+            <LabelBox {...this._props} />
+        ) : styleFromLayout?.height === '0px' ? null : (
             <div
                 className="formattedTextBox"
                 ref={r => {
@@ -2156,8 +2159,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
                                 paddingTop: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY}px`),
                                 paddingBottom: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY}px`),
                                 color: StrCast(this.layoutDoc.text_fontColor),
-                                fontWeight: `${this.layoutDoc.contentBold ? 'bold' : ''}`,
-                                textTransform: `${this.layoutDoc.textTransform}` as Property.TextTransform,
+                                fontWeight: this.layoutDoc.contentBold ? 'bold' : '',
+                                textTransform: StrCast(this.dataDoc[this.fieldKey + '_transform']) as Property.TextTransform,
                             }}
                         />
                     </div>
diff --git a/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts b/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts
index 7a8b72be0..3c84e5a10 100644
--- a/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts
+++ b/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts
@@ -349,7 +349,9 @@ export function buildKeymap<S extends Schema<any>>(schema: S, props: any): KeyMa
                                 dispatch(tx4);
                             }
 
-                            if (view.state.selection.$anchor.nodeAfter?.type === schema.nodes.text && once) {
+                            if (view.state.selection.$anchor.depth > 0 &&
+                                view.state.selection.$anchor.node(view.state.selection.$anchor.depth-1).type === schema.nodes.list_item &&
+                                view.state.selection.$anchor.nodeAfter?.type === schema.nodes.text && once) {
                                 // if text is selected across list items, then we need to forcibly insert a new line since the splitBlock code joins the two list items.
                                 enter(view.state, dispatch, view, false);
                             }
diff --git a/src/client/views/nodes/formattedText/RichTextMenu.tsx b/src/client/views/nodes/formattedText/RichTextMenu.tsx
index 55e6a3a5b..65a415a23 100644
--- a/src/client/views/nodes/formattedText/RichTextMenu.tsx
+++ b/src/client/views/nodes/formattedText/RichTextMenu.tsx
@@ -1,6 +1,6 @@
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { Tooltip } from '@mui/material';
-import { action, computed, IReactionDisposer, makeObservable, observable, runInAction } from 'mobx';
+import { action, computed, makeObservable, observable, runInAction } from 'mobx';
 import { observer } from 'mobx-react';
 import { lift, toggleMark, wrapIn } from 'prosemirror-commands';
 import { Mark, MarkType } from 'prosemirror-model';
@@ -32,7 +32,7 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
     public overMenu: boolean = false; // kind of hacky way to prevent selects not being selectable
     private _linkToRef = React.createRef<HTMLInputElement>();
 
-    layoutDoc: Doc | undefined;
+    dataDoc: Doc | undefined;
     @observable public view?: EditorView & { TextView?: FormattedTextBox } = undefined;
     public editorProps: FieldViewProps | AntimodeMenuProps | undefined;
 
@@ -49,6 +49,7 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
 
     @observable private _activeFontSize: string = '13px';
     @observable private _activeFontFamily: string = '';
+    @observable private _activeFitBox: boolean = false;
     @observable private _activeListType: string = '';
     @observable private _activeAlignment: string = 'left';
 
@@ -64,13 +65,12 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
     @observable private currentLink: string | undefined = '';
     @observable private showLinkDropdown: boolean = false;
 
-    _reaction: IReactionDisposer | undefined;
     constructor(props: AntimodeMenuProps) {
         super(props);
         makeObservable(this);
         runInAction(() => {
             RichTextMenu._instance.menu = this;
-            this.updateMenu(undefined, undefined, props, this.layoutDoc);
+            this.updateMenu(undefined, undefined, props, this.dataDoc);
             this._canFade = false;
             this.Pinned = true;
         });
@@ -101,6 +101,9 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
     @computed get fontHighlight() {
         return this._activeHighlightColor;
     }
+    @computed get fitBox() {
+        return this._activeFitBox;
+    }
     @computed get fontFamily() {
         return this._activeFontFamily;
     }
@@ -114,26 +117,16 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
         return this._activeAlignment;
     }
     @computed get textVcenter() {
-        return BoolCast(this.layoutDoc?._layout_centered);
-    }
-    _disposer: IReactionDisposer | undefined;
-    componentDidMount() {
-        // this._disposer = reaction(
-        //     () => DocumentView.Selected().slice(),
-        //     () => this.updateMenu(undefined, undefined, undefined, undefined)
-        // );
-    }
-    componentWillUnmount() {
-        this._disposer?.();
+        return BoolCast(this.dataDoc?._layout_centered, BoolCast(Doc.UserDoc().layout_centered));
     }
 
     @action
-    public updateMenu(view: EditorView | undefined, lastState: EditorState | undefined, props: FormattedTextBoxProps | AntimodeMenuProps | undefined, layoutDoc: Doc | undefined) {
+    public updateMenu(view: EditorView | undefined, lastState: EditorState | undefined, props: FormattedTextBoxProps | AntimodeMenuProps | undefined, dataDoc: Doc | undefined) {
         if (this._linkToRef.current?.getBoundingClientRect().width) {
             return;
         }
         this.view = view;
-        this.layoutDoc = layoutDoc;
+        this.dataDoc = dataDoc;
         props && (this.editorProps = props);
 
         // Don't do anything if the document/selection didn't change
@@ -147,12 +140,13 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
         const { activeSizes } = active;
         const { activeColors } = active;
         const { activeHighlights } = active;
-        const refDoc = DocumentView.Selected().lastElement()?.layoutDoc ?? Doc.UserDoc();
+        const refDoc = DocumentView.Selected().lastElement()?.dataDoc ?? Doc.UserDoc();
         const refField = (pfx => (pfx ? pfx + '_' : ''))(DocumentView.Selected().lastElement()?.LayoutFieldKey);
         const refVal = (field: string, dflt: string) => StrCast(refDoc[refField + field], StrCast(Doc.UserDoc()[field], dflt));
 
         this._activeListType = this.getActiveListStyle();
         this._activeAlignment = this.getActiveAlignment();
+        this._activeFitBox = BoolCast(refDoc[refField + 'fitBox'], BoolCast(Doc.UserDoc().fitBox));
         this._activeFontFamily = !activeFamilies.length ? StrCast(this.TextView?.Document._text_fontFamily, refVal('fontFamily', 'Arial')) : activeFamilies.length === 1 ? String(activeFamilies[0]) : 'various';
         this._activeFontSize = !activeSizes.length ? StrCast(this.TextView?.Document.fontSize, refVal('fontSize', '10px')) : activeSizes[0];
         this._activeFontColor = !activeColors.length ? StrCast(this.TextView?.Document.fontColor, refVal('fontColor', 'black')) : activeColors.length > 0 ? String(activeColors[0]) : '...';
@@ -181,7 +175,6 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
                     toggleMark(mark.type, mark.attrs)(state, dispatch);
                 }
             }
-            //    this.updateMenu(this.view, undefined, undefined, this.layoutDoc);
         }
     };
 
@@ -195,8 +188,10 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
                     return node.attrs.align || 'left';
                 }
             }
+        } else if (this.dataDoc) {
+            return StrCast(this.dataDoc.text_align) || 'left';
         }
-        return 'left';
+        return StrCast(Doc.UserDoc().textAlign) || 'left';
     };
 
     // finds font sizes and families in selection
@@ -330,6 +325,17 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
             this.view.focus();
         }
     };
+    toggleFitBox = () => {
+        if (this.dataDoc) {
+            const doc = this.dataDoc;
+            (document.activeElement as HTMLElement)?.blur();
+            doc.text_fitBox = !doc.text_fitBox;
+        } else {
+            Doc.UserDoc().fitBox = !Doc.UserDoc().fitBox;
+            Doc.UserDoc().textAlign = Doc.UserDoc().fitBox ? 'center' : undefined;
+        }
+        this.updateMenu(undefined, undefined, undefined, this.dataDoc);
+    };
     toggleBold = () => {
         if (this.view) {
             const mark = this.view.state.schema.mark(this.view.state.schema.marks.strong);
@@ -354,11 +360,14 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
         }
     };
 
-    setFontField = (value: string, fontField: 'fontSize' | 'fontFamily' | 'fontColor' | 'fontHighlight') => {
-        if (this.TextView && this.view) {
+    setFontField = (value: string, fontField: 'fitBox' | 'fontSize' | 'fontFamily' | 'fontColor' | 'fontHighlight') => {
+        if (this.dataDoc) {
+            this.dataDoc[`text_${fontField}`] = value;
+            this.updateMenu(undefined, undefined, undefined, this.dataDoc);
+        } else if (this.TextView && this.view) {
             const { text, paragraph } = this.view.state.schema.nodes;
             const selNode = this.view.state.selection.$anchor.node();
-            if (this.view.state.selection.from === 1 && this.view.state.selection.empty && [undefined, text, paragraph].includes(selNode?.type)) {
+            if ((fontField === 'fontSize' && value === '0px') || (this.view.state.selection.from === 1 && this.view.state.selection.empty && [undefined, text, paragraph].includes(selNode?.type))) {
                 this.TextView.dataDoc[this.TextView.fieldKey + `_${fontField}`] = value;
                 this.view.focus();
             }
@@ -369,7 +378,6 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
             this.view.focus();
         } else {
             Doc.UserDoc()[fontField] = value;
-            // this.updateMenu(this.view, undefined, this.props, this.layoutDoc);
         }
     };
 
@@ -395,7 +403,6 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
                 this.view!.dispatch(tx3);
             });
         this.view.focus();
-        // this.updateMenu(this.view, undefined, this.props, this.layoutDoc);
     };
 
     insertSummarizer(state: EditorState, dispatch: (tr: Transaction) => void) {
@@ -410,10 +417,11 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
     }
 
     vcenterToggle = () => {
-        this.layoutDoc && (this.layoutDoc._layout_centered = !this.layoutDoc._layout_centered);
+        if (this.dataDoc) this.dataDoc._layout_centered = !this.dataDoc._layout_centered;
+        else Doc.UserDoc()._layout_centered = !Doc.UserDoc()._layout_centered;
     };
-    align = (view: EditorView, dispatch: (tr: Transaction) => void, alignment: 'left' | 'right' | 'center') => {
-        if (this.RootSelected) {
+    align = (view: EditorView | undefined, dispatch: undefined | ((tr: Transaction) => void), alignment: 'left' | 'right' | 'center') => {
+        if (view && dispatch && this.RootSelected) {
             let { tr } = view.state;
             view.state.doc.nodesBetween(view.state.selection.from, view.state.selection.to, (node, pos) => {
                 if ([schema.nodes.paragraph, schema.nodes.heading].includes(node.type)) {
@@ -425,6 +433,11 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> {
             });
             view.focus();
             dispatch?.(tr);
+        } else {
+            if (this.dataDoc) {
+                this.dataDoc.text_align = alignment;
+            } else Doc.UserDoc().textAlign = alignment;
+            this.updateMenu(undefined, undefined, undefined, this.dataDoc);
         }
     };
 
@@ -702,7 +715,7 @@ interface RichTextMenuPluginProps {
 }
 export class RichTextMenuPlugin extends React.Component<RichTextMenuPluginProps> {
     update(view: EditorView & { TextView?: FormattedTextBox }, lastState: EditorState | undefined) {
-        RichTextMenu.Instance?.updateMenu(view, lastState, this.props.editorProps, view.TextView?.layoutDoc);
+        RichTextMenu.Instance?.updateMenu(view, lastState, this.props.editorProps, view.TextView?.dataDoc);
     }
     render() {
         return null;
diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts
index f58434906..3d14ce4c0 100644
--- a/src/client/views/nodes/formattedText/RichTextRules.ts
+++ b/src/client/views/nodes/formattedText/RichTextRules.ts
@@ -121,7 +121,7 @@ export class RichTextRules {
                     annotationOn: textDoc,
                     _layout_fitWidth: true,
                     _layout_autoHeight: true,
-                    _text_fontSize: '9px',
+                    text_fontSize: '9px',
                     title: 'inline comment',
                 });
                 textDocInline.title = inlineFieldKey; // give the annotation its own title
diff --git a/src/client/views/nodes/trails/PresBox.tsx b/src/client/views/nodes/trails/PresBox.tsx
index 5d3672aee..06869717a 100644
--- a/src/client/views/nodes/trails/PresBox.tsx
+++ b/src/client/views/nodes/trails/PresBox.tsx
@@ -2616,13 +2616,13 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() {
     createTemplate = (layout: string, input?: string) => {
         const x = this.activeItem && this.targetDoc ? NumCast(this.targetDoc.x) : 0;
         const y = this.activeItem && this.targetDoc ? NumCast(this.targetDoc.y) + NumCast(this.targetDoc._height) + 20 : 0;
-        const title = () => Docs.Create.TextDocument('Click to change title', { title: 'Slide title', _width: 380, _height: 60, x: 10, y: 58, _text_fontSize: '24pt' });
-        const subtitle = () => Docs.Create.TextDocument('Click to change subtitle', { title: 'Slide subtitle', _width: 380, _height: 50, x: 10, y: 118, _text_fontSize: '16pt' });
-        const header = () => Docs.Create.TextDocument('Click to change header', { title: 'Slide header', _width: 380, _height: 65, x: 10, y: 80, _text_fontSize: '20pt' });
-        const contentTitle = () => Docs.Create.TextDocument('Click to change title', { title: 'Slide title', _width: 380, _height: 60, x: 10, y: 10, _text_fontSize: '24pt' });
-        const content = () => Docs.Create.TextDocument('Click to change text', { title: 'Slide text', _width: 380, _height: 145, x: 10, y: 70, _text_fontSize: '14pt' });
-        const content1 = () => Docs.Create.TextDocument('Click to change text', { title: 'Column 1', _width: 185, _height: 140, x: 10, y: 80, _text_fontSize: '14pt' });
-        const content2 = () => Docs.Create.TextDocument('Click to change text', { title: 'Column 2', _width: 185, _height: 140, x: 205, y: 80, _text_fontSize: '14pt' });
+        const title = () => Docs.Create.TextDocument('Click to change title', { title: 'Slide title', _width: 380, _height: 60, x: 10, y: 58, text_fontSize: '24pt' });
+        const subtitle = () => Docs.Create.TextDocument('Click to change subtitle', { title: 'Slide subtitle', _width: 380, _height: 50, x: 10, y: 118, text_fontSize: '16pt' });
+        const header = () => Docs.Create.TextDocument('Click to change header', { title: 'Slide header', _width: 380, _height: 65, x: 10, y: 80, text_fontSize: '20pt' });
+        const contentTitle = () => Docs.Create.TextDocument('Click to change title', { title: 'Slide title', _width: 380, _height: 60, x: 10, y: 10, text_fontSize: '24pt' });
+        const content = () => Docs.Create.TextDocument('Click to change text', { title: 'Slide text', _width: 380, _height: 145, x: 10, y: 70, text_fontSize: '14pt' });
+        const content1 = () => Docs.Create.TextDocument('Click to change text', { title: 'Column 1', _width: 185, _height: 140, x: 10, y: 80, text_fontSize: '14pt' });
+        const content2 = () => Docs.Create.TextDocument('Click to change text', { title: 'Column 2', _width: 185, _height: 140, x: 205, y: 80, text_fontSize: '14pt' });
         // prettier-ignore
         switch (layout) {
             case 'blank': return Docs.Create.FreeformDocument([], { title: input || 'Blank slide', _width: 400, _height: 225, x, y });
diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.tsx b/src/client/views/pdf/GPTPopup/GPTPopup.tsx
index d5f5f620c..a7e78bcea 100644
--- a/src/client/views/pdf/GPTPopup/GPTPopup.tsx
+++ b/src/client/views/pdf/GPTPopup/GPTPopup.tsx
@@ -572,18 +572,19 @@ export class GPTPopup extends ObservableReactComponent<GPTPopupProps> {
         <div style={{ height: '80%' }}>
             {this.heading(this.mode === GPTPopupMode.SORT ? 'SORTING' : 'QUIZ')}
             <>
-                {!this.cardsDoneLoading ? (
-                    <div className="content-wrapper">
-                        <div className="loading-spinner">
-                            <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} />
-                            {this.loading ? <span>Loading...</span> : <span>Reading Cards...</span>}
+                {
+                    !this.cardsDoneLoading ? (
+                        <div className="content-wrapper">
+                            <div className="loading-spinner">
+                                <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} />
+                                {this.loading ? <span>Loading...</span> : <span>Reading Cards...</span>}
+                            </div>
                         </div>
-                    </div>
-                ) : this.mode === GPTPopupMode.CARD ? (
-                    this.cardMenu()
-                ) : (
-                    this.cardActual(this.mode)
-                ) // Call the functions to render JSX
+                    ) : this.mode === GPTPopupMode.CARD ? (
+                        this.cardMenu()
+                    ) : (
+                        this.cardActual(this.mode)
+                    ) // Call the functions to render JSX
                 }
             </>
         </div>
diff --git a/src/fields/documentSchemas.ts b/src/fields/documentSchemas.ts
index 335683270..b27816f55 100644
--- a/src/fields/documentSchemas.ts
+++ b/src/fields/documentSchemas.ts
@@ -48,8 +48,8 @@ export const documentSchema = createSchema({
     _columnsHideIfEmpty: 'boolean', // whether empty stacking view column headings should be hidden
     // _columnHeaders: listSpec(SchemaHeaderField), // header descriptions for stacking/masonry
     // _schemaHeaders: listSpec(SchemaHeaderField), // header descriptions for schema views
-    _text_fontSize: 'string',
-    _text_fontFamily: 'string',
+    text_fontSize: 'string',
+    text_fontFamily: 'string',
     _layout_sidebarWidthPercent: 'string', // percent of text window width taken up by sidebar
 
     // appearance properties on the data document
@@ -70,7 +70,7 @@ export const documentSchema = createSchema({
     stroke_startMarker: 'string',
     stroke_endMarker: 'string',
     stroke_dash: 'string',
-    textTransform: 'string',
+    text_transform: 'string',
     treeView_Open: 'boolean', //  flag denoting whether the documents sub-tree (contents) is visible or hidden
     treeView_ExpandedView: 'string', // name of field whose contents are being displayed as the document's subtree
     treeView_ExpandedViewLock: 'boolean', // whether the expanded view can be changed
@@ -112,5 +112,4 @@ export const collectionSchema = createSchema({
 });
 
 export type Document = makeInterface<[typeof documentSchema]>;
-// eslint-disable-next-line no-redeclare
 export const Document = makeInterface(documentSchema);
-- 
cgit v1.2.3-70-g09d2


From f6581aebd603dcdd84e033acffe46352ce5a4485 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Wed, 19 Feb 2025 15:05:47 -0500
Subject: more gptpopup cleanup.

---
 src/client/apis/gpt/GPT.ts                         | 59 +++++++-------
 src/client/util/CurrentUserUtils.ts                |  6 +-
 src/client/util/Import & Export/ImageUtils.ts      | 12 +--
 src/client/views/StyleProviderQuiz.tsx             |  2 +-
 src/client/views/collections/CollectionSubView.tsx | 33 ++++----
 src/client/views/global/globalScripts.ts           | 14 ++--
 src/client/views/nodes/ComparisonBox.tsx           |  6 +-
 .../views/nodes/chatbot/agentsystem/Agent.ts       |  3 +-
 .../nodes/chatbot/chatboxcomponents/ChatBox.tsx    |  3 +-
 .../views/nodes/chatbot/tools/ImageCreationTool.ts | 36 ++++----
 src/client/views/pdf/GPTPopup/GPTPopup.tsx         | 95 ++++++++++------------
 src/fields/Doc.ts                                  |  2 +-
 12 files changed, 126 insertions(+), 145 deletions(-)

(limited to 'src/client/views/StyleProviderQuiz.tsx')

diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts
index e1ae99d97..6d9bc1d06 100644
--- a/src/client/apis/gpt/GPT.ts
+++ b/src/client/apis/gpt/GPT.ts
@@ -1,12 +1,11 @@
 import { ChatCompletionMessageParam, Image } from 'openai/resources';
 import { openai } from './setup';
 
-export enum GPTTypeStyle {
+export enum GPTDocCommand {
     AssignTags = 1,
     Filter = 2,
-    DocInfo = 3,
-    GeneralInfo = 4,
-    SortDocs = 5,
+    GetInfo = 3,
+    Sort = 4,
 }
 
 export const DescriptionSeperator = '======';
@@ -18,8 +17,6 @@ enum GPTCallType {
     EDIT = 'edit',
     CHATCARD = 'chatcard', // a single flashcard style response to a question
     FLASHCARD = 'flashcard', // a set of flashcard qustion/answer responses to a topic
-    QUIZ = 'quiz',
-    SORT = 'sort',
     DESCRIBE = 'describe',
     MERMAID = 'mermaid',
     DATA = 'data',
@@ -27,15 +24,17 @@ enum GPTCallType {
     PRONUNCIATION = 'pronunciation',
     DRAW = 'draw',
     COLOR = 'color',
-    RUBRIC = 'rubric', // needs to be filled in below
-    TYPE = 'type', // needs to be filled in below
-    SUBSET = 'subset', // needs to be filled in below
-    INFO = 'info', // needs to be filled in below
     TEMPLATE = 'template',
     VIZSUM = 'vizsum',
     VIZSUM2 = 'vizsum2',
     FILL = 'fill',
     COMPLETEPROMPT = 'completeprompt',
+    QUIZDOC = 'quiz_doc',
+    MAKERUBRIC = 'make_rubric', // create a definition rubric for a document to be used when quizzing the user
+    COMMANDTYPE = 'command_type', // Determine the type of command being made (GPTQueryType - eg., AssignTags, Sort, Filter, DocInfo, GenInfo) and possibly some parameters (eg, Tag type for Tags)
+    SUBSETDOCS = 'subset_docs', // select a subset of documents based on their descriptions
+    DOCINFO = 'doc_info', // provide information about a document
+    SORTDOCS = 'sort_docs',
 }
 
 type GPTCallOpts = {
@@ -69,7 +68,7 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = {
         temp: 0.5,
         prompt: "You are a helpful resarch assistant. Analyze the user's data to find meaningful patterns and/or correlation. Please only return a JSON with a correlation column 1 propert, a correlation column 2 property, and an analysis property. ",
     },
-    sort: {
+    sort_docs: {
         model: 'gpt-4o',
         maxTokens: 2048,
         temp: 0.25,
@@ -89,7 +88,7 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = {
         prompt: 'Make flashcards out of this text with each question and answer labeled as question and answer. Create a title for each question and asnwer that is labeled as "title".  Do not label each flashcard and do not include asterisks: ',
     },
     chatcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Answer the following question as a short flashcard response. Do not include a label.' },
-    quiz: {
+    quiz_doc: {
         model: 'gpt-4-turbo',
         maxTokens: 1024,
         temp: 0,
@@ -138,20 +137,20 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = {
         temp: 0.5,
         prompt: 'You will be coloring drawings. You will be given what the drawing is, then a list of descriptions for parts of the drawing. Based on each description, respond with the stroke and fill color that it should be. Follow the rules: 1. Avoid using black for stroke color 2. Make the stroke color 1-3 shades darker than the fill color 3. Use the same colors when possible. Format as {#abcdef #abcdef}, making sure theres a color for each description, and do not include any additional text.',
     },
-    type: {
+    command_type: {
         model: 'gpt-4-turbo',
         maxTokens: 1024,
         temp: 0,
         prompt: `I'm going to provide you with a question. 
                  Based on the question, is the user asking you to 
-                     ${GPTTypeStyle.AssignTags}. Assigns docs with tags(like star / heart etc)/labels, 
-                     ${GPTTypeStyle.DocInfo}. Provide information about a specific doc 
-                     ${GPTTypeStyle.Filter}. Filter docs based on a question/information 
-                     ${GPTTypeStyle.GeneralInfo}. Provide general information 
-                     ${GPTTypeStyle.SortDocs}. Put cards in a specific order. 
-                Answer with only the number for 2-5. For number one, provide the number (1) and the appropriate tag`,
+                     ${GPTDocCommand.AssignTags}. Assigns docs with tags(like star / heart etc)/labels.
+                     ${GPTDocCommand.GetInfo}. Provide information about a specific doc.
+                     ${GPTDocCommand.Filter}. Filter docs based on a question/information.
+                     ${GPTDocCommand.Sort}. Put docs in a specific order. 
+                Answer with only the number for ${GPTDocCommand.GetInfo}-${GPTDocCommand.Sort}. 
+                For number one, provide the number (${GPTDocCommand.AssignTags}) and the appropriate tag`,
     },
-    subset: {
+    subset_docs: {
         model: 'gpt-4-turbo',
         maxTokens: 1024,
         temp: 0,
@@ -164,14 +163,14 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = {
                 It is VERY important that you format it exactly as described, ensuring the proper number of '${DescriptionSeperator[0]}' and '${DocSeperator[0]}' (${DescriptionSeperator.length} of each) and NO commas`,
     },
 
-    info: {
+    doc_info: {
         model: 'gpt-4-turbo',
         maxTokens: 1024,
         temp: 0,
         prompt: `Answer the user's question with a short (<100 word) response. 
                  If a particular document is selected I will provide that information (which may help with your response)`,
     },
-    rubric: {
+    make_rubric: {
         model: 'gpt-4-turbo',
         maxTokens: 1024,
         temp: 0,
@@ -188,17 +187,15 @@ let lastResp = '';
  * @returns AI Output
  */
 const gptAPICall = async (inputTextIn: string, callType: GPTCallType, prompt?: string, dontCache?: boolean) => {
-    const inputText = inputTextIn + ([GPTCallType.SUMMARY, GPTCallType.FLASHCARD, GPTCallType.QUIZ, GPTCallType.STACK].includes(callType) ? '.' : '');
+    const inputText = inputTextIn + ([GPTCallType.SUMMARY, GPTCallType.FLASHCARD, GPTCallType.QUIZDOC, GPTCallType.STACK].includes(callType) ? '.' : '');
     const opts = callTypeMap[callType];
     if (!opts) {
         console.log('The query type:' + callType + ' requires a configuration.');
         return 'Error connecting with API.';
     }
-    if (lastCall === inputText && dontCache !== true) return lastResp;
+    if (lastCall === inputText && dontCache !== true && lastResp) return lastResp;
     try {
-        lastCall = inputText;
-
-        const usePrompt = prompt ? prompt + opts.prompt : opts.prompt;
+        const usePrompt = prompt ? prompt + '.' + opts.prompt : opts.prompt;
         const messages: ChatCompletionMessageParam[] = [
             { role: 'system', content: usePrompt },
             { role: 'user', content: inputText },
@@ -210,8 +207,12 @@ const gptAPICall = async (inputTextIn: string, callType: GPTCallType, prompt?: s
             temperature: opts.temp,
             max_tokens: opts.maxTokens,
         });
-        lastResp = response.choices[0].message.content ?? '';
-        return lastResp;
+        const result = response.choices[0].message.content ?? '';
+        if (!dontCache) {
+            lastResp = result;
+            lastCall = inputText;
+        }
+        return result;
     } catch (err) {
         console.log(err);
         return 'Error connecting with API.';
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index 6ca181d92..fb349abd9 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -710,11 +710,7 @@ pie title Minerals in my tap water
             { title: "Type",      icon:"eye",            toolTip:"Sort by document type",                          btnType: ButtonType.ToggleButton, expertMode: false, toolType:"docType",    funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}},
             { title: "Color",     icon:"palette",        toolTip:"Sort by document color",                         btnType: ButtonType.ToggleButton, expertMode: false, toolType:"color",      funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}},
             { title: "Tags",      icon:"bolt",           toolTip:"Sort by document's tags",                        btnType: ButtonType.ToggleButton, expertMode: false, toolType:"tag",        funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}},
-            { title: "Sort",      icon: "sort" ,         toolTip: "Manage sort order / lock status",               btnType: ButtonType.MultiToggleButton,               toolType:"alignment", ignoreClick: true,
-              subMenu: [
-                { title: "Ascending", toolTip: "Sort the cards in ascending order", btnType: ButtonType.ToggleButton, icon: "sort-up",  toolType:"up",  ignoreClick: true,  scripts: {onClick: '{ return showFreeform(this.toolType, _readOnly_);}'} },
-                { title: "Descending",toolTip: "Sort the cards in descending order",btnType: ButtonType.ToggleButton, icon: "sort-down",toolType:"down",ignoreClick: true,  scripts: {onClick: '{ return showFreeform(this.toolType, _readOnly_);}'} },
-            ]},
+            { title: "Reverse",   icon: "sort-up",       toolTip: "Sort the cards in reverse order",               btnType: ButtonType.ToggleButton, expertMode: false, toolType:"reverse",    funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'} },
          ]
     }
 
diff --git a/src/client/util/Import & Export/ImageUtils.ts b/src/client/util/Import & Export/ImageUtils.ts
index 8d4eefa7e..f73149fdc 100644
--- a/src/client/util/Import & Export/ImageUtils.ts	
+++ b/src/client/util/Import & Export/ImageUtils.ts	
@@ -4,22 +4,16 @@ import { DocData } from '../../../fields/DocSymbols';
 import { Id } from '../../../fields/FieldSymbols';
 import { Cast, NumCast, StrCast } from '../../../fields/Types';
 import { ImageField } from '../../../fields/URLField';
+import { Upload } from '../../../server/SharedMediaTypes';
 import { Networking } from '../../Network';
 
 export namespace ImageUtils {
-    export type imgInfo = {
-        contentSize: number;
-        nativeWidth: number;
-        nativeHeight: number;
-        source: string;
-        exifData: { error: string | undefined; data: string };
-    };
-    export const ExtractImgInfo = async (document: Doc): Promise<imgInfo | undefined> => {
+    export const ExtractImgInfo = async (document: Doc): Promise<Upload.InspectionResults | undefined> => {
         const field = Cast(document.data, ImageField);
         return field ? Networking.PostToServer('/inspectImage', { source: field.url.href }) : undefined;
     };
 
-    export const AssignImgInfo = (document: Doc, data?: imgInfo) => {
+    export const AssignImgInfo = (document: Doc, data?: Upload.InspectionResults) => {
         if (data) {
             data.nativeWidth && (document._height = (NumCast(document._width) * data.nativeHeight) / data.nativeWidth);
             const proto = document[DocData];
diff --git a/src/client/views/StyleProviderQuiz.tsx b/src/client/views/StyleProviderQuiz.tsx
index b3fb8c930..d8eeb3490 100644
--- a/src/client/views/StyleProviderQuiz.tsx
+++ b/src/client/views/StyleProviderQuiz.tsx
@@ -265,7 +265,7 @@ export namespace styleProviderQuiz {
                     '. ' +
                     rubricText +
                     '. One sentence and evaluate based on meaning, not wording. Provide a hex color at the beginning with a period after it on a scale of green (minor details missed) to red (big error) for how correct the answer is. Example: "#FFFFFF. Pasta is delicious."';
-                const response = await gptAPICall(queryText, GPTCallType.QUIZ);
+                const response = await gptAPICall(queryText, GPTCallType.QUIZDOC);
                 const hexSent = extractHexAndSentences(response);
                 doc.quiz = hexSent.sentences?.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric');
                 doc.backgroundColor = '#' + hexSent.hexNumber;
diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx
index 954f9aa4c..b40cd2761 100644
--- a/src/client/views/collections/CollectionSubView.tsx
+++ b/src/client/views/collections/CollectionSubView.tsx
@@ -37,6 +37,9 @@ export enum docSortings {
     Tag = 'tag',
     None = '',
 }
+
+export const ChatSortField = 'chat_sortIndex';
+
 export interface CollectionViewProps extends React.PropsWithChildren<FieldViewProps> {
     isAnnotationOverlay?: boolean; // is the collection an annotation overlay (eg an overlay on an image/video/etc)
     isAnnotationOverlayScrollable?: boolean; // whether the annotation overlay can be vertically scrolled (just for tree views, currently)
@@ -229,23 +232,21 @@ export function CollectionSubView<X>() {
 
         childSortedDocs = (docsIn: Doc[], dragIndex: number) => {
             const sortType = StrCast(this.Document[this._props.fieldKey + '_sort']) as docSortings;
-            const isDesc = BoolCast(this.Document[this._props.fieldKey + '_sort_desc']);
+            const isDesc = BoolCast(this.Document[this._props.fieldKey + '_sort_reverse']);
             const docs = docsIn.slice();
-            if (sortType) {
-                docs.sort((docA, docB) => {
-                    const [typeA, typeB] = (() => {
-                        switch (sortType) {
-                            default:                
-                            case docSortings.Type: return [StrCast(docA.type),                                   StrCast(docB.type)];
-                            case docSortings.Chat: return [NumCast(docA.chatIndex, 9999),                        NumCast(docB.chatIndex,9999)];
-                            case docSortings.Time: return [DateCast(docA.author_date)?.date ?? Date.now(),       DateCast(docB.author_date)?.date ?? Date.now()];
-                            case docSortings.Color:return [DashColor(StrCast(docA.backgroundColor)).hsv().hue(), DashColor(StrCast(docB.backgroundColor)).hsv().hue()];
-                            case docSortings.Tag:  return [StrListCast(docA.tags).join(""),                      StrListCast(docB.tags).join("")];
-                        }
-                    })(); //prettier-ignore
-                    return (typeA < typeB ? -1 : typeA > typeB ? 1 : 0) * (isDesc ? 1 : -1);
-                });
-            }
+            sortType && docs.sort((docA, docB) => {
+                const [typeA, typeB] = (() => {
+                    switch (sortType) {
+                        default:                
+                        case docSortings.Type: return [StrCast(docA.type),                                   StrCast(docB.type)];
+                        case docSortings.Chat: return [NumCast(docA[ChatSortField], 9999),                   NumCast(docB[ChatSortField], 9999)];
+                        case docSortings.Time: return [DateCast(docA.author_date)?.date ?? Date.now(),       DateCast(docB.author_date)?.date ?? Date.now()];
+                        case docSortings.Color:return [DashColor(StrCast(docA.backgroundColor)).hsv().hue(), DashColor(StrCast(docB.backgroundColor)).hsv().hue()];
+                        case docSortings.Tag:  return [StrListCast(docA.tags).join(""),                      StrListCast(docB.tags).join("")];
+                    }
+                })(); 
+                return (typeA < typeB ? -1 : typeA > typeB ? 1 : 0) * (isDesc ? -1 : 1);
+            }); //prettier-ignore
             if (dragIndex !== -1) {
                 const draggedDoc = DragManager.docsBeingDragged[0];
                 const originalIndex = docs.findIndex(doc => doc === draggedDoc);
diff --git a/src/client/views/global/globalScripts.ts b/src/client/views/global/globalScripts.ts
index 2bc0e3338..79873ed8f 100644
--- a/src/client/views/global/globalScripts.ts
+++ b/src/client/views/global/globalScripts.ts
@@ -152,7 +152,7 @@ ScriptingGlobals.add(function toggleOverlay(checkResult?: boolean) {
 
 // eslint-disable-next-line prefer-arrow-callback
 ScriptingGlobals.add(function showFreeform(
-    attr: 'flashcards' | 'hcenter' | 'vcenter' | 'grid' | 'snaplines' | 'clusters' | 'viewAll' | 'fitOnce' | 'time' | 'docType' | 'color' | 'chat' | 'up' | 'down' | 'toggle-chat' | 'toggle-tags' | 'tag',
+    attr: 'flashcards' | 'hcenter' | 'vcenter' | 'grid' | 'snaplines' | 'clusters' | 'viewAll' | 'fitOnce' | 'time' | 'docType' | 'color' | 'chat' | 'reverse' | 'toggle-chat' | 'toggle-tags' | 'tag',
     checkResult?: boolean,
     persist?: boolean
 ) {
@@ -163,7 +163,7 @@ ScriptingGlobals.add(function showFreeform(
     }
 
     // prettier-ignore
-    const map: Map<'flashcards' | 'hcenter' | 'vcenter' | 'grid' | 'snaplines' | 'clusters' | 'viewAll' | 'fitOnce' | 'time' | 'docType' | 'color' | 'chat' | 'up' | 'down'| 'toggle-chat' | 'toggle-tags' | 'tag', 
+    const map: Map<'flashcards' | 'hcenter' | 'vcenter' | 'grid' | 'snaplines' | 'clusters' | 'viewAll' | 'fitOnce' | 'time' | 'docType' | 'color' | 'chat' | 'reverse'| 'toggle-chat' | 'toggle-tags' | 'tag', 
     { 
         waitForRender?: boolean; 
         checkResult: (doc: Doc) => boolean; 
@@ -214,13 +214,9 @@ ScriptingGlobals.add(function showFreeform(
             checkResult: (doc: Doc) => StrCast(doc?.[Doc.LayoutFieldKey(doc)+"_sort"]) === "tag",
             setDoc: (doc: Doc, dv: DocumentView) => { doc[Doc.LayoutFieldKey(doc)+"_sort"] === "tag" ? doc[Doc.LayoutFieldKey(doc)+"_sort"] = '' : doc[Doc.LayoutFieldKey(doc)+"_sort"] = docSortings.Tag}, // prettier-ignore
         }],
-        ['up', {
-            checkResult: (doc: Doc) => BoolCast(!doc?.[Doc.LayoutFieldKey(doc)+"_sort_desc"]),
-            setDoc: (doc: Doc, dv: DocumentView) => { doc[Doc.LayoutFieldKey(doc)+"_sort_desc"] = undefined; },
-        }],
-        ['down', {
-            checkResult: (doc: Doc) => BoolCast(doc?.[Doc.LayoutFieldKey(doc)+"_sort_desc"]),
-            setDoc: (doc: Doc, dv: DocumentView) => { doc[Doc.LayoutFieldKey(doc)+"_sort_desc"] = true; },
+        ['reverse', {
+            checkResult: (doc: Doc) => BoolCast(doc?.[Doc.LayoutFieldKey(doc)+"_sort_reverse"]),
+            setDoc: (doc: Doc, dv: DocumentView) => { doc[Doc.LayoutFieldKey(doc)+"_sort_reverse"] = !doc[Doc.LayoutFieldKey(doc)+"_sort_reverse"]; },
         }],
         ['toggle-chat', {
             checkResult: (doc: Doc) => SnappingManager.ChatVisible,
diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx
index cb0831d3c..5315612e1 100644
--- a/src/client/views/nodes/ComparisonBox.tsx
+++ b/src/client/views/nodes/ComparisonBox.tsx
@@ -291,7 +291,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             this.askGPTPhonemes(this._inputValue);
             this._renderSide = this.backKey;
             this._outputValue = '';
-        } else if (this._inputValue) this.askGPT(GPTCallType.QUIZ);
+        } else if (this._inputValue) this.askGPT(GPTCallType.QUIZDOC);
     };
 
     onPointerMove = ({ movementX }: PointerEvent) => {
@@ -511,7 +511,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
      */
     askGPT = async (callType: GPTCallType) => {
         const questionText = this.frontText;
-        const queryText = questionText + (callType == GPTCallType.QUIZ ? ' UserAnswer: ' + this._inputValue + '. ' + ' Rubric: ' + this.backText : '');
+        const queryText = questionText + (callType == GPTCallType.QUIZDOC ? ' UserAnswer: ' + this._inputValue + '. ' + ' Rubric: ' + this.backText : '');
 
         this.loading = true;
         const res = !this.frontText
@@ -522,7 +522,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
                           case GPTCallType.CHATCARD:
                               DocCast(this.dataDoc[this.backKey])[DocData].text = resp;
                               break;
-                          case GPTCallType.QUIZ:
+                          case GPTCallType.QUIZDOC:
                               this._renderSide = this.backKey;
                               this._outputValue = resp.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric');
                               break;
diff --git a/src/client/views/nodes/chatbot/agentsystem/Agent.ts b/src/client/views/nodes/chatbot/agentsystem/Agent.ts
index b2b0c9aea..2ed808622 100644
--- a/src/client/views/nodes/chatbot/agentsystem/Agent.ts
+++ b/src/client/views/nodes/chatbot/agentsystem/Agent.ts
@@ -22,6 +22,7 @@ import { ChatCompletionMessageParam } from 'openai/resources';
 import { Doc } from '../../../../../fields/Doc';
 import { parsedDoc } from '../chatboxcomponents/ChatBox';
 import { WebsiteInfoScraperTool } from '../tools/WebsiteInfoScraperTool';
+import { Upload } from '../../../../../server/SharedMediaTypes';
 //import { CreateTextDocTool } from '../tools/CreateTextDocumentTool';
 
 dotenv.config();
@@ -61,7 +62,7 @@ export class Agent {
         history: () => string,
         csvData: () => { filename: string; id: string; text: string }[],
         addLinkedUrlDoc: (url: string, id: string) => void,
-        createImage: (result: any, options: DocumentOptions) => void,
+        createImage: (result: Upload.FileInformation & Upload.InspectionResults, options: DocumentOptions) => void,
         addLinkedDoc: (doc: parsedDoc) => Doc | undefined,
         // eslint-disable-next-line @typescript-eslint/no-unused-vars
         createCSVInDash: (url: string, title: string, id: string, data: string) => void
diff --git a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx
index 16da360fc..6e9307d37 100644
--- a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx
+++ b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx
@@ -42,6 +42,7 @@ import './ChatBox.scss';
 import MessageComponentBox from './MessageComponent';
 import { ProgressBar } from './ProgressBar';
 import { OpenWhere } from '../../OpenWhere';
+import { Upload } from '../../../../../server/SharedMediaTypes';
 
 dotenv.config();
 
@@ -412,7 +413,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
         });
 
     @action
-    createImageInDash = async (result: any, options: DocumentOptions) => {
+    createImageInDash = async (result: Upload.FileInformation & Upload.InspectionResults, options: DocumentOptions) => {
         const newImgSrc =
             result.accessPaths.agnostic.client.indexOf('dashblobstore') === -1 //
                 ? ClientUtils.prepend(result.accessPaths.agnostic.client)
diff --git a/src/client/views/nodes/chatbot/tools/ImageCreationTool.ts b/src/client/views/nodes/chatbot/tools/ImageCreationTool.ts
index 177552c5c..dc6140871 100644
--- a/src/client/views/nodes/chatbot/tools/ImageCreationTool.ts
+++ b/src/client/views/nodes/chatbot/tools/ImageCreationTool.ts
@@ -1,10 +1,10 @@
-import { v4 as uuidv4 } from 'uuid';
 import { RTFCast } from '../../../../../fields/Types';
 import { DocumentOptions } from '../../../../documents/Documents';
 import { Networking } from '../../../../Network';
 import { ParametersType, ToolInfo } from '../types/tool_types';
 import { Observation } from '../types/types';
 import { BaseTool } from './BaseTool';
+import { Upload } from '../../../../../server/SharedMediaTypes';
 
 const imageCreationToolParams = [
     {
@@ -25,8 +25,8 @@ const imageCreationToolInfo: ToolInfo<ImageCreationToolParamsType> = {
 };
 
 export class ImageCreationTool extends BaseTool<ImageCreationToolParamsType> {
-    private _createImage: (result: any, options: DocumentOptions) => void;
-    constructor(createImage: (result: any, options: DocumentOptions) => void) {
+    private _createImage: (result: Upload.FileInformation & Upload.InspectionResults, options: DocumentOptions) => void;
+    constructor(createImage: (result: Upload.FileInformation & Upload.InspectionResults, options: DocumentOptions) => void) {
         super(imageCreationToolInfo);
         this._createImage = createImage;
     }
@@ -42,23 +42,19 @@ export class ImageCreationTool extends BaseTool<ImageCreationToolParamsType> {
             });
             console.log('Image generation result:', result);
             this._createImage(result, { text: RTFCast(image_prompt) });
-            if (url) {
-                const id = uuidv4();
-
-                return [
-                    {
-                        type: 'image_url',
-                        image_url: { url },
-                    },
-                ];
-            } else {
-                return [
-                    {
-                        type: 'text',
-                        text: `An error occurred while generating image.`,
-                    },
-                ];
-            }
+            return url
+                ? [
+                      {
+                          type: 'image_url',
+                          image_url: { url },
+                      },
+                  ]
+                : [
+                      {
+                          type: 'text',
+                          text: `An error occurred while generating image.`,
+                      },
+                  ];
         } catch (error) {
             console.log(error);
             return [
diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.tsx b/src/client/views/pdf/GPTPopup/GPTPopup.tsx
index 72381cfad..cb3e9b2d7 100644
--- a/src/client/views/pdf/GPTPopup/GPTPopup.tsx
+++ b/src/client/views/pdf/GPTPopup/GPTPopup.tsx
@@ -10,7 +10,7 @@ import { ClientUtils } from '../../../../ClientUtils';
 import { Doc } from '../../../../fields/Doc';
 import { NumCast, StrCast } from '../../../../fields/Types';
 import { Networking } from '../../../Network';
-import { DescriptionSeperator, DocSeperator, GPTCallType, GPTTypeStyle, gptAPICall, gptImageCall } from '../../../apis/gpt/GPT';
+import { DescriptionSeperator, DocSeperator, GPTCallType, GPTDocCommand, gptAPICall, gptImageCall } from '../../../apis/gpt/GPT';
 import { DocUtils } from '../../../documents/DocUtils';
 import { Docs } from '../../../documents/Documents';
 import { SettingsManager } from '../../../util/SettingsManager';
@@ -18,7 +18,7 @@ import { SnappingManager } from '../../../util/SnappingManager';
 import { undoable } from '../../../util/UndoManager';
 import { ObservableReactComponent } from '../../ObservableReactComponent';
 import { TagItem } from '../../TagsView';
-import { docSortings } from '../../collections/CollectionSubView';
+import { ChatSortField, docSortings } from '../../collections/CollectionSubView';
 import { DocumentView } from '../../nodes/DocumentView';
 import { AnchorMenu } from '../AnchorMenu';
 import './GPTPopup.scss';
@@ -36,6 +36,7 @@ export enum GPTPopupMode {
 export class GPTPopup extends ObservableReactComponent<object> {
     // eslint-disable-next-line no-use-before-define
     static Instance: GPTPopup;
+    static ChatTag = '#chat'; // tag used by GPT popup to filter docs
     private _messagesEndRef: React.RefObject<HTMLDivElement>;
     private _correlatedColumns: string[] = [];
     private _dataChatPrompt: string | undefined = undefined;
@@ -77,10 +78,10 @@ export class GPTPopup extends ObservableReactComponent<object> {
                 if (hasChildDocs) {
                     this._textToDocMap.clear();
                     this.setCollectionContext(selDoc.Document);
-                    this.onGptResponse = (sortResult: string, questionType: GPTTypeStyle, tag?: string) => this.processGptResponse(selDoc, this._textToDocMap, sortResult, questionType, tag);
+                    this.onGptResponse = (sortResult: string, questionType: GPTDocCommand, args?: string) => this.processGptResponse(selDoc, this._textToDocMap, sortResult, questionType, args);
                     this.onQuizRandom = () => this.randomlyChooseDoc(selDoc.Document, hasChildDocs());
                     this._documentDescriptions = Promise.all(hasChildDocs().map(doc =>
-                            Doc.getDescription(doc).then(text => this._textToDocMap.set(text, doc) && `${DescriptionSeperator}${text}${DescriptionSeperator}`)
+                            Doc.getDescription(doc).then(text => this._textToDocMap.set(text.trim(), doc) && `${DescriptionSeperator}${text}${DescriptionSeperator}`)
                         )).then(docDescriptions => docDescriptions.join()); // prettier-ignore
                 }
             },
@@ -110,8 +111,8 @@ export class GPTPopup extends ObservableReactComponent<object> {
     @action public setMode = (mode: GPTPopupMode) => (this._mode = mode);
 
     onQuizRandom?: () => void;
-    onGptResponse?: (sortResult: string, questionType: GPTTypeStyle, tag?: string) => void;
-    questionTypeNumberToStyle = (questionType: string) => +questionType.split(' ')[0][0];
+    onGptResponse?: (sortResult: string, questionType: GPTDocCommand, args?: string) => void;
+    NumberToCommandType = (questionType: string) => +questionType.split(' ')[0][0];
 
     /**
      * Processes gpt's output depending on the type of question the user asked. Converts gpt's string output to
@@ -120,44 +121,38 @@ export class GPTPopup extends ObservableReactComponent<object> {
      * @param questionType
      * @param tag
      */
-    processGptResponse = (docView: DocumentView, textToDocMap: Map<string, Doc>, gptOutput: string, questionType: GPTTypeStyle, tag?: string) =>
+    processGptResponse = (docView: DocumentView, textToDocMap: Map<string, Doc>, gptOutput: string, questionType: GPTDocCommand, args?: string) =>
         undoable(() => {
-            // Split the string into individual list items
-            const listItems = gptOutput.split('======').filter(item => item.trim() !== '');
-
-            if (questionType === GPTTypeStyle.Filter) {
-                docView.ComponentView?.hasChildDocs?.().forEach(d => TagItem.removeTagFromDoc(d, '#chat'));
-            }
-
-            if (questionType === GPTTypeStyle.SortDocs) {
-                docView.Document[docView.ComponentView?.fieldKey + '_sort'] = docSortings.Chat;
-            }
-
-            listItems.forEach((item, index) => {
-                const normalizedItem = item.replace(/\n/g, ' ').trim();
-                // find the corresponding Doc in the textToDoc map
-                const doc = textToDocMap.get(normalizedItem);
-                if (doc) {
+            switch (questionType) { // reset collection based on question typefc
+                case GPTDocCommand.Sort:
+                    docView.Document[docView.ComponentView?.fieldKey + '_sort'] = docSortings.Chat;
+                    break;
+                case GPTDocCommand.Filter:
+                    docView.ComponentView?.hasChildDocs?.().forEach(d => TagItem.removeTagFromDoc(d, GPTPopup.ChatTag));
+                    break;
+            } // prettier-ignore
+
+            gptOutput.split('======').filter(item => item.trim() !== '')  // Split output into individual document contents
+                .map(docContentRaw => textToDocMap.get(docContentRaw.replace(/\n/g, ' ').trim())) // the find the corresponding Doc using textToDoc map
+                .filter(doc => doc).map(doc => doc!) // filter out undefined values
+                .forEach((doc, index) => {
                     switch (questionType) {
-                        case GPTTypeStyle.SortDocs:
-                            doc.chatIndex = index;
+                        case GPTDocCommand.Sort:
+                            doc[ChatSortField] = index;
                             break;
-                        case GPTTypeStyle.AssignTags:
-                            if (tag) {
-                                const hashTag = tag.startsWith('#') ? tag : '#' + tag[0].toLowerCase() + tag.slice(1);
-                                const filterTag = Doc.MyFilterHotKeys.map(key => StrCast(key.toolType)).find(key => key.includes(tag)) ?? hashTag;
+                        case GPTDocCommand.AssignTags:
+                            if (args) {
+                                const hashTag = args.startsWith('#') ? args : '#' + args[0].toLowerCase() + args.slice(1);
+                                const filterTag = Doc.MyFilterHotKeys.map(key => StrCast(key.toolType)).find(key => key.includes(args)) ?? hashTag;
                                 TagItem.addTagToDoc(doc, filterTag);
                             }
                             break;
-                        case GPTTypeStyle.Filter:
-                            TagItem.addTagToDoc(doc, '#chat');
-                            Doc.setDocFilter(docView.Document, 'tags', '#chat', 'check');
+                        case GPTDocCommand.Filter:
+                            TagItem.addTagToDoc(doc, GPTPopup.ChatTag);
+                            Doc.setDocFilter(docView.Document, 'tags', GPTPopup.ChatTag, 'check');
                             break;
                     }
-                } else {
-                    console.warn(`No matching document found for item: ${normalizedItem}`);
-                }
-            });
+                }); // prettier-ignore
         }, '')();
 
     /**
@@ -173,7 +168,7 @@ export class GPTPopup extends ObservableReactComponent<object> {
         StrCast(doc.gptRubric)
             ? Promise.resolve(StrCast(doc.gptRubric))
             : Doc.getDescription(doc).then(desc =>
-                  gptAPICall(desc, GPTCallType.RUBRIC)
+                  gptAPICall(desc, GPTCallType.MAKERUBRIC)
                       .then(res => (doc.gptRubric = res))
                       .catch(err => console.error('GPT call failed', err))
               );
@@ -191,7 +186,7 @@ export class GPTPopup extends ObservableReactComponent<object> {
                     `Question: ${desc}; 
                     UserAnswer: ${quizAnswer};
                     Rubric: ${StrCast(doc.gptRubric)}`,
-                    GPTCallType.QUIZ
+                    GPTCallType.QUIZDOC
                 ).then(res => {
                     this._conversationArray.push(res || 'GPT provided no answer');
                     this.onQuizRandom?.();
@@ -205,20 +200,20 @@ export class GPTPopup extends ObservableReactComponent<object> {
      * @param userPrompt the user's input that chat will respond to
      */
     generateUserPromptResponse = (userPrompt: string) =>
-        gptAPICall(userPrompt, GPTCallType.TYPE).then(questionType =>
+        gptAPICall(userPrompt, GPTCallType.COMMANDTYPE, undefined, true).then((commandType, args = commandType.split(' ').slice(1).join(' ')) =>
             (async () => {
-                switch (this.questionTypeNumberToStyle(questionType)) {
-                    case GPTTypeStyle.AssignTags:
-                    case GPTTypeStyle.Filter:    return this._documentDescriptions?.then(descs => gptAPICall(descs, GPTCallType.SUBSET, userPrompt)) ?? "";
-                    case GPTTypeStyle.SortDocs:  return this._documentDescriptions?.then(descs => gptAPICall(descs, GPTCallType.SORT, userPrompt)) ?? "";
-                    default:                     return Doc.getDescription(DocumentView.SelectedDocs().lastElement()).then(desc => gptAPICall(desc, GPTCallType.INFO, userPrompt));
+                switch (this.NumberToCommandType(commandType)) {
+                    case GPTDocCommand.AssignTags:
+                    case GPTDocCommand.Filter:    return this._documentDescriptions?.then(descs => gptAPICall(userPrompt, GPTCallType.SUBSETDOCS, descs)) ?? "";
+                    case GPTDocCommand.Sort:      return this._documentDescriptions?.then(descs => gptAPICall(userPrompt, GPTCallType.SORTDOCS, descs)) ?? "";
+                    default:                      return Doc.getDescription(DocumentView.SelectedDocs().lastElement()).then(desc => gptAPICall(userPrompt, GPTCallType.DOCINFO, desc));
                 } // prettier-ignore
             })().then(
                 action(res => {
                     // Trigger the callback with the result
-                    this.onGptResponse?.(res || 'Something went wrong :(', this.questionTypeNumberToStyle(questionType), questionType.split(' ').slice(1).join(' '));
+                    this.onGptResponse?.(res || 'Something went wrong :(', this.NumberToCommandType(commandType), args);
                     this._conversationArray.push(
-                        [GPTTypeStyle.GeneralInfo, GPTTypeStyle.DocInfo].includes(this.questionTypeNumberToStyle(questionType)) ? res:
+                        this.NumberToCommandType(commandType) === GPTDocCommand.GetInfo ? res:
                         // Extract explanation surrounded by the DocSeperator string (defined in GPT.ts) at the top or both at the top and bottom
                         (res.match(new RegExp(`${DocSeperator}\\s*([\\s\\S]*?)\\s*(?:${DocSeperator}|$)`)) ?? [])[1]?.trim() ?? 'No explanation found'
                     );
@@ -583,10 +578,10 @@ export class GPTPopup extends ObservableReactComponent<object> {
                         tooltip="Clear Chat filter"
                         toggleType={ToggleType.BUTTON}
                         type={Type.PRIM}
-                        toggleStatus={Doc.hasDocFilter(this._collectionContext, 'tags', '#chat')}
-                        text={Doc.hasDocFilter(this._collectionContext, 'tags', '#chat') ? 'filtered' : ''}
-                        color={Doc.hasDocFilter(this._collectionContext, 'tags', '#chat') ? 'red' : 'transparent'}
-                        onClick={() => this._collectionContext && Doc.setDocFilter(this._collectionContext, 'tags', '#chat', 'remove')}
+                        toggleStatus={Doc.hasDocFilter(this._collectionContext, 'tags', GPTPopup.ChatTag)}
+                        text={Doc.hasDocFilter(this._collectionContext, 'tags', GPTPopup.ChatTag) ? 'filtered' : ''}
+                        color={Doc.hasDocFilter(this._collectionContext, 'tags', GPTPopup.ChatTag) ? 'red' : 'transparent'}
+                        onClick={() => this._collectionContext && Doc.setDocFilter(this._collectionContext, 'tags', GPTPopup.ChatTag, 'remove')}
                     />
                     {(this._mode === GPTPopupMode.USER_PROMPT || this._mode === GPTPopupMode.QUIZ_RESPONSE) && (
                         <IconButton color={StrCast(SettingsManager.userVariantColor)} tooltip="back" icon={<CgCornerUpLeft size="16px" />} onClick={() => (this._mode = GPTPopupMode.GPT_MENU)} />
diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts
index 950d9047c..bdd41b0bb 100644
--- a/src/fields/Doc.ts
+++ b/src/fields/Doc.ts
@@ -1482,7 +1482,7 @@ export namespace Doc {
                 case DocumentType.IMG: return curDescription || imageUrlToBase64(ImageCastWithSuffix(Doc.LayoutField(tdoc), '_o') ?? '')
                                                 .then(hrefBase64 => gptImageLabel(hrefBase64, 'Give three to five labels to describe this image.'));
                 case DocumentType.RTF: return RTFCast(tdoc[Doc.LayoutFieldKey(tdoc)]).Text;
-                default:               return StrCast(tdoc.title);
+                default:               return StrCast(tdoc.title).startsWith("Untitled") ? "" : StrCast(tdoc.title);
         }}); // prettier-ignore
         return docText(doc).then(text => (doc[DocData][Doc.LayoutFieldKey(doc) + '_description'] = text));
     }
-- 
cgit v1.2.3-70-g09d2


From 30ec70722678eba318fb7521a8b2bd642328bf54 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Sun, 23 Feb 2025 16:34:40 -0500
Subject: added option+maximize to open in doc editor view (currently only for
 images).  fixed imageEditor to generate edits the first time through.

---
 src/client/views/DocumentDecorations.tsx           |  7 +-
 src/client/views/StyleProviderQuiz.tsx             |  2 +-
 src/client/views/ViewBoxInterface.ts               |  1 +
 src/client/views/nodes/ImageBox.tsx                | 21 +++---
 src/client/views/nodes/calendarBox/CalendarBox.tsx |  2 +-
 src/client/views/nodes/imageEditor/ImageEditor.tsx | 83 ++++++++++------------
 .../views/nodes/imageEditor/ImageEditorButtons.tsx |  4 +-
 .../imageEditor/imageEditorUtils/ImageHandler.ts   |  1 -
 .../imageEditorUtils/imageEditorInterfaces.ts      |  5 +-
 9 files changed, 58 insertions(+), 68 deletions(-)

(limited to 'src/client/views/StyleProviderQuiz.tsx')

diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx
index 54ff3904d..d9b6bdf1a 100644
--- a/src/client/views/DocumentDecorations.tsx
+++ b/src/client/views/DocumentDecorations.tsx
@@ -36,6 +36,7 @@ import { ImageBox } from './nodes/ImageBox';
 import { OpenWhere, OpenWhereMod } from './nodes/OpenWhere';
 import { FormattedTextBox } from './nodes/formattedText/FormattedTextBox';
 import { TagsView } from './TagsView';
+import { ImageField } from '../../fields/URLField';
 
 interface DocumentDecorationsProps {
     PanelWidth: number;
@@ -284,8 +285,8 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora
                 embedding.y = -NumCast(embedding._height) / 2;
                 CollectionDockingView.AddSplit(Docs.Create.FreeformDocument([embedding], { title: 'Tab for ' + embedding.title }), OpenWhereMod.right);
             } else if (e.altKey) {
-                // open same document in new tab
-                CollectionDockingView.ToggleSplit(selView.Document, OpenWhereMod.right);
+                // open same document in new tab or in custom editor
+                selView.ComponentView?.docEditorView?.() ?? CollectionDockingView.ToggleSplit(selView.Document, OpenWhereMod.right);
             } else {
                 let openDoc = selView.Document;
                 if (openDoc.layout_fieldKey === 'layout_icon') {
@@ -821,7 +822,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora
                                 {hideDeleteButton ? null : topBtn('close', 'times', undefined, () => this.onCloseClick(true), 'Close')}
                                 {hideResizers || hideDeleteButton ? null : topBtn('minimize', 'window-maximize', undefined, () => this.onCloseClick(undefined), 'Minimize')}
                                 {titleArea}
-                                {hideOpenButton ? <div /> : topBtn('open', 'external-link-alt', this.onMaximizeDown, undefined, 'Open in Lightbox (ctrl: as alias, shift: in new collection)')}
+                                {hideOpenButton ? <div /> : topBtn('open', 'external-link-alt', this.onMaximizeDown, undefined, 'Open in Lightbox (ctrl: as alias, shift: in new collection, opption: in editor view)')}
                             </div>
                             {hideResizers ? null : (
                                 <>
diff --git a/src/client/views/StyleProviderQuiz.tsx b/src/client/views/StyleProviderQuiz.tsx
index d8eeb3490..db9ab831a 100644
--- a/src/client/views/StyleProviderQuiz.tsx
+++ b/src/client/views/StyleProviderQuiz.tsx
@@ -125,7 +125,7 @@ export namespace styleProviderQuiz {
         try {
             const hrefBase64 = await createCanvas(img);
             const response = await gptImageLabel(hrefBase64, 'Make flashcards out of this image with each question and answer labeled as "question" and "answer". Do not label each flashcard and do not include asterisks: ');
-            AnchorMenu.Instance.transferToFlashcard(response, NumCast(img.layoutDoc['x']), NumCast(img.layoutDoc['y']));
+            AnchorMenu.Instance.transferToFlashcard(response, NumCast(img.layoutDoc.x), NumCast(img.layoutDoc.y));
         } catch (error) {
             console.log('Error', error);
         }
diff --git a/src/client/views/ViewBoxInterface.ts b/src/client/views/ViewBoxInterface.ts
index 30da8c616..b943259ff 100644
--- a/src/client/views/ViewBoxInterface.ts
+++ b/src/client/views/ViewBoxInterface.ts
@@ -23,6 +23,7 @@ export abstract class ViewBoxInterface<P> extends ObservableReactComponent<React
     }
     promoteCollection?: () => void; // moves contents of collection to parent
     hasChildDocs?: () => Doc[];
+    docEditorView?: () => void;
     updateIcon?: (usePanelDimensions?: boolean) => Promise<void>; // updates the icon representation of the document
     getAnchor?: (addAsAnnotation: boolean, pinData?: PinProps) => Doc; // returns an Anchor Doc that represents the current state of the doc's componentview (e.g., the current playhead location of a an audio/video box)
     restoreView?: (viewSpec: Doc) => boolean; // DEPRECATED: do not use, it will go away.  see PresBox.restoreTargetDocView
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index ff6baf199..dd28832f9 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -316,6 +316,16 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
         return cropping;
     };
 
+    docEditorView = action(() => {
+        const field = Cast(this.dataDoc[this.fieldKey], ImageField);
+        if (field) {
+            ImageEditorData.Open = true;
+            ImageEditorData.Source = this.choosePath(field.url);
+            ImageEditorData.AddDoc = this._props.addDocument;
+            ImageEditorData.RootDoc = this.Document;
+        }
+    });
+
     specificContextMenu = (): void => {
         const field = Cast(this.dataDoc[this.fieldKey], ImageField);
         if (field) {
@@ -353,16 +363,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
                 icon: 'expand-arrows-alt',
             });
             funcs.push({ description: 'Copy path', event: () => ClientUtils.CopyText(this.choosePath(field.url)), icon: 'copy' });
-            funcs.push({
-                description: 'Open Image Editor',
-                event: action(() => {
-                    ImageEditorData.Open = true;
-                    ImageEditorData.Source = this.choosePath(field.url);
-                    ImageEditorData.AddDoc = this._props.addDocument;
-                    ImageEditorData.RootDoc = this.Document;
-                }),
-                icon: 'pencil-alt',
-            });
+            funcs.push({ description: 'Open Image Editor', event: this.docEditorView, icon: 'pencil-alt' });
             this.layoutDoc.ai &&
                 funcs.push({
                     description: 'Regenerate AI Image',
diff --git a/src/client/views/nodes/calendarBox/CalendarBox.tsx b/src/client/views/nodes/calendarBox/CalendarBox.tsx
index c1f6f5859..009eb82cd 100644
--- a/src/client/views/nodes/calendarBox/CalendarBox.tsx
+++ b/src/client/views/nodes/calendarBox/CalendarBox.tsx
@@ -6,7 +6,7 @@ import interactionPlugin from '@fullcalendar/interaction';
 import { IReactionDisposer, action, computed, makeObservable, observable, reaction } from 'mobx';
 import { observer } from 'mobx-react';
 import * as React from 'react';
-import { dateRangeStrToDates, simulateMouseClick } from '../../../../ClientUtils';
+import { dateRangeStrToDates } from '../../../../ClientUtils';
 import { Doc } from '../../../../fields/Doc';
 import { BoolCast, NumCast, StrCast } from '../../../../fields/Types';
 import { CollectionSubView, SubCollectionViewProps } from '../../collections/CollectionSubView';
diff --git a/src/client/views/nodes/imageEditor/ImageEditor.tsx b/src/client/views/nodes/imageEditor/ImageEditor.tsx
index 6b1d05031..3c0ab3da5 100644
--- a/src/client/views/nodes/imageEditor/ImageEditor.tsx
+++ b/src/client/views/nodes/imageEditor/ImageEditor.tsx
@@ -90,18 +90,8 @@ const ImageEditor = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc
      * @param type The new tool type we are changing to
      */
     const changeTool = (type: ImageToolType) => {
-        switch (type) {
-            case ImageToolType.GenerativeFill:
-                setCurrTool(genFillTool);
-                setCursorData(prev => ({ ...prev, width: genFillTool.sliderDefault as number }));
-                break;
-            case ImageToolType.Cut:
-                setCurrTool(cutTool);
-                setCursorData(prev => ({ ...prev, width: cutTool.sliderDefault as number }));
-                break;
-            default:
-                break;
-        }
+        setCurrToolType(type);
+        setCursorData(prev => ({ ...prev, width: currTool().sliderDefault as number }));
     };
     // Undo and Redo
     const handleUndo = () => {
@@ -171,9 +161,8 @@ const ImageEditor = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc
 
     // handles brushing on pointer movement
     useEffect(() => {
-        if (!isBrushing) return undefined;
         const canvas = canvasRef.current;
-        if (!canvas) return undefined;
+        if (!isBrushing || !canvas) return undefined;
         const ctx = ImageUtility.getCanvasContext(canvasRef);
         if (!ctx) return undefined;
 
@@ -188,33 +177,29 @@ const ImageEditor = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc
         };
 
         drawingAreaRef.current?.addEventListener('pointermove', handlePointerMove);
-        return () => {
-            drawingAreaRef.current?.removeEventListener('pointermove', handlePointerMove);
-        };
+        return () => drawingAreaRef.current?.removeEventListener('pointermove', handlePointerMove);
     }, [isBrushing]);
 
     // first load
     useEffect(() => {
-        const loadInitial = async () => {
-            if (!imageEditorSource || imageEditorSource === '') return;
-            const img = new Image();
-            const res = await ImageUtility.urlToBase64(imageEditorSource);
-            if (!res) return;
-            img.src = `data:image/png;base64,${res}`;
-
-            img.onload = () => {
-                currImg.current = img;
-                originalImg.current = img;
-                const imgWidth = img.naturalWidth;
-                const imgHeight = img.naturalHeight;
-                const scale = Math.min(canvasSize / imgWidth, canvasSize / imgHeight);
-                const width = imgWidth * scale;
-                const height = imgHeight * scale;
-                setCanvasDims({ width, height });
-            };
-        };
-
-        loadInitial();
+        if (imageEditorSource && imageEditorSource) {
+            ImageUtility.urlToBase64(imageEditorSource).then(res => {
+                if (res) {
+                    const img = new Image();
+                    img.src = `data:image/png;base64,${res}`;
+                    img.onload = () => {
+                        currImg.current = img;
+                        originalImg.current = img;
+                        const imgWidth = img.naturalWidth;
+                        const imgHeight = img.naturalHeight;
+                        const scale = Math.min(canvasSize / imgWidth, canvasSize / imgHeight);
+                        const width = imgWidth * scale;
+                        const height = imgHeight * scale;
+                        setCanvasDims({ width, height });
+                    };
+                }
+            });
+        }
 
         // cleanup
         return () => {
@@ -300,7 +285,7 @@ const ImageEditor = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc
             if (!canvasMask) return;
             const maskBlob = await ImageUtility.canvasToBlob(canvasMask);
             const imgBlob = await ImageUtility.canvasToBlob(canvasOriginalImg);
-            const res = await ImageUtility.getEdit(imgBlob, maskBlob, input !== '' ? input + ' in the same style' : 'Fill in the image in the same style', 2);
+            const res = await ImageUtility.getEdit(imgBlob, maskBlob, input || 'Fill in the image in the same style', 2);
 
             // create first image
             if (!newCollectionRef.current) {
@@ -569,11 +554,15 @@ const ImageEditor = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc
         setIsFirstDoc(true);
     };
 
+    function currTool() {
+        return imageEditTools.find(tool => tool.type === currToolType) ?? genFillTool;
+    }
+
     // defines the tools and sets current tool
-    const genFillTool: ImageEditTool = { type: ImageToolType.GenerativeFill, name: 'Generative Fill', btnText: 'GET EDITS', icon: 'fill', applyFunc: getEdit, sliderMin: 25, sliderMax: 500, sliderDefault: 150 };
-    const cutTool: ImageEditTool = { type: ImageToolType.Cut, name: 'Cut', btnText: 'CUT IMAGE', icon: 'scissors', applyFunc: cutImage, sliderMin: 1, sliderMax: 50, sliderDefault: 5 };
+    const genFillTool: ImageEditTool = { type: ImageToolType.GenerativeFill, btnText: 'GET EDITS', icon: 'fill', applyFunc: getEdit, sliderMin: 25, sliderMax: 500, sliderDefault: 150 };
+    const cutTool: ImageEditTool = { type: ImageToolType.Cut, btnText: 'CUT IMAGE', icon: 'scissors', applyFunc: cutImage, sliderMin: 1, sliderMax: 50, sliderDefault: 5 };
     const imageEditTools: ImageEditTool[] = [genFillTool, cutTool];
-    const [currTool, setCurrTool] = useState<ImageEditTool>(genFillTool);
+    const [currToolType, setCurrToolType] = useState<ImageToolType>(ImageToolType.GenerativeFill);
 
     // the top controls for making a new collection, resetting, and applying edits,
     function renderControls() {
@@ -595,7 +584,7 @@ const ImageEditor = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc
                         labelPlacement="end"
                         sx={{ whiteSpace: 'nowrap' }}
                     />
-                    <ApplyFuncButtons onClick={() => currTool.applyFunc(cutType, cursorData.width, edits, isFirstDoc)} loading={loading} onReset={handleReset} btnText={currTool.btnText} />
+                    <ApplyFuncButtons onClick={() => currTool().applyFunc(cutType, cursorData.width, edits, isFirstDoc)} loading={loading} onReset={handleReset} btnText={currTool().btnText} />
                     <IconButton color={activeColor} tooltip="close" icon={<CgClose size="16px" />} onClick={handleViewClose} />
                 </div>
             </div>
@@ -607,8 +596,8 @@ const ImageEditor = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc
         return (
             <div className="sideControlsContainer" style={{ backgroundColor: bgColor }}>
                 <div className="sideControls">
-                    <div className="imageToolsContainer">{imageEditTools.map(tool => ImageToolButton(tool, tool.type === currTool.type, changeTool))}</div>
-                    {currTool.type == ImageToolType.Cut && (
+                    <div className="imageToolsContainer">{imageEditTools.map(tool => ImageToolButton(tool, tool.type === currTool().type, changeTool))}</div>
+                    {currTool().type == ImageToolType.Cut && (
                         <div className="cutToolsContainer">
                             <Button style={{ width: '100%' }} text="Keep in" type={Type.TERT} color={cutType == CutMode.IN ? SettingsManager.userColor : bgColor} onClick={() => setCutType(CutMode.IN)} />
                             <Button style={{ width: '100%' }} text="Keep out" type={Type.TERT} color={cutType == CutMode.OUT ? SettingsManager.userColor : bgColor} onClick={() => setCutType(CutMode.OUT)} />
@@ -617,7 +606,7 @@ const ImageEditor = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc
                         </div>
                     )}
                     <div className="sliderContainer" onPointerDown={e => e.stopPropagation()}>
-                        {currTool.type === ImageToolType.GenerativeFill && (
+                        {currTool().type === ImageToolType.GenerativeFill && (
                             <Slider
                                 sx={{
                                     '& input[type="range"]': {
@@ -633,7 +622,7 @@ const ImageEditor = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc
                                 onChange={(e, val) => setCursorData(prev => ({ ...prev, width: val as number }))}
                             />
                         )}
-                        {currTool.type === ImageToolType.Cut && (
+                        {currTool().type === ImageToolType.Cut && (
                             <Slider
                                 sx={{
                                     '& input[type="range"]': {
@@ -780,7 +769,7 @@ const ImageEditor = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc
                 {renderSideIcons()}
                 {renderEditThumbnails()}
             </div>
-            {currTool.type === ImageToolType.GenerativeFill && renderPromptBox()}
+            {currTool().type === ImageToolType.GenerativeFill && renderPromptBox()}
         </div>
     );
 };
diff --git a/src/client/views/nodes/imageEditor/ImageEditorButtons.tsx b/src/client/views/nodes/imageEditor/ImageEditorButtons.tsx
index 985dc914f..3eaa251f2 100644
--- a/src/client/views/nodes/imageEditor/ImageEditorButtons.tsx
+++ b/src/client/views/nodes/imageEditor/ImageEditorButtons.tsx
@@ -53,10 +53,10 @@ export function ApplyFuncButtons({ loading, onClick: getEdit, onReset, btnText }
 
 export function ImageToolButton(tool: ImageEditTool, isActive: boolean, selectTool: (type: ImageToolType) => void) {
     return (
-        <div key={tool.name} className="imageEditorButtonContainer">
+        <div key={tool.type} className="imageEditorButtonContainer">
             <Button
                 style={{ width: '100%' }}
-                text={tool.name}
+                text={tool.type}
                 type={Type.TERT}
                 color={isActive ? SettingsManager.userVariantColor : bgColor}
                 icon={<FontAwesomeIcon icon={tool.icon} />}
diff --git a/src/client/views/nodes/imageEditor/imageEditorUtils/ImageHandler.ts b/src/client/views/nodes/imageEditor/imageEditorUtils/ImageHandler.ts
index ece0f4d7f..1c6a38a24 100644
--- a/src/client/views/nodes/imageEditor/imageEditorUtils/ImageHandler.ts
+++ b/src/client/views/nodes/imageEditor/imageEditorUtils/ImageHandler.ts
@@ -87,7 +87,6 @@ export class ImageUtility {
                 body: fd,
             });
             const data = await res.json();
-            console.log(data.data);
             return {
                 status: 'success',
                 urls: (data.data as { b64_json: string }[]).map(urlData => `data:image/png;base64,${urlData.b64_json}`),
diff --git a/src/client/views/nodes/imageEditor/imageEditorUtils/imageEditorInterfaces.ts b/src/client/views/nodes/imageEditor/imageEditorUtils/imageEditorInterfaces.ts
index a14b55439..02dbc0312 100644
--- a/src/client/views/nodes/imageEditor/imageEditorUtils/imageEditorInterfaces.ts
+++ b/src/client/views/nodes/imageEditor/imageEditorUtils/imageEditorInterfaces.ts
@@ -13,8 +13,8 @@ export interface Point {
 }
 
 export enum ImageToolType {
-    GenerativeFill = 'genFill',
-    Cut = 'cut',
+    GenerativeFill = 'Generative Fill',
+    Cut = 'Cut',
 }
 
 export enum CutMode {
@@ -26,7 +26,6 @@ export enum CutMode {
 
 export interface ImageEditTool {
     type: ImageToolType;
-    name: string;
     btnText: string;
     icon: IconProp;
     // this is the function that the image tool applies, so it can be defined depending on the tool
-- 
cgit v1.2.3-70-g09d2

