/*
* Copyright 2008 Google Inc.
*
* Licensed 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.eclipse.che.ide.util.input;
import org.eclipse.che.ide.collections.Jso;
import org.eclipse.che.ide.util.browser.QuirksConstants;
import org.eclipse.che.ide.util.browser.UserAgent;
import com.google.gwt.dom.client.Element;
import com.google.gwt.user.client.Event;
import java.util.HashSet;
import java.util.Set;
/**
* Attempts to bring sanity to the incredibly complex and inconsistent world of
* browser events, especially with regards to key events.
* <p/>
* 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.
* <p/>
* 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).
* <p/>
* 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!
* <p/>
* 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 SignalKeyLogic.UserAgentType currentUserAgent =
(UserAgent.isWebkit() ? SignalKeyLogic.UserAgentType.WEBKIT : (UserAgent
.isFirefox()
? SignalKeyLogic.UserAgentType.GECKO
: SignalKeyLogic.UserAgentType.IE));
private static final SignalKeyLogic.OperatingSystem currentOs =
(UserAgent.isWin() ? SignalKeyLogic.OperatingSystem.WINDOWS : (UserAgent.isMac()
? SignalKeyLogic.OperatingSystem.MAC
: SignalKeyLogic.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 Set<String> KEY_EVENTS = new HashSet<>();
private static final Set<String> COMPOSITION_EVENTS = new HashSet<>();
private static final Set<String> MOUSE_EVENTS = new HashSet<>();
private static final Set<String> MOUSE_BUTTON_EVENTS = new HashSet<>();
private static final Set<String> MOUSE_BUTTONLESS_EVENTS = new HashSet<>();
private static final Set<String> FOCUS_EVENTS = new HashSet<>();
private static final Set<String> CLIPBOARD_EVENTS = new HashSet<>();
/**
* Events affected by
* {@link QuirksConstants#CANCEL_BUBBLING_CANCELS_IME_COMPOSITION_AND_CONTEXTMENU}.
*/
private static final Set<String> CANCEL_BUBBLE_QUIRKS = new HashSet<>();
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.
* <p/>
* If the event is to be filtered, null is returned, and bubbling
* is cancelled if cancelBubbleIfNullified is true.
* (but the default is not prevented).
* <p/>
* 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
* <p/>
* 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.<Jso>cast().getJavaObjectField(EVENT_PROP);
}
private static <T extends SignalEventImpl> T createFor(SignalEventFactory<T> factory, Event event) {
T signal = factory.create();
event.<Jso>cast().addField(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);
logic.computeKeySignalType(keySignalResult, event.getType(), getNativeKeyCode(event), getWhich(event),
keyIdentifier, 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.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();
}
}
}