Skip to content

Commit 890fe1f

Browse files
aadsmCompuIves
authored andcommitted
[RFC] Shortcuts support for iOS devices (codesandbox#749)
1 parent b57562d commit 890fe1f

File tree

2 files changed

+153
-5
lines changed

2 files changed

+153
-5
lines changed

packages/app/src/app/store/providers/KeybindingManager.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { Provider } from 'cerebral';
22
import { KEYBINDINGS, normalizeKey } from 'common/utils/keybindings';
33

4+
const isIOS =
5+
typeof navigator !== 'undefined' &&
6+
!!navigator.platform.match(/(iPhone|iPod|iPad)/i);
7+
48
const state = {
59
keybindings: null,
610
keydownIndex: 0,
@@ -19,6 +23,124 @@ function handleKeyUp() {
1923
state.keydownIndex = 0;
2024
}
2125

26+
function hasAnyKeyModifier(event: KeyboardEvent) {
27+
return event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
28+
}
29+
30+
function hasKeyModifier(event: KeyboardEvent, modifier: string) {
31+
return {
32+
'Shift': event.shiftKey,
33+
'Alt': event.altKey,
34+
'Control': event.ctrlKey,
35+
'Meta': event.metaKey,
36+
}[modifier];
37+
}
38+
39+
function handleIosKeyDown(controller, event, _pressedKey) {
40+
// the linter doesn't allow to change parameters' values.
41+
let pressedKey = _pressedKey;
42+
// iOS shortcuts only work if there's at least one modifier key at work (shift or alt).
43+
// I'm aborting this handling right here to minimize the number of cycles needed for
44+
// every key press.
45+
if (!hasAnyKeyModifier(event)) {
46+
return;
47+
}
48+
// This is just here to facilitate testing on a desktop environment since on desktop we do get
49+
// pressedKey === 'Alt' (on iOS you'd get pressedKey === 'Dead').
50+
if (pressedKey.length > 1) {
51+
pressedKey = '';
52+
}
53+
54+
const filterMatchingBindings = function filterMatchingBindings(pendingBindings) {
55+
return pendingBindings.map((binding) => {
56+
const bindingKeys = binding.bindings[0];
57+
// Sanity check
58+
if (!bindingKeys || bindingKeys.length === 0) {
59+
return null;
60+
}
61+
62+
// Try to match the currently pressed keys with the binding keys. Generate new
63+
// keys for the bindings matched that correspond to the remaining keys needed to be
64+
// pressed in order to have a hit.
65+
let matchedKeyIndex = -1;
66+
for (let i = 0; i < bindingKeys.length; i++) {
67+
const keyToMatch = bindingKeys[i];
68+
if (keyToMatch === pressedKey) {
69+
matchedKeyIndex = i;
70+
break;
71+
}
72+
// We try to consume as many modifiers as possible before we find the key to match.
73+
if (!hasKeyModifier(event, keyToMatch)) {
74+
break;
75+
}
76+
}
77+
78+
if (matchedKeyIndex === -1) {
79+
// No key was matched so we skip this binding.
80+
return null;
81+
}
82+
83+
return {
84+
...binding,
85+
bindings: [
86+
binding.bindings[0].slice(matchedKeyIndex + 1),
87+
binding.bindings[1],
88+
]
89+
};
90+
}).filter(Boolean)
91+
.sort((a, b) => a.bindings.length < b.bindings.length);
92+
};
93+
94+
// We filter out any hits on full list of bindings on first key down, or just move
95+
// on filtering on existing pending bindings
96+
state.pendingPrimaryBindings = filterMatchingBindings(state.keydownIndex === 0
97+
? state.keybindings
98+
: state.pendingPrimaryBindings
99+
);
100+
101+
state.keydownIndex++;
102+
103+
const longestBinding = state.pendingPrimaryBindings[state.pendingPrimaryBindings.length - 1];
104+
if (!longestBinding) {
105+
// Nothing matched so back to the beginning!
106+
reset();
107+
return;
108+
}
109+
110+
// We partially matched some bindings so avoid printing that key.
111+
event.preventDefault();
112+
event.stopPropagation();
113+
114+
for (let i = state.pendingPrimaryBindings.length - 1; i >= 0; i--) {
115+
const completedBinding = state.pendingPrimaryBindings[i];
116+
// Check if the binding has actually been completed, if it has then we have already
117+
// processed all completed bindings since they're ordered as such.
118+
if (completedBinding.bindings[0].length > 0) {
119+
break;
120+
}
121+
122+
// This binding has been completed (not more keys needed to match) so call its payload
123+
// function or add it as a pending secondary binding.
124+
if (completedBinding.bindings.length > 0 && completedBinding.bindings[1]) {
125+
this.pendingSecondaryBindings.push(completedBinding);
126+
} else {
127+
const keybinding = KEYBINDINGS[completedBinding.key];
128+
129+
reset();
130+
event.preventDefault();
131+
event.stopPropagation();
132+
133+
const payload =
134+
typeof keybinding.payload === 'function'
135+
? keybinding.payload(controller.getState())
136+
: keybinding.payload || {};
137+
controller.getSignal(keybinding.signal)(payload);
138+
// When we find a completed binding and call its payload, we're done.
139+
break;
140+
}
141+
}
142+
}
143+
22144
function handleKeyDown(controller, e) {
23145
if (state.timeout) {
24146
clearTimeout(state.timeout);
@@ -35,6 +157,10 @@ function handleKeyDown(controller, e) {
35157
return;
36158
}
37159

160+
if (isIOS) {
161+
handleIosKeyDown(controller, e, key);
162+
return;
163+
}
38164
// First we check if we have any pending secondary bindings to identify
39165
if (state.pendingSecondaryBindings.length) {
40166
// We filter out any hits by verifying that the current key matches the next

packages/common/utils/keybindings.js

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,35 @@
1+
const isIOS =
2+
typeof navigator !== 'undefined' &&
3+
!!navigator.platform.match(/(iPhone|iPod|iPad)/i);
14
const isMac =
25
typeof navigator !== 'undefined' &&
3-
!!navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i);
4-
const metaKey = isMac ? 'Meta' : 'Alt';
5-
const metaOrCtrlKey = isMac ? 'Meta' : 'Control';
6+
(isIOS || !!navigator.platform.match(/Mac/i));
7+
const metaKey = isMac ? (isIOS ? 'Alt' : 'Meta') : 'Alt';
8+
const metaOrCtrlKey = isMac ? (isIOS ? 'Alt' : 'Meta') : 'Control';
9+
const ctrlOrAltKey = isIOS ? 'Alt' : 'Control';
10+
11+
// String.fromCharCode receives UTF-16 code units, but the keyCode represents the actual
12+
// "physical" key on the keyboard. For this reason it's sketchy (some do match) to
13+
// String.fromCharCode(e.keyCode) so we have this table with the correct mapping.
14+
// KeyCode is a weird spec (it is a key event api after all) but it's defined in a way that
15+
// it's i18n safe: In the US keyboard "," and "<" are on the same physical key so they
16+
// both have keyCode 188. One might expect this will break in non-US keyboards since
17+
// these characters are in different physical keys, however, the spec is defined in a way
18+
// that no matter which physical key the "," and the "<" are in, they'll always be keyCode 188.
19+
// http://www.javascripter.net/faq/keycodes.htm
20+
const keyCodeMapping = {
21+
'188': ',',
22+
};
623

724
export function normalizeKey(e: KeyboardEvent) {
825
if (e.key) {
926
if (e.key.split('').length === 1) {
10-
const key = String.fromCharCode(e.keyCode).toUpperCase();
27+
let key;
28+
if (Object.prototype.hasOwnProperty.call(keyCodeMapping, e.keyCode)) {
29+
key = keyCodeMapping[e.keyCode];
30+
} else {
31+
key = String.fromCharCode(e.keyCode).toUpperCase();
32+
}
1133
if (key === ' ') {
1234
return 'Space';
1335
}
@@ -60,7 +82,7 @@ export const KEYBINDINGS = {
6082
'editor.close-tab': {
6183
title: 'Close Current Tab',
6284
type: 'View',
63-
bindings: [['Control', 'W']],
85+
bindings: [[ctrlOrAltKey, 'W']],
6486
signal: 'editor.tabClosed',
6587
payload: state => ({
6688
tabIndex: state.editor.tabs

0 commit comments

Comments
 (0)