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 ( + + + + + ); + } +} 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)} > 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 })} + /> +