/** * 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.dom.client.EventTarget; 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(); EventTarget getEventTarget(); } /** * @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(); private static final String DOM_CHARDATAMOD_EVENT = "DOMCharacterDataModified"; /** * 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 Element.as(nativeEvent.getEventTarget()); } /** * @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(); } } @Override public final boolean isDOMCharacterEvent() { return DOM_CHARDATAMOD_EVENT.equalsIgnoreCase(nativeEvent.getType()); } }