| /** |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| package org.waveprotocol.wave.client.common.util; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.gwt.event.dom.client.KeyCodes; |
| import com.google.gwt.user.client.Event; |
| |
| import org.waveprotocol.wave.client.common.util.SignalEvent.KeySignalType; |
| import org.waveprotocol.wave.client.editor.EditorStaticDeps; |
| import org.waveprotocol.wave.model.util.CollectionUtils; |
| import org.waveprotocol.wave.model.util.ReadableStringMap.ProcV; |
| import org.waveprotocol.wave.model.util.StringMap; |
| |
| import java.util.HashSet; |
| import java.util.Set; |
| |
| /** |
| * Instances of this class encapsulate the event to signal mapping logic for a |
| * specific environment (os/browser). |
| * |
| * Contains as much of the signal event logic as possible in a POJO testable |
| * manner. |
| * |
| * @author danilatos@google.com (Daniel Danilatos) |
| */ |
| public final class SignalKeyLogic { |
| |
| /** |
| * For webkit + IE |
| * I think also all browsers on windows? |
| */ |
| public static final int IME_CODE = 229; |
| |
| private static final String DELETE_KEY_IDENTIFIER = "U+007F"; |
| |
| //TODO(danilatos): Use int map |
| private static final Set<Integer> NAVIGATION_KEYS = new HashSet<Integer>(); |
| private static final StringMap<Integer> NAVIGATION_KEY_IDENTIFIERS = |
| CollectionUtils.createStringMap(); |
| static { |
| |
| NAVIGATION_KEY_IDENTIFIERS.put("Left", KeyCodes.KEY_LEFT); |
| NAVIGATION_KEY_IDENTIFIERS.put("Right", KeyCodes.KEY_RIGHT); |
| NAVIGATION_KEY_IDENTIFIERS.put("Up", KeyCodes.KEY_UP); |
| NAVIGATION_KEY_IDENTIFIERS.put("Down", KeyCodes.KEY_DOWN); |
| NAVIGATION_KEY_IDENTIFIERS.put("PageUp", KeyCodes.KEY_PAGEUP); |
| NAVIGATION_KEY_IDENTIFIERS.put("PageDown", KeyCodes.KEY_PAGEDOWN); |
| NAVIGATION_KEY_IDENTIFIERS.put("Home", KeyCodes.KEY_HOME); |
| NAVIGATION_KEY_IDENTIFIERS.put("End", KeyCodes.KEY_END); |
| |
| NAVIGATION_KEY_IDENTIFIERS.each(new ProcV<Integer>() { |
| public void apply(String key, Integer keyCode) { |
| NAVIGATION_KEYS.add(keyCode); |
| } |
| }); |
| } |
| |
| /** |
| * KeyboardEvent.key values for navigation |
| * See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values |
| */ |
| private static final Set<String> NAVIGATION_KEY_VALUES = new HashSet<String>(); |
| static { |
| NAVIGATION_KEY_VALUES.add("ArrowDown"); |
| NAVIGATION_KEY_VALUES.add("ArrowLeft"); |
| NAVIGATION_KEY_VALUES.add("ArrowRight"); |
| NAVIGATION_KEY_VALUES.add("ArrowUp"); |
| NAVIGATION_KEY_VALUES.add("End"); |
| NAVIGATION_KEY_VALUES.add("Home"); |
| NAVIGATION_KEY_VALUES.add("PageDown"); |
| NAVIGATION_KEY_VALUES.add("PageUp"); |
| } |
| |
| /** |
| * KeyboardEvent.key values for deletion |
| * See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values |
| */ |
| private static final Set<String> DELETE_KEY_VALUES = new HashSet<String>(); |
| static { |
| DELETE_KEY_VALUES.add("Backspace"); |
| DELETE_KEY_VALUES.add("Delete"); |
| } |
| |
| /** |
| * KeyboardEvent.key special values we consider input |
| * See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values |
| */ |
| private static final String TAB_KEY_VALUE = "Tab"; |
| |
| |
| public enum UserAgentType { |
| WEBKIT, |
| GECKO, |
| IE |
| } |
| |
| public enum OperatingSystem { |
| WINDOWS, |
| MAC, |
| LINUX |
| } |
| |
| @VisibleForTesting |
| public static class Result { |
| @VisibleForTesting |
| public int keyCode; |
| // Sentinal by default for testing purposes |
| @VisibleForTesting |
| public KeySignalType type = KeySignalType.SENTINAL; |
| } |
| |
| private final UserAgentType userAgent; |
| private final boolean commandIsCtrl; |
| |
| |
| // Hack, get rid of this |
| final boolean commandComboDoesntGiveKeypress; |
| |
| /** |
| * @param userAgent |
| * @param os Operating system |
| */ |
| public SignalKeyLogic(UserAgentType userAgent, OperatingSystem os, |
| boolean commandComboDoesntGiveKeypress) { |
| this.userAgent = userAgent; |
| this.commandComboDoesntGiveKeypress = commandComboDoesntGiveKeypress; |
| commandIsCtrl = os != OperatingSystem.MAC; |
| } |
| |
| public boolean commandIsCtrl() { |
| return commandIsCtrl; |
| } |
| |
| |
| |
| public void computeKeySignalType( |
| Result result, |
| String typeName, |
| int keyCode, int which, String keyIdentifier, String key, |
| boolean metaKey, boolean ctrlKey, boolean altKey, boolean shiftKey) { |
| |
| boolean ret = true; |
| |
| int typeInt; |
| if ("keydown".equals(typeName)) { |
| typeInt = Event.ONKEYDOWN; |
| } else if ("keypress".equals(typeName)) { |
| typeInt = Event.ONKEYPRESS; |
| } else if ("keyup".equals(typeName)) { |
| result.type = null; |
| return; |
| } else { |
| throw new AssertionError("Non-key-event passed to computeKeySignalType"); |
| } |
| |
| KeySignalType type; |
| |
| int computedKeyCode = which != 0 ? which : keyCode; |
| |
| if (computedKeyCode == 10) { |
| computedKeyCode = KeyCodes.KEY_ENTER; |
| } |
| |
| // Some trace logging very useful to debug |
| EditorStaticDeps.logger.trace().log( |
| "KEY SIGNAL IN PROCESS identifier/key = " + (keyIdentifier == null ? key : "?") + " code = " + computedKeyCode |
| + " type = " |
| + (typeInt == Event.ONKEYDOWN ? "KeyDown" : "KeyPress") + (ctrlKey ? " CTRL" : "") |
| + (shiftKey ? " SHIFT" : "") + (altKey ? " ALT" : "")); |
| |
| // For non-firefox browsers, we only get keydown events for IME, no keypress |
| boolean isIME = computedKeyCode == IME_CODE; |
| |
| boolean commandKey = commandIsCtrl ? ctrlKey : metaKey; |
| |
| |
| switch (userAgent) { |
| case WEBKIT: |
| // boolean isPossiblyCtrlInput = typeInt == Event.ONKEYDOWN && ret.getCtrlKey(); |
| boolean isActuallyCtrlInput = false; |
| |
| // Keep this for older Webkit versions (Chrome < v54) where normal typing |
| // is detected with keyIdentifier containing U+ prefix |
| boolean startsWithUPlus = keyIdentifier != null && keyIdentifier.startsWith("U+"); |
| |
| // Mix older way to detect normal typing (keyIdentifier) with new one (key) |
| boolean normalTypingKeydown = startsWithUPlus || (key != null && !"undefined".equals(key) && !metaKey && !ctrlKey && !altKey); |
| |
| // Need to use identifier for the delete key because the keycode conflicts |
| // with the keycode for the full stop. |
| if (isIME) { |
| // If is IME, override the logic below - we get keyIdentifiers for IME events, |
| // but those are basically useless as the event is basically still an IME input |
| // event (e.g. keyIdentifier might say "Up", but it's certainly not navigation, |
| // it's just the user selecting from the IME dialog). |
| type = KeySignalType.INPUT; |
| } else if (computedKeyCode == KeyCodes.KEY_BACKSPACE) { |
| type = KeySignalType.DELETE; |
| |
| } else if (keyIdentifier != null && DELETE_KEY_IDENTIFIER.equals(keyIdentifier) && typeInt == Event.ONKEYDOWN) { |
| // WAVE-407 Avoid missing the '.' char (KEYPRESS + CODE 46) |
| // ensuring it's a KEYDOWN event with a DELETE_KEY_IDENTIFIER |
| type = KeySignalType.DELETE; |
| |
| } else if (keyIdentifier != null && NAVIGATION_KEY_IDENTIFIERS.containsKey(keyIdentifier) && typeInt == Event.ONKEYDOWN) { |
| // WAVE-407 Avoid missing chars with NAVIGATION_KEY_IDENTIFIERS but |
| // represeting a SHIFT + key char (! " · ...). Navigation events come |
| // with KEYDOWN, not with KEYPRESS |
| type = KeySignalType.NAVIGATION; |
| |
| } else if (key != null && NAVIGATION_KEY_VALUES.contains(key) && typeInt == Event.ONKEYDOWN) { |
| // Starting chrome v54 KeyboardEvent.keyIdentifier is replaced by KeyboardEvent.key |
| type = KeySignalType.NAVIGATION; |
| |
| } else if (key != null && DELETE_KEY_VALUES.contains(key) && typeInt == Event.ONKEYDOWN) { |
| // Starting chrome v54 KeyboardEvent.keyIdentifier is replaced by KeyboardEvent.key |
| type = KeySignalType.DELETE; |
| |
| } else if (computedKeyCode == KeyCodes.KEY_ESCAPE || "U+0010".equals(keyIdentifier)) { |
| // Escape, backspace and context-menu-key (U+0010) are, to my knowledge, |
| // the only non-navigation keys that |
| // have a "U+..." keyIdentifier, so we handle them explicitly. |
| // (Backspace was handled earlier). |
| type = KeySignalType.NOEFFECT; |
| |
| } else if (key != null && TAB_KEY_VALUE.equals(key) && typeInt == Event.ONKEYDOWN) { |
| // ** EXPERIMENTAL ** |
| // use tabs as input |
| // Starting chrome v54 KeyboardEvent.keyIdentifier is replaced by KeyboardEvent.key |
| type = KeySignalType.INPUT; |
| |
| } else if (computedKeyCode == KeyCodes.KEY_TAB) { |
| // ** EXPERIMENTAL ** |
| // use tabs as input |
| type = KeySignalType.INPUT; |
| |
| } else if (typeInt == Event.ONKEYPRESS || // if it's a regular keypress |
| normalTypingKeydown || |
| computedKeyCode == KeyCodes.KEY_ENTER) { |
| type = KeySignalType.INPUT; |
| isActuallyCtrlInput = ctrlKey || (commandComboDoesntGiveKeypress && commandKey); |
| } else { |
| type = KeySignalType.NOEFFECT; |
| } |
| |
| // Maybe nullify it with the same logic as IE, EXCEPT for the special |
| // Ctrl Input webkit behaviour, and IME for windows |
| if (isActuallyCtrlInput) { |
| if (computedKeyCode == KeyCodes.KEY_ENTER) { |
| ret = typeInt == Event.ONKEYDOWN; |
| } |
| // HACK(danilatos): Don't actually nullify isActuallyCtrlInput for key press. |
| // We get that for AltGr combos on non-mac computers. |
| } else if (isIME || computedKeyCode == KeyCodes.KEY_TAB) { |
| ret = typeInt == Event.ONKEYDOWN; |
| } else { |
| ret = maybeNullWebkitIE(ret, typeInt, type); |
| } |
| if (!ret) { |
| result.type = null; |
| return; |
| } |
| break; |
| case GECKO: |
| boolean hasKeyCodeButNotWhich = keyCode != 0 && which == 0; |
| |
| // Firefox is easy for deciding signal events, because it issues a keypress for |
| // whenever we would want a signal. So we can basically ignore all keydown events. |
| // It also, on all OSes, does any default action AFTER the keypress (even for |
| // things like Ctrl/Meta+C, etc). So keypress is perfect for us. |
| // Ctrl+Space is an exception, where we don't get a keypress |
| // Firefox also gives us keypress events even for Windows IME input |
| if (ctrlKey && !altKey && !shiftKey && computedKeyCode == ' ') { |
| if (typeInt != Event.ONKEYDOWN) { |
| result.type = null; |
| return; |
| } |
| } else if (typeInt == Event.ONKEYDOWN) { |
| result.type = null; |
| return; |
| } |
| |
| // Backspace fails the !hasKeyCodeButNotWhich test, so check it explicitly first |
| if (computedKeyCode == KeyCodes.KEY_BACKSPACE) { |
| type = KeySignalType.DELETE; |
| // This 'keyCode' but not 'which' works very nicely for catching normal typing input keys, |
| // the only 'exceptions' I've seen so far are bksp & enter which have both |
| } else if (!hasKeyCodeButNotWhich || computedKeyCode == KeyCodes.KEY_ENTER |
| || computedKeyCode == KeyCodes.KEY_TAB) { |
| type = KeySignalType.INPUT; |
| } else if (computedKeyCode == KeyCodes.KEY_DELETE) { |
| type = KeySignalType.DELETE; |
| } else if (NAVIGATION_KEYS.contains(computedKeyCode)) { |
| type = KeySignalType.NAVIGATION; |
| } else { |
| type = KeySignalType.NOEFFECT; |
| } |
| |
| break; |
| case IE: |
| |
| // Unfortunately IE gives us the least information, so there are no nifty tricks. |
| // So we pretty much need to use some educated guessing based on key codes. |
| // Experimentation page to the rescue. |
| |
| boolean isKeydownForInputKey = isInputKeyCodeIE(computedKeyCode); |
| |
| // IE has some strange behaviour with modifiers and whether or not there will |
| // be a keypress. Ctrl kills the keypress, unless shift is also held. |
| // Meta doesn't kill it. Alt always kills the keypress, overriding other rules. |
| boolean hasModifiersThatResultInNoKeyPress = |
| altKey || (ctrlKey && !shiftKey); |
| |
| if (typeInt == Event.ONKEYDOWN) { |
| if (isKeydownForInputKey) { |
| type = KeySignalType.INPUT; |
| } else if (computedKeyCode == KeyCodes.KEY_BACKSPACE || |
| computedKeyCode == KeyCodes.KEY_DELETE) { |
| type = KeySignalType.DELETE; |
| } else if (NAVIGATION_KEYS.contains(computedKeyCode)) { |
| type = KeySignalType.NAVIGATION; |
| } else { |
| type = KeySignalType.NOEFFECT; |
| } |
| } else { |
| // Escape is the only non-input thing that has a keypress event |
| if (computedKeyCode == KeyCodes.KEY_ESCAPE) { |
| result.type = null; |
| return; |
| } |
| assert typeInt == Event.ONKEYPRESS; |
| // I think the guessCommandFromModifiers() check here isn't needed, |
| // but i feel safer putting it in. |
| type = KeySignalType.INPUT; |
| } |
| |
| if (hasModifiersThatResultInNoKeyPress || isIME || computedKeyCode == KeyCodes.KEY_TAB) { |
| ret = typeInt == Event.ONKEYDOWN ? ret : false; |
| } else { |
| ret = maybeNullWebkitIE(ret, typeInt, type); |
| } |
| if (!ret) { |
| result.type = null; |
| return; |
| } |
| break; |
| default: |
| throw new UnsupportedOperationException("Unhandled user agent"); |
| } |
| |
| if (ret) { |
| result.type = type; |
| result.keyCode = computedKeyCode; |
| } else { |
| result.type = null; |
| return; |
| } |
| } |
| |
| private static final boolean isInputKeyCodeIE(int keyCode) { |
| /* |
| DATA |
| ---- |
| For KEYDOWN: |
| |
| "Input" |
| 48-57 (numbers) |
| 65-90 (a-z) |
| 96-111 (Numpad digits & other keys, with numlock off. with numlock on, they |
| behave like their corresponding keys on the rest of the keyboard) |
| 186-192 219-222 (random non-alphanumeric next to letters on RHS + backtick) |
| 229 Code that the input has passed to an IME |
| |
| Non-"input" |
| < 48 ('0') |
| 91-93 (Left & Right Win keys, ContextMenu key) |
| 112-123 (F1-F12) |
| 144-5 (NUMLOCK,SCROLL LOCK) |
| |
| For KEYPRESS: only "input" things get this event! yay! not even backspace! |
| Well, one exception: ESCAPE |
| */ |
| // boundaries in keycode ranges where the keycode for a keydown is for an input |
| // key. at "ON" it is, starting from the number going up, and the opposite for "OFF". |
| final int A_ON = 48; |
| final int B_OFF = 91; |
| final int C_ON = 96; |
| final int D_OFF = 112; |
| final int E_ON = 186; |
| |
| return |
| (keyCode == 9 || keyCode == 32 || keyCode == 13) || // And tab, enter & spacebar, of course! |
| (keyCode >= A_ON && keyCode < B_OFF) || |
| (keyCode >= C_ON && keyCode < D_OFF) || |
| (keyCode >= E_ON); |
| } |
| |
| /** |
| * Common logic between Webkit and IE for deciding whether we want the keydown |
| * or the keypress |
| */ |
| private static boolean maybeNullWebkitIE(boolean ret, int typeInt, |
| KeySignalType type) { |
| // Use keydown as the signal for everything except input. |
| // This is because the mutation always happens after the keypress for |
| // input (this is especially important for chrome, |
| // which interleaves deferred commands between keydown and keypress). |
| // |
| // For everything else, keypress is redundant with keydown, and also, the resulting default |
| // dom mutation (if any) often happens after the keydown but before the keypress in webkit. |
| // Also, if the 'Command' key is held for chrome/safari etc, we want to get the keydown |
| // event, NOT the keypress event, for everything because of things like ctrl+c etc. |
| // where sometimes it'll happen just after the keydown, or sometimes we just won't |
| // get a keypress at all |
| if (typeInt == (type == KeySignalType.INPUT ? Event.ONKEYDOWN : Event.ONKEYPRESS)) { |
| return false; |
| } |
| |
| return ret; |
| } |
| } |