diff --git a/src/commandPaletteService.js b/src/commandPaletteService.js
new file mode 100644
index 0000000..273e170
--- /dev/null
+++ b/src/commandPaletteService.js
@@ -0,0 +1,19 @@
+export const commandPaletteService = {
+ subscriptions: {},
+ subscribe(eventName, callback) {
+ this.subscriptions[eventName] = this.subscriptions[eventName] || [];
+ this.subscriptions[eventName].push(callback);
+ return () => {
+ this.subscriptions[eventName].splice(
+ this.subscriptions[eventName].indexOf(callback),
+ 1
+ );
+ };
+ },
+ publish(eventName, ...args) {
+ const callbacks = this.subscriptions[eventName] || [];
+ callbacks.forEach(callback => {
+ callback.apply(null, args);
+ });
+ }
+};
diff --git a/src/commands.js b/src/commands.js
new file mode 100644
index 0000000..c0f58ac
--- /dev/null
+++ b/src/commands.js
@@ -0,0 +1,34 @@
+export const SWITCH_FILE_EVENT = 'switchFileEvent';
+export const NEW_CREATION_EVENT = 'newCreationEvent';
+export const OPEN_SAVED_CREATIONS_EVENT = 'openSavedCreationsEvent';
+export const SAVE_EVENT = 'saveEvent';
+export const OPEN_SETTINGS_EVENT = 'openSettingsEvent';
+export const SHOW_KEYBOARD_SHORTCUTS_EVENT = 'showKeyboardShortcutsEvent';
+
+export const commands = [
+ {
+ name: 'Start New Creation',
+ event: NEW_CREATION_EVENT,
+ keyboardShortcut: ''
+ },
+ {
+ name: 'Open Creation',
+ event: OPEN_SAVED_CREATIONS_EVENT,
+ keyboardShortcut: 'Cmd+O'
+ },
+ {
+ name: 'Save Creation',
+ event: SAVE_EVENT,
+ keyboardShortcut: 'Cmd+S'
+ },
+ {
+ name: 'Open Settings',
+ event: OPEN_SETTINGS_EVENT,
+ keyboardShortcut: ''
+ },
+ {
+ name: 'Show Keyboard Shortcuts',
+ event: SHOW_KEYBOARD_SHORTCUTS_EVENT,
+ keyboardShortcut: ''
+ }
+];
diff --git a/src/components/CommandPalette.jsx b/src/components/CommandPalette.jsx
new file mode 100644
index 0000000..8b7bf72
--- /dev/null
+++ b/src/components/CommandPalette.jsx
@@ -0,0 +1,124 @@
+import { h, Component } from 'preact';
+import Modal from './Modal';
+import { AutoFocusInput } from './common';
+import { commands, SWITCH_FILE_EVENT } from '../commands';
+
+import { commandPaletteService } from '../commandPaletteService';
+import { FileIcon } from './FileIcon';
+import { UP_KEY, DOWN_KEY, ENTER_KEY } from '../keyboardKeys';
+
+function getFolder(filePath) {
+ const split = filePath.split('/');
+ if (split.length > 1) {
+ split.length = split.length - 1;
+ return split.join('/');
+ }
+ return '';
+}
+function Row({ item, onClick, isSelected }) {
+ return (
+
+
+
+ );
+}
+export class CommandPalette extends Component {
+ state = { list: [], search: '', selectedIndex: 0 };
+ componentDidUpdate(previousProps) {
+ if (this.props.show && !previousProps.show) {
+ this.state.search = '';
+
+ this.isCommandMode = this.props.isCommandMode;
+ if (this.isCommandMode) {
+ this.setState({ search: '>' });
+ }
+
+ this.setState({
+ list: this.getFilteredList()
+ });
+ }
+ }
+
+ getFilteredList(search = '') {
+ const list = this.isCommandMode ? commands : this.props.files;
+ return list.filter(
+ item =>
+ item.name
+ .toLowerCase()
+ .indexOf(this.isCommandMode ? search.substr(1) : search) !== -1
+ );
+ }
+
+ keyDownHandler(e) {
+ const diff = { [UP_KEY]: -1, [DOWN_KEY]: 1 }[e.which];
+ if (diff) {
+ this.setState({
+ selectedIndex:
+ (this.state.selectedIndex + diff) % this.state.list.length
+ });
+ return;
+ }
+ if (e.which === ENTER_KEY) {
+ this.selectOption(this.state.list[this.state.selectedIndex]);
+ }
+ }
+ inputHandler(e) {
+ const search = e.target.value;
+ this.setState({ search });
+ this.isCommandMode = search.indexOf('>') === 0;
+ this.setState({
+ list: this.getFilteredList(search),
+ selectedIndex: 0
+ });
+ }
+ optionClickHandler(option) {
+ this.selectOption(option);
+ }
+ selectOption(option) {
+ commandPaletteService.publish(
+ option.path ? SWITCH_FILE_EVENT : option.event,
+ option
+ );
+ this.props.closeHandler();
+ }
+ render() {
+ return (
+
+
+
+ {this.state.list.map((item, index) => (
+
+ ))}
+
+
+ );
+ }
+}
diff --git a/src/components/ContentWrapFiles.jsx b/src/components/ContentWrapFiles.jsx
index e59d555..882028f 100644
--- a/src/components/ContentWrapFiles.jsx
+++ b/src/components/ContentWrapFiles.jsx
@@ -17,6 +17,8 @@ import 'codemirror/mode/meta';
import { deferred } from '../deferred';
import { SidePane } from './SidePane';
import { Console } from './Console';
+import { SWITCH_FILE_EVENT } from '../commands';
+import { commandPaletteService } from '../commandPaletteService';
const minCodeWrapSize = 33;
@@ -105,6 +107,21 @@ export default class ContentWrapFiles extends Component {
}
componentDidMount() {
this.props.onRef(this);
+ this.commandPaletteSubscriptions = [];
+ this.commandPaletteSubscriptions.push(
+ commandPaletteService.subscribe(SWITCH_FILE_EVENT, file => {
+ const targetFile = getFileFromPath(
+ this.props.currentItem.files,
+ file.path
+ );
+ if (targetFile.file) {
+ this.fileSelectHandler(targetFile.file);
+ }
+ })
+ );
+ }
+ componentWillUnmount() {
+ this.commandPaletteSubscriptions.forEach(unsubscribeFn => unsubscribeFn());
}
getEditorOptions(fileName = '') {
diff --git a/src/components/Modal.jsx b/src/components/Modal.jsx
index 6b5f4ba..101c431 100644
--- a/src/components/Modal.jsx
+++ b/src/components/Modal.jsx
@@ -24,13 +24,20 @@ export default class Modal extends Component {
}
componentDidUpdate(prevProps) {
if (this.props.show !== prevProps.show) {
- document.body.classList[this.props.show ? 'add' : 'remove'](
- 'overlay-visible'
- );
+ if (!this.props.noOverlay) {
+ document.body.classList[this.props.show ? 'add' : 'remove'](
+ 'overlay-visible'
+ );
+ }
if (this.props.show) {
// HACK: refs will evaluate on next tick due to portals
setTimeout(() => {
- this.overlayEl.querySelector('.js-modal__close-btn').focus();
+ const closeButton = this.overlayEl.querySelector(
+ '.js-modal__close-btn'
+ );
+ if (closeButton) {
+ closeButton.focus();
+ }
}, 0);
/* We insert a dummy hidden input which will take focus as soon as focus
@@ -63,15 +70,17 @@ export default class Modal extends Component {
onClick={this.onOverlayClick.bind(this)}
>
-
+ {this.props.hideCloseButton ? null : (
+
+ )}
{this.props.children}
diff --git a/src/components/app.jsx b/src/components/app.jsx
index ade9d86..183747b 100644
--- a/src/components/app.jsx
+++ b/src/components/app.jsx
@@ -55,6 +55,15 @@ import { Js13KModal } from './Js13KModal';
import { CreateNewModal } from './CreateNewModal';
import { Icons } from './Icons';
import JSZip from 'jszip';
+import { CommandPalette } from './CommandPalette';
+import {
+ OPEN_SAVED_CREATIONS_EVENT,
+ SAVE_EVENT,
+ OPEN_SETTINGS_EVENT,
+ NEW_CREATION_EVENT,
+ SHOW_KEYBOARD_SHORTCUTS_EVENT
+} from '../commands';
+import { commandPaletteService } from '../commandPaletteService';
if (module.hot) {
require('preact/debug');
@@ -84,7 +93,8 @@ export default class App extends Component {
isAskToImportModalOpen: false,
isOnboardModalOpen: false,
isJs13KModalOpen: false,
- isCreateNewModalOpen: false
+ isCreateNewModalOpen: false,
+ isCommandPaletteOpen: false
};
this.state = {
isSavedItemPaneOpen: false,
@@ -491,8 +501,20 @@ export default class App extends Component {
this.editorWithFocus.focus();
}
}
+ openSettings() {
+ this.setState({ isSettingsModalOpen: true });
+ }
+ openKeyboardShortcuts() {
+ this.setState({ isKeyboardShortcutsModalOpen: true });
+ }
+
componentDidMount() {
- document.body.style.height = `${window.innerHeight}px`;
+ function setBodySize() {
+ document.body.style.height = `${window.innerHeight}px`;
+ }
+ window.addEventListener('resize', () => {
+ setBodySize();
+ });
// Editor keyboard shortucuts
window.addEventListener('keydown', event => {
@@ -532,6 +554,12 @@ export default class App extends Component {
// We might be listening on keydown for some input inside the app. In that case
// we don't want this to trigger which in turn focuses back the last editor.
this.closeSavedItemsPane();
+ } else if ((event.ctrlKey || event.metaKey) && event.keyCode === 80) {
+ this.setState({
+ isCommandPaletteOpen: true,
+ isCommandPaletteInCommandMode: !!event.shiftKey
+ });
+ event.preventDefault();
}
});
@@ -548,6 +576,29 @@ export default class App extends Component {
}
}
});
+ const commandPalleteHooks = {
+ [NEW_CREATION_EVENT]: () => {
+ this.openNewCreationModal();
+ },
+ [OPEN_SAVED_CREATIONS_EVENT]: () => {
+ this.openSavedItemsPane();
+ },
+ [SAVE_EVENT]: () => {
+ this.saveItem();
+ },
+ [OPEN_SETTINGS_EVENT]: () => {
+ this.openSettings();
+ },
+ [SHOW_KEYBOARD_SHORTCUTS_EVENT]: () => {
+ this.openKeyboardShortcuts();
+ }
+ };
+ for (let eventName in commandPalleteHooks) {
+ commandPaletteService.subscribe(
+ eventName,
+ commandPalleteHooks[eventName]
+ );
+ }
}
closeAllOverlays() {
@@ -915,8 +966,7 @@ export default class App extends Component {
this.forkItem(item);
}, 350);
}
- newBtnClickHandler() {
- trackEvent('ui', 'newBtnClick');
+ openNewCreationModal() {
if (this.state.unsavedEditCount) {
var shouldDiscard = confirm(
'You have unsaved changes. Do you still want to create something new?'
@@ -932,6 +982,10 @@ export default class App extends Component {
});
}
}
+ newBtnClickHandler() {
+ trackEvent('ui', 'newBtnClick');
+ this.openNewCreationModal();
+ }
openBtnClickHandler() {
trackEvent('ui', 'openBtnClick');
this.openSavedItemsPane();
@@ -1396,9 +1450,7 @@ export default class App extends Component {
prefs={this.state.prefs}
layoutBtnClickHandler={this.layoutBtnClickHandler.bind(this)}
helpBtnClickHandler={() => this.setState({ isHelpModalOpen: true })}
- settingsBtnClickHandler={() =>
- this.setState({ isSettingsModalOpen: true })
- }
+ settingsBtnClickHandler={this.openSettings.bind(this)}
notificationsBtnClickHandler={this.notificationsBtnClickHandler.bind(
this
)}
@@ -1410,9 +1462,9 @@ export default class App extends Component {
)}
codepenBtnClickHandler={this.codepenBtnClickHandler.bind(this)}
saveHtmlBtnClickHandler={this.saveHtmlBtnClickHandler.bind(this)}
- keyboardShortcutsBtnClickHandler={() =>
- this.setState({ isKeyboardShortcutsModalOpen: true })
- }
+ keyboardShortcutsBtnClickHandler={this.openKeyboardShortcuts.bind(
+ this
+ )}
screenshotBtnClickHandler={this.screenshotBtnClickHandler.bind(
this
)}
@@ -1553,6 +1605,14 @@ export default class App extends Component {
onTemplateSelect={this.templateSelectHandler.bind(this)}
/>
+ this.setState({ isCommandPaletteOpen: false })}
+ files={linearizeFiles(this.state.currentItem.files || [])}
+ isCommandMode={this.state.isCommandPaletteInCommandMode}
+ closeHandler={() => this.setState({ isCommandPaletteOpen: false })}
+ />
+
;
}
+
+export function AutoFocusInput(props) {
+ return (
+ el && setTimeout(() => el.focus(), 100)} {...props} />
+ );
+}
diff --git a/src/keyboardKeys.js b/src/keyboardKeys.js
new file mode 100644
index 0000000..ad4538c
--- /dev/null
+++ b/src/keyboardKeys.js
@@ -0,0 +1,6 @@
+export const ENTER_KEY = 13;
+export const ESCAPE_KEY = 27;
+export const LEFT_KEY = 37;
+export const UP_KEY = 38;
+export const RIGHT_KEY = 39;
+export const DOWN_KEY = 40;
diff --git a/src/style.css b/src/style.css
index 885dc92..898f531 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,5 +1,6 @@
:root {
- --color-text: #d4cde9;
+ --color-text: #e2daf9;
+ --color-text-dark-1: #b3aec4;
--color-bg: #252637;
--color-popup: #3a2b63;
--code-font-size: 16px;
@@ -74,6 +75,10 @@ button {
.d-i {
display: inline;
}
+
+.d-b {
+ display: block;
+}
.flex {
display: flex;
}
@@ -1688,28 +1693,6 @@ body:not(.is-app) .show-when-app {
.is-file-mode .hide-in-file-mode {
display: none !important;
}
-@media screen and (max-width: 600px) {
- body {
- font-size: 70%;
- }
-
- .main-header {
- overflow-x: auto;
- }
-
- .main-header__btn-wrap {
- flex-shrink: 0;
- }
-
- .modal__content {
- padding: 1em;
- }
-
- .saved-items-pane {
- width: 77vw;
- padding: 10px 20px;
- }
-}
/* Codemirror themes basic bg styles. This is here so that there is no big FOUC
while the theme CSS file is loading */
@@ -1807,3 +1790,47 @@ while the theme CSS file is loading */
.cm-s-midnight .CodeMirror-activeline-background {
background: #253540;
}
+.command-palette__option-list {
+ margin: 0;
+ margin-top: 10px;
+ padding: 0;
+ list-style: none;
+}
+.command-palette__option-row {
+ padding: 4px 5px;
+ width: 100%;
+ text-align: left;
+ border: 0;
+ background: transparent;
+ color: var(--color-text);
+}
+.command-palette__option-row--selected {
+ background: rgba(0, 0, 0, 0.2);
+}
+.command-palette__option-subtitle {
+ color: var(--color-text-dark-1);
+ margin-left: 10px;
+ font-size: 0.8em;
+}
+@media screen and (max-width: 600px) {
+ body {
+ font-size: 70%;
+ }
+
+ .main-header {
+ overflow-x: auto;
+ }
+
+ .main-header__btn-wrap {
+ flex-shrink: 0;
+ }
+
+ .modal__content {
+ padding: 1em;
+ }
+
+ .saved-items-pane {
+ width: 77vw;
+ padding: 10px 20px;
+ }
+}