aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/quickSwitch/Component.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/quickSwitch/Component.tsx')
-rw-r--r--src/features/quickSwitch/Component.tsx349
1 files changed, 349 insertions, 0 deletions
diff --git a/src/features/quickSwitch/Component.tsx b/src/features/quickSwitch/Component.tsx
new file mode 100644
index 000000000..fb85d61e1
--- /dev/null
+++ b/src/features/quickSwitch/Component.tsx
@@ -0,0 +1,349 @@
1import { ChangeEvent, Component, createRef, ReactElement } from 'react';
2import { getCurrentWindow } from '@electron/remote';
3import { observer, inject } from 'mobx-react';
4import { reaction } from 'mobx';
5import withStyles, { WithStylesProps } from 'react-jss';
6import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
7import { compact, invoke, noop } from 'lodash';
8import { StoresProps } from '../../@types/ferdium-components.types';
9import Service from '../../models/Service';
10import Input from '../../components/ui/input/index';
11import { H1 } from '../../components/ui/headline';
12import Modal from '../../components/ui/Modal';
13import { state as ModalState } from './store';
14
15const messages = defineMessages({
16 title: {
17 id: 'feature.quickSwitch.title',
18 defaultMessage: 'QuickSwitch',
19 },
20 search: {
21 id: 'feature.quickSwitch.search',
22 defaultMessage: 'Search...',
23 },
24 info: {
25 id: 'feature.quickSwitch.info',
26 defaultMessage:
27 'Select a service with TAB, ↑ and ↓. Open a service with ENTER.',
28 },
29});
30
31const styles = theme => ({
32 modal: {
33 width: '80%',
34 maxWidth: 600,
35 background: theme.styleTypes.primary.contrast,
36 paddingTop: 30,
37 },
38 headline: {
39 fontSize: 20,
40 marginBottom: 20,
41 marginTop: -27,
42 },
43 services: {
44 width: '100%',
45 maxHeight: '50vh',
46 overflowX: 'hidden',
47 overflowY: 'auto',
48 },
49 service: {
50 background: theme.styleTypes.primary.contrast,
51 color: theme.colorText,
52 borderRadius: 6,
53 padding: '3px 25px',
54 marginBottom: 10,
55 display: 'flex',
56 alignItems: 'center',
57 '&:last-child': {
58 marginBottom: 0,
59 },
60 '&:hover': {
61 cursor: 'pointer',
62 },
63 },
64 activeService: {
65 background: `${theme.styleTypes.primary.accent} !important`,
66 color: theme.styleTypes.primary.contrast,
67 cursor: 'pointer',
68 },
69 serviceIcon: {
70 width: 50,
71 height: 50,
72 paddingRight: 20,
73 objectFit: 'contain',
74 },
75});
76
77interface IProps
78 extends WithStylesProps<typeof styles>,
79 Partial<StoresProps>,
80 WrappedComponentProps {}
81
82interface IState {
83 selected: number;
84 search: string;
85 wasPrevVisible: boolean;
86}
87
88@inject('stores', 'actions')
89@observer
90class QuickSwitchModal extends Component<IProps, IState> {
91 ARROW_DOWN = 40;
92
93 ARROW_UP = 38;
94
95 ENTER = 13;
96
97 TAB = 9;
98
99 inputRef = createRef<HTMLDivElement>();
100
101 serviceElements = {};
102
103 constructor(props) {
104 super(props);
105
106 this.state = {
107 selected: 0,
108 search: '',
109 wasPrevVisible: false,
110 };
111
112 this._handleKeyDown = this._handleKeyDown.bind(this);
113 this._handleSearchUpdate = this._handleSearchUpdate.bind(this);
114 this._handleVisibilityChange = this._handleVisibilityChange.bind(this);
115 this.openService = this.openService.bind(this);
116
117 reaction(
118 () => ModalState.isModalVisible,
119 () => {
120 this._handleVisibilityChange();
121 },
122 );
123 }
124
125 // Add global keydown listener when component mounts
126 componentDidMount(): void {
127 document.addEventListener('keydown', this._handleKeyDown);
128 }
129
130 // Remove global keydown listener when component unmounts
131 componentWillUnmount(): void {
132 document.removeEventListener('keydown', this._handleKeyDown);
133 }
134
135 // Get currently shown services
136 services(): Service[] {
137 let services: Service[] = [];
138 if (
139 this.state.search &&
140 compact(invoke(this.state.search, 'match', /^[\da-z]/i)).length > 0
141 ) {
142 // Apply simple search algorythm to list of all services
143 services = this.props.stores!.services.allDisplayed;
144 services = services.filter(
145 service =>
146 service.name.toLowerCase().search(this.state.search.toLowerCase()) !==
147 -1,
148 );
149 } else if (this.props.stores!.services.allDisplayed.length > 0) {
150 // Add the currently active service first
151 const currentService = this.props.stores!.services.active;
152 if (currentService) {
153 services.push(currentService);
154 }
155
156 // Add last used services to services array
157 for (const service of this.props.stores!.services.lastUsedServices) {
158 const tempService = this.props.stores!.services.one(service);
159 if (tempService && !services.includes(tempService)) {
160 services.push(tempService);
161 }
162 }
163
164 // Add all other services in the default order
165 for (const service of this.props.stores!.services.allDisplayed) {
166 if (!services.includes(service)) {
167 services.push(service);
168 }
169 }
170 }
171
172 return services;
173 }
174
175 openService(index): void {
176 // Open service
177 const service = this.services()[index];
178 this.props.actions!.service.setActive({ serviceId: service.id });
179
180 // Reset and close modal
181 this.setState({
182 selected: 0,
183 search: '',
184 });
185 this.close();
186 }
187
188 // Change the selected service
189 // factor should be -1 or 1
190 changeSelected(factor: number): any {
191 this.setState(state => {
192 let newSelected = state.selected + factor;
193 const services = this.services().length;
194
195 // Roll around when on edge of list
196 if (state.selected < 1 && factor === -1) {
197 newSelected = services - 1;
198 } else if (state.selected >= services - 1 && factor === 1) {
199 newSelected = 0;
200 }
201
202 // Make sure new selection is visible
203 const serviceElement = this.serviceElements[newSelected];
204 if (serviceElement) {
205 serviceElement.scrollIntoViewIfNeeded(false);
206 }
207
208 return {
209 selected: newSelected,
210 };
211 });
212 }
213
214 // Handle global key presses to change the selection
215 _handleKeyDown(event: KeyboardEvent): void {
216 if (ModalState.isModalVisible) {
217 switch (event.keyCode) {
218 case this.ARROW_DOWN:
219 this.changeSelected(1);
220 break;
221 case this.TAB:
222 if (event.shiftKey) {
223 this.changeSelected(-1);
224 } else {
225 this.changeSelected(1);
226 }
227 break;
228 case this.ARROW_UP:
229 this.changeSelected(-1);
230 break;
231 case this.ENTER:
232 this.openService(this.state.selected);
233 break;
234 default:
235 break;
236 }
237 }
238 }
239
240 // Handle update of the search query
241 _handleSearchUpdate(event: ChangeEvent<HTMLInputElement>): void {
242 this.setState({
243 search: event.target.value,
244 });
245 }
246
247 _handleVisibilityChange(): void {
248 const { isModalVisible } = ModalState;
249
250 if (isModalVisible && !this.state.wasPrevVisible) {
251 // Set focus back on current window if its in a service
252 // TODO: Find a way to gain back focus
253 getCurrentWindow().blurWebView();
254 getCurrentWindow().webContents.focus();
255
256 // The input "focus" attribute will only work on first modal open
257 // Manually add focus to the input element
258 // Wrapped inside timeout to let the modal render first
259 setTimeout(() => {
260 if (this.inputRef.current) {
261 this.inputRef.current.querySelectorAll('input')[0].focus();
262 }
263 }, 10);
264
265 this.setState({
266 wasPrevVisible: true,
267 });
268 } else if (!isModalVisible && this.state.wasPrevVisible) {
269 // Manually blur focus from the input element to prevent
270 // search query change when modal not visible
271 setTimeout(() => {
272 if (this.inputRef.current) {
273 this.inputRef.current.querySelectorAll('input')[0].blur();
274 }
275 }, 100);
276
277 this.setState({
278 wasPrevVisible: false,
279 });
280 }
281 }
282
283 // Close this modal
284 close(): void {
285 ModalState.isModalVisible = false;
286 }
287
288 render(): ReactElement {
289 const { isModalVisible } = ModalState;
290 const { openService } = this;
291 const { classes, intl } = this.props;
292 const services = this.services();
293
294 return (
295 <Modal
296 isOpen={isModalVisible}
297 className={`${classes.modal} quick-switch`}
298 shouldCloseOnOverlayClick
299 close={this.close.bind(this)}
300 >
301 <H1 className={classes.headline}>
302 {intl.formatMessage(messages.title)}
303 </H1>
304 <div ref={this.inputRef}>
305 <Input
306 placeholder={intl.formatMessage(messages.search)}
307 focus
308 value={this.state.search}
309 onChange={this._handleSearchUpdate}
310 />
311 </div>
312
313 <div className={classes.services}>
314 {services.map((service, index) => (
315 <div
316 className={`${classes.service} ${
317 this.state.selected === index
318 ? `${classes.activeService} active`
319 : ''
320 } service`}
321 onClick={() => openService(index)}
322 onKeyDown={noop}
323 key={service.id}
324 ref={el => {
325 this.serviceElements[index] = el;
326 }}
327 >
328 <img
329 src={service.icon}
330 className={classes.serviceIcon}
331 alt={service.recipe.name}
332 />
333 <div>{service.name}</div>
334 </div>
335 ))}
336 </div>
337
338 <p>
339 <br />
340 {intl.formatMessage(messages.info)}
341 </p>
342 </Modal>
343 );
344 }
345}
346
347export default injectIntl(
348 withStyles(styles, { injectTheme: true })(QuickSwitchModal),
349);