From 80d4bf70cb8e29f15d8e51b1e0bd378a26d68fcf Mon Sep 17 00:00:00 2001
From: vellichora <fangrui_tong@brown.edu>
Date: Tue, 7 Jan 2020 03:07:18 -0500
Subject: converted tool tip text menu to react component with basic marks and
 dropdown

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

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

diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index a08c14436..7e81876a2 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -47,6 +47,7 @@ import { AudioBox } from './AudioBox';
 import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView';
 import { InkTool } from '../../../new_fields/InkField';
 import { TraceMobx } from '../../../new_fields/util';
+import RichTextMenu from '../../util/RichTextMenu';
 
 library.add(faEdit);
 library.add(faSmile, faTextHeight, faUpload);
@@ -1032,7 +1033,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         const self = FormattedTextBox;
         return new Plugin({
             view(newView) {
-                return self.ToolTipTextMenu = FormattedTextBox.getToolTip(newView);
+                // return self.ToolTipTextMenu = FormattedTextBox.getToolTip(newView);
+                RichTextMenu.Instance.changeView(newView);
+                return RichTextMenu.Instance;
             }
         });
     }
@@ -1121,7 +1124,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         const rounded = StrCast(this.layoutDoc.borderRounding) === "100%" ? "-rounded" : "";
         const interactive = InkingControl.Instance.selectedTool || this.layoutDoc.isBackground;
         if (this.props.isSelected()) {
-            FormattedTextBox.ToolTipTextMenu!.updateFromDash(this._editorView!, undefined, this.props);
+            // TODO: ftong --> update from dash in richtextmenu
+            RichTextMenu.Instance.updateFromDash(this._editorView!, undefined, this.props);
+            // FormattedTextBox.ToolTipTextMenu!.updateFromDash(this._editorView!, undefined, this.props);
         } else if (FormattedTextBoxComment.textBox === this) {
             FormattedTextBoxComment.Hide();
         }
-- 
cgit v1.2.3-70-g09d2


From ced3532239c582938271635802e0d2d62703e148 Mon Sep 17 00:00:00 2001
From: vellichora <fangrui_tong@brown.edu>
Date: Tue, 7 Jan 2020 14:20:36 -0500
Subject: richtextmenu appears whenever formattedtextbox is focused

---
 src/client/util/RichTextMenu.scss           | 64 +++++++++++++++++++++++++++++
 src/client/util/RichTextMenu.tsx            | 58 +++++++++++++++++---------
 src/client/util/SelectionManager.ts         |  3 +-
 src/client/util/TooltipTextMenu.tsx         |  2 +
 src/client/views/DocumentDecorations.tsx    |  8 ----
 src/client/views/nodes/FormattedTextBox.tsx |  7 ++++
 6 files changed, 113 insertions(+), 29 deletions(-)

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

diff --git a/src/client/util/RichTextMenu.scss b/src/client/util/RichTextMenu.scss
index f7414cc7f..85d2765e3 100644
--- a/src/client/util/RichTextMenu.scss
+++ b/src/client/util/RichTextMenu.scss
@@ -20,9 +20,73 @@
         box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25);
         min-width: 150px;
         padding: 5px;
+
+        button {
+            background-color: #323232;
+            border: 1px solid black;
+            border-radius: 1px;
+            padding: 6px;
+            margin: 5px 0;
+
+            &:hover {
+                background-color: black;
+            }
+        }
     }
 
     input {
         color: black;
     }
+}
+
+.link-menu {
+    .divider {
+        background-color: white;
+        height: 1px;
+        width: 100%;
+    }
+}
+
+.color-preview-button {
+    .color-preview {
+        width: 100%;
+        height: 3px;
+        margin-top: 3px;
+    }
+}
+
+.color-wrapper {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: space-between;
+
+    button.color-button {
+        width: 20px;
+        height: 20px;
+        border-radius: 15px !important;
+        margin: 3px;
+        border: 2px solid transparent !important;
+        padding: 3px;
+
+        &.active {
+            border: 2px solid white !important;
+        }
+    }
+}
+
+select {
+    background-color: #323232;
+    color: white;
+    border: 1px solid black;
+    border-top: none;
+    border-bottom: none;
+
+    &:focus,
+    &:hover {
+        background-color: black;
+    }
+
+    &::-ms-expand {
+        color: white;
+    }
 }
\ No newline at end of file
diff --git a/src/client/util/RichTextMenu.tsx b/src/client/util/RichTextMenu.tsx
index 111822307..8c373b818 100644
--- a/src/client/util/RichTextMenu.tsx
+++ b/src/client/util/RichTextMenu.tsx
@@ -29,6 +29,7 @@ library.add(faBold, faItalic, faUnderline, faStrikethrough, faSuperscript, faSub
 @observer
 export default class RichTextMenu extends AntimodeMenu {
     static Instance: RichTextMenu;
+    @observable private isVisible: boolean = false;
 
     private view?: EditorView;
     private editorProps: FieldViewProps & FormattedTextBoxProps | undefined;
@@ -53,7 +54,6 @@ export default class RichTextMenu extends AntimodeMenu {
     constructor(props: Readonly<{}>) {
         super(props);
         RichTextMenu.Instance = this;
-        this.Pinned = true;
     }
 
     @action
@@ -73,6 +73,7 @@ export default class RichTextMenu extends AntimodeMenu {
         }
         this.view = view;
         const state = view.state;
+        this.isVisible = true;
         // DocumentDecorations.Instance.showTextBar();
         props && (this.editorProps = props);
         // Don't do anything if the document/selection didn't change
@@ -146,7 +147,6 @@ export default class RichTextMenu extends AntimodeMenu {
     }
 
     destroy() {
-        console.log("destroy");
     }
 
     createButton(faIcon: string, title: string, command?: any, onclick?: any) {
@@ -213,7 +213,6 @@ export default class RichTextMenu extends AntimodeMenu {
     }
 
     changeFontSize = (mark: Mark, view: EditorView) => {
-        console.log("change font size!!");
         const size = mark.attrs.fontSize;
         if (this.editorProps) {
             const ruleProvider = this.editorProps.ruleProvider;
@@ -241,9 +240,7 @@ export default class RichTextMenu extends AntimodeMenu {
     // TODO: remove doesn't work
     //remove all node type and apply the passed-in one to the selected text
     changeListType = (nodeType: NodeType | undefined) => {
-        console.log("change the list type ");
         if (!this.view) return;
-        console.log("change the list type has view");
 
         if (nodeType === schema.nodes.bullet_list) {
             wrapInList(nodeType)(this.view.state, this.view.dispatch);
@@ -354,7 +351,6 @@ export default class RichTextMenu extends AntimodeMenu {
                 this.brushIsEmpty = !this.brushIsEmpty;
             }
         }
-        console.log("brush marks are ", this.brushMarks);
     }
 
     @action toggleColorDropdown() { this.showColorDropdown = !this.showColorDropdown; }
@@ -397,13 +393,23 @@ export default class RichTextMenu extends AntimodeMenu {
 
         return (
             <div className="button-dropdown-wrapper">
-                <button className="antimodeMenu-button" title="" onPointerDown={onColorClick}><FontAwesomeIcon icon="palette" size="lg" /></button>
+                <button className="antimodeMenu-button color-preview-button" title="" onPointerDown={onColorClick}>
+                    <FontAwesomeIcon icon="palette" size="lg" />
+                    <div className="color-preview" style={{ backgroundColor: this.activeFontColor }}></div>
+                </button>
                 <button className="dropdown-button antimodeMenu-button" onPointerDown={onDropdownClick}><FontAwesomeIcon icon="caret-down" size="sm" /></button>
                 {this.showColorDropdown ?
                     (<div className="dropdown">
-                        {colors.map(color => {
-                            if (color) return <button style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button>;
-                        })}
+                        <p>Change font color:</p>
+                        <div className="color-wrapper">
+                            {colors.map(color => {
+                                if (color) {
+                                    return this.activeFontColor === color ?
+                                        <button className="color-button active" style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button> :
+                                        <button className="color-button" style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button>;
+                                }
+                            })}
+                        </div>
                     </div>)
                     : <></>}
             </div>
@@ -459,13 +465,23 @@ export default class RichTextMenu extends AntimodeMenu {
 
         return (
             <div className="button-dropdown-wrapper">
-                <button className="antimodeMenu-button" title="" onPointerDown={onHighlightClick}><FontAwesomeIcon icon="highlighter" size="lg" /></button>
+                <button className="antimodeMenu-button color-preview-button" title="" onPointerDown={onHighlightClick}>
+                    <FontAwesomeIcon icon="highlighter" size="lg" />
+                    <div className="color-preview" style={{ backgroundColor: this.activeHighlightColor }}></div>
+                </button>
                 <button className="dropdown-button antimodeMenu-button" onPointerDown={onDropdownClick}><FontAwesomeIcon icon="caret-down" size="sm" /></button>
                 {this.showHighlightDropdown ?
                     (<div className="dropdown">
-                        {colors.map(color => {
-                            if (color) return <button style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}></button>;
-                        })}
+                        <p>Change highlight color:</p>
+                        <div className="color-wrapper">
+                            {colors.map(color => {
+                                if (color) {
+                                    return this.activeHighlightColor === color ?
+                                        <button className="color-button active" style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}>{color === "transparent" ? "X" : ""}</button> :
+                                        <button className="color-button" style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}>{color === "transparent" ? "X" : ""}</button>;
+                                }
+                            })}
+                        </div>
                     </div>)
                     : <></>}
             </div>
@@ -492,9 +508,6 @@ export default class RichTextMenu extends AntimodeMenu {
             self.setCurrentLink(e.target.value);
         }
 
-        // const targetTitle = this.getTextLinkTargetTitle();
-        console.log("link curr is ", this.currentLink);
-        // // this.setCurrentLink(targetTitle);
         const link = this.currentLink ? this.currentLink : "";
 
         return (
@@ -502,11 +515,12 @@ export default class RichTextMenu extends AntimodeMenu {
                 <button className="antimodeMenu-button" title="" onPointerDown={onDropdownClick}><FontAwesomeIcon icon="link" size="lg" /></button>
                 <button className="dropdown-button antimodeMenu-button" onPointerDown={onDropdownClick}><FontAwesomeIcon icon="caret-down" size="sm" /></button>
                 {this.showLinkDropdown ?
-                    (<div className="dropdown">
+                    (<div className="dropdown link-menu">
                         <p>Linked to:</p>
                         <input value={link} placeholder="Enter URL" onChange={onLinkChange} />
-                        <button onPointerDown={e => this.makeLinkToURL(link, "onRight")}>Apply hyperlink</button>
-                        <button onPointerDown={e => this.deleteLink()}>Remove link</button>
+                        <button className="make-button" onPointerDown={e => this.makeLinkToURL(link, "onRight")}>Apply hyperlink</button>
+                        <div className="divider"></div>
+                        <button className="remove-button" onPointerDown={e => this.deleteLink()}>Remove link</button>
                     </div>)
                     : <></>}
             </div>
@@ -634,6 +648,10 @@ export default class RichTextMenu extends AntimodeMenu {
     }
 
     render() {
+        // if (!this.isVisible) return <></>;
+        SelectionManager.SelectedDocuments()
+        // if (this.Pinned || )
+
         const fontSizeOptions = [
             { mark: schema.marks.pFontSize.create({ fontSize: 7 }), title: "Set font size", label: "7pt", command: this.changeFontSize },
             { mark: schema.marks.pFontSize.create({ fontSize: 8 }), title: "Set font size", label: "8pt", command: this.changeFontSize },
diff --git a/src/client/util/SelectionManager.ts b/src/client/util/SelectionManager.ts
index 0c733ac47..86a7a620e 100644
--- a/src/client/util/SelectionManager.ts
+++ b/src/client/util/SelectionManager.ts
@@ -3,6 +3,8 @@ import { Doc } from "../../new_fields/Doc";
 import { DocumentView } from "../views/nodes/DocumentView";
 import { computedFn } from "mobx-utils";
 import { List } from "../../new_fields/List";
+import { DocumentDecorations } from "../views/DocumentDecorations";
+import RichTextMenu from "./RichTextMenu";
 
 export namespace SelectionManager {
 
@@ -14,7 +16,6 @@ export namespace SelectionManager {
 
         @action
         SelectDoc(docView: DocumentView, ctrlPressed: boolean): void {
-            console.log("select doc!!!");
             // if doc is not in SelectedDocuments, add it
             if (!manager.SelectedDocuments.get(docView)) {
                 if (!ctrlPressed) {
diff --git a/src/client/util/TooltipTextMenu.tsx b/src/client/util/TooltipTextMenu.tsx
index 33257b658..2c4d08b82 100644
--- a/src/client/util/TooltipTextMenu.tsx
+++ b/src/client/util/TooltipTextMenu.tsx
@@ -20,6 +20,8 @@ import { PastelSchemaPalette, DarkPastelSchemaPalette } from '../../new_fields/S
 const { toggleMark, setBlockType } = require("prosemirror-commands");
 const { openPrompt, TextField } = require("./ProsemirrorCopy/prompt.js");
 
+// deprecated in favor of richtextmenu
+
 //appears above a selection of text in a RichTextBox to give user options such as Bold, Italics, etc.
 export class TooltipTextMenu {
     public static Toolbar: HTMLDivElement | undefined;
diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx
index 093761f1f..799b3695c 100644
--- a/src/client/views/DocumentDecorations.tsx
+++ b/src/client/views/DocumentDecorations.tsx
@@ -77,11 +77,6 @@ export class DocumentDecorations extends React.Component<{}, { value: string }>
         DocumentDecorations.Instance = this;
         this._keyinput = React.createRef();
         reaction(() => SelectionManager.SelectedDocuments().slice(), docs => this.titleBlur(false));
-        console.log("constructing document decorations!!!");
-    }
-
-    componentDidMount() {
-        console.log("mounting deocument deoctirioeon!!!!");
     }
 
     @action titleChanged = (event: any) => this._accumulatedTitle = event.target.value;
@@ -579,9 +574,6 @@ export class DocumentDecorations extends React.Component<{}, { value: string }>
         if (bounds.y > bounds.b) {
             bounds.y = bounds.b - (this._resizeBorderWidth + this._linkBoxHeight + this._titleHeight);
         }
-        // // RichTextMenu.Instance.jumpTo(this._lastX, this._lastY - 50);
-        // console.log("jump to ");
-        // RichTextMenu.Instance.jumpTo(500, 300);
         return (<div className="documentDecorations">
             <div className="documentDecorations-background" style={{
                 width: (bounds.r - bounds.x + this._resizeBorderWidth) + "px",
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index f00a24d41..7d48a0859 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -48,6 +48,7 @@ import { CollectionFreeFormView } from '../collections/collectionFreeForm/Collec
 import { InkTool } from '../../../new_fields/InkField';
 import { TraceMobx } from '../../../new_fields/util';
 import RichTextMenu from '../../util/RichTextMenu';
+import { DocumentDecorations } from '../DocumentDecorations';
 
 library.add(faEdit);
 library.add(faSmile, faTextHeight, faUpload);
@@ -910,6 +911,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         prosediv && (prosediv.keeplocation = undefined);
         const pos = this._editorView?.state.selection.$from.pos || 1;
         keeplocation && setTimeout(() => this._editorView?.dispatch(this._editorView?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos))));
+
+        // jump rich text menu to this textbox
+        this._ref.current && RichTextMenu.Instance.jumpTo(this._ref.current.getBoundingClientRect().x, this._ref.current?.getBoundingClientRect().y - 70);
     }
     onPointerWheel = (e: React.WheelEvent): void => {
         // if a text note is not selected and scrollable, this prevents us from being able to scroll and zoom out at the same time
@@ -1055,6 +1059,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
             this._undoTyping = undefined;
         }
         this.doLinkOnDeselect();
+
+        // move the richtextmenu offscreen
+        if (!RichTextMenu.Instance.Pinned) RichTextMenu.Instance.jumpTo(-300, -300);
     }
 
     _lastTimedMark: Mark | undefined = undefined;
-- 
cgit v1.2.3-70-g09d2


From e9dcc0f18498d9415d204dfd3e61465a70486374 Mon Sep 17 00:00:00 2001
From: vellichora <fangrui_tong@brown.edu>
Date: Tue, 7 Jan 2020 15:08:31 -0500
Subject: kind of hack to get selects in richtextmenu to be selectable again

---
 src/client/util/RichTextMenu.scss           |  9 ++++++--
 src/client/util/RichTextMenu.tsx            | 35 ++++++++++++++++++-----------
 src/client/views/AntimodeMenu.scss          |  9 ++++++++
 src/client/views/AntimodeMenu.tsx           | 10 +++++++++
 src/client/views/nodes/FormattedTextBox.tsx |  5 +++--
 5 files changed, 51 insertions(+), 17 deletions(-)

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

diff --git a/src/client/util/RichTextMenu.scss b/src/client/util/RichTextMenu.scss
index 85d2765e3..5a57f4b98 100644
--- a/src/client/util/RichTextMenu.scss
+++ b/src/client/util/RichTextMenu.scss
@@ -20,6 +20,7 @@
         box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25);
         min-width: 150px;
         padding: 5px;
+        font-size: 12px;
 
         button {
             background-color: #323232;
@@ -27,6 +28,7 @@
             border-radius: 1px;
             padding: 6px;
             margin: 5px 0;
+            font-size: 12px;
 
             &:hover {
                 background-color: black;
@@ -78,8 +80,11 @@ select {
     background-color: #323232;
     color: white;
     border: 1px solid black;
-    border-top: none;
-    border-bottom: none;
+    // border-top: none;
+    // border-bottom: none;
+    font-size: 12px;
+    height: 100%;
+    margin-right: 3px;
 
     &:focus,
     &:hover {
diff --git a/src/client/util/RichTextMenu.tsx b/src/client/util/RichTextMenu.tsx
index 8c373b818..4538a77d6 100644
--- a/src/client/util/RichTextMenu.tsx
+++ b/src/client/util/RichTextMenu.tsx
@@ -29,7 +29,7 @@ library.add(faBold, faItalic, faUnderline, faStrikethrough, faSuperscript, faSub
 @observer
 export default class RichTextMenu extends AntimodeMenu {
     static Instance: RichTextMenu;
-    @observable private isVisible: boolean = false;
+    public overDropdown: boolean = false; // kind of hacky way to prevent selects not being selectable
 
     private view?: EditorView;
     private editorProps: FieldViewProps & FormattedTextBoxProps | undefined;
@@ -73,7 +73,6 @@ export default class RichTextMenu extends AntimodeMenu {
         }
         this.view = view;
         const state = view.state;
-        this.isVisible = true;
         // DocumentDecorations.Instance.showTextBar();
         props && (this.editorProps = props);
         // Don't do anything if the document/selection didn't change
@@ -179,14 +178,19 @@ export default class RichTextMenu extends AntimodeMenu {
         });
 
         const self = this;
-        function onChange(val: string) {
+        function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
+            e.stopPropagation();
+            e.preventDefault();
+            console.log("on change marks");
             options.forEach(({ label, mark, command }) => {
-                if (val === label) {
+                if (e.target.value === label) {
                     self.view && mark && command(mark, self.view);
                 }
             });
         }
-        return <select onChange={e => onChange(e.target.value)}>{items}</select>;
+        function onPointerEnter() { self.overDropdown = true; }
+        function onPointerLeave() { self.overDropdown = false; }
+        return <select onChange={onChange} onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave}>{items}</select>;
     }
 
     createNodesDropdown(activeOption: string, options: { node: NodeType | any | null, title: string, label: string, command: (node: NodeType | any) => void, hidden?: boolean }[]): JSX.Element {
@@ -209,7 +213,9 @@ export default class RichTextMenu extends AntimodeMenu {
                 }
             });
         }
-        return <select onChange={e => onChange(e.target.value)}>{items}</select>;
+        function onPointerEnter() { self.overDropdown = true; }
+        function onPointerLeave() { self.overDropdown = false; }
+        return <select onChange={e => onChange(e.target.value)} onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave}>{items}</select>;
     }
 
     changeFontSize = (mark: Mark, view: EditorView) => {
@@ -274,8 +280,7 @@ export default class RichTextMenu extends AntimodeMenu {
         return true;
     }
 
-    @action
-    toggleBrushDropdown() { this.showBrushDropdown = !this.showBrushDropdown; }
+    @action toggleBrushDropdown() { this.showBrushDropdown = !this.showBrushDropdown; }
 
     createBrushButton() {
         const self = this;
@@ -648,9 +653,6 @@ export default class RichTextMenu extends AntimodeMenu {
     }
 
     render() {
-        // if (!this.isVisible) return <></>;
-        SelectionManager.SelectedDocuments()
-        // if (this.Pinned || )
 
         const fontSizeOptions = [
             { mark: schema.marks.pFontSize.create({ fontSize: 7 }), title: "Set font size", label: "7pt", command: this.changeFontSize },
@@ -689,7 +691,7 @@ export default class RichTextMenu extends AntimodeMenu {
             { node: undefined, title: "Set list type", label: "Remove", command: this.changeListType },
         ];
 
-        const buttons = [
+        const row1 = <div className="antimodeMenu-row">{[
             this.createButton("bold", "Bold", toggleMark(schema.marks.strong)),
             this.createButton("italic", "Italic", toggleMark(schema.marks.em)),
             this.createButton("underline", "Underline", toggleMark(schema.marks.underline)),
@@ -701,11 +703,18 @@ export default class RichTextMenu extends AntimodeMenu {
             this.createLinkButton(),
             this.createBrushButton(),
             this.createButton("indent", "Summarize", undefined, this.insertSummarizer),
+        ]}</div>
+
+        const row2 = <div className="antimodeMenu-row">{[
             this.createMarksDropdown(this.activeFontSize, fontSizeOptions),
             this.createMarksDropdown(this.activeFontFamily, fontFamilyOptions),
             this.createNodesDropdown(this.activeListType, listTypeOptions),
+        ]}</div>
+
+        const buttons = [
+            row1, row2
         ];
 
-        return this.getElement(buttons);
+        return this.getElementWithRows(buttons, 2);
     }
 }
\ No newline at end of file
diff --git a/src/client/views/AntimodeMenu.scss b/src/client/views/AntimodeMenu.scss
index a45f3346a..f78b1dbfb 100644
--- a/src/client/views/AntimodeMenu.scss
+++ b/src/client/views/AntimodeMenu.scss
@@ -8,6 +8,15 @@
     // overflow: hidden;    
     display: flex;
 
+    &.with-rows {
+        flex-direction: column
+    }
+
+    .antimodeMenu-row {
+        display: flex;
+        height: 35px;
+    }
+
     .antimodeMenu-button {
         background-color: transparent;
         width: 35px;
diff --git a/src/client/views/AntimodeMenu.tsx b/src/client/views/AntimodeMenu.tsx
index 2e9d29ef9..713a8a189 100644
--- a/src/client/views/AntimodeMenu.tsx
+++ b/src/client/views/AntimodeMenu.tsx
@@ -125,4 +125,14 @@ export default abstract class AntimodeMenu extends React.Component {
             </div>
         );
     }
+
+    protected getElementWithRows(rows: JSX.Element[], numRows: number) {
+        return (
+            <div className="antimodeMenu-cont with-rows" onPointerLeave={this.pointerLeave} onPointerEnter={this.pointerEntered} ref={this._mainCont} onContextMenu={this.handleContextMenu}
+                style={{ left: this._left, top: this._top, opacity: this._opacity, transition: this._transition, transitionDelay: this._transitionDelay, height: 35 * numRows + "px" }}>
+                {rows}
+                <div className="antimodeMenu-dragger" onPointerDown={this.dragStart} style={{ width: this.Pinned ? "20px" : "0px" }} />
+            </div>
+        );
+    }
 }
\ No newline at end of file
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index 7d48a0859..7291d2e8e 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -913,7 +913,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         keeplocation && setTimeout(() => this._editorView?.dispatch(this._editorView?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos))));
 
         // jump rich text menu to this textbox
-        this._ref.current && RichTextMenu.Instance.jumpTo(this._ref.current.getBoundingClientRect().x, this._ref.current?.getBoundingClientRect().y - 70);
+        this._ref.current && RichTextMenu.Instance.jumpTo(this._ref.current.getBoundingClientRect().x, this._ref.current?.getBoundingClientRect().y - 105);
     }
     onPointerWheel = (e: React.WheelEvent): void => {
         // if a text note is not selected and scrollable, this prevents us from being able to scroll and zoom out at the same time
@@ -1053,6 +1053,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         });
     }
     onBlur = (e: any) => {
+        console.log("formated blur");
         //DictationManager.Controls.stop(false);
         if (this._undoTyping) {
             this._undoTyping.end();
@@ -1061,7 +1062,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         this.doLinkOnDeselect();
 
         // move the richtextmenu offscreen
-        if (!RichTextMenu.Instance.Pinned) RichTextMenu.Instance.jumpTo(-300, -300);
+        if (!RichTextMenu.Instance.Pinned && !RichTextMenu.Instance.overDropdown) RichTextMenu.Instance.jumpTo(-300, -300);
     }
 
     _lastTimedMark: Mark | undefined = undefined;
-- 
cgit v1.2.3-70-g09d2


From 89d3ec54886a588f3b7c8e46e771830eeefc3c8b Mon Sep 17 00:00:00 2001
From: vellichora <fangrui_tong@brown.edu>
Date: Tue, 7 Jan 2020 15:58:10 -0500
Subject: added logic so that text nodes started on the very left still show
 the full richtextmenu

---
 src/client/util/RichTextMenu.scss           |  9 ++++++
 src/client/util/RichTextMenu.tsx            | 49 ++++++++++++++++++++---------
 src/client/views/AntimodeMenu.tsx           |  6 +++-
 src/client/views/nodes/FormattedTextBox.tsx |  8 +++--
 4 files changed, 55 insertions(+), 17 deletions(-)

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

diff --git a/src/client/util/RichTextMenu.scss b/src/client/util/RichTextMenu.scss
index 5a57f4b98..ff9270829 100644
--- a/src/client/util/RichTextMenu.scss
+++ b/src/client/util/RichTextMenu.scss
@@ -94,4 +94,13 @@ select {
     &::-ms-expand {
         color: white;
     }
+}
+
+.row-2 {
+    display: flex;
+    justify-content: space-between;
+
+    >div {
+        display: flex;
+    }
 }
\ No newline at end of file
diff --git a/src/client/util/RichTextMenu.tsx b/src/client/util/RichTextMenu.tsx
index 4538a77d6..ae55dbd30 100644
--- a/src/client/util/RichTextMenu.tsx
+++ b/src/client/util/RichTextMenu.tsx
@@ -29,7 +29,7 @@ library.add(faBold, faItalic, faUnderline, faStrikethrough, faSuperscript, faSub
 @observer
 export default class RichTextMenu extends AntimodeMenu {
     static Instance: RichTextMenu;
-    public overDropdown: boolean = false; // kind of hacky way to prevent selects not being selectable
+    public overMenu: boolean = false; // kind of hacky way to prevent selects not being selectable
 
     private view?: EditorView;
     private editorProps: FieldViewProps & FormattedTextBoxProps | undefined;
@@ -188,9 +188,7 @@ export default class RichTextMenu extends AntimodeMenu {
                 }
             });
         }
-        function onPointerEnter() { self.overDropdown = true; }
-        function onPointerLeave() { self.overDropdown = false; }
-        return <select onChange={onChange} onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave}>{items}</select>;
+        return <select onChange={onChange}>{items}</select>;
     }
 
     createNodesDropdown(activeOption: string, options: { node: NodeType | any | null, title: string, label: string, command: (node: NodeType | any) => void, hidden?: boolean }[]): JSX.Element {
@@ -213,9 +211,7 @@ export default class RichTextMenu extends AntimodeMenu {
                 }
             });
         }
-        function onPointerEnter() { self.overDropdown = true; }
-        function onPointerLeave() { self.overDropdown = false; }
-        return <select onChange={e => onChange(e.target.value)} onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave}>{items}</select>;
+        return <select onChange={e => onChange(e.target.value)}>{items}</select>;
     }
 
     changeFontSize = (mark: Mark, view: EditorView) => {
@@ -311,7 +307,9 @@ export default class RichTextMenu extends AntimodeMenu {
 
         return (
             <div className="button-dropdown-wrapper">
-                <button className="antimodeMenu-button" title="" onPointerDown={onBrushClick}><FontAwesomeIcon icon="eye-dropper" size="lg" /></button>
+                <button className="antimodeMenu-button" title="" onPointerDown={onBrushClick} style={this.brushMarks && this.brushMarks.size > 0 ? { backgroundColor: "121212" } : {}}>
+                    <FontAwesomeIcon icon="eye-dropper" size="lg" style={{ transition: "transform 0.1s", transform: this.brushMarks && this.brushMarks.size > 0 ? "rotate(45deg)" : "" }} />
+                </button>
                 <button className="dropdown-button antimodeMenu-button" onPointerDown={onDropdownClick}><FontAwesomeIcon icon="caret-down" size="sm" /></button>
                 {this.showBrushDropdown ?
                     (<div className="dropdown">
@@ -652,6 +650,17 @@ export default class RichTextMenu extends AntimodeMenu {
         return ref_node;
     }
 
+    @action onPointerEnter(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = true; }
+    @action onPointerLeave(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = false; }
+
+    @action
+    toggleMenuPin = (e: React.MouseEvent) => {
+        this.Pinned = !this.Pinned;
+        if (!this.Pinned) {
+            this.fadeOut(true);
+        }
+    }
+
     render() {
 
         const fontSizeOptions = [
@@ -705,16 +714,28 @@ export default class RichTextMenu extends AntimodeMenu {
             this.createButton("indent", "Summarize", undefined, this.insertSummarizer),
         ]}</div>
 
-        const row2 = <div className="antimodeMenu-row">{[
-            this.createMarksDropdown(this.activeFontSize, fontSizeOptions),
-            this.createMarksDropdown(this.activeFontFamily, fontFamilyOptions),
-            this.createNodesDropdown(this.activeListType, listTypeOptions),
-        ]}</div>
+        const row2 = <div className="antimodeMenu-row row-2">
+            <div>{[
+                this.createMarksDropdown(this.activeFontSize, fontSizeOptions),
+                this.createMarksDropdown(this.activeFontFamily, fontFamilyOptions),
+                this.createNodesDropdown(this.activeListType, listTypeOptions),
+            ]}</div>
+            <div>
+                <button className="antimodeMenu-button" title="Pin menu" onClick={this.toggleMenuPin} style={this.Pinned ? { backgroundColor: "#121212" } : {}}>
+                    <FontAwesomeIcon icon="thumbtack" size="lg" style={{ transition: "transform 0.1s", transform: this.Pinned ? "rotate(45deg)" : "" }} />
+                </button>
+                {this.getDragger()}
+            </div>
+        </div>
 
         const buttons = [
             row1, row2
         ];
 
-        return this.getElementWithRows(buttons, 2);
+        return (
+            <div className="richTextMenu" onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave}>
+                {this.getElementWithRows(buttons, 2)}
+            </div>
+        );
     }
 }
\ No newline at end of file
diff --git a/src/client/views/AntimodeMenu.tsx b/src/client/views/AntimodeMenu.tsx
index 713a8a189..62ecdffaf 100644
--- a/src/client/views/AntimodeMenu.tsx
+++ b/src/client/views/AntimodeMenu.tsx
@@ -116,6 +116,10 @@ export default abstract class AntimodeMenu extends React.Component {
         e.preventDefault();
     }
 
+    protected getDragger = () => {
+        return <div className="antimodeMenu-dragger" onPointerDown={this.dragStart} style={{ width: this.Pinned ? "20px" : "0px" }} />
+    }
+
     protected getElement(buttons: JSX.Element[]) {
         return (
             <div className="antimodeMenu-cont" onPointerLeave={this.pointerLeave} onPointerEnter={this.pointerEntered} ref={this._mainCont} onContextMenu={this.handleContextMenu}
@@ -131,7 +135,7 @@ export default abstract class AntimodeMenu extends React.Component {
             <div className="antimodeMenu-cont with-rows" onPointerLeave={this.pointerLeave} onPointerEnter={this.pointerEntered} ref={this._mainCont} onContextMenu={this.handleContextMenu}
                 style={{ left: this._left, top: this._top, opacity: this._opacity, transition: this._transition, transitionDelay: this._transitionDelay, height: 35 * numRows + "px" }}>
                 {rows}
-                <div className="antimodeMenu-dragger" onPointerDown={this.dragStart} style={{ width: this.Pinned ? "20px" : "0px" }} />
+                {/* <div className="antimodeMenu-dragger" onPointerDown={this.dragStart} style={{ width: this.Pinned ? "20px" : "0px" }} /> */}
             </div>
         );
     }
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index 7291d2e8e..4712b1974 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -913,7 +913,11 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         keeplocation && setTimeout(() => this._editorView?.dispatch(this._editorView?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos))));
 
         // jump rich text menu to this textbox
-        this._ref.current && RichTextMenu.Instance.jumpTo(this._ref.current.getBoundingClientRect().x, this._ref.current?.getBoundingClientRect().y - 105);
+        if (this._ref.current) {
+            let x = Math.min(Math.max(this._ref.current!.getBoundingClientRect().x, 0), window.innerWidth - 445);
+            let y = this._ref.current!.getBoundingClientRect().y - 105;
+            RichTextMenu.Instance.jumpTo(x, y);
+        }
     }
     onPointerWheel = (e: React.WheelEvent): void => {
         // if a text note is not selected and scrollable, this prevents us from being able to scroll and zoom out at the same time
@@ -1062,7 +1066,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         this.doLinkOnDeselect();
 
         // move the richtextmenu offscreen
-        if (!RichTextMenu.Instance.Pinned && !RichTextMenu.Instance.overDropdown) RichTextMenu.Instance.jumpTo(-300, -300);
+        if (!RichTextMenu.Instance.Pinned && !RichTextMenu.Instance.overMenu) RichTextMenu.Instance.jumpTo(-300, -300);
     }
 
     _lastTimedMark: Mark | undefined = undefined;
-- 
cgit v1.2.3-70-g09d2


From 8f030c9a8ca1f61f8430e5ea92c48a7a6b6f079f Mon Sep 17 00:00:00 2001
From: Fawn <fangrui_tong@brown.edu>
Date: Thu, 9 Jan 2020 13:32:13 -0500
Subject: bounded antimodemenu to the boundaries of the screen and minor
 styling of richtextmenu

---
 package-lock.json                           | 95 ++++++++++++-----------------
 src/client/util/RichTextMenu.scss           |  7 ++-
 src/client/util/RichTextMenu.tsx            | 19 +++---
 src/client/views/AntimodeMenu.tsx           | 25 ++++----
 src/client/views/nodes/FormattedTextBox.tsx | 22 +++----
 5 files changed, 79 insertions(+), 89 deletions(-)

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

diff --git a/package-lock.json b/package-lock.json
index 2c70ba94d..cf61e6aab 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2561,7 +2561,7 @@
         },
         "browserify-aes": {
             "version": "1.2.0",
-            "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
+            "resolved": "http://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
             "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
             "requires": {
                 "buffer-xor": "^1.0.3",
@@ -2595,7 +2595,7 @@
         },
         "browserify-rsa": {
             "version": "4.0.1",
-            "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
+            "resolved": "http://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
             "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=",
             "requires": {
                 "bn.js": "^4.1.0",
@@ -2632,7 +2632,7 @@
         },
         "buffer": {
             "version": "4.9.1",
-            "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz",
+            "resolved": "http://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz",
             "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=",
             "dev": true,
             "requires": {
@@ -2779,7 +2779,7 @@
         },
         "camelcase-keys": {
             "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz",
+            "resolved": "http://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz",
             "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=",
             "requires": {
                 "camelcase": "^2.0.0",
@@ -3563,7 +3563,7 @@
         },
         "create-hash": {
             "version": "1.2.0",
-            "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
+            "resolved": "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
             "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
             "requires": {
                 "cipher-base": "^1.0.1",
@@ -3575,7 +3575,7 @@
         },
         "create-hmac": {
             "version": "1.1.7",
-            "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
+            "resolved": "http://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
             "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
             "requires": {
                 "cipher-base": "^1.0.3",
@@ -4118,7 +4118,7 @@
         },
         "diffie-hellman": {
             "version": "5.0.3",
-            "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
+            "resolved": "http://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
             "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
             "requires": {
                 "bn.js": "^4.1.0",
@@ -5361,8 +5361,7 @@
                 },
                 "ansi-regex": {
                     "version": "2.1.1",
-                    "bundled": true,
-                    "optional": true
+                    "bundled": true
                 },
                 "aproba": {
                     "version": "1.2.0",
@@ -5380,13 +5379,11 @@
                 },
                 "balanced-match": {
                     "version": "1.0.0",
-                    "bundled": true,
-                    "optional": true
+                    "bundled": true
                 },
                 "brace-expansion": {
                     "version": "1.1.11",
                     "bundled": true,
-                    "optional": true,
                     "requires": {
                         "balanced-match": "^1.0.0",
                         "concat-map": "0.0.1"
@@ -5399,18 +5396,15 @@
                 },
                 "code-point-at": {
                     "version": "1.1.0",
-                    "bundled": true,
-                    "optional": true
+                    "bundled": true
                 },
                 "concat-map": {
                     "version": "0.0.1",
-                    "bundled": true,
-                    "optional": true
+                    "bundled": true
                 },
                 "console-control-strings": {
                     "version": "1.1.0",
-                    "bundled": true,
-                    "optional": true
+                    "bundled": true
                 },
                 "core-util-is": {
                     "version": "1.0.2",
@@ -5513,8 +5507,7 @@
                 },
                 "inherits": {
                     "version": "2.0.3",
-                    "bundled": true,
-                    "optional": true
+                    "bundled": true
                 },
                 "ini": {
                     "version": "1.3.5",
@@ -5524,7 +5517,6 @@
                 "is-fullwidth-code-point": {
                     "version": "1.0.0",
                     "bundled": true,
-                    "optional": true,
                     "requires": {
                         "number-is-nan": "^1.0.0"
                     }
@@ -5537,20 +5529,17 @@
                 "minimatch": {
                     "version": "3.0.4",
                     "bundled": true,
-                    "optional": true,
                     "requires": {
                         "brace-expansion": "^1.1.7"
                     }
                 },
                 "minimist": {
                     "version": "0.0.8",
-                    "bundled": true,
-                    "optional": true
+                    "bundled": true
                 },
                 "minipass": {
                     "version": "2.3.5",
                     "bundled": true,
-                    "optional": true,
                     "requires": {
                         "safe-buffer": "^5.1.2",
                         "yallist": "^3.0.0"
@@ -5567,7 +5556,6 @@
                 "mkdirp": {
                     "version": "0.5.1",
                     "bundled": true,
-                    "optional": true,
                     "requires": {
                         "minimist": "0.0.8"
                     }
@@ -5640,8 +5628,7 @@
                 },
                 "number-is-nan": {
                     "version": "1.0.1",
-                    "bundled": true,
-                    "optional": true
+                    "bundled": true
                 },
                 "object-assign": {
                     "version": "4.1.1",
@@ -5651,7 +5638,6 @@
                 "once": {
                     "version": "1.4.0",
                     "bundled": true,
-                    "optional": true,
                     "requires": {
                         "wrappy": "1"
                     }
@@ -5727,8 +5713,7 @@
                 },
                 "safe-buffer": {
                     "version": "5.1.2",
-                    "bundled": true,
-                    "optional": true
+                    "bundled": true
                 },
                 "safer-buffer": {
                     "version": "2.1.2",
@@ -5758,7 +5743,6 @@
                 "string-width": {
                     "version": "1.0.2",
                     "bundled": true,
-                    "optional": true,
                     "requires": {
                         "code-point-at": "^1.0.0",
                         "is-fullwidth-code-point": "^1.0.0",
@@ -5776,7 +5760,6 @@
                 "strip-ansi": {
                     "version": "3.0.1",
                     "bundled": true,
-                    "optional": true,
                     "requires": {
                         "ansi-regex": "^2.0.0"
                     }
@@ -5815,13 +5798,11 @@
                 },
                 "wrappy": {
                     "version": "1.0.2",
-                    "bundled": true,
-                    "optional": true
+                    "bundled": true
                 },
                 "yallist": {
                     "version": "3.0.3",
-                    "bundled": true,
-                    "optional": true
+                    "bundled": true
                 }
             }
         },
@@ -7073,7 +7054,7 @@
                 "babel-runtime": "^6.22.0",
                 "bluebird": "^3.4.7",
                 "boolify-string": "^2.0.2",
-                "emit-logger": "github:chocolateboy/emit-logger#better-emitter-name",
+                "emit-logger": "github:chocolateboy/emit-logger#b9d25a2d939e42f29c940861e9648bd0fb810070",
                 "lodash": "^4.17.4",
                 "semver": "^5.0.3",
                 "source-map-support": "^0.5.9"
@@ -7081,7 +7062,7 @@
         },
         "is-accessor-descriptor": {
             "version": "0.1.6",
-            "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+            "resolved": "http://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
             "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=",
             "requires": {
                 "kind-of": "^3.0.2"
@@ -7130,7 +7111,7 @@
         },
         "is-data-descriptor": {
             "version": "0.1.4",
-            "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+            "resolved": "http://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
             "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=",
             "requires": {
                 "kind-of": "^3.0.2"
@@ -7915,7 +7896,7 @@
         },
         "load-json-file": {
             "version": "1.1.0",
-            "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
+            "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
             "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=",
             "requires": {
                 "graceful-fs": "^4.1.2",
@@ -8229,7 +8210,7 @@
         },
         "media-typer": {
             "version": "0.3.0",
-            "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+            "resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
             "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
         },
         "mem": {
@@ -8261,7 +8242,7 @@
         },
         "meow": {
             "version": "3.7.0",
-            "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz",
+            "resolved": "http://registry.npmjs.org/meow/-/meow-3.7.0.tgz",
             "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=",
             "requires": {
                 "camelcase-keys": "^2.0.0",
@@ -8454,7 +8435,7 @@
         },
         "mkdirp": {
             "version": "0.5.1",
-            "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+            "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
             "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
             "requires": {
                 "minimist": "0.0.8"
@@ -8800,7 +8781,7 @@
         },
         "next-tick": {
             "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
+            "resolved": "http://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
             "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw="
         },
         "nice-try": {
@@ -8964,7 +8945,7 @@
                 },
                 "semver": {
                     "version": "5.3.0",
-                    "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
+                    "resolved": "http://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
                     "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8="
                 },
                 "tar": {
@@ -12573,7 +12554,7 @@
         },
         "os-homedir": {
             "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+            "resolved": "http://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
             "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M="
         },
         "os-locale": {
@@ -12586,7 +12567,7 @@
         },
         "os-tmpdir": {
             "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+            "resolved": "http://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
             "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
         },
         "osenv": {
@@ -12826,7 +12807,7 @@
         },
         "path-is-absolute": {
             "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+            "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
             "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
         },
         "path-is-inside": {
@@ -14146,7 +14127,7 @@
         },
         "readable-stream": {
             "version": "2.3.6",
-            "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
+            "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
             "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
             "requires": {
                 "core-util-is": "~1.0.0",
@@ -14491,7 +14472,7 @@
         },
         "safe-regex": {
             "version": "1.1.0",
-            "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
+            "resolved": "http://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
             "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
             "requires": {
                 "ret": "~0.1.10"
@@ -14756,7 +14737,7 @@
         },
         "sha.js": {
             "version": "2.4.11",
-            "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
+            "resolved": "http://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
             "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
             "requires": {
                 "inherits": "^2.0.1",
@@ -15499,7 +15480,7 @@
         },
         "string_decoder": {
             "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+            "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
             "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
             "requires": {
                 "safe-buffer": "~5.1.0"
@@ -15529,7 +15510,7 @@
         },
         "strip-ansi": {
             "version": "3.0.1",
-            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+            "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
             "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
             "requires": {
                 "ansi-regex": "^2.0.0"
@@ -15545,7 +15526,7 @@
         },
         "strip-eof": {
             "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
+            "resolved": "http://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
             "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8="
         },
         "strip-indent": {
@@ -16298,7 +16279,7 @@
         },
         "tty-browserify": {
             "version": "0.0.0",
-            "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
+            "resolved": "http://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
             "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=",
             "dev": true
         },
@@ -17740,7 +17721,7 @@
         },
         "wrap-ansi": {
             "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
+            "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
             "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=",
             "requires": {
                 "string-width": "^1.0.1",
diff --git a/src/client/util/RichTextMenu.scss b/src/client/util/RichTextMenu.scss
index ff9270829..6383e0b7b 100644
--- a/src/client/util/RichTextMenu.scss
+++ b/src/client/util/RichTextMenu.scss
@@ -21,6 +21,7 @@
         min-width: 150px;
         padding: 5px;
         font-size: 12px;
+        z-index: 10001;
 
         button {
             background-color: #323232;
@@ -28,11 +29,15 @@
             border-radius: 1px;
             padding: 6px;
             margin: 5px 0;
-            font-size: 12px;
+            font-size: 10px;
 
             &:hover {
                 background-color: black;
             }
+
+            &:last-child {
+                margin-bottom: 0;
+            }
         }
     }
 
diff --git a/src/client/util/RichTextMenu.tsx b/src/client/util/RichTextMenu.tsx
index af2e7cc48..ca6b90e36 100644
--- a/src/client/util/RichTextMenu.tsx
+++ b/src/client/util/RichTextMenu.tsx
@@ -8,7 +8,7 @@ import { EditorView } from "prosemirror-view";
 import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 import { IconProp, library } from '@fortawesome/fontawesome-svg-core';
-import { faBold, faItalic, faUnderline, faStrikethrough, faSubscript, faSuperscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink } from "@fortawesome/free-solid-svg-icons";
+import { faBold, faItalic, faUnderline, faStrikethrough, faSubscript, faSuperscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller } from "@fortawesome/free-solid-svg-icons";
 import { MenuItem, Dropdown } from "prosemirror-menu";
 import { updateBullets } from "./ProsemirrorExampleTransfer";
 import { FieldViewProps } from "../views/nodes/FieldView";
@@ -24,7 +24,7 @@ import { SelectionManager } from "./SelectionManager";
 import { LinkManager } from "./LinkManager";
 const { toggleMark, setBlockType } = require("prosemirror-commands");
 
-library.add(faBold, faItalic, faUnderline, faStrikethrough, faSuperscript, faSubscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink);
+library.add(faBold, faItalic, faUnderline, faStrikethrough, faSuperscript, faSubscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller);
 
 @observer
 export default class RichTextMenu extends AntimodeMenu {
@@ -86,8 +86,9 @@ export default class RichTextMenu extends AntimodeMenu {
         const activeFamilies = active && active.get("families");
         const activeSizes = active && active.get("sizes");
 
-        this.activeFontFamily = !activeFamilies || activeFamilies.length === 0 ? "default" : activeFamilies.length === 1 ? String(activeFamilies[0]) : "various";
-        this.activeFontSize = !activeSizes || activeSizes.length === 0 ? "default" : activeSizes.length === 1 ? String(activeSizes[0]) + "pt" : "various";
+        console.log("update from dash, activefontsize", this.activeFontSize, activeSizes, activeSizes && activeSizes.length, activeSizes && String(activeSizes[0]));
+        this.activeFontFamily = !activeFamilies || activeFamilies.length === 0 ? "Arial" : activeFamilies.length === 1 ? String(activeFamilies[0]) : "various";
+        this.activeFontSize = !activeSizes || activeSizes.length === 0 ? "13pt" : activeSizes.length === 1 ? String(activeSizes[0]) + "pt" : "various";
 
         // update link in current selection
         const targetTitle = await this.getTextLinkTargetTitle();
@@ -99,7 +100,7 @@ export default class RichTextMenu extends AntimodeMenu {
     setMark = (mark: Mark, state: EditorState<any>, dispatch: any) => {
         if (mark) {
             const node = (state.selection as NodeSelection).node;
-            if (node?.type === schema.nodes.ordered_list) {
+            if (node ?.type === schema.nodes.ordered_list) {
                 let attrs = node.attrs;
                 if (mark.type === schema.marks.pFontFamily) attrs = { ...attrs, setFontFamily: mark.attrs.family };
                 if (mark.type === schema.marks.pFontSize) attrs = { ...attrs, setFontSize: mark.attrs.fontSize };
@@ -142,7 +143,7 @@ export default class RichTextMenu extends AntimodeMenu {
     getMarksInSelection(state: EditorState<any>) {
         const found = new Set<Mark>();
         const { from, to } = state.selection as TextSelection;
-        state.doc.nodesBetween(from, to, (node) => node.marks?.forEach(m => found.add(m)));
+        state.doc.nodesBetween(from, to, (node) => node.marks ?.forEach(m => found.add(m)));
         return found;
     }
 
@@ -309,7 +310,7 @@ export default class RichTextMenu extends AntimodeMenu {
         return (
             <div className="button-dropdown-wrapper">
                 <button className="antimodeMenu-button" title="" onPointerDown={onBrushClick} style={this.brushMarks && this.brushMarks.size > 0 ? { backgroundColor: "121212" } : {}}>
-                    <FontAwesomeIcon icon="eye-dropper" size="lg" style={{ transition: "transform 0.1s", transform: this.brushMarks && this.brushMarks.size > 0 ? "rotate(45deg)" : "" }} />
+                    <FontAwesomeIcon icon="paint-roller" size="lg" style={{ transition: "transform 0.1s", transform: this.brushMarks && this.brushMarks.size > 0 ? "rotate(45deg)" : "" }} />
                 </button>
                 <button className="dropdown-button antimodeMenu-button" onPointerDown={onDropdownClick}><FontAwesomeIcon icon="caret-down" size="sm" /></button>
                 {this.showBrushDropdown ?
@@ -679,7 +680,7 @@ export default class RichTextMenu extends AntimodeMenu {
             { mark: schema.marks.pFontSize.create({ fontSize: 48 }), title: "Set font size", label: "48pt", command: this.changeFontSize },
             { mark: schema.marks.pFontSize.create({ fontSize: 72 }), title: "Set font size", label: "72pt", command: this.changeFontSize },
             { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true },
-            { mark: null, title: "", label: "default", command: unimplementedFunction, hidden: true },
+            { mark: null, title: "", label: "13pt", command: unimplementedFunction, hidden: true }, // this is here because the default size is 13, but there is no actual 13pt option
         ];
 
         const fontFamilyOptions = [
@@ -691,7 +692,7 @@ export default class RichTextMenu extends AntimodeMenu {
             { mark: schema.marks.pFontFamily.create({ family: "Impact" }), title: "Set font family", label: "Impact", command: this.changeFontFamily },
             { mark: schema.marks.pFontFamily.create({ family: "Crimson Text" }), title: "Set font family", label: "Crimson Text", command: this.changeFontFamily },
             { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true },
-            { mark: null, title: "", label: "default", command: unimplementedFunction, hidden: true },
+            // { mark: null, title: "", label: "default", command: unimplementedFunction, hidden: true },
         ];
 
         const listTypeOptions = [
diff --git a/src/client/views/AntimodeMenu.tsx b/src/client/views/AntimodeMenu.tsx
index 25bd90d87..e358d88cf 100644
--- a/src/client/views/AntimodeMenu.tsx
+++ b/src/client/views/AntimodeMenu.tsx
@@ -22,6 +22,9 @@ export default abstract class AntimodeMenu extends React.Component {
 
     @observable public Pinned: boolean = false;
 
+    get width() { return this._mainCont.current ? this._mainCont.current.getBoundingClientRect().width : 0; }
+    get height() { return this._mainCont.current ? this._mainCont.current.getBoundingClientRect().height : 0; }
+
     @action
     /**
      * @param x
@@ -44,7 +47,7 @@ export default abstract class AntimodeMenu extends React.Component {
      * Called when you want the menu to disappear
      */
     public fadeOut = (forceOut: boolean) => {
-        if (!this.Pinned && this._canFade) {
+        if (!this.Pinned) {
             if (this._opacity === 0.2) {
                 this._transition = "opacity 0.1s";
                 this._transitionDelay = "";
@@ -89,8 +92,8 @@ export default abstract class AntimodeMenu extends React.Component {
         document.removeEventListener("pointerup", this.dragEnd);
         document.addEventListener("pointerup", this.dragEnd);
 
-        this._offsetX = this._mainCont.current!.getBoundingClientRect().width - e.nativeEvent.offsetX;
-        this._offsetY = e.nativeEvent.offsetY;
+        this._offsetX = e.pageX - this._mainCont.current!.getBoundingClientRect().left;
+        this._offsetY = e.pageY - this._mainCont.current!.getBoundingClientRect().top;
 
         e.stopPropagation();
         e.preventDefault();
@@ -98,14 +101,14 @@ export default abstract class AntimodeMenu extends React.Component {
 
     @action
     protected dragging = (e: PointerEvent) => {
-        this._left = e.pageX - this._offsetX;
-        this._top = e.pageY - this._offsetY;
-
-        // assumes dragger is on right
-        // let width = this._mainCont.current!.getBoundingClientRect().width;
-        // let height = this._mainCont.current!.getBoundingClientRect().width;
-        // this._left = Math.max(width, e.pageX - this._offsetX);
-        // this._top = Math.min(height, e.pageY - this._offsetY);
+        const width = this._mainCont.current!.getBoundingClientRect().width;
+        const height = this._mainCont.current!.getBoundingClientRect().height;
+
+        const left = e.pageX - this._offsetX;
+        const top = e.pageY - this._offsetY;
+
+        this._left = Math.min(Math.max(left, 0), window.innerWidth - width);
+        this._top = Math.min(Math.max(top, 0), window.innerHeight - height);
 
         e.stopPropagation();
         e.preventDefault();
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index 4712b1974..027bd492c 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -906,16 +906,16 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         this.tryUpdateHeight();
 
         // see if we need to preserve the insertion point
-        const prosediv = this.ProseRef?.children?.[0] as any;
-        const keeplocation = prosediv?.keeplocation;
+        const prosediv = this.ProseRef ?.children ?.[0] as any;
+        const keeplocation = prosediv ?.keeplocation;
         prosediv && (prosediv.keeplocation = undefined);
-        const pos = this._editorView?.state.selection.$from.pos || 1;
-        keeplocation && setTimeout(() => this._editorView?.dispatch(this._editorView?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos))));
+        const pos = this._editorView ?.state.selection.$from.pos || 1;
+        keeplocation && setTimeout(() => this._editorView ?.dispatch(this._editorView ?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos))));
 
         // jump rich text menu to this textbox
         if (this._ref.current) {
-            let x = Math.min(Math.max(this._ref.current!.getBoundingClientRect().x, 0), window.innerWidth - 445);
-            let y = this._ref.current!.getBoundingClientRect().y - 105;
+            const x = Math.min(Math.max(this._ref.current!.getBoundingClientRect().left, 0), window.innerWidth - RichTextMenu.Instance.width);
+            const y = this._ref.current!.getBoundingClientRect().top - RichTextMenu.Instance.height - 50;
             RichTextMenu.Instance.jumpTo(x, y);
         }
     }
@@ -933,7 +933,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         if ((this._editorView!.root as any).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 = this._editorView!.posAtCoords({ left: e.clientX, top: e.clientY });
             const node = pcords && this._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 === this._editorView!.state.schema.nodes.dashComment) {
+            if (pcords && node ?.type === this._editorView!.state.schema.nodes.dashComment) {
                 this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pcords.pos + 2)));
                 e.preventDefault();
             }
@@ -996,7 +996,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
                 for (let off = 1; off < 100; off++) {
                     const pos = this._editorView!.posAtCoords({ left: x + off, top: y });
                     const node = pos && this._editorView!.state.doc.nodeAt(pos.pos);
-                    if (node?.type === schema.nodes.list_item) {
+                    if (node ?.type === schema.nodes.list_item) {
                         list_node = node;
                         break;
                     }
@@ -1088,7 +1088,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         }
         if (e.key === "Escape") {
             this._editorView!.dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from)));
-            (document.activeElement as any).blur?.();
+            (document.activeElement as any).blur ?.();
             SelectionManager.DeselectAll();
         }
         e.stopPropagation();
@@ -1110,7 +1110,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
 
     @action
     tryUpdateHeight(limitHeight?: number) {
-        let scrollHeight = this._ref.current?.scrollHeight;
+        let scrollHeight = this._ref.current ?.scrollHeight;
         if (!this.layoutDoc.animateToPos && this.layoutDoc.autoHeight && scrollHeight &&
             getComputedStyle(this._ref.current!.parentElement!).top === "0px") {  // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation
             if (limitHeight && scrollHeight > limitHeight) {
@@ -1173,7 +1173,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
                 {this.props.Document.hideSidebar ? (null) : this.sidebarWidthPercent === "0%" ?
                     <div className="formattedTextBox-sidebar-handle" onPointerDown={this.sidebarDown} onClick={e => this.toggleSidebar()} /> :
                     <div className={"formattedTextBox-sidebar" + (InkingControl.Instance.selectedTool !== InkTool.None ? "-inking" : "")}
-                        style={{ width: `${this.sidebarWidthPercent}`, backgroundColor: `${StrCast(this.extensionDoc?.backgroundColor, "transparent")}` }}>
+                        style={{ width: `${this.sidebarWidthPercent}`, backgroundColor: `${StrCast(this.extensionDoc ?.backgroundColor, "transparent")}` }}>
                         <CollectionFreeFormView {...this.props}
                             PanelHeight={this.props.PanelHeight}
                             PanelWidth={() => this.sidebarWidth}
-- 
cgit v1.2.3-70-g09d2


From 3b08b8c340bf889c99f7b2fa76cda0f8b2d4b0a1 Mon Sep 17 00:00:00 2001
From: Fawn <fangrui_tong@brown.edu>
Date: Thu, 9 Jan 2020 15:03:54 -0500
Subject: refactored dropdown buttons into a buttondropdown component for
 richtextmenu

---
 src/client/util/RichTextMenu.tsx            | 382 +++++++++++++++-------------
 src/client/views/nodes/FormattedTextBox.tsx |   1 -
 2 files changed, 206 insertions(+), 177 deletions(-)

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

diff --git a/src/client/util/RichTextMenu.tsx b/src/client/util/RichTextMenu.tsx
index ca6b90e36..2d4f0fc1b 100644
--- a/src/client/util/RichTextMenu.tsx
+++ b/src/client/util/RichTextMenu.tsx
@@ -34,6 +34,12 @@ export default class RichTextMenu extends AntimodeMenu {
     private view?: EditorView;
     private editorProps: FieldViewProps & FormattedTextBoxProps | undefined;
 
+    private fontSizeOptions: { mark: Mark | null, title: string, label: string, command: any, hidden?: boolean }[];
+    private fontFamilyOptions: { mark: Mark | null, title: string, label: string, command: any, hidden?: boolean }[];
+    private listTypeOptions: { node: NodeType | any | null, title: string, label: string, command: any }[];
+    private fontColors: (string | undefined)[];
+    private highlightColors: (string | undefined)[];
+
     @observable private activeFontSize: string = "";
     @observable private activeFontFamily: string = "";
     @observable private activeListType: string = "";
@@ -55,6 +61,69 @@ export default class RichTextMenu extends AntimodeMenu {
         super(props);
         RichTextMenu.Instance = this;
         this._canFade = false;
+
+        this.fontSizeOptions = [
+            { mark: schema.marks.pFontSize.create({ fontSize: 7 }), title: "Set font size", label: "7pt", command: this.changeFontSize },
+            { mark: schema.marks.pFontSize.create({ fontSize: 8 }), title: "Set font size", label: "8pt", command: this.changeFontSize },
+            { mark: schema.marks.pFontSize.create({ fontSize: 9 }), title: "Set font size", label: "8pt", command: this.changeFontSize },
+            { mark: schema.marks.pFontSize.create({ fontSize: 10 }), title: "Set font size", label: "10pt", command: this.changeFontSize },
+            { mark: schema.marks.pFontSize.create({ fontSize: 12 }), title: "Set font size", label: "12pt", command: this.changeFontSize },
+            { mark: schema.marks.pFontSize.create({ fontSize: 14 }), title: "Set font size", label: "14pt", command: this.changeFontSize },
+            { mark: schema.marks.pFontSize.create({ fontSize: 16 }), title: "Set font size", label: "16pt", command: this.changeFontSize },
+            { mark: schema.marks.pFontSize.create({ fontSize: 18 }), title: "Set font size", label: "18pt", command: this.changeFontSize },
+            { mark: schema.marks.pFontSize.create({ fontSize: 20 }), title: "Set font size", label: "20pt", command: this.changeFontSize },
+            { mark: schema.marks.pFontSize.create({ fontSize: 24 }), title: "Set font size", label: "24pt", command: this.changeFontSize },
+            { mark: schema.marks.pFontSize.create({ fontSize: 32 }), title: "Set font size", label: "32pt", command: this.changeFontSize },
+            { mark: schema.marks.pFontSize.create({ fontSize: 48 }), title: "Set font size", label: "48pt", command: this.changeFontSize },
+            { mark: schema.marks.pFontSize.create({ fontSize: 72 }), title: "Set font size", label: "72pt", command: this.changeFontSize },
+            { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true },
+            { mark: null, title: "", label: "13pt", command: unimplementedFunction, hidden: true }, // this is here because the default size is 13, but there is no actual 13pt option
+        ];
+
+        this.fontFamilyOptions = [
+            { mark: schema.marks.pFontFamily.create({ family: "Times New Roman" }), title: "Set font family", label: "Times New Roman", command: this.changeFontFamily },
+            { mark: schema.marks.pFontFamily.create({ family: "Arial" }), title: "Set font family", label: "Arial", command: this.changeFontFamily },
+            { mark: schema.marks.pFontFamily.create({ family: "Georgia" }), title: "Set font family", label: "Georgia", command: this.changeFontFamily },
+            { mark: schema.marks.pFontFamily.create({ family: "Comic Sans MS" }), title: "Set font family", label: "Comic Sans MS", command: this.changeFontFamily },
+            { mark: schema.marks.pFontFamily.create({ family: "Tahoma" }), title: "Set font family", label: "Tahoma", command: this.changeFontFamily },
+            { mark: schema.marks.pFontFamily.create({ family: "Impact" }), title: "Set font family", label: "Impact", command: this.changeFontFamily },
+            { mark: schema.marks.pFontFamily.create({ family: "Crimson Text" }), title: "Set font family", label: "Crimson Text", command: this.changeFontFamily },
+            { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true },
+            // { mark: null, title: "", label: "default", command: unimplementedFunction, hidden: true },
+        ];
+
+        this.listTypeOptions = [
+            { node: schema.nodes.ordered_list.create({ mapStyle: "bullet" }), title: "Set list type", label: ":", command: this.changeListType },
+            { node: schema.nodes.ordered_list.create({ mapStyle: "decimal" }), title: "Set list type", label: "1.1", command: this.changeListType },
+            { node: schema.nodes.ordered_list.create({ mapStyle: "multi" }), title: "Set list type", label: "1.A", command: this.changeListType },
+            { node: undefined, title: "Set list type", label: "Remove", command: this.changeListType },
+        ];
+
+        this.fontColors = [
+            DarkPastelSchemaPalette.get("pink2"),
+            DarkPastelSchemaPalette.get("purple4"),
+            DarkPastelSchemaPalette.get("bluegreen1"),
+            DarkPastelSchemaPalette.get("yellow4"),
+            DarkPastelSchemaPalette.get("red2"),
+            DarkPastelSchemaPalette.get("bluegreen7"),
+            DarkPastelSchemaPalette.get("bluegreen5"),
+            DarkPastelSchemaPalette.get("orange1"),
+            "#757472",
+            "#000"
+        ];
+
+        this.highlightColors = [
+            PastelSchemaPalette.get("pink2"),
+            PastelSchemaPalette.get("purple4"),
+            PastelSchemaPalette.get("bluegreen1"),
+            PastelSchemaPalette.get("yellow4"),
+            PastelSchemaPalette.get("red2"),
+            PastelSchemaPalette.get("bluegreen7"),
+            PastelSchemaPalette.get("bluegreen5"),
+            PastelSchemaPalette.get("orange1"),
+            "white",
+            "transparent"
+        ];
     }
 
     @action
@@ -74,27 +143,23 @@ export default class RichTextMenu extends AntimodeMenu {
         }
         this.view = view;
         const state = view.state;
-        // DocumentDecorations.Instance.showTextBar();
         props && (this.editorProps = props);
+
         // Don't do anything if the document/selection didn't change
         if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) return;
 
-        // this.reset_mark_doms();
 
         // update active font family and size
         const active = this.getActiveFontStylesOnSelection();
         const activeFamilies = active && active.get("families");
         const activeSizes = active && active.get("sizes");
 
-        console.log("update from dash, activefontsize", this.activeFontSize, activeSizes, activeSizes && activeSizes.length, activeSizes && String(activeSizes[0]));
         this.activeFontFamily = !activeFamilies || activeFamilies.length === 0 ? "Arial" : activeFamilies.length === 1 ? String(activeFamilies[0]) : "various";
         this.activeFontSize = !activeSizes || activeSizes.length === 0 ? "13pt" : activeSizes.length === 1 ? String(activeSizes[0]) + "pt" : "various";
 
         // update link in current selection
         const targetTitle = await this.getTextLinkTargetTitle();
         this.setCurrentLink(targetTitle);
-
-        // this.update_mark_doms();
     }
 
     setMark = (mark: Mark, state: EditorState<any>, dispatch: any) => {
@@ -134,7 +199,7 @@ export default class RichTextMenu extends AntimodeMenu {
             });
         }
 
-        let styles = new Map<String, String[]>();
+        const styles = new Map<String, String[]>();
         styles.set("families", activeFamilies);
         styles.set("sizes", activeSizes);
         return styles;
@@ -183,7 +248,6 @@ export default class RichTextMenu extends AntimodeMenu {
         function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
             e.stopPropagation();
             e.preventDefault();
-            console.log("on change marks");
             options.forEach(({ label, mark, command }) => {
                 if (e.target.value === label) {
                     self.view && mark && command(mark, self.view);
@@ -230,7 +294,6 @@ export default class RichTextMenu extends AntimodeMenu {
 
     changeFontFamily = (mark: Mark, view: EditorView) => {
         const fontName = mark.attrs.family;
-        // if (fontName) { this.updateFontStyleDropdown(fontName); }
         if (this.editorProps) {
             const ruleProvider = this.editorProps.ruleProvider;
             const heading = NumCast(this.editorProps.Document.heading);
@@ -288,12 +351,6 @@ export default class RichTextMenu extends AntimodeMenu {
             self.view && self.view.focus();
             self.view && self.fillBrush(self.view.state, self.view.dispatch);
         }
-        function onDropdownClick(e: React.PointerEvent) {
-            e.preventDefault();
-            e.stopPropagation();
-            self.view && self.view.focus();
-            self.toggleBrushDropdown();
-        }
 
         let label = "Stored marks: ";
         if (this.brushMarks && this.brushMarks.size > 0) {
@@ -307,20 +364,20 @@ export default class RichTextMenu extends AntimodeMenu {
             label = "No marks are currently stored";
         }
 
+        const button =
+            <button className="antimodeMenu-button" title="" onPointerDown={onBrushClick} style={this.brushMarks && this.brushMarks.size > 0 ? { backgroundColor: "121212" } : {}}>
+                <FontAwesomeIcon icon="paint-roller" size="lg" style={{ transition: "transform 0.1s", transform: this.brushMarks && this.brushMarks.size > 0 ? "rotate(45deg)" : "" }} />
+            </button>;
+
+        const dropdownContent =
+            <div className="dropdown">
+                <p>{label}</p>
+                <button onPointerDown={this.clearBrush}>Clear brush</button>
+                {/* <input placeholder="Enter URL"></input> */}
+            </div>;
+
         return (
-            <div className="button-dropdown-wrapper">
-                <button className="antimodeMenu-button" title="" onPointerDown={onBrushClick} style={this.brushMarks && this.brushMarks.size > 0 ? { backgroundColor: "121212" } : {}}>
-                    <FontAwesomeIcon icon="paint-roller" size="lg" style={{ transition: "transform 0.1s", transform: this.brushMarks && this.brushMarks.size > 0 ? "rotate(45deg)" : "" }} />
-                </button>
-                <button className="dropdown-button antimodeMenu-button" onPointerDown={onDropdownClick}><FontAwesomeIcon icon="caret-down" size="sm" /></button>
-                {this.showBrushDropdown ?
-                    (<div className="dropdown">
-                        <p>{label}</p>
-                        <button onPointerDown={this.clearBrush}>Clear brush</button>
-                        {/* <input placeholder="Enter URL"></input> */}
-                    </div>)
-                    : <></>}
-            </div>
+            <ButtonDropdown view={this.view} button={button} dropdownContent={dropdownContent} />
         );
     }
 
@@ -340,7 +397,6 @@ export default class RichTextMenu extends AntimodeMenu {
                 this.brushMarks = selected_marks;
                 this.brushIsEmpty = !this.brushIsEmpty;
             }
-            // }
         }
         else {
             const { from, to, $from } = this.view.state.selection;
@@ -369,12 +425,6 @@ export default class RichTextMenu extends AntimodeMenu {
             self.view && self.view.focus();
             self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch);
         }
-        function onDropdownClick(e: React.PointerEvent) {
-            e.preventDefault();
-            e.stopPropagation();
-            self.view && self.view.focus();
-            self.toggleColorDropdown();
-        }
         function changeColor(e: React.PointerEvent, color: string) {
             e.preventDefault();
             e.stopPropagation();
@@ -383,41 +433,28 @@ export default class RichTextMenu extends AntimodeMenu {
             self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch);
         }
 
-        const colors = [
-            DarkPastelSchemaPalette.get("pink2"),
-            DarkPastelSchemaPalette.get("purple4"),
-            DarkPastelSchemaPalette.get("bluegreen1"),
-            DarkPastelSchemaPalette.get("yellow4"),
-            DarkPastelSchemaPalette.get("red2"),
-            DarkPastelSchemaPalette.get("bluegreen7"),
-            DarkPastelSchemaPalette.get("bluegreen5"),
-            DarkPastelSchemaPalette.get("orange1"),
-            "#757472",
-            "#000"
-        ];
+        const button =
+            <button className="antimodeMenu-button color-preview-button" title="" onPointerDown={onColorClick}>
+                <FontAwesomeIcon icon="palette" size="lg" />
+                <div className="color-preview" style={{ backgroundColor: this.activeFontColor }}></div>
+            </button>;
+
+        const dropdownContent =
+            <div className="dropdown">
+                <p>Change font color:</p>
+                <div className="color-wrapper">
+                    {this.fontColors.map(color => {
+                        if (color) {
+                            return this.activeFontColor === color ?
+                                <button className="color-button active" style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button> :
+                                <button className="color-button" style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button>;
+                        }
+                    })}
+                </div>
+            </div>;
 
         return (
-            <div className="button-dropdown-wrapper">
-                <button className="antimodeMenu-button color-preview-button" title="" onPointerDown={onColorClick}>
-                    <FontAwesomeIcon icon="palette" size="lg" />
-                    <div className="color-preview" style={{ backgroundColor: this.activeFontColor }}></div>
-                </button>
-                <button className="dropdown-button antimodeMenu-button" onPointerDown={onDropdownClick}><FontAwesomeIcon icon="caret-down" size="sm" /></button>
-                {this.showColorDropdown ?
-                    (<div className="dropdown">
-                        <p>Change font color:</p>
-                        <div className="color-wrapper">
-                            {colors.map(color => {
-                                if (color) {
-                                    return this.activeFontColor === color ?
-                                        <button className="color-button active" style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button> :
-                                        <button className="color-button" style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button>;
-                                }
-                            })}
-                        </div>
-                    </div>)
-                    : <></>}
-            </div>
+            <ButtonDropdown view={this.view} button={button} dropdownContent={dropdownContent} />
         );
     }
 
@@ -441,12 +478,6 @@ export default class RichTextMenu extends AntimodeMenu {
             self.view && self.view.focus();
             self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch);
         }
-        function onDropdownClick(e: React.PointerEvent) {
-            e.preventDefault();
-            e.stopPropagation();
-            self.view && self.view.focus();
-            self.toggleHighlightDropdown();
-        }
         function changeHighlight(e: React.PointerEvent, color: string) {
             e.preventDefault();
             e.stopPropagation();
@@ -455,41 +486,28 @@ export default class RichTextMenu extends AntimodeMenu {
             self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch);
         }
 
-        const colors = [
-            PastelSchemaPalette.get("pink2"),
-            PastelSchemaPalette.get("purple4"),
-            PastelSchemaPalette.get("bluegreen1"),
-            PastelSchemaPalette.get("yellow4"),
-            PastelSchemaPalette.get("red2"),
-            PastelSchemaPalette.get("bluegreen7"),
-            PastelSchemaPalette.get("bluegreen5"),
-            PastelSchemaPalette.get("orange1"),
-            "white",
-            "transparent"
-        ];
+        const button =
+            <button className="antimodeMenu-button color-preview-button" title="" onPointerDown={onHighlightClick}>
+                <FontAwesomeIcon icon="highlighter" size="lg" />
+                <div className="color-preview" style={{ backgroundColor: this.activeHighlightColor }}></div>
+            </button>;
+
+        const dropdownContent =
+            <div className="dropdown">
+                <p>Change highlight color:</p>
+                <div className="color-wrapper">
+                    {this.highlightColors.map(color => {
+                        if (color) {
+                            return this.activeHighlightColor === color ?
+                                <button className="color-button active" style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}>{color === "transparent" ? "X" : ""}</button> :
+                                <button className="color-button" style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}>{color === "transparent" ? "X" : ""}</button>;
+                        }
+                    })}
+                </div>
+            </div>;
 
         return (
-            <div className="button-dropdown-wrapper">
-                <button className="antimodeMenu-button color-preview-button" title="" onPointerDown={onHighlightClick}>
-                    <FontAwesomeIcon icon="highlighter" size="lg" />
-                    <div className="color-preview" style={{ backgroundColor: this.activeHighlightColor }}></div>
-                </button>
-                <button className="dropdown-button antimodeMenu-button" onPointerDown={onDropdownClick}><FontAwesomeIcon icon="caret-down" size="sm" /></button>
-                {this.showHighlightDropdown ?
-                    (<div className="dropdown">
-                        <p>Change highlight color:</p>
-                        <div className="color-wrapper">
-                            {colors.map(color => {
-                                if (color) {
-                                    return this.activeHighlightColor === color ?
-                                        <button className="color-button active" style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}>{color === "transparent" ? "X" : ""}</button> :
-                                        <button className="color-button" style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}>{color === "transparent" ? "X" : ""}</button>;
-                                }
-                            })}
-                        </div>
-                    </div>)
-                    : <></>}
-            </div>
+            <ButtonDropdown view={this.view} button={button} dropdownContent={dropdownContent} />
         );
     }
 
@@ -503,32 +521,26 @@ export default class RichTextMenu extends AntimodeMenu {
 
     createLinkButton() {
         const self = this;
-        function onDropdownClick(e: React.PointerEvent) {
-            e.preventDefault();
-            e.stopPropagation();
-            self.view && self.view.focus();
-            self.toggleLinkDropdown();
-        }
+
         function onLinkChange(e: React.ChangeEvent<HTMLInputElement>) {
             self.setCurrentLink(e.target.value);
         }
 
         const link = this.currentLink ? this.currentLink : "";
 
+        const button = <button className="antimodeMenu-button" title=""><FontAwesomeIcon icon="link" size="lg" /></button>;
+
+        const dropdownContent =
+            <div className="dropdown link-menu">
+                <p>Linked to:</p>
+                <input value={link} placeholder="Enter URL" onChange={onLinkChange} />
+                <button className="make-button" onPointerDown={e => this.makeLinkToURL(link, "onRight")}>Apply hyperlink</button>
+                <div className="divider"></div>
+                <button className="remove-button" onPointerDown={e => this.deleteLink()}>Remove link</button>
+            </div>;
+
         return (
-            <div className="button-dropdown-wrapper">
-                <button className="antimodeMenu-button" title="" onPointerDown={onDropdownClick}><FontAwesomeIcon icon="link" size="lg" /></button>
-                <button className="dropdown-button antimodeMenu-button" onPointerDown={onDropdownClick}><FontAwesomeIcon icon="caret-down" size="sm" /></button>
-                {this.showLinkDropdown ?
-                    (<div className="dropdown link-menu">
-                        <p>Linked to:</p>
-                        <input value={link} placeholder="Enter URL" onChange={onLinkChange} />
-                        <button className="make-button" onPointerDown={e => this.makeLinkToURL(link, "onRight")}>Apply hyperlink</button>
-                        <div className="divider"></div>
-                        <button className="remove-button" onPointerDown={e => this.deleteLink()}>Remove link</button>
-                    </div>)
-                    : <></>}
-            </div>
+            <ButtonDropdown view={this.view} button={button} dropdownContent={dropdownContent} openDropdownOnButton={true} />
         );
     }
 
@@ -598,7 +610,7 @@ export default class RichTextMenu extends AntimodeMenu {
                 }
             } else {
                 if (node) {
-                    let extension = this.linkExtend(this.view!.state.selection.$anchor, href);
+                    const extension = this.linkExtend(this.view!.state.selection.$anchor, href);
                     this.view!.dispatch(this.view!.state.tr.removeMark(extension.from, extension.to, this.view!.state.schema.marks.link));
                 }
             }
@@ -617,7 +629,7 @@ export default class RichTextMenu extends AntimodeMenu {
         let startPos = $start.start();
         let endPos = startPos;
         for (let i = 0; i < endIndex; i++) {
-            let size = $start.parent.child(i).nodeSize;
+            const size = $start.parent.child(i).nodeSize;
             if (i < startIndex) startPos += size;
             endPos += size;
         }
@@ -665,43 +677,6 @@ export default class RichTextMenu extends AntimodeMenu {
 
     render() {
 
-        const fontSizeOptions = [
-            { mark: schema.marks.pFontSize.create({ fontSize: 7 }), title: "Set font size", label: "7pt", command: this.changeFontSize },
-            { mark: schema.marks.pFontSize.create({ fontSize: 8 }), title: "Set font size", label: "8pt", command: this.changeFontSize },
-            { mark: schema.marks.pFontSize.create({ fontSize: 9 }), title: "Set font size", label: "8pt", command: this.changeFontSize },
-            { mark: schema.marks.pFontSize.create({ fontSize: 10 }), title: "Set font size", label: "10pt", command: this.changeFontSize },
-            { mark: schema.marks.pFontSize.create({ fontSize: 12 }), title: "Set font size", label: "12pt", command: this.changeFontSize },
-            { mark: schema.marks.pFontSize.create({ fontSize: 14 }), title: "Set font size", label: "14pt", command: this.changeFontSize },
-            { mark: schema.marks.pFontSize.create({ fontSize: 16 }), title: "Set font size", label: "16pt", command: this.changeFontSize },
-            { mark: schema.marks.pFontSize.create({ fontSize: 18 }), title: "Set font size", label: "18pt", command: this.changeFontSize },
-            { mark: schema.marks.pFontSize.create({ fontSize: 20 }), title: "Set font size", label: "20pt", command: this.changeFontSize },
-            { mark: schema.marks.pFontSize.create({ fontSize: 24 }), title: "Set font size", label: "24pt", command: this.changeFontSize },
-            { mark: schema.marks.pFontSize.create({ fontSize: 32 }), title: "Set font size", label: "32pt", command: this.changeFontSize },
-            { mark: schema.marks.pFontSize.create({ fontSize: 48 }), title: "Set font size", label: "48pt", command: this.changeFontSize },
-            { mark: schema.marks.pFontSize.create({ fontSize: 72 }), title: "Set font size", label: "72pt", command: this.changeFontSize },
-            { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true },
-            { mark: null, title: "", label: "13pt", command: unimplementedFunction, hidden: true }, // this is here because the default size is 13, but there is no actual 13pt option
-        ];
-
-        const fontFamilyOptions = [
-            { mark: schema.marks.pFontFamily.create({ family: "Times New Roman" }), title: "Set font family", label: "Times New Roman", command: this.changeFontFamily },
-            { mark: schema.marks.pFontFamily.create({ family: "Arial" }), title: "Set font family", label: "Arial", command: this.changeFontFamily },
-            { mark: schema.marks.pFontFamily.create({ family: "Georgia" }), title: "Set font family", label: "Georgia", command: this.changeFontFamily },
-            { mark: schema.marks.pFontFamily.create({ family: "Comic Sans MS" }), title: "Set font family", label: "Comic Sans MS", command: this.changeFontFamily },
-            { mark: schema.marks.pFontFamily.create({ family: "Tahoma" }), title: "Set font family", label: "Tahoma", command: this.changeFontFamily },
-            { mark: schema.marks.pFontFamily.create({ family: "Impact" }), title: "Set font family", label: "Impact", command: this.changeFontFamily },
-            { mark: schema.marks.pFontFamily.create({ family: "Crimson Text" }), title: "Set font family", label: "Crimson Text", command: this.changeFontFamily },
-            { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true },
-            // { mark: null, title: "", label: "default", command: unimplementedFunction, hidden: true },
-        ];
-
-        const listTypeOptions = [
-            { node: schema.nodes.ordered_list.create({ mapStyle: "bullet" }), title: "Set list type", label: ":", command: this.changeListType },
-            { node: schema.nodes.ordered_list.create({ mapStyle: "decimal" }), title: "Set list type", label: "1.1", command: this.changeListType },
-            { node: schema.nodes.ordered_list.create({ mapStyle: "multi" }), title: "Set list type", label: "1.A", command: this.changeListType },
-            { node: undefined, title: "Set list type", label: "Remove", command: this.changeListType },
-        ];
-
         const row1 = <div className="antimodeMenu-row">{[
             this.createButton("bold", "Bold", toggleMark(schema.marks.strong)),
             this.createButton("italic", "Italic", toggleMark(schema.marks.em)),
@@ -714,30 +689,85 @@ export default class RichTextMenu extends AntimodeMenu {
             this.createLinkButton(),
             this.createBrushButton(),
             this.createButton("indent", "Summarize", undefined, this.insertSummarizer),
-        ]}</div>
+        ]}</div>;
 
         const row2 = <div className="antimodeMenu-row row-2">
-            <div>{[
-                this.createMarksDropdown(this.activeFontSize, fontSizeOptions),
-                this.createMarksDropdown(this.activeFontFamily, fontFamilyOptions),
-                this.createNodesDropdown(this.activeListType, listTypeOptions),
-            ]}</div>
+            <div>
+                {[this.createMarksDropdown(this.activeFontSize, this.fontSizeOptions),
+                this.createMarksDropdown(this.activeFontFamily, this.fontFamilyOptions),
+                this.createNodesDropdown(this.activeListType, this.listTypeOptions)]}
+            </div>
             <div>
                 <button className="antimodeMenu-button" title="Pin menu" onClick={this.toggleMenuPin} style={this.Pinned ? { backgroundColor: "#121212" } : {}}>
                     <FontAwesomeIcon icon="thumbtack" size="lg" style={{ transition: "transform 0.1s", transform: this.Pinned ? "rotate(45deg)" : "" }} />
                 </button>
                 {this.getDragger()}
             </div>
-        </div>
-
-        const buttons = [
-            row1, row2
-        ];
+        </div>;
 
         return (
             <div className="richTextMenu" onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave}>
-                {this.getElementWithRows(buttons, 2, false)}
+                {this.getElementWithRows([row1, row2], 2, false)}
             </div>
         );
     }
+}
+
+interface ButtonDropdownProps {
+    // Document: Doc;
+    // remove: (self: ImportMetadataEntry) => void;
+    // next: () => void;
+    view?: EditorView;
+    button: JSX.Element;
+    dropdownContent: JSX.Element;
+    openDropdownOnButton?: boolean;
+}
+
+@observer
+class ButtonDropdown extends React.Component<ButtonDropdownProps> {
+
+    @observable private showDropdown: boolean = false;
+    private ref: HTMLDivElement | null = null;
+
+    componentDidMount() {
+        document.addEventListener("pointerdown", this.onBlur);
+    }
+
+    componentWillUnmount() {
+        document.removeEventListener("pointerdown", this.onBlur);
+    }
+
+    @action
+    setShowDropdown(show: boolean) {
+        this.showDropdown = show;
+    }
+    @action
+    toggleDropdown() {
+        this.showDropdown = !this.showDropdown;
+    }
+
+    onDropdownClick = (e: React.PointerEvent) => {
+        e.preventDefault();
+        e.stopPropagation();
+        this.props.view && this.props.view.focus();
+        this.toggleDropdown();
+    }
+
+    onBlur = (e: PointerEvent) => {
+        setTimeout(() => {
+            if (this.ref !== null && !this.ref.contains(e.target as Node)) {
+                this.setShowDropdown(false);
+            }
+        }, 0);
+    }
+
+    render() {
+        return (
+            <div className="button-dropdown-wrapper" ref={node => this.ref = node}>
+                {this.props.openDropdownOnButton ? <div onPointerDown={this.onDropdownClick}>{this.props.button}</div> : this.props.button}
+                <button className="dropdown-button antimodeMenu-button" onPointerDown={this.onDropdownClick}><FontAwesomeIcon icon="caret-down" size="sm" /></button>
+                {this.showDropdown ? this.props.dropdownContent : <></>}
+            </div>
+        )
+    }
 }
\ No newline at end of file
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index 027bd492c..df055a2ab 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -1057,7 +1057,6 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         });
     }
     onBlur = (e: any) => {
-        console.log("formated blur");
         //DictationManager.Controls.stop(false);
         if (this._undoTyping) {
             this._undoTyping.end();
-- 
cgit v1.2.3-70-g09d2


From 6162c951e07874fbb12717d4bcd2cd649e41d0d2 Mon Sep 17 00:00:00 2001
From: Sam Wilkins <samwilkins333@gmail.com>
Date: Mon, 13 Jan 2020 00:00:56 -0500
Subject: deleted old Session folder and fixed linter errors

---
 src/client/util/ProseMirrorEditorView.tsx          |   8 +-
 src/client/util/RichTextMenu.tsx                   |  13 +-
 src/client/views/EditableView.tsx                  |   2 +-
 .../collectionFreeForm/CollectionFreeFormView.tsx  |   4 +-
 src/client/views/linking/LinkMenuGroup.tsx         |   2 +-
 src/client/views/nodes/DocumentView.tsx            |   2 +-
 src/client/views/nodes/FormattedTextBox.tsx        |  23 +-
 src/pen-gestures/ndollar.ts                        |  66 +-
 src/server/MemoryDatabase.ts                       |   7 +-
 src/server/RouteManager.ts                         |   3 +-
 src/server/Session/session.ts                      | 686 ---------------------
 11 files changed, 67 insertions(+), 749 deletions(-)
 delete mode 100644 src/server/Session/session.ts

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

diff --git a/src/client/util/ProseMirrorEditorView.tsx b/src/client/util/ProseMirrorEditorView.tsx
index 3e5fd0604..b42adfbb4 100644
--- a/src/client/util/ProseMirrorEditorView.tsx
+++ b/src/client/util/ProseMirrorEditorView.tsx
@@ -18,23 +18,23 @@ export class ProseMirrorEditorView extends React.Component<ProseMirrorEditorView
     private _editorView?: EditorView;
 
     _createEditorView = (element: HTMLDivElement | null) => {
-        if (element != null) {
+        if (element !== null) {
             this._editorView = new EditorView(element, {
                 state: this.props.editorState,
                 dispatchTransaction: this.dispatchTransaction,
             });
         }
-    };
+    }
 
     dispatchTransaction = (tx: any) => {
         // In case EditorView makes any modification to a state we funnel those
         // modifications up to the parent and apply to the EditorView itself.
         const editorState = this.props.editorState.apply(tx);
-        if (this._editorView != null) {
+        if (this._editorView) {
             this._editorView.updateState(editorState);
         }
         this.props.onEditorState(editorState);
-    };
+    }
 
     focus() {
         if (this._editorView) {
diff --git a/src/client/util/RichTextMenu.tsx b/src/client/util/RichTextMenu.tsx
index 639772faa..419d7caf9 100644
--- a/src/client/util/RichTextMenu.tsx
+++ b/src/client/util/RichTextMenu.tsx
@@ -175,7 +175,7 @@ export default class RichTextMenu extends AntimodeMenu {
     setMark = (mark: Mark, state: EditorState<any>, dispatch: any) => {
         if (mark) {
             const node = (state.selection as NodeSelection).node;
-            if (node ?.type === schema.nodes.ordered_list) {
+            if (node?.type === schema.nodes.ordered_list) {
                 let attrs = node.attrs;
                 if (mark.type === schema.marks.pFontFamily) attrs = { ...attrs, setFontFamily: mark.attrs.family };
                 if (mark.type === schema.marks.pFontSize) attrs = { ...attrs, setFontSize: mark.attrs.fontSize };
@@ -294,8 +294,8 @@ export default class RichTextMenu extends AntimodeMenu {
             e.preventDefault();
             e.stopPropagation();
             self.view && self.view.focus();
-            self.view && command && command(self.view!.state, self.view!.dispatch, self.view);
-            self.view && onclick && onclick(self.view!.state, self.view!.dispatch, self.view);
+            self.view && command && command(self.view.state, self.view.dispatch, self.view);
+            self.view && onclick && onclick(self.view.state, self.view.dispatch, self.view);
             self.setActiveMarkButtons(self.getActiveMarksOnSelection());
         }
 
@@ -602,7 +602,7 @@ export default class RichTextMenu extends AntimodeMenu {
 
         const link = this.currentLink ? this.currentLink : "";
 
-        const button = <FontAwesomeIcon icon="link" size="lg" />
+        const button = <FontAwesomeIcon icon="link" size="lg" />;
 
         const dropdownContent =
             <div className="dropdown link-menu">
@@ -684,8 +684,9 @@ export default class RichTextMenu extends AntimodeMenu {
                 }
             } else {
                 if (node) {
-                    const extension = this.linkExtend(this.view!.state.selection.$anchor, href);
-                    this.view!.dispatch(this.view!.state.tr.removeMark(extension.from, extension.to, this.view!.state.schema.marks.link));
+                    const { tr, schema, selection } = this.view.state;
+                    const extension = this.linkExtend(selection.$anchor, href);
+                    this.view.dispatch(tr.removeMark(extension.from, extension.to, schema.marks.link));
                 }
             }
         }
diff --git a/src/client/views/EditableView.tsx b/src/client/views/EditableView.tsx
index 54def38b5..faf02b946 100644
--- a/src/client/views/EditableView.tsx
+++ b/src/client/views/EditableView.tsx
@@ -36,7 +36,7 @@ export interface EditableProps {
         resetValue: () => void;
         value: string,
         onChange: (e: React.ChangeEvent, { newValue }: { newValue: string }) => void,
-        autosuggestProps: Autosuggest.AutosuggestProps<string>
+        autosuggestProps: Autosuggest.AutosuggestProps<string, any>
 
     };
     oneLine?: boolean;
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
index 936c4413f..e3780261d 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
@@ -988,8 +988,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
         </MarqueeView>;
     }
     @computed get contentScaling() {
-        let hscale = this.nativeHeight ? this.props.PanelHeight() / this.nativeHeight : 1;
-        let wscale = this.nativeWidth ? this.props.PanelWidth() / this.nativeWidth : 1;
+        const hscale = this.nativeHeight ? this.props.PanelHeight() / this.nativeHeight : 1;
+        const wscale = this.nativeWidth ? this.props.PanelWidth() / this.nativeWidth : 1;
         return wscale < hscale ? wscale : hscale;
     }
     render() {
diff --git a/src/client/views/linking/LinkMenuGroup.tsx b/src/client/views/linking/LinkMenuGroup.tsx
index abd17ec4d..ace9a9e4c 100644
--- a/src/client/views/linking/LinkMenuGroup.tsx
+++ b/src/client/views/linking/LinkMenuGroup.tsx
@@ -47,7 +47,7 @@ export class LinkMenuGroup extends React.Component<LinkMenuGroupProps> {
             document.removeEventListener("pointerup", this.onLinkButtonUp);
 
             const targets = this.props.group.map(l => LinkManager.Instance.getOppositeAnchor(l, this.props.sourceDoc)).filter(d => d) as Doc[];
-            DragManager.StartLinkTargetsDrag(this._drag.current!, e.x, e.y, this.props.sourceDoc, targets);
+            DragManager.StartLinkTargetsDrag(this._drag.current, e.x, e.y, this.props.sourceDoc, targets);
         }
         e.stopPropagation();
     }
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index 10d2e2b3e..ba35366ff 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -196,7 +196,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
                 ScriptBox.EditButtonScript("On Button Clicked ...", this.props.Document, "onClick", e.clientX, e.clientY);
             } else if (this.props.Document.isButton === "Selector") {  // this should be moved to an OnClick script
                 FormattedTextBoxComment.Hide();
-                this.Document.links?.[0] instanceof Doc && (Doc.UserDoc().SelectedDocs = new List([Doc.LinkOtherAnchor(this.Document.links[0]!, this.props.Document)]));
+                this.Document.links?.[0] instanceof Doc && (Doc.UserDoc().SelectedDocs = new List([Doc.LinkOtherAnchor(this.Document.links[0], this.props.Document)]));
             } else if (this.Document.isButton) {
                 SelectionManager.SelectDoc(this, e.ctrlKey); // don't think this should happen if a button action is actually triggered.
                 this.buttonClick(e.altKey, e.ctrlKey);
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index 8e28cf928..8b5c24878 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -906,15 +906,16 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         this.tryUpdateHeight();
 
         // see if we need to preserve the insertion point
-        const prosediv = this.ProseRef ?.children ?.[0] as any;
-        const keeplocation = prosediv ?.keeplocation;
+        const prosediv = this.ProseRef?.children?.[0] as any;
+        const keeplocation = prosediv?.keeplocation;
         prosediv && (prosediv.keeplocation = undefined);
-        const pos = this._editorView ?.state.selection.$from.pos || 1;
-        keeplocation && setTimeout(() => this._editorView ?.dispatch(this._editorView ?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos))));
+        const pos = this._editorView?.state.selection.$from.pos || 1;
+        keeplocation && setTimeout(() => this._editorView?.dispatch(this._editorView?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos))));
 
         // jump rich text menu to this textbox
-        if (this._ref.current) {
-            const x = Math.min(Math.max(this._ref.current!.getBoundingClientRect().left, 0), window.innerWidth - RichTextMenu.Instance.width);
+        const { current } = this._ref;
+        if (current) {
+            const x = Math.min(Math.max(current.getBoundingClientRect().left, 0), window.innerWidth - RichTextMenu.Instance.width);
             const y = this._ref.current!.getBoundingClientRect().top - RichTextMenu.Instance.height - 50;
             RichTextMenu.Instance.jumpTo(x, y);
         }
@@ -933,7 +934,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         if ((this._editorView!.root as any).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 = this._editorView!.posAtCoords({ left: e.clientX, top: e.clientY });
             const node = pcords && this._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 === this._editorView!.state.schema.nodes.dashComment) {
+            if (pcords && node?.type === this._editorView!.state.schema.nodes.dashComment) {
                 this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pcords.pos + 2)));
                 e.preventDefault();
             }
@@ -996,7 +997,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
                 for (let off = 1; off < 100; off++) {
                     const pos = this._editorView!.posAtCoords({ left: x + off, top: y });
                     const node = pos && this._editorView!.state.doc.nodeAt(pos.pos);
-                    if (node ?.type === schema.nodes.list_item) {
+                    if (node?.type === schema.nodes.list_item) {
                         list_node = node;
                         break;
                     }
@@ -1087,7 +1088,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
         }
         if (e.key === "Escape") {
             this._editorView!.dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from)));
-            (document.activeElement as any).blur ?.();
+            (document.activeElement as any).blur?.();
             SelectionManager.DeselectAll();
         }
         e.stopPropagation();
@@ -1109,7 +1110,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
 
     @action
     tryUpdateHeight(limitHeight?: number) {
-        let scrollHeight = this._ref.current ?.scrollHeight;
+        let scrollHeight = this._ref.current?.scrollHeight;
         if (!this.layoutDoc.animateToPos && this.layoutDoc.autoHeight && scrollHeight &&
             getComputedStyle(this._ref.current!.parentElement!).top === "0px") {  // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation
             if (limitHeight && scrollHeight > limitHeight) {
@@ -1171,7 +1172,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
                 {this.props.Document.hideSidebar ? (null) : this.sidebarWidthPercent === "0%" ?
                     <div className="formattedTextBox-sidebar-handle" onPointerDown={this.sidebarDown} onClick={e => this.toggleSidebar()} /> :
                     <div className={"formattedTextBox-sidebar" + (InkingControl.Instance.selectedTool !== InkTool.None ? "-inking" : "")}
-                        style={{ width: `${this.sidebarWidthPercent}`, backgroundColor: `${StrCast(this.extensionDoc ?.backgroundColor, "transparent")}` }}>
+                        style={{ width: `${this.sidebarWidthPercent}`, backgroundColor: `${StrCast(this.extensionDoc?.backgroundColor, "transparent")}` }}>
                         <CollectionFreeFormView {...this.props}
                             PanelHeight={this.props.PanelHeight}
                             PanelWidth={() => this.sidebarWidth}
diff --git a/src/pen-gestures/ndollar.ts b/src/pen-gestures/ndollar.ts
index 872c524d6..ef5ca38c6 100644
--- a/src/pen-gestures/ndollar.ts
+++ b/src/pen-gestures/ndollar.ts
@@ -257,16 +257,16 @@ export class NDollarRecognizer {
         {
             if (!requireSameNoOfStrokes || strokes.length === this.Multistrokes[i].NumStrokes) // optional -- only attempt match when same # of component strokes
             {
-                for (var j = 0; j < this.Multistrokes[i].Unistrokes.length; j++) // for each unistroke within this multistroke
+                for (const unistroke of this.Multistrokes[i].Unistrokes) // for each unistroke within this multistroke
                 {
-                    if (AngleBetweenUnitVectors(candidate.StartUnitVector, this.Multistrokes[i].Unistrokes[j].StartUnitVector) <= AngleSimilarityThreshold) // strokes start in the same direction
+                    if (AngleBetweenUnitVectors(candidate.StartUnitVector, unistroke.StartUnitVector) <= AngleSimilarityThreshold) // strokes start in the same direction
                     {
                         var d;
                         if (useProtractor) {
-                            d = OptimalCosineDistance(this.Multistrokes[i].Unistrokes[j].Vector, candidate.Vector); // Protractor
+                            d = OptimalCosineDistance(unistroke.Vector, candidate.Vector); // Protractor
                         }
                         else {
-                            d = DistanceAtBestAngle(candidate.Points, this.Multistrokes[i].Unistrokes[j], -AngleRange, +AngleRange, AnglePrecision); // Golden Section Search (original $N)
+                            d = DistanceAtBestAngle(candidate.Points, unistroke, -AngleRange, +AngleRange, AnglePrecision); // Golden Section Search (original $N)
                         }
                         if (d < b) {
                             b = d; // best (least) distance
@@ -283,8 +283,8 @@ export class NDollarRecognizer {
     AddGesture = (name: string, useBoundedRotationInvariance: boolean, strokes: any[]) => {
         this.Multistrokes[this.Multistrokes.length] = new Multistroke(name, useBoundedRotationInvariance, strokes);
         var num = 0;
-        for (var i = 0; i < this.Multistrokes.length; i++) {
-            if (this.Multistrokes[i].Name === name) {
+        for (const multistroke of this.Multistrokes) {
+            if (multistroke.Name === name) {
                 num++;
             }
         }
@@ -322,20 +322,20 @@ function HeapPermute(n: number, order: any[], /*out*/ orders: any[]) {
 
 function MakeUnistrokes(strokes: any, orders: any) {
     const unistrokes = new Array(); // array of point arrays
-    for (var r = 0; r < orders.length; r++) {
-        for (var b = 0; b < Math.pow(2, orders[r].length); b++) // use b's bits for directions
+    for (const order of orders) {
+        for (var b = 0; b < Math.pow(2, order.length); b++) // use b's bits for directions
         {
             const unistroke = new Array(); // array of points
-            for (var i = 0; i < orders[r].length; i++) {
+            for (var i = 0; i < order.length; i++) {
                 var pts;
                 if (((b >> i) & 1) === 1) {// is b's bit at index i on?
-                    pts = strokes[orders[r][i]].slice().reverse(); // copy and reverse
+                    pts = strokes[order[i]].slice().reverse(); // copy and reverse
                 }
                 else {
-                    pts = strokes[orders[r][i]].slice(); // copy
+                    pts = strokes[order[i]].slice(); // copy
                 }
-                for (var p = 0; p < pts.length; p++) {
-                    unistroke[unistroke.length] = pts[p]; // append points
+                for (const point of pts) {
+                    unistroke[unistroke.length] = point; // append points
                 }
             }
             unistrokes[unistrokes.length] = unistroke; // add one unistroke to set
@@ -346,9 +346,9 @@ function MakeUnistrokes(strokes: any, orders: any) {
 
 function CombineStrokes(strokes: any) {
     const points = new Array();
-    for (var s = 0; s < strokes.length; s++) {
-        for (var p = 0; p < strokes[s].length; p++) {
-            points[points.length] = new Point(strokes[s][p].X, strokes[s][p].Y);
+    for (const stroke of strokes) {
+        for (const { X, Y } of stroke) {
+            points[points.length] = new Point(X, Y);
         }
     }
     return points;
@@ -384,9 +384,9 @@ function RotateBy(points: any, radians: any) // rotates points around centroid
     const cos = Math.cos(radians);
     const sin = Math.sin(radians);
     const newpoints = new Array();
-    for (var i = 0; i < points.length; i++) {
-        const qx = (points[i].X - c.X) * cos - (points[i].Y - c.Y) * sin + c.X;
-        const qy = (points[i].X - c.X) * sin + (points[i].Y - c.Y) * cos + c.Y;
+    for (const point of points) {
+        const qx = (point.X - c.X) * cos - (point.Y - c.Y) * sin + c.X;
+        const qy = (point.X - c.X) * sin + (point.Y - c.Y) * cos + c.Y;
         newpoints[newpoints.length] = new Point(qx, qy);
     }
     return newpoints;
@@ -396,9 +396,9 @@ function ScaleDimTo(points: any, size: any, ratio1D: any) // scales bbox uniform
     const B = BoundingBox(points);
     const uniformly = Math.min(B.Width / B.Height, B.Height / B.Width) <= ratio1D; // 1D or 2D gesture test
     const newpoints = new Array();
-    for (var i = 0; i < points.length; i++) {
-        const qx = uniformly ? points[i].X * (size / Math.max(B.Width, B.Height)) : points[i].X * (size / B.Width);
-        const qy = uniformly ? points[i].Y * (size / Math.max(B.Width, B.Height)) : points[i].Y * (size / B.Height);
+    for (const { X, Y } of points) {
+        const qx = uniformly ? X * (size / Math.max(B.Width, B.Height)) : X * (size / B.Width);
+        const qy = uniformly ? Y * (size / Math.max(B.Width, B.Height)) : Y * (size / B.Height);
         newpoints[newpoints.length] = new Point(qx, qy);
     }
     return newpoints;
@@ -407,9 +407,9 @@ function TranslateTo(points: any, pt: any) // translates points' centroid
 {
     const c = Centroid(points);
     const newpoints = new Array();
-    for (var i = 0; i < points.length; i++) {
-        const qx = points[i].X + pt.X - c.X;
-        const qy = points[i].Y + pt.Y - c.Y;
+    for (const { X, Y } of points) {
+        const qx = X + pt.X - c.X;
+        const qy = Y + pt.Y - c.Y;
         newpoints[newpoints.length] = new Point(qx, qy);
     }
     return newpoints;
@@ -478,9 +478,9 @@ function DistanceAtAngle(points: any, T: any, radians: any) {
 }
 function Centroid(points: any) {
     var x = 0.0, y = 0.0;
-    for (var i = 0; i < points.length; i++) {
-        x += points[i].X;
-        y += points[i].Y;
+    for (const point of points) {
+        x += point.X;
+        y += point.Y;
     }
     x /= points.length;
     y /= points.length;
@@ -488,11 +488,11 @@ function Centroid(points: any) {
 }
 function BoundingBox(points: any) {
     var minX = +Infinity, maxX = -Infinity, minY = +Infinity, maxY = -Infinity;
-    for (var i = 0; i < points.length; i++) {
-        minX = Math.min(minX, points[i].X);
-        minY = Math.min(minY, points[i].Y);
-        maxX = Math.max(maxX, points[i].X);
-        maxY = Math.max(maxY, points[i].Y);
+    for (const { X, Y } of points) {
+        minX = Math.min(minX, X);
+        minY = Math.min(minY, Y);
+        maxX = Math.max(maxX, X);
+        maxY = Math.max(maxY, Y);
     }
     return new Rectangle(minX, minY, maxX - minX, maxY - minY);
 }
diff --git a/src/server/MemoryDatabase.ts b/src/server/MemoryDatabase.ts
index e7396babf..543f96e7f 100644
--- a/src/server/MemoryDatabase.ts
+++ b/src/server/MemoryDatabase.ts
@@ -7,7 +7,7 @@ export class MemoryDatabase implements IDatabase {
     private db: { [collectionName: string]: { [id: string]: any } } = {};
 
     private getCollection(collectionName: string) {
-        let collection = this.db[collectionName];
+        const collection = this.db[collectionName];
         if (collection) {
             return collection;
         } else {
@@ -17,9 +17,10 @@ export class MemoryDatabase implements IDatabase {
 
     public update(id: string, value: any, callback: (err: mongodb.MongoError, res: mongodb.UpdateWriteOpResult) => void, _upsert?: boolean, collectionName = DocumentsCollection): Promise<void> {
         const collection = this.getCollection(collectionName);
-        if ("$set" in value) {
+        const set = "$set";
+        if (set in value) {
             let currentVal = collection[id] ?? (collection[id] = {});
-            const val = value["$set"];
+            const val = value[set];
             for (const key in val) {
                 const keys = key.split(".");
                 for (let i = 0; i < keys.length - 1; i++) {
diff --git a/src/server/RouteManager.ts b/src/server/RouteManager.ts
index a7ee405a7..5afd607fd 100644
--- a/src/server/RouteManager.ts
+++ b/src/server/RouteManager.ts
@@ -86,7 +86,8 @@ export default class RouteManager {
         const { method, subscription, secureHandler: onValidation, publicHandler: onUnauthenticated, errorHandler: onError } = initializer;
         const isRelease = this._isRelease;
         const supervised = async (req: express.Request, res: express.Response) => {
-            let { user, originalUrl: target } = req;
+            let { user } = req;
+            const { originalUrl: target } = req;
             if (process.env.DB === "MEM" && !user) {
                 user = { id: "guest", email: "", userDocumentId: "guestDocId" };
             }
diff --git a/src/server/Session/session.ts b/src/server/Session/session.ts
deleted file mode 100644
index ec3d46ac1..000000000
--- a/src/server/Session/session.ts
+++ /dev/null
@@ -1,686 +0,0 @@
-import { red, cyan, green, yellow, magenta, blue, white, Color, grey, gray, black } from "colors";
-import { on, fork, setupMaster, Worker, isMaster, isWorker } from "cluster";
-import { get } from "request-promise";
-import { Utils } from "../../Utils";
-import Repl, { ReplAction } from "../repl";
-import { readFileSync } from "fs";
-import { validate, ValidationError } from "jsonschema";
-import { configurationSchema } from "./session_config_schema";
-import { exec, ExecOptions } from "child_process";
-
-/**
- * This namespace relies on NodeJS's cluster module, which allows a parent (master) process to share
- * code with its children (workers). A simple `isMaster` flag indicates who is trying to access
- * the code, and thus determines the functionality that actually gets invoked (checked by the caller, not internally).
- * 
- * Think of the master thread as a factory, and the workers as the helpers that actually run the server.
- * 
- * So, when we run `npm start`, given the appropriate check, initializeMaster() is called in the parent process
- * This will spawn off its own child process (by default, mirrors the execution path of its parent),
- * in which initializeWorker() is invoked.
- */
-export namespace Session {
-
-    type ColorLabel = "yellow" | "red" | "cyan" | "green" | "blue" | "magenta" | "grey" | "gray" | "white" | "black";
-    const colorMapping: Map<ColorLabel, Color> = new Map([
-        ["yellow", yellow],
-        ["red", red],
-        ["cyan", cyan],
-        ["green", green],
-        ["blue", blue],
-        ["magenta", magenta],
-        ["grey", grey],
-        ["gray", gray],
-        ["white", white],
-        ["black", black]
-    ]);
-
-    export abstract class AppliedSessionAgent {
-
-        // the following two methods allow the developer to create a custom
-        // session and use the built in customization options for each thread
-        protected abstract async launchMonitor(): Promise<Session.Monitor>;
-        protected abstract async launchServerWorker(): Promise<Session.ServerWorker>;
-
-        private launched = false;
-
-        public killSession = (reason: string, graceful = true, errorCode = 0) => {
-            const target = isMaster ? this.sessionMonitor : this.serverWorker;
-            target.killSession(reason, graceful, errorCode);
-        }
-
-        private sessionMonitorRef: Session.Monitor | undefined;
-        public get sessionMonitor(): Session.Monitor {
-            if (!isMaster) {
-                this.serverWorker.sendMonitorAction("kill", {
-                    graceful: false,
-                    reason: "Cannot access the session monitor directly from the server worker thread.",
-                    errorCode: 1
-                });
-                throw new Error();
-            }
-            return this.sessionMonitorRef!;
-        }
-
-        private serverWorkerRef: Session.ServerWorker | undefined;
-        public get serverWorker(): Session.ServerWorker {
-            if (isMaster) {
-                throw new Error("Cannot access the server worker directly from the session monitor thread");
-            }
-            return this.serverWorkerRef!;
-        }
-
-        public async launch(): Promise<void> {
-            if (!this.launched) {
-                this.launched = true;
-                if (isMaster) {
-                    this.sessionMonitorRef = await this.launchMonitor();
-                } else {
-                    this.serverWorkerRef = await this.launchServerWorker();
-                }
-            } else {
-                throw new Error("Cannot launch a session thread more than once per process.");
-            }
-        }
-
-    }
-
-    interface Identifier {
-        text: string;
-        color: ColorLabel;
-    }
-
-    interface Identifiers {
-        master: Identifier;
-        worker: Identifier;
-        exec: Identifier;
-    }
-
-    interface Configuration {
-        showServerOutput: boolean;
-        identifiers: Identifiers;
-        ports: { [description: string]: number };
-        polling: {
-            route: string;
-            intervalSeconds: number;
-            failureTolerance: number;
-        };
-    }
-
-    const defaultConfig: Configuration = {
-        showServerOutput: false,
-        identifiers: {
-            master: {
-                text: "__monitor__",
-                color: "yellow"
-            },
-            worker: {
-                text: "__server__",
-                color: "magenta"
-            },
-            exec: {
-                text: "__exec__",
-                color: "green"
-            }
-        },
-        ports: { server: 3000 },
-        polling: {
-            route: "/",
-            intervalSeconds: 30,
-            failureTolerance: 0
-        }
-    };
-
-    export type ExitHandler = (reason: Error | boolean) => void | Promise<void>;
-
-    export namespace Monitor {
-
-        export interface NotifierHooks {
-            key?: (key: string) => (boolean | Promise<boolean>);
-            crash?: (error: Error) => (boolean | Promise<boolean>);
-        }
-
-        export interface Action {
-            message: string;
-            args: any;
-        }
-
-        export type ServerMessageHandler = (action: Action) => void | Promise<void>;
-
-    }
-
-    /**
-     * Validates and reads the configuration file, accordingly builds a child process factory
-     * and spawns off an initial process that will respawn as predecessors die.
-     */
-    export class Monitor {
-
-        private static count = 0;
-        private exitHandlers: ExitHandler[] = [];
-        private readonly notifiers: Monitor.NotifierHooks | undefined;
-        private readonly config: Configuration;
-        private onMessage: { [message: string]: Monitor.ServerMessageHandler[] | undefined } = {};
-        private activeWorker: Worker | undefined;
-        private key: string | undefined;
-        private repl: Repl;
-
-        public static Create(notifiers?: Monitor.NotifierHooks) {
-            if (isWorker) {
-                process.send?.({
-                    action: {
-                        message: "kill",
-                        args: {
-                            reason: "cannot create a monitor on the worker process.",
-                            graceful: false,
-                            errorCode: 1
-                        }
-                    }
-                });
-                process.exit(1);
-            } else if (++Monitor.count > 1) {
-                console.error(red("cannot create more than one monitor."));
-                process.exit(1);
-            } else {
-                return new Monitor(notifiers);
-            }
-        }
-
-        /**
-         * Kill this session and its active child
-         * server process, either gracefully (may wait
-         * indefinitely, but at least allows active networking
-         * requests to complete) or immediately.
-         */
-        public killSession = async (reason: string, graceful = true, errorCode = 0) => {
-            this.mainLog(cyan(`exiting session ${graceful ? "clean" : "immediate"}ly`));
-            this.mainLog(`session exit reason: ${(red(reason))}`);
-            await this.executeExitHandlers(true);
-            this.killActiveWorker(graceful, true);
-            process.exit(errorCode);
-        }
-
-        /**
-         * Execute the list of functions registered to be called
-         * whenever the process exits.
-         */
-        public addExitHandler = (handler: ExitHandler) => this.exitHandlers.push(handler);
-
-        /**
-         * Extend the default repl by adding in custom commands
-         * that can invoke application logic external to this module
-         */
-        public addReplCommand = (basename: string, argPatterns: (RegExp | string)[], action: ReplAction) => {
-            this.repl.registerCommand(basename, argPatterns, action);
-        }
-
-        public exec = (command: string, options?: ExecOptions) => {
-            return new Promise<void>(resolve => {
-                exec(command, { ...options, encoding: "utf8" }, (error, stdout, stderr) => {
-                    if (error) {
-                        this.execLog(red(`unable to execute ${white(command)}`));
-                        error.message.split("\n").forEach(line => line.length && this.execLog(red(`(error) ${line}`)));
-                    } else {
-                        let outLines: string[], errorLines: string[];
-                        if ((outLines = stdout.split("\n").filter(line => line.length)).length) {
-                            outLines.forEach(line => line.length && this.execLog(cyan(`(stdout) ${line}`)));
-                        }
-                        if ((errorLines = stderr.split("\n").filter(line => line.length)).length) {
-                            errorLines.forEach(line => line.length && this.execLog(yellow(`(stderr) ${line}`)));
-                        }
-                    }
-                    resolve();
-                });
-            });
-        }
-
-        /**
-         * Add a listener at this message. When the monitor process
-         * receives a message, it will invoke all registered functions.
-         */
-        public addServerMessageListener = (message: string, handler: Monitor.ServerMessageHandler) => {
-            const handlers = this.onMessage[message];
-            if (handlers) {
-                handlers.push(handler);
-            } else {
-                this.onMessage[message] = [handler];
-            }
-        }
-
-        /**
-         * Unregister a given listener at this message.
-         */
-        public removeServerMessageListener = (message: string, handler: Monitor.ServerMessageHandler) => {
-            const handlers = this.onMessage[message];
-            if (handlers) {
-                const index = handlers.indexOf(handler);
-                if (index > -1) {
-                    handlers.splice(index, 1);
-                }
-            }
-        }
-
-        /**
-         * Unregister all listeners at this message.
-         */
-        public clearServerMessageListeners = (message: string) => this.onMessage[message] = undefined;
-
-        private constructor(notifiers?: Monitor.NotifierHooks) {
-            this.notifiers = notifiers;
-
-            console.log(this.timestamp(), cyan("initializing session..."));
-
-            this.config = this.loadAndValidateConfiguration();
-
-            this.initializeSessionKey();
-            // determines whether or not we see the compilation / initialization / runtime output of each child server process
-            const output = this.config.showServerOutput ? "inherit" : "ignore";
-            setupMaster({ stdio: ["ignore", output, output, "ipc"] });
-
-            // handle exceptions in the master thread - there shouldn't be many of these
-            // the IPC (inter process communication) channel closed exception can't seem
-            // to be caught in a try catch, and is inconsequential, so it is ignored
-            process.on("uncaughtException", ({ message, stack }): void => {
-                if (message !== "Channel closed") {
-                    this.mainLog(red(message));
-                    if (stack) {
-                        this.mainLog(`uncaught exception\n${red(stack)}`);
-                    }
-                }
-            });
-
-            // a helpful cluster event called on the master thread each time a child process exits
-            on("exit", ({ process: { pid } }, code, signal) => {
-                const prompt = `server worker with process id ${pid} has exited with code ${code}${signal === null ? "" : `, having encountered signal ${signal}`}.`;
-                this.mainLog(cyan(prompt));
-                // to make this a robust, continuous session, every time a child process dies, we immediately spawn a new one
-                this.spawn();
-            });
-
-            this.repl = this.initializeRepl();
-            this.spawn();
-        }
-
-        /**
-         * Generates a blue UTC string associated with the time
-         * of invocation.
-         */
-        private timestamp = () => blue(`[${new Date().toUTCString()}]`);
-
-        /**
-         * A formatted, identified and timestamped log in color
-         */
-        public mainLog = (...optionalParams: any[]) => {
-            console.log(this.timestamp(), this.config.identifiers.master.text, ...optionalParams);
-        }
-
-        /**
-         * A formatted, identified and timestamped log in color for non-
-         */
-        private execLog = (...optionalParams: any[]) => {
-            console.log(this.timestamp(), this.config.identifiers.exec.text, ...optionalParams);
-        }
-
-        /**
-         * If the caller has indicated an interest
-         * in being notified of this feature, creates
-         * a GUID for this session that can, for example,
-         * be used as authentication for killing the server
-         * (checked externally).
-         */
-        private initializeSessionKey = async (): Promise<void> => {
-            if (this.notifiers?.key) {
-                this.key = Utils.GenerateGuid();
-                const success = await this.notifiers.key(this.key);
-                const statement = success ? green("distributed session key to recipients") : red("distribution of session key failed");
-                this.mainLog(statement);
-            }
-        }
-
-        /**
-         * At any arbitrary layer of nesting within the configuration objects, any single value that
-         * is not specified by the configuration is given the default counterpart. If, within an object,
-         * one peer is given by configuration and two are not, the one is preserved while the two are given
-         * the default value.
-         * @returns the composition of all of the assigned objects, much like Object.assign(), but with more
-         * granularity in the overwriting of nested objects
-         */
-        private preciseAssign = (target: any, ...sources: any[]): any => {
-            for (const source of sources) {
-                this.preciseAssignHelper(target, source);
-            }
-            return target;
-        }
-
-        private preciseAssignHelper = (target: any, source: any) => {
-            Array.from(new Set([...Object.keys(target), ...Object.keys(source)])).map(property => {
-                let targetValue: any, sourceValue: any;
-                if (sourceValue = source[property]) {
-                    if (typeof sourceValue === "object" && typeof (targetValue = target[property]) === "object") {
-                        this.preciseAssignHelper(targetValue, sourceValue);
-                    } else {
-                        target[property] = sourceValue;
-                    }
-                }
-            });
-        }
-
-        /**
-         * Reads in configuration .json file only once, in the master thread
-         * and pass down any variables the pertinent to the child processes as environment variables.
-         */
-        private loadAndValidateConfiguration = (): Configuration => {
-            let config: Configuration;
-            try {
-                console.log(this.timestamp(), cyan("validating configuration..."));
-                config = JSON.parse(readFileSync('./session.config.json', 'utf8'));
-                const options = {
-                    throwError: true,
-                    allowUnknownAttributes: false
-                };
-                // ensure all necessary and no excess information is specified by the configuration file
-                validate(config, configurationSchema, options);
-                config = this.preciseAssign({}, defaultConfig, config);
-            } catch (error) {
-                if (error instanceof ValidationError) {
-                    console.log(red("\nSession configuration failed."));
-                    console.log("The given session.config.json configuration file is invalid.");
-                    console.log(`${error.instance}: ${error.stack}`);
-                    process.exit(0);
-                } else if (error.code === "ENOENT" && error.path === "./session.config.json") {
-                    console.log(cyan("Loading default session parameters..."));
-                    console.log("Consider including a session.config.json configuration file in your project root for customization.");
-                    config = this.preciseAssign({}, defaultConfig);
-                } else {
-                    console.log(red("\nSession configuration failed."));
-                    console.log("The following unknown error occurred during configuration.");
-                    console.log(error.stack);
-                    process.exit(0);
-                }
-            } finally {
-                const { identifiers } = config!;
-                Object.keys(identifiers).forEach(key => {
-                    const resolved = key as keyof Identifiers;
-                    const { text, color } = identifiers[resolved];
-                    identifiers[resolved].text = (colorMapping.get(color) || white)(`${text}:`);
-                });
-                return config!;
-            }
-        }
-
-        /**
-         * Builds the repl that allows the following commands to be typed into stdin of the master thread.
-         */
-        private initializeRepl = (): Repl => {
-            const repl = new Repl({ identifier: () => `${this.timestamp()} ${this.config.identifiers.master.text}` });
-            const boolean = /true|false/;
-            const number = /\d+/;
-            const letters = /[a-zA-Z]+/;
-            repl.registerCommand("exit", [/clean|force/], args => this.killSession("manual exit requested by repl", args[0] === "clean", 0));
-            repl.registerCommand("restart", [/clean|force/], args => this.killActiveWorker(args[0] === "clean"));
-            repl.registerCommand("set", [letters, "port", number, boolean], args => this.setPort(args[0], Number(args[2]), args[3] === "true"));
-            repl.registerCommand("set", [/polling/, number, boolean], args => {
-                const newPollingIntervalSeconds = Math.floor(Number(args[2]));
-                if (newPollingIntervalSeconds < 0) {
-                    this.mainLog(red("the polling interval must be a non-negative integer"));
-                } else {
-                    if (newPollingIntervalSeconds !== this.config.polling.intervalSeconds) {
-                        this.config.polling.intervalSeconds = newPollingIntervalSeconds;
-                        if (args[3] === "true") {
-                            this.activeWorker?.send({ newPollingIntervalSeconds });
-                        }
-                    }
-                }
-            });
-            return repl;
-        }
-
-        private executeExitHandlers = async (reason: Error | boolean) => Promise.all(this.exitHandlers.map(handler => handler(reason)));
-
-        /**
-         * Attempts to kill the active worker gracefully, unless otherwise specified.
-         */
-        private killActiveWorker = (graceful = true, isSessionEnd = false): void => {
-            if (this.activeWorker && !this.activeWorker.isDead()) {
-                if (graceful) {
-                    this.activeWorker.send({ manualExit: { isSessionEnd } });
-                } else {
-                    this.activeWorker.process.kill();
-                }
-            }
-        }
-
-        /**
-         * Allows the caller to set the port at which the target (be it the server,
-         * the websocket, some other custom port) is listening. If an immediate restart
-         * is specified, this monitor will kill the active child and re-launch the server
-         * at the port. Otherwise, the updated port won't be used until / unless the child
-         * dies on its own and triggers a restart.
-         */
-        private setPort = (port: "server" | "socket" | string, value: number, immediateRestart: boolean): void => {
-            if (value > 1023 && value < 65536) {
-                this.config.ports[port] = value;
-                if (immediateRestart) {
-                    this.killActiveWorker();
-                }
-            } else {
-                this.mainLog(red(`${port} is an invalid port number`));
-            }
-        }
-
-        /**
-         * Kills the current active worker and proceeds to spawn a new worker,
-         * feeding in configuration information as environment variables.
-         */
-        private spawn = (): void => {
-            const {
-                polling: {
-                    route,
-                    failureTolerance,
-                    intervalSeconds
-                },
-                ports
-            } = this.config;
-            this.killActiveWorker();
-            this.activeWorker = fork({
-                pollingRoute: route,
-                pollingFailureTolerance: failureTolerance,
-                serverPort: ports.server,
-                socketPort: ports.socket,
-                pollingIntervalSeconds: intervalSeconds,
-                session_key: this.key,
-                DB: process.env.DB
-            });
-            this.mainLog(cyan(`spawned new server worker with process id ${this.activeWorker.process.pid}`));
-            // an IPC message handler that executes actions on the master thread when prompted by the active worker
-            this.activeWorker.on("message", async ({ lifecycle, action }) => {
-                if (action) {
-                    const { message, args } = action as Monitor.Action;
-                    console.log(this.timestamp(), `${this.config.identifiers.worker.text} action requested (${cyan(message)})`);
-                    switch (message) {
-                        case "kill":
-                            const { reason, graceful, errorCode } = args;
-                            this.killSession(reason, graceful, errorCode);
-                            break;
-                        case "notify_crash":
-                            if (this.notifiers?.crash) {
-                                const { error } = args;
-                                const success = await this.notifiers.crash(error);
-                                const statement = success ? green("distributed crash notification to recipients") : red("distribution of crash notification failed");
-                                this.mainLog(statement);
-                            }
-                            break;
-                        case "set_port":
-                            const { port, value, immediateRestart } = args;
-                            this.setPort(port, value, immediateRestart);
-                            break;
-                    }
-                    const handlers = this.onMessage[message];
-                    if (handlers) {
-                        handlers.forEach(handler => handler({ message, args }));
-                    }
-                }
-                if (lifecycle) {
-                    console.log(this.timestamp(), `${this.config.identifiers.worker.text} lifecycle phase (${lifecycle})`);
-                }
-            });
-        }
-
-    }
-
-    /**
-     * Effectively, each worker repairs the connection to the server by reintroducing a consistent state
-     * if its predecessor has died. It itself also polls the server heartbeat, and exits with a notification
-     * email if the server encounters an uncaught exception or if the server cannot be reached.
-     */
-    export class ServerWorker {
-
-        private static count = 0;
-        private shouldServerBeResponsive = false;
-        private exitHandlers: ExitHandler[] = [];
-        private pollingFailureCount = 0;
-        private pollingIntervalSeconds: number;
-        private pollingFailureTolerance: number;
-        private pollTarget: string;
-        private serverPort: number;
-
-        public static Create(work: Function) {
-            if (isMaster) {
-                console.error(red("cannot create a worker on the monitor process."));
-                process.exit(1);
-            } else if (++ServerWorker.count > 1) {
-                process.send?.({
-                    action: {
-                        message: "kill", args: {
-                            reason: "cannot create more than one worker on a given worker process.",
-                            graceful: false,
-                            errorCode: 1
-                        }
-                    }
-                });
-                process.exit(1);
-            } else {
-                return new ServerWorker(work);
-            }
-        }
-
-        /**
-         * Allows developers to invoke application specific logic
-         * by hooking into the exiting of the server process.
-         */
-        public addExitHandler = (handler: ExitHandler) => this.exitHandlers.push(handler);
-
-        /**
-         * Kill the session monitor (parent process) from this
-         * server worker (child process). This will also kill
-         * this process (child process).
-         */
-        public killSession = (reason: string, graceful = true, errorCode = 0) => this.sendMonitorAction("kill", { reason, graceful, errorCode });
-
-        /**
-         * A convenience wrapper to tell the session monitor (parent process)
-         * to carry out the action with the specified message and arguments.
-         */
-        public sendMonitorAction = (message: string, args?: any) => process.send!({ action: { message, args } });
-
-        private constructor(work: Function) {
-            this.lifecycleNotification(green(`initializing process... ${white(`[${process.execPath} ${process.execArgv.join(" ")}]`)}`));
-
-            const { pollingRoute, serverPort, pollingIntervalSeconds, pollingFailureTolerance } = process.env;
-            this.serverPort = Number(serverPort);
-            this.pollingIntervalSeconds = Number(pollingIntervalSeconds);
-            this.pollingFailureTolerance = Number(pollingFailureTolerance);
-            this.pollTarget = `http://localhost:${serverPort}${pollingRoute}`;
-
-            this.configureProcess();
-            work();
-            this.pollServer();
-        }
-
-        /**
-         * Set up message and uncaught exception handlers for this
-         * server process.
-         */
-        private configureProcess = () => {
-            // updates the local values of variables to the those sent from master
-            process.on("message", async ({ newPollingIntervalSeconds, manualExit }) => {
-                if (newPollingIntervalSeconds !== undefined) {
-                    this.pollingIntervalSeconds = newPollingIntervalSeconds;
-                }
-                if (manualExit !== undefined) {
-                    const { isSessionEnd } = manualExit;
-                    await this.executeExitHandlers(isSessionEnd);
-                    process.exit(0);
-                }
-            });
-
-            // one reason to exit, as the process might be in an inconsistent state after such an exception
-            process.on('uncaughtException', this.proactiveUnplannedExit);
-            process.on('unhandledRejection', reason => {
-                const appropriateError = reason instanceof Error ? reason : new Error(`unhandled rejection: ${reason}`);
-                this.proactiveUnplannedExit(appropriateError);
-            });
-        }
-
-        /**
-         * Execute the list of functions registered to be called
-         * whenever the process exits.
-         */
-        private executeExitHandlers = async (reason: Error | boolean) => Promise.all(this.exitHandlers.map(handler => handler(reason)));
-
-        /**
-         * Notify master thread (which will log update in the console) of initialization via IPC.
-         */
-        public lifecycleNotification = (event: string) => process.send?.({ lifecycle: event });
-
-        /**
-         * Called whenever the process has a reason to terminate, either through an uncaught exception
-         * in the process (potentially inconsistent state) or the server cannot be reached.
-         */
-        private proactiveUnplannedExit = async (error: Error): Promise<void> => {
-            this.shouldServerBeResponsive = false;
-            // communicates via IPC to the master thread that it should dispatch a crash notification email
-            this.sendMonitorAction("notify_crash", { error });
-            await this.executeExitHandlers(error);
-            // notify master thread (which will log update in the console) of crash event via IPC
-            this.lifecycleNotification(red(`crash event detected @ ${new Date().toUTCString()}`));
-            this.lifecycleNotification(red(error.message));
-            process.exit(1);
-        }
-
-        /**
-         * This monitors the health of the server by submitting a get request to whatever port / route specified
-         * by the configuration every n seconds, where n is also given by the configuration. 
-         */
-        private pollServer = async (): Promise<void> => {
-            await new Promise<void>(resolve => {
-                setTimeout(async () => {
-                    try {
-                        await get(this.pollTarget);
-                        if (!this.shouldServerBeResponsive) {
-                            // notify monitor thread that the server is up and running
-                            this.lifecycleNotification(green(`listening on ${this.serverPort}...`));
-                        }
-                        this.shouldServerBeResponsive = true;
-                    } catch (error) {
-                        // if we expect the server to be unavailable, i.e. during compilation,
-                        // the listening variable is false, activeExit will return early and the child
-                        // process will continue
-                        if (this.shouldServerBeResponsive) {
-                            if (++this.pollingFailureCount > this.pollingFailureTolerance) {
-                                this.proactiveUnplannedExit(error);
-                            } else {
-                                this.lifecycleNotification(yellow(`the server has encountered ${this.pollingFailureCount} of ${this.pollingFailureTolerance} tolerable failures`));
-                            }
-                        }
-                    } finally {
-                        resolve();
-                    }
-                }, 1000 * this.pollingIntervalSeconds);
-            });
-            // controlled, asynchronous infinite recursion achieves a persistent poll that does not submit a new request until the previous has completed
-            this.pollServer();
-        }
-
-    }
-
-}
-- 
cgit v1.2.3-70-g09d2


From 8212288108c3acfd129e45b9f496f0606a8d2848 Mon Sep 17 00:00:00 2001
From: bob <bcz@cs.brown.edu>
Date: Wed, 15 Jan 2020 17:17:35 -0500
Subject: changed freeformdocument to use contentfittingviews if specified with
 FitToBox.   fixed bugs with contentfittingviews & titles.

---
 src/Utils.ts                                          |  2 +-
 src/client/views/Templates.tsx                        | 11 ++++++++++-
 .../views/collections/CollectionStackingView.tsx      |  4 ++--
 .../collectionFreeForm/CollectionFreeFormView.tsx     |  8 ++++++--
 .../views/nodes/CollectionFreeFormDocumentView.tsx    | 19 ++++++++++---------
 src/client/views/nodes/DocumentView.scss              | 13 ++++++++++++-
 src/client/views/nodes/DocumentView.tsx               | 13 +++++++------
 src/client/views/nodes/FormattedTextBox.tsx           |  2 +-
 src/new_fields/Doc.ts                                 |  2 +-
 src/new_fields/documentSchemas.ts                     |  6 +++++-
 10 files changed, 55 insertions(+), 25 deletions(-)

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

diff --git a/src/Utils.ts b/src/Utils.ts
index f50655f6c..7bf05a6fc 100644
--- a/src/Utils.ts
+++ b/src/Utils.ts
@@ -337,7 +337,7 @@ export function aggregateBounds(boundsList: { x: number, y: number, width: numbe
             r: Math.max(bptX, bounds.r), b: Math.max(bptY, bounds.b)
         };
     }, { x: Number.MAX_VALUE, y: Number.MAX_VALUE, r: -Number.MAX_VALUE, b: -Number.MAX_VALUE });
-    return { x: bounds.x !== Number.MAX_VALUE ? bounds.x - xpad : bounds.x, y: bounds.y !== Number.MAX_VALUE ? bounds.y - ypad : bounds.y, r: bounds.r !== -Number.MAX_VALUE ? bounds.r + 2 * xpad : bounds.r, b: bounds.b !== -Number.MAX_VALUE ? bounds.b + 2 * ypad : bounds.b };
+    return { x: bounds.x !== Number.MAX_VALUE ? bounds.x - xpad : bounds.x, y: bounds.y !== Number.MAX_VALUE ? bounds.y - ypad : bounds.y, r: bounds.r !== -Number.MAX_VALUE ? bounds.r + xpad : bounds.r, b: bounds.b !== -Number.MAX_VALUE ? bounds.b + ypad : bounds.b };
 }
 export function intersectRect(r1: { left: number, top: number, width: number, height: number },
     r2: { left: number, top: number, width: number, height: number }) {
diff --git a/src/client/views/Templates.tsx b/src/client/views/Templates.tsx
index ef78b60d4..8af8a6280 100644
--- a/src/client/views/Templates.tsx
+++ b/src/client/views/Templates.tsx
@@ -56,8 +56,17 @@ export namespace Templates {
                 <div style="width:100%;overflow:auto">{layout}</div>
             </div>
         </div>` );
+    export const TitleHover = new Template("TitleHover", TemplatePosition.InnerTop,
+        `<div>
+            <div style="height:25px; width:100%; background-color: rgba(0, 0, 0, .4); color: white; z-index: 100">
+                <span style="text-align:center;width:100%;font-size:20px;position:absolute;overflow:hidden;white-space:nowrap;text-overflow:ellipsis">{props.Document.title}</span>
+            </div>
+            <div style="height:calc(100% - 25px);">
+                <div style="width:100%;overflow:auto">{layout}</div>
+            </div>
+        </div>` );
 
-    export const TemplateList: Template[] = [Title, Caption];
+    export const TemplateList: Template[] = [Title, TitleHover, Caption];
 
     export function sortTemplates(a: Template, b: Template) {
         if (a.Position < b.Position) { return -1; }
diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx
index 3ab0326e2..886a9c870 100644
--- a/src/client/views/collections/CollectionStackingView.tsx
+++ b/src/client/views/collections/CollectionStackingView.tsx
@@ -41,7 +41,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) {
     @computed get sectionFilter() { return StrCast(this.props.Document.sectionFilter); }
     @computed get filteredChildren() { return this.childDocs.filter(d => !d.isMinimized).map(d => (Doc.GetLayoutDataDocPair(this.props.Document, this.props.DataDoc, this.props.fieldKey, d).layout as Doc) || d); }
     @computed get xMargin() { return NumCast(this.props.Document.xMargin, 2 * this.gridGap); }
-    @computed get yMargin() { return Math.max(this.props.Document.showTitle ? 30 : 0, NumCast(this.props.Document.yMargin, 2 * this.gridGap)); }
+    @computed get yMargin() { return Math.max(this.props.Document.showTitle && !this.props.Document.showTitleHover ? 30 : 0, NumCast(this.props.Document.yMargin, 2 * this.gridGap)); }
     @computed get gridGap() { return NumCast(this.props.Document.gridGap, 10); }
     @computed get isStackingView() { return BoolCast(this.props.Document.singleColumn, true); }
     @computed get numGroupColumns() { return this.isStackingView ? Math.max(1, this.Sections.size + (this.showAddAGroup ? 1 : 0)) : 1; }
@@ -152,7 +152,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) {
     }
 
     overlays = (doc: Doc) => {
-        return doc.type === DocumentType.IMG || doc.type === DocumentType.VID ? { title: StrCast(this.props.Document.showTitles), caption: StrCast(this.props.Document.showCaptions) } : {};
+        return doc.type === DocumentType.IMG || doc.type === DocumentType.VID ? { title: StrCast(this.props.Document.showTitles), titleHover: StrCast(this.props.Document.showTitleHovers), caption: StrCast(this.props.Document.showCaptions) } : {};
     }
 
     @computed get onChildClickHandler() { return ScriptCast(this.Document.onChildClick); }
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
index 29f9ccfcd..7985e541f 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
@@ -56,6 +56,8 @@ export const panZoomSchema = createSchema({
     useClusters: "boolean",
     isRuleProvider: "boolean",
     fitToBox: "boolean",
+    xPadding: "number",         // pixels of padding on left/right of collectionfreeformview contents when fitToBox is set
+    yPadding: "number",         // pixels of padding on left/right of collectionfreeformview contents when fitToBox is set
     panTransformType: "string",
     scrollHeight: "number",
     fitX: "number",
@@ -802,9 +804,11 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
         }
         this.childLayoutPairs.filter((pair, i) => this.isCurrent(pair.layout)).forEach(pair =>
             computedElementData.elements.push({
-                ele: <CollectionFreeFormDocumentView key={pair.layout[Id]} dataProvider={this.childDataProvider}
+                ele: <CollectionFreeFormDocumentView key={pair.layout[Id]}  {...this.getChildDocumentViewProps(pair.layout, pair.data)}
+                    dataProvider={this.childDataProvider}
                     ruleProvider={this.Document.isRuleProvider ? this.props.Document : this.props.ruleProvider}
-                    jitterRotation={NumCast(this.props.Document.jitterRotation)} {...this.getChildDocumentViewProps(pair.layout, pair.data)} />,
+                    jitterRotation={NumCast(this.props.Document.jitterRotation)}
+                    fitToBox={this.props.fitToBox || this.Document.freeformLayoutEngine === "pivot"} />,
                 bounds: this.childDataProvider(pair.layout)
             }));
 
diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx
index f79496ab7..614a68e7a 100644
--- a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx
+++ b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx
@@ -11,6 +11,8 @@ import { DocumentView, DocumentViewProps } from "./DocumentView";
 import React = require("react");
 import { PositionDocument } from "../../../new_fields/documentSchemas";
 import { TraceMobx } from "../../../new_fields/util";
+import { returnFalse } from "../../../Utils";
+import { ContentFittingDocumentView } from "./ContentFittingDocumentView";
 
 export interface CollectionFreeFormDocumentViewProps extends DocumentViewProps {
     dataProvider?: (doc: Doc) => { x: number, y: number, width: number, height: number, z: number, transition?: string } | undefined;
@@ -20,6 +22,7 @@ export interface CollectionFreeFormDocumentViewProps extends DocumentViewProps {
     height?: number;
     jitterRotation: number;
     transition?: string;
+    fitToBox?: boolean;
 }
 
 @observer
@@ -83,8 +86,8 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF
 
     @observable _animPos: number[] | undefined = undefined;
 
-    finalPanelWidth = () => this.dataProvider ? this.dataProvider.width : this.panelWidth();
-    finalPanelHeight = () => this.dataProvider ? this.dataProvider.height : this.panelHeight();
+    finalPanelWidth = () => (this.dataProvider ? this.dataProvider.width : this.panelWidth());
+    finalPanelHeight = () => (this.dataProvider ? this.dataProvider.height : this.panelHeight());
 
     render() {
         TraceMobx();
@@ -104,24 +107,22 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF
                 zIndex: this.Document.zIndex || 0,
             }} >
 
-            <DocumentView {...this.props}
+
+            {!this.props.fitToBox ? <DocumentView {...this.props}
                 dragDivName={"collectionFreeFormDocumentView-container"}
                 ContentScaling={this.contentScaling}
                 ScreenToLocalTransform={this.getTransform}
                 backgroundColor={this.clusterColorFunc}
                 PanelWidth={this.finalPanelWidth}
                 PanelHeight={this.finalPanelHeight}
-            />
-            {/* <ContentFittingDocumentView {...this.props}
-                //dragDivName={"collectionFreeFormDocumentView-container"}
-                //ContentScaling={this.contentScaling}
+            /> : <ContentFittingDocumentView {...this.props}
+                DataDocument={this.props.DataDoc}
                 getTransform={this.getTransform}
                 active={returnFalse}
                 focus={(doc: Doc) => this.props.focus(doc, false)}
-                // backgroundColor={this.clusterColorFunc}
                 PanelWidth={this.finalPanelWidth}
                 PanelHeight={this.finalPanelHeight}
-            /> */}
+                />}
         </div>;
     }
 }
diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss
index f44c6dd3b..2ce56c73d 100644
--- a/src/client/views/nodes/DocumentView.scss
+++ b/src/client/views/nodes/DocumentView.scss
@@ -55,7 +55,7 @@
             position: absolute;
         }
 
-        .documentView-titleWrapper {
+        .documentView-titleWrapper, .documentView-titleWrapper-hover {
             overflow: hidden;
             color: white;
             transform-origin: top left;
@@ -68,6 +68,9 @@
             text-overflow: ellipsis;
             white-space: pre;
         }
+        .documentView-titleWrapper-hover {
+            display:none;
+        }
 
         .documentView-searchHighlight {
             position: absolute;
@@ -85,4 +88,12 @@
         }
     }
 
+}
+
+.documentView-node:hover, .documentView-node-topmost:hover {
+    > .documentView-styleWrapper {
+        > .documentView-titleWrapper-hover {
+            display:inline-block;
+        }
+    }
 }
\ No newline at end of file
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index 8f3fa4530..b2c2ccff5 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -29,7 +29,6 @@ import { CollectionDockingView } from "../collections/CollectionDockingView";
 import { CollectionView } from "../collections/CollectionView";
 import { ContextMenu } from "../ContextMenu";
 import { ContextMenuProps } from '../ContextMenuItem';
-import { DictationOverlay } from '../DictationOverlay';
 import { DocComponent } from "../DocComponent";
 import { EditableView } from '../EditableView';
 import { OverlayView } from '../OverlayView';
@@ -64,7 +63,7 @@ export interface DocumentViewProps {
     moveDocument?: (doc: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => boolean;
     ScreenToLocalTransform: () => Transform;
     renderDepth: number;
-    showOverlays?: (doc: Doc) => { title?: string, caption?: string };
+    showOverlays?: (doc: Doc) => { title?: string, titleHover?: string, caption?: string };
     ContentScaling: () => number;
     ruleProvider: Doc | undefined;
     PanelWidth: () => number;
@@ -741,7 +740,8 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
     chromeHeight = () => {
         const showOverlays = this.props.showOverlays ? this.props.showOverlays(this.Document) : undefined;
         const showTitle = showOverlays && "title" in showOverlays ? showOverlays.title : StrCast(this.layoutDoc.showTitle);
-        return (showTitle ? 25 : 0) + 1;
+        const showTitleHover = showOverlays && "titleHover" in showOverlays ? showOverlays.titleHover : StrCast(this.layoutDoc.showTitleHover);
+        return (showTitle && !showTitleHover ? 0 : 0) + 1;
     }
 
     @computed get finalLayoutKey() { return this.props.layoutKey || "layout"; }
@@ -751,6 +751,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
         return (<DocumentContentsView ContainingCollectionView={this.props.ContainingCollectionView}
             ContainingCollectionDoc={this.props.ContainingCollectionDoc}
             Document={this.props.Document}
+            DataDoc={this.props.DataDoc}
             fitToBox={this.props.fitToBox}
             LibraryPath={this.props.LibraryPath}
             addDocument={this.props.addDocument}
@@ -777,8 +778,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
             isSelected={this.isSelected}
             select={this.select}
             onClick={this.onClickHandler}
-            layoutKey={this.finalLayoutKey}
-            DataDoc={this.props.DataDoc} />);
+            layoutKey={this.finalLayoutKey} />);
     }
     linkEndpoint = (linkDoc: Doc) => Doc.LinkEndpoint(linkDoc, this.props.Document);
 
@@ -795,6 +795,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
         TraceMobx();
         const showOverlays = this.props.showOverlays ? this.props.showOverlays(this.Document) : undefined;
         const showTitle = showOverlays && "title" in showOverlays ? showOverlays.title : StrCast(this.getLayoutPropStr("showTitle"));
+        const showTitleHover = showOverlays && "titleHover" in showOverlays ? showOverlays.titleHover : StrCast(this.getLayoutPropStr("showTitleHover"));
         const showCaption = showOverlays && "caption" in showOverlays ? showOverlays.caption : this.getLayoutPropStr("showCaption");
         const showTextTitle = showTitle && StrCast(this.layoutDoc.layout).indexOf("FormattedTextBox") !== -1 ? showTitle : undefined;
         const searchHighlight = (!this.Document.searchFields ? (null) :
@@ -810,7 +811,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
                 />
             </div>);
         const titleView = (!showTitle ? (null) :
-            <div className="documentView-titleWrapper" style={{
+            <div className={`documentView-titleWrapper${showTitleHover ? "-hover" : ""}`} style={{
                 position: showTextTitle ? "relative" : "absolute",
                 pointerEvents: SelectionManager.GetIsDragging() ? "none" : "all",
             }}>
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index 8b5c24878..60842bcb0 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -940,7 +940,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
             }
             if (!node && this.ProseRef) {
                 const lastNode = this.ProseRef.children[this.ProseRef.children.length - 1].children[this.ProseRef.children[this.ProseRef.children.length - 1].children.length - 1]; // get the last prosemirror div
-                if (e.clientY > lastNode.getBoundingClientRect().bottom) { // if we clicked below the last prosemirror div, then set the selection to be the end of the document
+                if (e.clientY > lastNode?.getBoundingClientRect().bottom) { // if we clicked below the last prosemirror div, then set the selection to be the end of the document
                     this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, this._editorView!.state.doc.content.size)));
                 }
             }
diff --git a/src/new_fields/Doc.ts b/src/new_fields/Doc.ts
index 26acfa9c3..e0ab5d97c 100644
--- a/src/new_fields/Doc.ts
+++ b/src/new_fields/Doc.ts
@@ -455,7 +455,7 @@ export namespace Doc {
         if (resolvedDataDoc && Doc.WillExpandTemplateLayout(childDocLayout, resolvedDataDoc)) {
             const extensionDoc = fieldExtensionDoc(resolvedDataDoc, StrCast(childDocLayout.templateField, StrCast(childDocLayout.title)));
             layoutDoc = Doc.expandTemplateLayout(childDocLayout, extensionDoc !== resolvedDataDoc ? extensionDoc : undefined);
-            layoutDoc && (layoutDoc.resolvedDataDoc = resolvedDataDoc);
+            setTimeout(() => layoutDoc && (layoutDoc.resolvedDataDoc = resolvedDataDoc), 0);
         } else layoutDoc = childDocLayout;
         return { layout: layoutDoc, data: resolvedDataDoc };
     }
diff --git a/src/new_fields/documentSchemas.ts b/src/new_fields/documentSchemas.ts
index 21e69fbed..909fdc6c3 100644
--- a/src/new_fields/documentSchemas.ts
+++ b/src/new_fields/documentSchemas.ts
@@ -41,7 +41,8 @@ export const documentSchema = createSchema({
     searchFields: "string",     // the search fields to display when this document matches a search in its metadata
     heading: "number",          // the logical layout 'heading' of this document (used by rule provider to stylize h1 header elements, from h2, etc)
     showCaption: "string",      // whether editable caption text is overlayed at the bottom of the document 
-    showTitle: "string",        // whether an editable title banner is displayed at tht top of the document
+    showTitle: "string",        // the fieldkey whose contents should be displayed at the top of the document
+    showTitleHover: "string",   // the showTitle should be shown only on hover
     isButton: "boolean",        // whether document functions as a button (overiding native interactions of its content)    
     ignoreClick: "boolean",     // whether documents ignores input clicks (but does not ignore manipulation and other events) 
     isAnimating: "string",      // whether the document is in the midst of animating between two layouts (used by icons to de/iconify documents).  value is undefined|"min"|"max"
@@ -49,6 +50,9 @@ export const documentSchema = createSchema({
     scrollToLinkID: "string",   // id of link being traversed. allows this doc to scroll/highlight/etc its link anchor. scrollToLinkID should be set to undefined by this doc after it sets up its scroll,etc.
     strokeWidth: "number",
     fontSize: "string",
+    fitToBox: "boolean",        // whether freeform view contents should be zoomed/panned to fill the area of the document view
+    xPadding: "number",         // pixels of padding on left/right of collectionfreeformview contents when fitToBox is set
+    yPadding: "number",         // pixels of padding on left/right of collectionfreeformview contents when fitToBox is set
     LODarea: "number",          // area (width*height) where CollectionFreeFormViews switch from a label to rendering contents
     LODdisable: "boolean",      // whether to disbale LOD switching for CollectionFreeFormViews
 });
-- 
cgit v1.2.3-70-g09d2

