| /** |
| * 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.gwt.dom.client.Element; |
| import com.google.gwt.user.client.Event; |
| |
| import org.waveprotocol.wave.client.common.util.SignalKeyLogic.OperatingSystem; |
| import org.waveprotocol.wave.client.common.util.SignalKeyLogic.UserAgentType; |
| import org.waveprotocol.wave.model.util.CollectionUtils; |
| import org.waveprotocol.wave.model.util.StringSet; |
| |
| /** |
| * Attempts to bring sanity to the incredibly complex and inconsistent world of |
| * browser events, especially with regards to key events. |
| * |
| * A new concept of the "signal" is introduced. A signal is basically an event, |
| * but an event that we actually care about, with the information we care about. |
| * Redundant events are merged into a single signal. For key events, a signal |
| * corresponds to the key-repeat signal we get from the keyboard. For normal |
| * typing input, this will always be the keypress event. For other types of key |
| * events, it depends on the browser. For clipboard events, the "beforeXYZ" and |
| * "XYZ" events are merged into a single one, the one that actually happens |
| * right before the action (browser dependent). Key events are also classified |
| * into subtypes identified by KeySignalType. This reflects the intended usage |
| * of the event, not something to do with the event data itself. |
| * |
| * Currently the "filtering" needs to be done manually - simply construct a |
| * signal from an event using {@link #create(Event, boolean)}, and if it returns null, |
| * drop the event and do nothing with it (cancelling bubbling might be a good |
| * idea though). |
| * |
| * NOTE(danilatos): getting the physical key pressed, even on a key down, is |
| * inherently not possible without a big lookup table, because of international |
| * input methods. e.g. press 'b' but in greek mode on safari on osx. nothing in |
| * any of the events you receive will tell you it was a 'b', instead, you'll get |
| * a beta for the keypress and 0 (zero) for the keydown. mmm, useful! |
| * |
| * TODO(danilatos): Hook this into the application's event plumbing in a more |
| * invasive manner. |
| * |
| * @author danilatos@google.com (Daniel Danilatos) |
| */ |
| public class SignalEventImpl implements SignalEvent { |
| |
| public interface SignalEventFactory<T extends SignalEventImpl> { |
| T create(); |
| } |
| |
| public static SignalEventFactory<SignalEventImpl> DEFAULT_FACTORY = |
| new SignalEventFactory<SignalEventImpl>() { |
| @Override public SignalEventImpl create() { |
| return new SignalEventImpl(); |
| } |
| }; |
| |
| interface NativeEvent { |
| String getType(); |
| int getButton(); |
| boolean getCtrlKey(); |
| boolean getMetaKey(); |
| boolean getAltKey(); |
| boolean getShiftKey(); |
| void preventDefault(); |
| void stopPropagation(); |
| } |
| |
| /** |
| * @param event |
| * @return True if the given event is a key event |
| */ |
| public static boolean isKeyEvent(Event event) { |
| return KEY_EVENTS.contains(event.getType()); |
| } |
| |
| private static final UserAgentType currentUserAgent = |
| (UserAgent.isWebkit() ? UserAgentType.WEBKIT : ( |
| UserAgent.isFirefox() ? UserAgentType.GECKO : UserAgentType.IE)); |
| private static final OperatingSystem currentOs = |
| (UserAgent.isWin() ? OperatingSystem.WINDOWS : ( |
| UserAgent.isMac() ? OperatingSystem.MAC : OperatingSystem.LINUX)); |
| private static final SignalKeyLogic logic = new SignalKeyLogic( |
| currentUserAgent, currentOs, QuirksConstants.COMMAND_COMBO_DOESNT_GIVE_KEYPRESS); |
| |
| /** |
| * This variable will be filled with mappings of unshifted keys to their shifted versions. |
| */ |
| private static final int[] shiftMappings = new int[128]; |
| static { |
| for (int a = 'A'; a <= 'Z'; a++) { |
| shiftMappings[a] = a + 'a' - 'A'; |
| } |
| // TODO(danilatos): Who knows what these mappings should be on other |
| // keyboard layouts... e.g. pound signs? euros? etc? argh! |
| shiftMappings['1'] = '!'; |
| shiftMappings['2'] = '@'; |
| shiftMappings['3'] = '#'; |
| shiftMappings['4'] = '$'; |
| shiftMappings['5'] = '%'; |
| shiftMappings['6'] = '^'; |
| shiftMappings['7'] = '&'; |
| shiftMappings['8'] = '*'; |
| shiftMappings['9'] = '('; |
| shiftMappings['0'] = ')'; |
| shiftMappings['`'] = '~'; |
| shiftMappings['-'] = '_'; |
| shiftMappings['='] = '+'; |
| shiftMappings['['] = '{'; |
| shiftMappings[']'] = '}'; |
| shiftMappings['\\'] = '|'; |
| shiftMappings[';'] = ':'; |
| shiftMappings['\''] = '"'; |
| shiftMappings[','] = '<'; |
| shiftMappings['.'] = '>'; |
| shiftMappings['/'] = '?'; |
| // invalidate the inverse mappings |
| for (int i = 1; i < shiftMappings.length; i++) { |
| int m = shiftMappings[i]; |
| if (m > 0) { |
| shiftMappings[m] = i; |
| } |
| } |
| } |
| |
| private static final StringSet KEY_EVENTS = CollectionUtils.createStringSet(); |
| private static final StringSet COMPOSITION_EVENTS = CollectionUtils.createStringSet(); |
| private static final StringSet MOUSE_EVENTS = CollectionUtils.createStringSet(); |
| private static final StringSet MOUSE_BUTTON_EVENTS = CollectionUtils.createStringSet(); |
| private static final StringSet MOUSE_BUTTONLESS_EVENTS = CollectionUtils.createStringSet(); |
| private static final StringSet FOCUS_EVENTS = CollectionUtils.createStringSet(); |
| private static final StringSet CLIPBOARD_EVENTS = CollectionUtils.createStringSet(); |
| |
| /** |
| * Events affected by |
| * {@link QuirksConstants#CANCEL_BUBBLING_CANCELS_IME_COMPOSITION_AND_CONTEXTMENU}. |
| */ |
| private static final StringSet CANCEL_BUBBLE_QUIRKS = CollectionUtils.createStringSet(); |
| |
| static { |
| for (String e : new String[]{"keydown", "keypress", "keyup"}) { |
| KEY_EVENTS.add(e); |
| } |
| for (String e : new String[]{ |
| "compositionstart", "compositionend", "compositionupdate", "text"}) { |
| COMPOSITION_EVENTS.add(e); |
| CANCEL_BUBBLE_QUIRKS.add(e); |
| } |
| COMPOSITION_EVENTS.add("textInput"); |
| CANCEL_BUBBLE_QUIRKS.add("contextmenu"); |
| for (String e : new String[]{ |
| "mousewheel", "DOMMouseScroll", "mousemove", "mouseover", "mouseout", |
| /* not strictly a mouse event*/ "contextmenu"}) { |
| MOUSE_BUTTONLESS_EVENTS.add(e); |
| MOUSE_EVENTS.add(e); |
| } |
| for (String e : new String[]{ |
| "mousedown", "mouseup", "click", "dblclick"}) { |
| MOUSE_BUTTON_EVENTS.add(e); |
| MOUSE_EVENTS.add(e); |
| } |
| for (String e : new String[]{"focus", "blur", "beforeeditfocus"}) { |
| FOCUS_EVENTS.add(e); |
| } |
| for (String e : new String[]{"cut", "copy", "paste"}) { |
| CLIPBOARD_EVENTS.add(e); |
| CLIPBOARD_EVENTS.add("before" + e); |
| } |
| } |
| |
| protected NativeEvent nativeEvent; |
| private KeySignalType keySignalType = null; |
| private int cachedKeyCode = -1; |
| private boolean hasBeenConsumed = false; |
| |
| protected SignalEventImpl() { |
| } |
| |
| static class JsoNativeEvent extends Event implements NativeEvent { |
| protected JsoNativeEvent() {} |
| } |
| |
| /** |
| * Create a signal from an event, possibly filtering the event |
| * if it is deemed redundant. |
| * |
| * If the event is to be filtered, null is returned, and bubbling |
| * is cancelled if cancelBubbleIfNullified is true. |
| * (but the default is not prevented). |
| * |
| * NOTE(danilatos): So far, for key events, the following have been tested: |
| * - Safari 3.1 OS/X (incl. num pad, with USB keyboard) |
| * - Safari 3.0 OS/X, hosted mode only (so no ctrl+c, etc) |
| * - Firefox 3, OS/X, WinXP |
| * - IE7, WinXP |
| * Needs testing: |
| * - FF3 linux, Safari 3.0/3.1 Windows |
| * - All kinds of weirdo keyboards (mac, international) |
| * - Linux IME |
| * |
| * Currently, only key events have serious logic applied to them. |
| * Maybe some logic for copy/paste, and mouse events? |
| * |
| * @param event Raw Event JSO |
| * @param cancelBubbleIfNullified stops propagation if the event is nullified |
| * @return SignalEvent mapping, or null, if the event is to be discarded |
| */ |
| public static SignalEventImpl create(Event event, boolean cancelBubbleIfNullified) { |
| return create(DEFAULT_FACTORY, event, cancelBubbleIfNullified); |
| } |
| |
| public static <T extends SignalEventImpl> T create(SignalEventFactory<T> factory, |
| Event event, boolean cancelBubbleIfNullified) { |
| if (hasBeenConsumed(event)) { |
| return null; |
| } else { |
| T signal = createInner(factory, event); |
| if (cancelBubbleIfNullified && signal == null) { |
| event.stopPropagation(); |
| } |
| return signal; |
| } |
| } |
| |
| private static boolean hasBeenConsumed(Event event) { |
| SignalEventImpl existing = getFor(null, event); |
| return existing != null && existing.hasBeenConsumed(); |
| } |
| |
| private static final String EVENT_PROP = "$se"; |
| @SuppressWarnings("unchecked") |
| private static <T extends SignalEventImpl> T getFor(SignalEventFactory<T> factory, Event event) { |
| return (T) (SignalEventImpl) event.<JsoView>cast().getObject(EVENT_PROP); |
| } |
| |
| private static <T extends SignalEventImpl> T createFor( |
| SignalEventFactory<T> factory, Event event) { |
| |
| T signal = factory.create(); |
| event.<JsoView>cast().setObject(EVENT_PROP, signal); |
| return signal; |
| } |
| |
| /** This would be a static local variable if java allowed it. Grouping it here. */ |
| private static final SignalKeyLogic.Result computeKeySignalTypeResult = |
| new SignalKeyLogic.Result(); |
| |
| private static <T extends SignalEventImpl> T createInner( |
| SignalEventFactory<T> factory, Event event) { |
| |
| SignalKeyLogic.Result keySignalResult; |
| if (isKeyEvent(event)) { |
| keySignalResult = computeKeySignalTypeResult; |
| |
| String keyIdentifier = getKeyIdentifier(event); |
| String key = getKey(event); |
| |
| logic.computeKeySignalType(keySignalResult, |
| event.getType(), getNativeKeyCode(event), getWhich(event), keyIdentifier, key, |
| event.getMetaKey(), event.getCtrlKey(), event.getAltKey(), event.getShiftKey()); |
| |
| } else { |
| keySignalResult = null; |
| } |
| |
| return createInner(createFor(factory, event), event.<JsoNativeEvent>cast(), keySignalResult); |
| } |
| |
| /** |
| * Populate a SignalEventImpl with the necessary information |
| * |
| * @param ret |
| * @param keySignalResult only required if it's a key event |
| * @return the signal, or null if it is to be ignored. |
| */ |
| protected static <T extends SignalEventImpl> T createInner(T ret, |
| NativeEvent event, SignalKeyLogic.Result keySignalResult) { |
| ret.nativeEvent = event; |
| if (ret.isKeyEvent()) { |
| KeySignalType type = keySignalResult.type; |
| |
| if (type != null) { |
| ret.cacheKeyCode(keySignalResult.keyCode); |
| ret.setup(type); |
| } else { |
| ret = null; |
| } |
| |
| } else if ((UserAgent.isIE() ? "paste" : "beforepaste").equals(event.getType())) { |
| // Only want 'beforepaste' for ie and 'paste' for everything else. |
| // TODO(danilatos): Generalise clipboard events |
| ret = null; |
| } |
| |
| // TODO: return null if it's something we should ignore. |
| return ret; |
| } |
| |
| public static native int getNativeKeyCode(Event event) /*-{ |
| return event.keyCode || 0; |
| }-*/; |
| |
| public static native int getWhich(Event event) /*-{ |
| return event.which || 0; |
| }-*/; |
| |
| public static native String getKeyIdentifier(Event event) /*-{ |
| return event.keyIdentifier |
| }-*/; |
| |
| public static native String getKey(Event event) /*-{ |
| return event.key; |
| }-*/; |
| |
| /** |
| * @return Event type as a string, e.g. "keypress" |
| */ |
| public final String getType() { |
| return nativeEvent.getType(); |
| } |
| |
| /** |
| * @return The target element of the event |
| */ |
| public Element getTarget() { |
| return asEvent().getTarget(); |
| } |
| |
| /** |
| * @return true if the event is a key event |
| * TODO(danilatos): Have a top level EventSignalType enum |
| */ |
| public final boolean isKeyEvent() { |
| return KEY_EVENTS.contains(nativeEvent.getType()); |
| } |
| |
| /** |
| * @return true if it is an IME composition event |
| */ |
| public final boolean isCompositionEvent() { |
| return COMPOSITION_EVENTS.contains(getType()); |
| } |
| |
| /** |
| * Returns true if the key event is an IME input event. |
| * Only makes sense to call this method if this is a key signal. |
| * Does not work on FF. (TODO(danilatos): Can it be done? Tricks |
| * with dom mutation events?) |
| * |
| * @return true if this is an IME input event |
| */ |
| public final boolean isImeKeyEvent() { |
| return getKeyCode() == SignalKeyLogic.IME_CODE; |
| } |
| |
| /** |
| * @return true if this is a mouse event |
| * TODO(danilatos): Have a top level EventSignalType enum |
| */ |
| public final boolean isMouseEvent() { |
| return MOUSE_EVENTS.contains(getType()); |
| } |
| |
| /** |
| * TODO(danilatos): Click + drag? I.e. return true for mouse move, if the |
| * button is pressed? (this might be useful for tracking changing selections |
| * as the user holds & drags) |
| * @return true if this is an event involving some use of mouse buttons |
| */ |
| public final boolean isMouseButtonEvent() { |
| return MOUSE_BUTTON_EVENTS.contains(getType()); |
| } |
| |
| /** |
| * @return true if this is a mouse event but not {@link #isMouseButtonEvent()} |
| */ |
| public final boolean isMouseButtonlessEvent() { |
| return MOUSE_BUTTONLESS_EVENTS.contains(getType()); |
| } |
| |
| /** |
| * @return true if this is a "click" event |
| */ |
| public final boolean isClickEvent() { |
| return "click".equals(getType()); |
| } |
| |
| /** |
| * @return True if this is a dom mutation event |
| */ |
| public final boolean isMutationEvent() { |
| // What about DOMMouseScroll? |
| return getType().startsWith("DOM"); |
| } |
| |
| /** |
| * @return true if this is any sort of clipboard event |
| */ |
| public final boolean isClipboardEvent() { |
| return CLIPBOARD_EVENTS.contains(getType()); |
| } |
| |
| /** |
| * @return If this is a focus event |
| */ |
| public final boolean isFocusEvent() { |
| return FOCUS_EVENTS.contains(getType()); |
| } |
| |
| /** |
| * @return true if this is a paste event |
| * TODO(danilatos): Make a ClipboardSignalType enum instead |
| */ |
| public final boolean isPasteEvent() { |
| return (UserAgent.isIE() ? "beforepaste" : "paste").equals(nativeEvent.getType()); |
| } |
| |
| /** |
| * @return true if this is a cut event |
| * TODO(danilatos): Make a ClipboardSignalType enum instead |
| */ |
| public final boolean isCutEvent() { |
| return (UserAgent.isIE() ? "beforecut" : "cut").equals(nativeEvent.getType()); |
| } |
| |
| /** |
| * @return true if this is a copy event |
| * TODO(danilatos): Make a ClipboardSignalType enum instead |
| */ |
| public final boolean isCopyEvent() { |
| return "copy".equals(nativeEvent.getType()); |
| } |
| |
| /** |
| * @return true if the command key is depressed |
| * @see SignalKeyLogic#commandIsCtrl() |
| */ |
| public final boolean getCommandKey() { |
| return logic.commandIsCtrl() ? getCtrlKey() : getMetaKey(); |
| } |
| |
| public static boolean getCommandKey(com.google.gwt.dom.client.NativeEvent event) { |
| return logic.commandIsCtrl() ? event.getCtrlKey() : event.getMetaKey(); |
| } |
| |
| /** |
| * @return true if the ctrl key is depressed |
| */ |
| public final boolean getCtrlKey() { |
| return nativeEvent.getCtrlKey(); |
| } |
| |
| /** |
| * @return true if the meta key is depressed |
| */ |
| public final boolean getMetaKey() { |
| return nativeEvent.getMetaKey(); |
| } |
| |
| /** |
| * @return true if the alt key is depressed |
| */ |
| public final boolean getAltKey() { |
| // TODO(danilatos): Handle Alt vs Option on OSX? |
| return nativeEvent.getAltKey(); |
| } |
| |
| /** |
| * @return true if the shift key is depressed |
| */ |
| public final boolean getShiftKey() { |
| return nativeEvent.getShiftKey(); |
| } |
| |
| /** |
| * @return The underlying event view of this event |
| */ |
| public final Event asEvent() { |
| return (Event) nativeEvent; |
| } |
| |
| /** |
| * Only valid for key events. |
| * Currently only implemented for deleting, not actual navigating. |
| * @return The move unit of this event |
| */ |
| public final MoveUnit getMoveUnit() { |
| if (getKeySignalType() == KeySignalType.DELETE) { |
| if (UserAgent.isMac()) { |
| if (getAltKey()) { |
| // Note: in practice, some combinations of bkspc/delete + modifier key |
| // have no effect. This is inconsistent across browsers. It's probably |
| // ok to normalise it here, as we will be manually implementing everything |
| // except character-sized deletes on collapsed selections, and so users |
| // would get a more consistent (and logical and symmetrical) experience. |
| return MoveUnit.WORD; |
| } else if (getCommandKey()) { |
| return MoveUnit.LINE; |
| } else { |
| return MoveUnit.CHARACTER; |
| } |
| } else { |
| if (getCommandKey()) { |
| return MoveUnit.WORD; |
| } else { |
| return MoveUnit.CHARACTER; |
| } |
| } |
| } else { |
| // TODO(danilatos): Also implement for mere navigation events? |
| // Currently just for deleting... so we'll at least for now just pretend |
| // everything else is of character magnitude. This is because we |
| // probably won't be using the information anyway, instead letting |
| // the browser just do its default navigation behaviour. |
| |
| return MoveUnit.CHARACTER; |
| } |
| } |
| |
| @Override |
| public final boolean isUndoCombo() { |
| return isCombo('Z', KeyModifier.COMMAND); |
| } |
| |
| @Override |
| public final boolean isRedoCombo() { |
| if ((UserAgent.isMac() || UserAgent.isLinux()) && |
| isCombo('Z', KeyModifier.COMMAND_SHIFT)) { |
| // Mac and Linux accept command-shift-z for undo |
| return true; |
| } |
| // NOTE(user): COMMAND + Y for redo, except for Mac OS X (for chrome, |
| // default behaviour is browser history) |
| return !UserAgent.isMac() && isCombo('Y', KeyModifier.COMMAND); |
| } |
| |
| /** |
| * Because we must use keypress events for FF, in order to get repeats, |
| * but prefer keydowns for combo type events for the other browsers, |
| * we need to convert the case here. |
| * |
| * @param letter |
| */ |
| private final int comboInputKeyCode(char letter) { |
| // TODO(danilatos): Check the compiled javascript to make sure it does simple |
| // numerical operations and not string manipulations and conversions... char is |
| // used all over this file |
| return UserAgent.isFirefox() |
| ? letter + 'a' - 'A' |
| : letter; |
| } |
| |
| /** |
| * @param letter Treated case-insensitive, including things like '1' vs '!' |
| * User may provide either, but upper case for letters and unshifted for |
| * other keys is recommended |
| * @param modifier |
| * @return True if the given letter is pressed, and only the given modifiers. |
| */ |
| public final boolean isCombo(int letter, KeyModifier modifier) { |
| assert letter > 0 && letter < shiftMappings.length; |
| int keyCode = getKeyCode(); |
| if (keyCode >= shiftMappings.length) { |
| return false; |
| } |
| |
| return (letter == keyCode || letter == shiftMappings[keyCode]) && modifier.check(this); |
| } |
| |
| /** |
| * @param letter |
| * @return true, if the given letter was pressed without modifiers. Takes into |
| * account the caps lock key being pressed (it will be as if it |
| * weren't pressed) |
| */ |
| public final boolean isOnly(int letter) { |
| return isCombo(letter, KeyModifier.NONE); |
| } |
| |
| @Override |
| public final int getMouseButton() { |
| return nativeEvent.getButton(); |
| } |
| |
| /** |
| * @return The key signal type of this even, or null if it is not a key event |
| * @see SignalEvent.KeySignalType |
| */ |
| public KeySignalType getKeySignalType() { |
| return this.keySignalType; |
| } |
| |
| /** |
| * @return The gwtKeyCode of this event, with some minor compatibility |
| * adjustments |
| */ |
| public int getKeyCode() { |
| return this.cachedKeyCode; |
| } |
| |
| /** |
| * Returns true if the event has effectively had its propagation stopped, since |
| * we couldn't physically stop it due to browser quirkiness. See {@link #stopPropagation()}. |
| */ |
| private boolean hasBeenConsumed() { |
| return hasBeenConsumed; |
| } |
| |
| private void markAsConsumed() { |
| hasBeenConsumed = true; |
| } |
| |
| protected void cacheKeyCode(int keyCode) { |
| this.cachedKeyCode = keyCode; |
| } |
| |
| private boolean stopPropagationPreventsDefault() { |
| if (QuirksConstants.CANCEL_BUBBLING_CANCELS_IME_COMPOSITION_AND_CONTEXTMENU) { |
| return CANCEL_BUBBLE_QUIRKS.contains(getType()); |
| } else { |
| return false; |
| } |
| } |
| |
| private boolean isPreventDefaultEffective() { |
| if (QuirksConstants.PREVENT_DEFAULT_STOPS_CONTEXTMENT) { |
| return true; |
| } else { |
| String type = nativeEvent.getType(); |
| return !type.equals("contextmenu"); |
| } |
| } |
| |
| @Override |
| public final void stopPropagation() { |
| if (stopPropagationPreventsDefault()) { |
| markAsConsumed(); |
| } else { |
| nativeEvent.stopPropagation(); |
| } |
| } |
| |
| protected final void setup(KeySignalType signalType) { |
| this.keySignalType = signalType; |
| } |
| |
| @Override |
| public final void preventDefault() { |
| nativeEvent.preventDefault(); |
| if (!isPreventDefaultEffective()) { |
| // HACK(user): Really we would like the event to continue to propagate |
| // and stop it immediately before reaching the top, rather than at this |
| // point. |
| nativeEvent.stopPropagation(); |
| } |
| } |
| } |