From 464fa03d6ebb2a7aaef1d7622afa3e1e7ee816a3 Mon Sep 17 00:00:00 2001 From: Tyler Schicke Date: Tue, 18 Jun 2019 20:11:31 -0400 Subject: Context menu improvements and error fixes --- src/client/views/ContextMenuItem.tsx | 47 ++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) (limited to 'src/client/views/ContextMenuItem.tsx') diff --git a/src/client/views/ContextMenuItem.tsx b/src/client/views/ContextMenuItem.tsx index fcda0db89..dc0751049 100644 --- a/src/client/views/ContextMenuItem.tsx +++ b/src/client/views/ContextMenuItem.tsx @@ -1,9 +1,12 @@ import React = require("react"); import { observable, action } from "mobx"; import { observer } from "mobx-react"; -import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { IconProp, library } from '@fortawesome/fontawesome-svg-core'; +import { faAngleRight } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +library.add(faAngleRight); + export interface OriginalMenuProps { description: string; event: (e: React.MouseEvent) => void; @@ -14,6 +17,7 @@ export interface OriginalMenuProps { export interface SubmenuProps { description: string; subitems: ContextMenuProps[]; + icon?: IconProp; //maybe should be optional (icon?) closeMenu?: () => void; } @@ -41,13 +45,40 @@ export class ContextMenuItem extends React.Component { } } + currentTimeout?: any; + static readonly timeout = 300; + onPointerEnter = () => { + if (this.currentTimeout) { + clearTimeout(this.currentTimeout); + this.currentTimeout = undefined; + } + if (this.overItem) { + return; + } + this.currentTimeout = setTimeout(action(() => this.overItem = true), ContextMenuItem.timeout); + } + + onPointerLeave = () => { + if (this.currentTimeout) { + clearTimeout(this.currentTimeout); + this.currentTimeout = undefined; + } + if (!this.overItem) { + return; + } + this.currentTimeout = setTimeout(action(() => this.overItem = false), ContextMenuItem.timeout); + + } + render() { if ("event" in this.props) { return (
- - {this.props.icon ? : } - + {this.props.icon ? ( + + + + ) : null}
{this.props.description}
@@ -60,9 +91,15 @@ export class ContextMenuItem extends React.Component { {this._items.map(prop => )}
; return ( -
{ this.overItem = true; })} onMouseLeave={action(() => this.overItem = false)}> +
+ {this.props.icon ? ( + + + + ) : null}
{this.props.description} +
{submenu}
-- cgit v1.2.3-70-g09d2 From 35e73f369a2145d8a042e0011a43e71763d57998 Mon Sep 17 00:00:00 2001 From: Tyler Schicke Date: Wed, 19 Jun 2019 15:02:48 -0400 Subject: added better search to context menu --- src/client/views/ContextMenu.scss | 23 ++++++++++ src/client/views/ContextMenu.tsx | 83 +++++++++++++++++++++++++----------- src/client/views/ContextMenuItem.tsx | 7 +-- src/client/views/MainView.tsx | 2 +- 4 files changed, 84 insertions(+), 31 deletions(-) (limited to 'src/client/views/ContextMenuItem.tsx') diff --git a/src/client/views/ContextMenu.scss b/src/client/views/ContextMenu.scss index e363c5158..a1a2b06f1 100644 --- a/src/client/views/ContextMenu.scss +++ b/src/client/views/ContextMenu.scss @@ -53,6 +53,29 @@ font-size: 20px; } +.contextMenu-group { + // width: 11vw; //10vw + height: 30px; //2vh + background: rgb(200, 200, 200); + display: flex; //comment out to allow search icon to be inline with search text + justify-content: left; + align-items: center; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + transition: all .1s; + border-width: .11px; + border-style: none; + border-color: $intermediate-color; // rgb(187, 186, 186); + border-bottom-style: solid; + // padding: 10px 0px 10px 0px; + white-space: nowrap; + font-size: 20px; +} + .contextMenu-item:hover { transition: all 0.1s; background: $lighter-alt-accent; diff --git a/src/client/views/ContextMenu.tsx b/src/client/views/ContextMenu.tsx index eb1937683..1133f70a1 100644 --- a/src/client/views/ContextMenu.tsx +++ b/src/client/views/ContextMenu.tsx @@ -1,6 +1,6 @@ import React = require("react"); import { ContextMenuItem, ContextMenuProps } from "./ContextMenuItem"; -import { observable, action } from "mobx"; +import { observable, action, computed } from "mobx"; import { observer } from "mobx-react"; import "./ContextMenu.scss"; import { library } from '@fortawesome/fontawesome-svg-core'; @@ -17,11 +17,12 @@ export class ContextMenu extends React.Component { @observable private _items: Array = [{ description: "test", event: (e: React.MouseEvent) => e.preventDefault(), icon: "smile" }]; @observable private _pageX: number = 0; @observable private _pageY: number = 0; - @observable private _display: string = "none"; + @observable private _display: boolean = false; @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; + private _searchRef = React.createRef(); private ref: React.RefObject; @@ -36,7 +37,6 @@ export class ContextMenu extends React.Component { @action clearItems() { this._items = []; - this._display = "none"; } @action @@ -62,34 +62,70 @@ export class ContextMenu extends React.Component { this._searchString = ""; - this._display = "flex"; - } - - intersects = (x: number, y: number): boolean => { - if (this.ref.current && this._display !== "none") { - let menuSize = { width: this.ref.current.getBoundingClientRect().width, height: this.ref.current.getBoundingClientRect().height }; + this._display = true; - let upperLeft = { x: this._pageX, y: this._yRelativeToTop ? this._pageY : window.innerHeight - (this._pageY + menuSize.height) }; - let bottomRight = { x: this._pageX + menuSize.width, y: this._yRelativeToTop ? this._pageY + menuSize.height : window.innerHeight - this._pageY }; - - if (x >= upperLeft.x && x <= bottomRight.x) { - if (y >= upperLeft.y && y <= bottomRight.y) { - return true; - } - } + if (this._searchRef.current) { + this._searchRef.current.focus(); } - return false; } @action closeMenu = () => { this.clearItems(); + this._display = false; } - render() { - let style = this._yRelativeToTop ? { left: this._pageX, top: this._pageY, display: this._display } : - { left: this._pageX, bottom: this._pageY, display: this._display }; + @computed get filteredItems() { + const searchString = this._searchString.toLowerCase().split(" "); + const matches = (descriptions: string[]): boolean => { + return searchString.every(s => descriptions.some(desc => desc.includes(s))); + }; + const createGroupHeader = (contents: any) => { + return ( +
+
{contents}
+
+ ); + }; + const createItem = (item: ContextMenuProps) => ; + const flattenItems = (items: ContextMenuProps[], groupFunc: (contents: any) => JSX.Element, getPath: () => string[]) => { + let eles: JSX.Element[] = []; + + for (const item of items) { + const description = item.description.toLowerCase(); + const path = [...getPath(), description]; + if ("subitems" in item) { + const children = flattenItems(item.subitems, contents => groupFunc(<>{item.description} -> {contents}), () => path); + if (children.length || matches(path)) { + eles.push(groupFunc(item.description)); + eles = eles.concat(children); + } + } else { + if (!matches(path)) { + continue; + } + eles.push(createItem(item)); + } + } + return eles; + }; + return flattenItems(this._items, createGroupHeader, () => []); + } + + @computed get menuItems() { + if (!this._searchString) { + return this._items.map(item => ); + } + return this.filteredItems; + } + + render() { + if (!this._display) { + return null; + } + let style = this._yRelativeToTop ? { left: this._pageX, top: this._pageY } : + { left: this._pageX, bottom: this._pageY }; return (
@@ -97,10 +133,9 @@ export class ContextMenu extends React.Component { - + - {this._items.filter(prop => prop.description.toLowerCase().indexOf(this._searchString.toLowerCase()) !== -1). - map(prop => )} + {this.menuItems}
); } diff --git a/src/client/views/ContextMenuItem.tsx b/src/client/views/ContextMenuItem.tsx index dc0751049..88ebd95bc 100644 --- a/src/client/views/ContextMenuItem.tsx +++ b/src/client/views/ContextMenuItem.tsx @@ -21,9 +21,6 @@ export interface SubmenuProps { closeMenu?: () => void; } -export interface ContextMenuItemProps { - type: ContextMenuProps | SubmenuProps; -} export type ContextMenuProps = OriginalMenuProps | SubmenuProps; @observer @@ -67,7 +64,6 @@ export class ContextMenuItem extends React.Component { return; } this.currentTimeout = setTimeout(action(() => this.overItem = false), ContextMenuItem.timeout); - } render() { @@ -84,8 +80,7 @@ export class ContextMenuItem extends React.Component {
); - } - else { + } else if ("subitems" in this.props) { let submenu = !this.overItem ? (null) :
{this._items.map(prop => )} diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 7f979cd3b..e3d4ff8b5 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -118,7 +118,7 @@ export class MainView extends React.Component { const targets = document.elementsFromPoint(e.x, e.y); if (targets && targets.length && targets[0].className.toString().indexOf("contextMenu") === -1) { - ContextMenu.Instance.clearItems(); + ContextMenu.Instance.closeMenu(); } }), true); } -- cgit v1.2.3-70-g09d2 From 565b27cca8953a60067de367cae4c0a99beb3cab Mon Sep 17 00:00:00 2001 From: Tyler Schicke Date: Wed, 19 Jun 2019 16:21:03 -0400 Subject: started adding selection to context menu --- src/client/views/ContextMenu.scss | 4 +++ src/client/views/ContextMenu.tsx | 58 +++++++++++++++++++++++------------- src/client/views/ContextMenuItem.tsx | 6 ++-- 3 files changed, 45 insertions(+), 23 deletions(-) (limited to 'src/client/views/ContextMenuItem.tsx') diff --git a/src/client/views/ContextMenu.scss b/src/client/views/ContextMenu.scss index a1a2b06f1..254163b53 100644 --- a/src/client/views/ContextMenu.scss +++ b/src/client/views/ContextMenu.scss @@ -53,6 +53,10 @@ font-size: 20px; } +.contextMenu-itemSelected { + background: rgb(136, 136, 136) +} + .contextMenu-group { // width: 11vw; //10vw height: 30px; //2vh 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(); @@ -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 ( -
-
{contents}
-
- ); - }; - const createItem = (item: ContextMenuProps) => ; - 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 ( +
+
{contents}
+
+ ); + }; + const createItem = (item: ContextMenuProps, selected: boolean) => ; + 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 => ); } - return this.filteredItems; + return this.filteredViews; } render() { @@ -143,5 +158,8 @@ export class ContextMenu extends React.Component { @action onChange = (e: React.ChangeEvent) => { this._searchString = e.target.value; + if (this._searchString && this.selectedIndex === -1) { + this.selectedIndex = 0; + } } } \ No newline at end of file diff --git a/src/client/views/ContextMenuItem.tsx b/src/client/views/ContextMenuItem.tsx index 88ebd95bc..ebcac7428 100644 --- a/src/client/views/ContextMenuItem.tsx +++ b/src/client/views/ContextMenuItem.tsx @@ -24,7 +24,7 @@ export interface SubmenuProps { export type ContextMenuProps = OriginalMenuProps | SubmenuProps; @observer -export class ContextMenuItem extends React.Component { +export class ContextMenuItem extends React.Component { @observable private _items: Array = []; @observable private overItem = false; @@ -69,7 +69,7 @@ export class ContextMenuItem extends React.Component { render() { if ("event" in this.props) { return ( -
+
{this.props.icon ? ( @@ -86,7 +86,7 @@ export class ContextMenuItem extends React.Component { {this._items.map(prop => )}
; return ( -
+
{this.props.icon ? ( -- cgit v1.2.3-70-g09d2 From 05e50f27a15e8a02ffb27606c51026d1b85bc677 Mon Sep 17 00:00:00 2001 From: Tyler Schicke 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/ContextMenuItem.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 = [{ description: "test", event: (e: React.MouseEvent) => e.preventDefault(), icon: "smile" }]; + @observable private _items: Array = []; @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 { - + {this.menuItems}
); } + @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) => { 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) => void; + event: () => void; icon?: IconProp; //maybe should be optional (icon?) closeMenu?: () => void; } @@ -37,7 +37,7 @@ export class ContextMenuItem extends React.Component) => { 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(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(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 { return ; } - 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