From 565b27cca8953a60067de367cae4c0a99beb3cab Mon Sep 17 00:00:00 2001
From: Tyler Schicke <tyler_schicke@brown.edu>
Date: Wed, 19 Jun 2019 16:21:03 -0400
Subject: started adding selection to context menu

---
 src/client/views/ContextMenu.tsx | 58 ++++++++++++++++++++++++++--------------
 1 file changed, 38 insertions(+), 20 deletions(-)

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

diff --git a/src/client/views/ContextMenu.tsx b/src/client/views/ContextMenu.tsx
index 1133f70a1..33de57cfa 100644
--- a/src/client/views/ContextMenu.tsx
+++ b/src/client/views/ContextMenu.tsx
@@ -1,5 +1,5 @@
 import React = require("react");
-import { ContextMenuItem, ContextMenuProps } from "./ContextMenuItem";
+import { ContextMenuItem, ContextMenuProps, OriginalMenuProps } from "./ContextMenuItem";
 import { observable, action, computed } from "mobx";
 import { observer } from "mobx-react";
 import "./ContextMenu.scss";
@@ -21,6 +21,7 @@ export class ContextMenu extends React.Component {
     @observable private _searchString: string = "";
     // afaik displaymenu can be called before all the items are added to the menu, so can't determine in displayMenu what the height of the menu will be
     @observable private _yRelativeToTop: boolean = true;
+    @observable selectedIndex = -1;
 
     private _searchRef = React.createRef<HTMLInputElement>();
 
@@ -75,49 +76,63 @@ export class ContextMenu extends React.Component {
         this._display = false;
     }
 
-    @computed get filteredItems() {
+    @computed get filteredItems(): (OriginalMenuProps | string[])[] {
         const searchString = this._searchString.toLowerCase().split(" ");
         const matches = (descriptions: string[]): boolean => {
-            return searchString.every(s => descriptions.some(desc => desc.includes(s)));
+            return searchString.every(s => descriptions.some(desc => desc.toLowerCase().includes(s)));
         };
-        const createGroupHeader = (contents: any) => {
-            return (
-                <div className="contextMenu-group">
-                    <div className="contextMenu-description">{contents}</div>
-                </div>
-            );
-        };
-        const createItem = (item: ContextMenuProps) => <ContextMenuItem {...item} key={item.description} closeMenu={this.closeMenu} />;
-        const flattenItems = (items: ContextMenuProps[], groupFunc: (contents: any) => JSX.Element, getPath: () => string[]) => {
-            let eles: JSX.Element[] = [];
+        const flattenItems = (items: ContextMenuProps[], groupFunc: (groupName: any) => string[]) => {
+            let eles: (OriginalMenuProps | string[])[] = [];
 
+            const leaves: OriginalMenuProps[] = [];
             for (const item of items) {
-                const description = item.description.toLowerCase();
-                const path = [...getPath(), description];
+                const description = item.description;
+                const path = groupFunc(description);
                 if ("subitems" in item) {
-                    const children = flattenItems(item.subitems, contents => groupFunc(<>{item.description} -> {contents}</>), () => path);
+                    const children = flattenItems(item.subitems, name => [...groupFunc(description), name]);
                     if (children.length || matches(path)) {
-                        eles.push(groupFunc(item.description));
+                        eles.push(path);
                         eles = eles.concat(children);
                     }
                 } else {
                     if (!matches(path)) {
                         continue;
                     }
-                    eles.push(createItem(item));
+                    leaves.push(item);
                 }
             }
 
+            eles = [...leaves, ...eles];
+
             return eles;
         };
-        return flattenItems(this._items, createGroupHeader, () => []);
+        return flattenItems(this._items, name => [name]);
+    }
+
+    @computed get filteredViews() {
+        const createGroupHeader = (contents: any) => {
+            return (
+                <div className="contextMenu-group">
+                    <div className="contextMenu-description">{contents}</div>
+                </div>
+            );
+        };
+        const createItem = (item: ContextMenuProps, selected: boolean) => <ContextMenuItem {...item} key={item.description} closeMenu={this.closeMenu} selected={selected} />;
+        let itemIndex = 0;
+        return this.filteredItems.map(value => {
+            if (Array.isArray(value)) {
+                return createGroupHeader(value.join(" -> "));
+            } else {
+                return createItem(value, ++itemIndex === this.selectedIndex);
+            }
+        });
     }
 
     @computed get menuItems() {
         if (!this._searchString) {
             return this._items.map(item => <ContextMenuItem {...item} key={item.description} closeMenu={this.closeMenu} />);
         }
-        return this.filteredItems;
+        return this.filteredViews;
     }
 
     render() {
@@ -143,5 +158,8 @@ export class ContextMenu extends React.Component {
     @action
     onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
         this._searchString = e.target.value;
+        if (this._searchString && this.selectedIndex === -1) {
+            this.selectedIndex = 0;
+        }
     }
 }
\ No newline at end of file
-- 
cgit v1.2.3-70-g09d2


From 05e50f27a15e8a02ffb27606c51026d1b85bc677 Mon Sep 17 00:00:00 2001
From: Tyler Schicke <tyler_schicke@brown.edu>
Date: Wed, 19 Jun 2019 17:36:52 -0400
Subject: Added basic keyboard controls to context menu

---
 src/client/views/ContextMenu.tsx        | 40 ++++++++++++++++++++++++++++----
 src/client/views/ContextMenuItem.tsx    |  4 ++--
 src/client/views/nodes/DocumentView.tsx | 41 +++++++++++++++++----------------
 src/client/views/nodes/IconBox.tsx      |  6 ++---
 4 files changed, 61 insertions(+), 30 deletions(-)

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

diff --git a/src/client/views/ContextMenu.tsx b/src/client/views/ContextMenu.tsx
index 33de57cfa..59a0de2a0 100644
--- a/src/client/views/ContextMenu.tsx
+++ b/src/client/views/ContextMenu.tsx
@@ -14,7 +14,7 @@ library.add(faCircle);
 export class ContextMenu extends React.Component {
     static Instance: ContextMenu;
 
-    @observable private _items: Array<ContextMenuProps> = [{ description: "test", event: (e: React.MouseEvent) => e.preventDefault(), icon: "smile" }];
+    @observable private _items: Array<ContextMenuProps> = [];
     @observable private _pageX: number = 0;
     @observable private _pageY: number = 0;
     @observable private _display: boolean = false;
@@ -109,6 +109,10 @@ export class ContextMenu extends React.Component {
         return flattenItems(this._items, name => [name]);
     }
 
+    @computed get flatItems(): OriginalMenuProps[] {
+        return this.filteredItems.filter(item => !Array.isArray(item)) as OriginalMenuProps[];
+    }
+
     @computed get filteredViews() {
         const createGroupHeader = (contents: any) => {
             return (
@@ -123,7 +127,7 @@ export class ContextMenu extends React.Component {
             if (Array.isArray(value)) {
                 return createGroupHeader(value.join(" -> "));
             } else {
-                return createItem(value, ++itemIndex === this.selectedIndex);
+                return createItem(value, itemIndex++ === this.selectedIndex);
             }
         });
     }
@@ -148,18 +152,44 @@ export class ContextMenu extends React.Component {
                     <span className="icon-background">
                         <FontAwesomeIcon icon="search" size="lg" />
                     </span>
-                    <input className="contextMenu-item contextMenu-description" type="text" placeholder="Search . . ." value={this._searchString} onChange={this.onChange} ref={this._searchRef} autoFocus />
+                    <input className="contextMenu-item contextMenu-description" type="text" placeholder="Search . . ." value={this._searchString} onKeyDown={this.onKeyDown} onChange={this.onChange} ref={this._searchRef} autoFocus />
                 </span>
                 {this.menuItems}
             </div>
         );
     }
 
+    @action
+    onKeyDown = (e: React.KeyboardEvent) => {
+        if (e.key === "ArrowDown") {
+            if (this.selectedIndex < this.flatItems.length - 1) {
+                this.selectedIndex++;
+            }
+            e.preventDefault();
+        } else if (e.key === "ArrowUp") {
+            if (this.selectedIndex > 0) {
+                this.selectedIndex--;
+            }
+            e.preventDefault();
+        } else if (e.key === "Enter") {
+            const item = this.flatItems[this.selectedIndex];
+            item.event();
+            this.closeMenu();
+        }
+    }
+
     @action
     onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
         this._searchString = e.target.value;
-        if (this._searchString && this.selectedIndex === -1) {
-            this.selectedIndex = 0;
+        if (!this._searchString) {
+            this.selectedIndex = -1;
+        }
+        else {
+            if (this.selectedIndex === -1) {
+                this.selectedIndex = 0;
+            } else {
+                this.selectedIndex = Math.min(this.flatItems.length - 1, this.selectedIndex);
+            }
         }
     }
 }
\ No newline at end of file
diff --git a/src/client/views/ContextMenuItem.tsx b/src/client/views/ContextMenuItem.tsx
index ebcac7428..9bbb97d7e 100644
--- a/src/client/views/ContextMenuItem.tsx
+++ b/src/client/views/ContextMenuItem.tsx
@@ -9,7 +9,7 @@ library.add(faAngleRight);
 
 export interface OriginalMenuProps {
     description: string;
-    event: (e: React.MouseEvent<HTMLDivElement>) => void;
+    event: () => void;
     icon?: IconProp; //maybe should be optional (icon?)
     closeMenu?: () => void;
 }
@@ -37,7 +37,7 @@ export class ContextMenuItem extends React.Component<ContextMenuProps & { select
 
     handleEvent = (e: React.MouseEvent<HTMLDivElement>) => {
         if ("event" in this.props) {
-            this.props.event(e);
+            this.props.event();
             this.props.closeMenu && this.props.closeMenu();
         }
     }
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index f7836d947..2062fe1b5 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -1,5 +1,5 @@
 import { library } from '@fortawesome/fontawesome-svg-core';
-import { faAlignCenter, faCaretSquareRight, faCompressArrowsAlt, faUnlock, faLock, faExpandArrowsAlt, faLayerGroup, faSquare, faTrash, faConciergeBell, faFolder, faShare, faMapPin, faLink, faFingerprint, faCrosshairs, faDesktop } from '@fortawesome/free-solid-svg-icons';
+import * as fa from '@fortawesome/free-solid-svg-icons';
 import { action, computed, IReactionDisposer, reaction, trace, observable, runInAction } from "mobx";
 import { observer } from "mobx-react";
 import { Doc, DocListCast, HeightSym, Opt, WidthSym, DocListCastAsync } from "../../../new_fields/Doc";
@@ -34,23 +34,24 @@ import { ContextMenuProps } from '../ContextMenuItem';
 import { RouteStore } from '../../../server/RouteStore';
 const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this?
 
-library.add(faTrash);
-library.add(faShare);
-library.add(faExpandArrowsAlt);
-library.add(faCompressArrowsAlt);
-library.add(faLayerGroup);
-library.add(faAlignCenter);
-library.add(faCaretSquareRight);
-library.add(faSquare);
-library.add(faConciergeBell);
-library.add(faFolder);
-library.add(faMapPin);
-library.add(faLink);
-library.add(faFingerprint);
-library.add(faCrosshairs);
-library.add(faDesktop);
-library.add(faUnlock);
-library.add(faLock);
+library.add(fa.faTrash);
+library.add(fa.faShare);
+library.add(fa.faExpandArrowsAlt);
+library.add(fa.faCompressArrowsAlt);
+library.add(fa.faLayerGroup);
+library.add(fa.faExternalLinkAlt);
+library.add(fa.faAlignCenter);
+library.add(fa.faCaretSquareRight);
+library.add(fa.faSquare);
+library.add(fa.faConciergeBell);
+library.add(fa.faFolder);
+library.add(fa.faMapPin);
+library.add(fa.faLink);
+library.add(fa.faFingerprint);
+library.add(fa.faCrosshairs);
+library.add(fa.faDesktop);
+library.add(fa.faUnlock);
+library.add(fa.faLock);
 
 const linkSchema = createSchema({
     title: "string",
@@ -447,7 +448,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
         this.templates = this.templates;
     }
 
-    freezeNativeDimensions = (e: React.MouseEvent): void => {
+    freezeNativeDimensions = (): void => {
         let proto = Doc.GetProto(this.props.Document);
         if (proto.ignoreAspect === undefined && !proto.nativeWidth) {
             proto.nativeWidth = this.props.PanelWidth();
@@ -476,7 +477,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
         subitems.push({ description: "Open Right", event: () => this.props.addDocTab && this.props.addDocTab(this.props.Document, "onRight"), icon: "caret-square-right" });
         subitems.push({ description: "Open Right Alias", event: () => this.props.addDocTab && this.props.addDocTab(Doc.MakeAlias(this.props.Document), "onRight"), icon: "caret-square-right" });
         subitems.push({ description: "Open Fields", event: this.fieldsClicked, icon: "layer-group" });
-        cm.addItem({ description: "Open...", subitems: subitems });
+        cm.addItem({ description: "Open...", subitems: subitems, icon: "external-link-alt" });
         cm.addItem({ description: BoolCast(this.props.Document.ignoreAspect, false) || !this.props.Document.nativeWidth || !this.props.Document.nativeHeight ? "Freeze" : "Unfreeze", event: this.freezeNativeDimensions, icon: "edit" });
         cm.addItem({ description: "Pin to Pres", event: () => PresentationView.Instance.PinDoc(this.props.Document), icon: "map-pin" });
         cm.addItem({ description: BoolCast(this.props.Document.lockedPosition) ? "Unlock Pos" : "Lock Pos", event: () => this.props.Document.lockedPosition = BoolCast(this.props.Document.lockedPosition) ? undefined : true, icon: BoolCast(this.props.Document.lockedPosition) ? "unlock" : "lock" });
diff --git a/src/client/views/nodes/IconBox.tsx b/src/client/views/nodes/IconBox.tsx
index 00021bc78..d6ab2a34a 100644
--- a/src/client/views/nodes/IconBox.tsx
+++ b/src/client/views/nodes/IconBox.tsx
@@ -37,14 +37,14 @@ export class IconBox extends React.Component<FieldViewProps> {
         return <FontAwesomeIcon icon={button} className="documentView-minimizedIcon" />;
     }
 
-    setLabelField = (e: React.MouseEvent): void => {
+    setLabelField = (): void => {
         this.props.Document.hideLabel = !BoolCast(this.props.Document.hideLabel);
     }
-    setUseOwnTitleField = (e: React.MouseEvent): void => {
+    setUseOwnTitleField = (): void => {
         this.props.Document.useOwnTitle = !BoolCast(this.props.Document.useTargetTitle);
     }
 
-    specificContextMenu = (e: React.MouseEvent): void => {
+    specificContextMenu = (): void => {
         ContextMenu.Instance.addItem({
             description: BoolCast(this.props.Document.hideLabel) ? "Show label with icon" : "Remove label from icon",
             event: this.setLabelField
-- 
cgit v1.2.3-70-g09d2

