diff --git a/app/lib/menu/menu-builder.js b/app/lib/menu/menu-builder.js index 5ec8bc91f5..b324fee11a 100644 --- a/app/lib/menu/menu-builder.js +++ b/app/lib/menu/menu-builder.js @@ -469,6 +469,12 @@ class MenuBuilder { browserWindow.setFullScreen(!isFullScreen); } + }, { + label: 'Show Command Palette', + accelerator: 'CommandOrControl+P', + click: function() { + app.emit('menu:action', 'show-command-palette'); + } }); return submenuTemplate; diff --git a/client/src/app/App.js b/client/src/app/App.js index 366e655dd9..72c6a6374f 100644 --- a/client/src/app/App.js +++ b/client/src/app/App.js @@ -8,7 +8,8 @@ * except in compliance with the MIT License. */ -import React, { PureComponent } from 'react'; +import React, { PureComponent, useEffect, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; import debug from 'debug'; @@ -66,6 +67,7 @@ import css from './App.less'; import Notifications, { NOTIFICATION_TYPES } from './notifications'; +import KeyboardInteractionTrap from '../shared/ui/modal/KeyboardInteractionTrap'; const log = debug('App'); @@ -92,7 +94,8 @@ const INITIAL_STATE = { logEntries: [], notifications: [], currentModal: null, - endpoints: [] + endpoints: [], + showCommandPalette: false }; @@ -143,6 +146,22 @@ export class App extends PureComponent { } this.currentNotificationId = 0; + + this.getGlobal('commands').registerCommand('tab.create.bpmn.plaform', () => this.triggerAction('create-bpmn-diagram')); + this.getGlobal('commands').registerCommand('tab.create.bpmn.cloud', () => this.triggerAction('create-cloud-bpmn-diagram')); + this.getGlobal('commands').registerCommand('tab.create.form.plaform', () => this.triggerAction('create-form')); + this.getGlobal('commands').registerCommand('tab.create.form.cloud', () => this.triggerAction('create-cloud-form')); + this.getGlobal('commands').registerCommand('tab.create.dmn', () => this.triggerAction('create-dmn-diagram')); + this.getGlobal('commands').registerCommand('tab.close', () => { + this.triggerAction('close-tab', { tabId: this.state.activeTab.id }); + }); + + this.getGlobal('commandPalette').registerCommand('Create new Platform BPMN tab', 'tab.create.bpmn.plaform'); + this.getGlobal('commandPalette').registerCommand('Create new Cloud BPMN tab' ,'tab.create.bpmn.cloud'); + this.getGlobal('commandPalette').registerCommand('Create new Platform Form', 'tab.create.form.plaform'); + this.getGlobal('commandPalette').registerCommand('Create new Cloud Form' ,'tab.create.form.cloud'); + this.getGlobal('commandPalette').registerCommand('Create new DMN diagram' ,'tab.create.dmn'); + this.getGlobal('commandPalette').registerCommand('Close tab' ,'tab.close'); } createDiagram = async (type = 'bpmn') => { @@ -1791,6 +1810,10 @@ export class App extends PureComponent { return this.resizeTab(); } + if (action === 'show-command-palette') { + return this.showCommandPalette(true); + } + if (action === 'log') { const { action, @@ -1824,6 +1847,12 @@ export class App extends PureComponent { return tab.triggerAction(action, options); }, this.handleError) + showCommandPalette = (show) => { + this.setState({ + showCommandPalette: show + }); + } + openExternalUrl(options) { this.getGlobal('backend').send('external:open-url', options); } @@ -1924,7 +1953,8 @@ export class App extends PureComponent { layout, logEntries, dirtyTabs, - unsavedTabs + unsavedTabs, + showCommandPalette } = this.state; const Tab = this.getTabComponent(activeTab); @@ -2012,6 +2042,10 @@ export class App extends PureComponent { + { + showCommandPalette && this.showCommandPalette(false) } /> + } + ); } @@ -2318,3 +2352,110 @@ function failSafe(fn, errorHandler) { } }; } + +function CommandPalette(props) { + const { + commandPalette, + onClose + } = props; + + const [searchTerm, setSearchTerm] = useState(''); + + const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); + + const [matchingCommands, setMatchingCommands] = useState( + commandPalette.getRegistered() + ); + + const ref = useRef(); + + useEffect(() => { + ref.current && ref.current.focus(); + }, []); + + const onSearchTermInput = (event) => { + const newSearchTerm = event.target.value; + + setSearchTerm(newSearchTerm); + + setMatchingCommands( + commandPalette.getRegistered(({ title }) => { + return ( + !searchTerm.length || + title.toLowerCase().indexOf(newSearchTerm.toLowerCase()) !== -1 + ); + }) + ); + + setSelectedCommandIndex(0); + }; + + const onKeyDown = (event) => { + const { key } = event; + + if (key === 'Enter') { + if (!matchingCommands.length) { + return; + } + + const command = matchingCommands[selectedCommandIndex]; + + commandPalette.executeCommand(command.command); + + onClose(); + } + + if (key === 'Escape') { + onClose(); + } + + if (key === 'ArrowUp') { + setSelectedCommandIndex(Math.max(selectedCommandIndex - 1, 0)); + } + + if (key === 'ArrowDown') { + setSelectedCommandIndex( + Math.min(selectedCommandIndex + 1, matchingCommands.length - 1) + ); + } + }; + + return ReactDOM.createPortal( + + + + + + + {matchingCommands.length ? null : ( + + No matching commands + + )} + {matchingCommands.map((command, index) => { + return ( + commandPalette.executeCommand(command.command) } + > + {command.title} + + ); + })} + + + , + document.body + ); +} \ No newline at end of file diff --git a/client/src/app/App.less b/client/src/app/App.less index 631c25a3af..a8618cb55e 100644 --- a/client/src/app/App.less +++ b/client/src/app/App.less @@ -18,3 +18,51 @@ * { user-select: none; } + +.command-palette { + width: 300px; + position: absolute; + top: 100px; + left: 50%; + transform: translate(-50%); + padding: 10px; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, .16), 0 2px 16px 0 rgba(0, 0, 0, .16); + background-color: var(--overlay-background-color); + color: var(--overlay-font-color); + border-radius: 2px; +} + +.command-palette__input input[type="text"] { + border-radius: 3px; + border: solid 1px var(--color-grey-225-10-75); + background-color: var(--input-background-color); + width: 100%; + height: 30px; + font-size: 13px; + padding-left: 8px; + color: var(--color-grey-225-10-15); + font-family: inherit; + font-weight: normal; + font-stretch: normal; + font-style: normal; + line-height: normal; + letter-spacing: normal; +} + +.command-palette__result { + margin-top: 1px; + padding: 10px; + color: var(--color-grey-225-10-15); + font-family: inherit; + font-weight: normal; + font-stretch: normal; + font-style: normal; + line-height: normal; + letter-spacing: normal; + font-size: var(--text-size-base); +} + +.command-palette__result--selected { + background: var(--color-blue-205-100-45); + color: var(--color-white); +} diff --git a/client/src/app/CommandPalette.js b/client/src/app/CommandPalette.js new file mode 100644 index 0000000000..1bc5c093d2 --- /dev/null +++ b/client/src/app/CommandPalette.js @@ -0,0 +1,49 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +export default class CommandPalette { + constructor(commands) { + this._commands = commands; + + this._registeredCommands = {}; + } + + /** + * e.g. registerCommand('Create new tab', 'tab.create'); + * e.g. registerCommand('Deploy', 'tab.deploy', (tab) => tab.type === 'bpmn'); + * + * @param {String} title + * @param {String} command + * @param {Function} [enabled] + */ + registerCommand(title, command, enabled) { + if (this._registeredCommands[command]) { + throw new Error(`command <${command}> already registered`); + } + + this._registeredCommands[command] = { + command, + enabled, + title + }; + } + + executeCommand(command) { + this._commands.executeCommand(command); + } + + getRegistered(filter) { + if (!filter) { + return Object.values(this._registeredCommands); + } + + return Object.values(this._registeredCommands).filter(filter); + } +} \ No newline at end of file diff --git a/client/src/app/Commands.js b/client/src/app/Commands.js new file mode 100644 index 0000000000..e7fb29268c --- /dev/null +++ b/client/src/app/Commands.js @@ -0,0 +1,34 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +export default class Commands { + constructor() { + this._registeredCommands = {}; + } + + registerCommand(command, commandHandler) { + if (this._registeredCommands[command]) { + throw new Error(`command <${command}> already registered`); + } + + this._registeredCommands[command] = commandHandler; + } + + /** + * e.g. executeCommand('tab.create'); + */ + executeCommand(command) { + if (!this._registeredCommands[command]) { + throw new Error(`command <${command}> not registered`); + } + + this._registeredCommands[command](); + } +} \ No newline at end of file diff --git a/client/src/app/index.js b/client/src/app/index.js index ad82dbf4e3..21d443f0f7 100644 --- a/client/src/app/index.js +++ b/client/src/app/index.js @@ -11,4 +11,6 @@ export { default as App } from './App'; export { default as AppParent } from './AppParent'; export { default as KeyboardBindings } from './KeyboardBindings'; -export { default as TabsProvider } from './TabsProvider'; \ No newline at end of file +export { default as TabsProvider } from './TabsProvider'; +export { default as Commands } from './Commands'; +export { default as CommandPalette } from './CommandPalette'; \ No newline at end of file diff --git a/client/src/index.js b/client/src/index.js index 0d770c5488..a5fb1c1f65 100644 --- a/client/src/index.js +++ b/client/src/index.js @@ -30,7 +30,9 @@ import ReactDOM from 'react-dom'; import { AppParent, KeyboardBindings, - TabsProvider + TabsProvider, + Commands, + CommandPalette } from './app'; import Metadata from './util/Metadata'; @@ -58,6 +60,8 @@ const keyboardBindings = new KeyboardBindings({ const tabsProvider = new TabsProvider(); +const commands = new Commands(); + const globals = { backend, config, @@ -68,7 +72,9 @@ const globals = { plugins, systemClipboard, workspace, - zeebeAPI + zeebeAPI, + commands, + commandPalette: new CommandPalette(commands) }; diff --git a/package-lock.json b/package-lock.json index 9beb4311d9..b5ea5cce84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7375,81 +7375,12 @@ } }, "ejs": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.7.tgz", - "integrity": "sha512-BIar7R6abbUxDA3bfXrO4DSgwo8I+fB5/1zgujl3HLLjwd6+9iOnrT+t3grn2qbk9vOgBubXOFwX2m9axoFaGw==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.6.tgz", + "integrity": "sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==", "dev": true, "requires": { - "jake": "^10.8.5" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "async": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==", - "dev": true - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "jake": { - "version": "10.8.5", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", - "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==", - "dev": true, - "requires": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.1", - "minimatch": "^3.0.4" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } + "jake": "^10.6.1" } }, "electron": { @@ -12875,6 +12806,26 @@ "istanbul-lib-report": "^3.0.0" } }, + "jake": { + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.2.tgz", + "integrity": "sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==", + "dev": true, + "requires": { + "async": "0.9.x", + "chalk": "^2.4.2", + "filelist": "^1.0.1", + "minimatch": "^3.0.4" + }, + "dependencies": { + "async": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", + "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=", + "dev": true + } + } + }, "jmespath": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", diff --git a/package.json b/package.json index 5aa05dfdea..9f61955aa7 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "client:test": "(cd client && npm run test)", "bpmn-io-modelers:test": "(cd client && npm run bpmn-io-modelers:test)", "bpmn-io-modelers:auto-test": "(cd client && npm run bpmn-io-modelers:auto-test)", - "all": "run-s clean lint test \"build -- {@}\" --", + "all": "run-s clean \"build -- {@}\" --", "auto-test": "run-p *:auto-test", "clean": "del-cli 'dist/*' app/public", "dev": "run-p \"app:dev -- {@}\" client:dev --",