Skip to content

Commit

Permalink
Command Palette
Browse files Browse the repository at this point in the history
This is inspired by Sublime Text. The command palette can give quick access to
every command in the application. It was introduced to make it easier to add new
commands (which may take parameters), because adding proper UI (menus, dialogs
etc.) is complex.

This way, new or experimental features may first be added in the command
palette, and can later be integrated in the UI proper.
  • Loading branch information
ekuiter committed Oct 21, 2018
1 parent 067bafb commit b329762
Show file tree
Hide file tree
Showing 9 changed files with 203 additions and 6 deletions.
10 changes: 5 additions & 5 deletions client/src/components/CommandBarContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,16 @@ const CommandBarContainer = (props: StateDerivedProps) => (
makeDivider(),
commands.featureDiagram.feature.collapseAll(props.onCollapseAllFeatures!),
commands.featureDiagram.feature.expandAll(props.onExpandAllFeatures!),
commands.featureDiagram.fitToScreen(props.onFitToScreen!),
makeDivider(),
commands.settings(props.onShowOverlay!)
commands.featureDiagram.fitToScreen(props.onFitToScreen!)
]
}
}, {
key: 'help',
text: i18n.t('commands.help'),
key: 'more',
text: i18n.t('commands.more'),
subMenuProps: {
items: [
commands.commandPalette(props.onShowOverlay!),
commands.settings(props.onShowOverlay!),
commands.about(props.onShowOverlay!)
]
}
Expand Down
1 change: 1 addition & 0 deletions client/src/components/ShortcutContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default connect(
onRedo: () => dispatch<any>(actions.server.redo({}))
})
)(withKeys(
getShortcutKeyBinding('commandPalette', ifGlobal, ({props}: {props: StateDerivedProps}) => props.onShowOverlay!({overlay: OverlayType.commandPalette, overlayProps: {}})),
getShortcutKeyBinding('undo', ifGlobal, ({props}: {props: StateDerivedProps}) => props.onUndo!()),
getShortcutKeyBinding('redo', ifGlobal, ({props}: {props: StateDerivedProps}) => props.onRedo!()),
getShortcutKeyBinding('settings', ifGlobal, ({props}: {props: StateDerivedProps}) => props.onShowOverlay!({overlay: OverlayType.settingsPanel, overlayProps: {}})),
Expand Down
6 changes: 6 additions & 0 deletions client/src/components/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export const collapseCommand = (features: Feature[], onCollapseFeatures: OnColla
});

const commands = {
commandPalette: (onShowOverlay: OnShowOverlayFunction) => ({
key: 'commandPalette',
text: i18n.t('commands.commandPalette'),
secondaryText: getShortcutText('commandPalette'),
onClick: () => onShowOverlay({overlay: OverlayType.commandPalette, overlayProps: {}})
}),
settings: (onShowOverlay: OnShowOverlayFunction) => ({
key: 'settings',
text: i18n.t('commands.settings'),
Expand Down
132 changes: 132 additions & 0 deletions client/src/components/overlays/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React from 'react';
import {Modal} from 'office-ui-fabric-react/lib/Modal';
import {TextField} from 'office-ui-fabric-react/lib/TextField';
import i18n from '../../i18n';
import {OnShowOverlayFunction, OnUndoFunction, OnRedoFunction} from '../../store/types';
import {getShortcutText} from '../../shortcuts';
import {OverlayType} from '../../types';
import {Icon} from 'office-ui-fabric-react/lib/Icon';
import defer from 'src/helpers/defer';

interface Props {
isOpen: boolean,
onDismiss: () => void,
onShowOverlay: OnShowOverlayFunction,
onUndo: OnUndoFunction,
onRedo: OnRedoFunction
};

interface State {
value?: string
};

interface Command {
title: string,
action: () => void,
icon?: string,
shortcut?: string
};

export default class extends React.Component<Props, State> {
state: State = {};
commandUsage: {
[x: string]: number
} = {};

componentDidUpdate(prevProps: Props) {
if (!prevProps.isOpen && this.props.isOpen)
this.setState({value: undefined});
}

onChange = (_event: any, value?: string) => this.setState({value});

onKeyPress = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
const results = this.getSearchResults(this.state.value);
if (results.length > 0)
this.runCommand(results[0]);
}
};

onClick = (command: Command) => () => this.runCommand(command);

runCommand(command: Command) {
this.props.onDismiss();
command.action();
defer(() => this.commandUsage[command.title] = +new Date())();
}

commands: Command[] = [
{ // TODO
title: 'Join',
action: () => (window as any).app.sendMessage({type:"JOIN", artifact: "FeatureModeling::CTV"})
}, {
title: i18n.t('commands.settings'),
icon: 'Settings',
shortcut: getShortcutText('settings'),
action: () => this.props.onShowOverlay({overlay: OverlayType.settingsPanel, overlayProps: {}})
}, {
title: i18n.t('commands.about'),
icon: 'Info',
action: () => this.props.onShowOverlay({overlay: OverlayType.aboutPanel, overlayProps: {}})
}, {
title: i18n.t('commands.undo'),
icon: 'Undo',
shortcut: getShortcutText('undo'),
action: this.props.onUndo
}, {
title: i18n.t('commands.redo'),
icon: 'Redo',
shortcut: getShortcutText('redo'),
action: this.props.onRedo
}
];

sortCommands(commands: Command[]): Command[] {
const usedCommands = commands.filter(command => typeof this.commandUsage[command.title] !== 'undefined'),
unusedCommands = commands.filter(command => !usedCommands.includes(command));
usedCommands.sort((a, b) => this.commandUsage[b.title] - this.commandUsage[a.title]);
return [...usedCommands, ...unusedCommands];
}

getSearchResults(search?: string): Command[] {
return this.sortCommands(search
? this.commands.filter(command =>
command.title.toLowerCase().includes(this.state.value!.toLowerCase()))
: this.commands);
}

render() {
const results = this.getSearchResults(this.state.value);

return (
<Modal
className="commandPalette"
isOpen={this.props.isOpen}
onDismiss={this.props.onDismiss}>
<div>
<TextField borderless
value={this.state.value}
onChange={this.onChange}
onKeyPress={this.onKeyPress}
styles={{
field: {fontSize: 21},
fieldGroup: {height: 48}
}}/>
</div>
<ul>
{results.length > 0
? results.map(command =>
<li key={command.title} onClick={this.onClick(command)}>
{command.icon
? <Icon iconName={command.icon} />
: <i>&nbsp;</i>}
{command.title}
<span>{command.shortcut}</span>
</li>)
: <li className="notFound">{i18n.t('overlays.commandPalette.notFound')}</li>}
</ul>
</Modal>
);
}
};
10 changes: 10 additions & 0 deletions client/src/components/overlays/OverlayContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,17 @@ import ExportDialog from './ExportDialog';
import {OverlayType} from '../../types';
import {State, StateDerivedProps} from '../../store/types';
import logger from '../../helpers/logger';
import CommandPalette from './CommandPalette';

const OverlayContainer = (props: StateDerivedProps) => (
<React.Fragment>
<CommandPalette
isOpen={props.overlay === OverlayType.commandPalette}
onDismiss={() => props.onHideOverlay!({overlay: OverlayType.commandPalette})}
onShowOverlay={props.onShowOverlay!}
onUndo={props.onUndo!}
onRedo={props.onRedo!}/>

<SettingsPanel
isOpen={props.overlay === OverlayType.settingsPanel}
onDismissed={() => props.onHideOverlay!({overlay: OverlayType.settingsPanel})}
Expand Down Expand Up @@ -142,6 +150,8 @@ export default connect(
featureModel: getFeatureModel(state)
})),
(dispatch): StateDerivedProps => ({
onUndo: () => dispatch<any>(actions.server.undo({})),
onRedo: () => dispatch<any>(actions.server.redo({})),
onHideOverlay: payload => dispatch(actions.ui.overlay.hide(payload)),
onShowOverlay: payload => dispatch(actions.ui.overlay.show(payload)),
onDeselectAllFeatures: () => dispatch(actions.ui.featureDiagram.feature.deselectAll()),
Expand Down
6 changes: 5 additions & 1 deletion client/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ const translationMap = {
file: 'File',
edit: 'Edit',
view: 'View',
help: 'Help',
more: 'More',
commandPalette: 'Command Palette…',
settings: 'Settings…',
about: 'About…',
undo: 'Undo',
Expand Down Expand Up @@ -88,6 +89,9 @@ const translationMap = {
}
},
overlays: {
commandPalette: {
notFound: 'No command found.'
},
aboutPanel: {
title: 'About',
content: (
Expand Down
1 change: 1 addition & 0 deletions client/src/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const shiftCommandShortcut = (key: string): Shortcut => ({
});

export const shortcuts = {
commandPalette: shiftCommandShortcut('p'),
undo: commandShortcut('z'),
redo: commandShortcut('y'),
settings: commandShortcut(','),
Expand Down
42 changes: 42 additions & 0 deletions client/src/stylesheets/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,46 @@ p {

textarea {
line-height: 1.5em !important;
}

.commandPalette {
align-items: flex-start;
}

.commandPalette .ms-Dialog-main {
top: 10%;
max-height: 80%;
width: 400px;
max-width: 90%;
}

.commandPalette ul {
list-style-type: none;
padding: 0;
margin: 0;
}

.commandPalette ul li {
padding: 8px 12px;
cursor: pointer;
}

.commandPalette ul li.notFound {
cursor: default;
}

.commandPalette ul li:not(.notFound):hover {
background-color: #eee;
}

.commandPalette ul li i {
float: left;
color: rgb(0, 120, 212);
width: 24px;
}

.commandPalette ul li span {
color: rgb(102, 102, 102);
padding-left: 20px;
float: right;
}
1 change: 1 addition & 0 deletions client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export enum FeatureDiagramLayoutType {

export enum OverlayType {
none = 'none',
commandPalette = 'commandPalette',
settingsPanel = 'settingsPanel',
aboutPanel = 'aboutPanel',
featurePanel = 'featurePanel',
Expand Down

0 comments on commit b329762

Please sign in to comment.