/**
* 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.waveprotocol.wave.client.editor.event;
import com.google.common.annotations.VisibleForTesting;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.Node;
import com.google.gwt.dom.client.Text;
import com.google.gwt.event.dom.client.KeyCodes;
import org.waveprotocol.wave.client.common.util.DomHelper;
import org.waveprotocol.wave.client.common.util.EventWrapper;
import org.waveprotocol.wave.client.common.util.KeyCombo;
import org.waveprotocol.wave.client.common.util.QuirksConstants;
import org.waveprotocol.wave.client.common.util.SignalEvent;
import org.waveprotocol.wave.client.common.util.SignalEvent.KeyModifier;
import org.waveprotocol.wave.client.common.util.SignalEvent.KeySignalType;
import org.waveprotocol.wave.client.common.util.SignalEvent.MoveUnit;
import org.waveprotocol.wave.client.common.util.UserAgent;
import org.waveprotocol.wave.client.debug.logger.LogLevel;
import org.waveprotocol.wave.client.editor.EditorStaticDeps;
import org.waveprotocol.wave.client.editor.constants.BrowserEvents;
import org.waveprotocol.wave.client.editor.content.ContentElement;
import org.waveprotocol.wave.client.editor.content.ContentNode;
import org.waveprotocol.wave.client.editor.content.ContentPoint;
import org.waveprotocol.wave.client.editor.content.FocusedContentRange;
import org.waveprotocol.wave.client.editor.content.NodeEventRouter;
import org.waveprotocol.wave.client.editor.event.CompositionEventHandler.CompositionListener;
import org.waveprotocol.wave.client.scheduler.Scheduler;
import org.waveprotocol.wave.client.scheduler.SchedulerInstance;
import org.waveprotocol.wave.client.scheduler.SchedulerTimerService;
import org.waveprotocol.wave.client.scheduler.TimerService;
import org.waveprotocol.wave.common.logging.LoggerBundle;
import org.waveprotocol.wave.model.document.AnnotationBehaviour.CursorDirection;
import org.waveprotocol.wave.model.document.util.FocusedPointRange;
import org.waveprotocol.wave.model.document.util.Point;
/**
* Central event handler for the editor, encapsulating the core logic for event
* routing and handling. Application specific handling for combos, etc are done via a
* subhandler.
*
* TODO(user): Remove gwt dependencies so that this is junit testable.
*
* @author danilatos@google.com (Daniel Danilatos)
* @author mtsui@google.com (Mark Tsui)
*/
public final class EditorEventHandler {
/**
* States the event handler may be in.
*/
// TODO(danilatos): Consider separating out other states from normal, such as
// TYPING, CLIPBOARD, etc, when we are in these transient states.
enum State {
/** Normal state */
NORMAL,
/** IME composition state */
COMPOSITION
}
/** Reduces the times selection logging is sent to eye3, reporting seems linear with users. */
private static final int SELECTION_LOG_CULL_FACTOR = 100; // 1/100 sent
private static final LoggerBundle logger = EditorStaticDeps.logger;
/**
* Sets whether unsafe key events are cancelled (set to false for testing)
*/
private static boolean cancelUnsafeKeyEvents = true;
private final CompositionListener<EditorEvent> compositionListener =
new CompositionListener<EditorEvent>() {
@Override
public void compositionStart(EditorEvent event) {
EditorEventHandler.this.compositionStart(event);
}
@Override
public void compositionUpdate() {
EditorEventHandler.this.compositionUpdate();
}
@Override
public void compositionEnd() {
EditorStaticDeps.startIgnoreMutations();
try {
EditorEventHandler.this.compositionEnd();
} finally {
EditorStaticDeps.endIgnoreMutations();
}
}
};
private final boolean weirdComposition =
QuirksConstants.MODIFIES_DOM_AND_FIRES_TEXTINPUT_AFTER_COMPOSITION;
private final boolean useCompositionEvents;
/**
* Sets whether we use whitelisting or blacklisting to potentially cancel
* unhandled keycombos.
*/
private final boolean useWhiteListing;
/**
* Current selection. Ensure this is always set correctly, especially
* if it's changed or invalidated.
*/
private FocusedContentRange cachedSelection;
/**
* Interact with the editor through this interface.
*/
private final EditorInteractor editorInteractor;
private final NodeEventRouter router;
/**
* We keep track of whether selection affinity is up to date. When we receive
* an event, we assume that the event will invalidate the selection affinity,
* thus we set selectionAffinityMaybeChanged to true. If we later find out
* that the event does not modify selection affinity, we set
* selectionAffinityMaybeChanged to false.
*
* If at the end of the event loop, selectionAffinityMaybeChanged
*/
private boolean needToSetSelectionAffinity = true;
private boolean selectionAffinityMaybeChanged = true;
/** Tracks whether there was selection at the start of an event handling run. */
private boolean hadInitialSelection;
private State state = State.NORMAL;
/**
* Handler for higher level, application specific event handling.
*/
private final EditorEventsSubHandler subHandler;
private final CompositionEventHandler<EditorEvent> compositionHandler;
/**
* @param editorInteractor
* @param subHandler
*/
public EditorEventHandler(EditorInteractor editorInteractor, EditorEventsSubHandler subHandler,
NodeEventRouter router,
boolean useWhiteListFlag, boolean useWebkitCompositionFlag) {
this(new SchedulerTimerService(SchedulerInstance.get(), Scheduler.Priority.CRITICAL),
editorInteractor, subHandler, router,
useWhiteListFlag,
// We may want to turn off composition events for webkit if something goes wrong...
QuirksConstants.SUPPORTS_COMPOSITION_EVENTS &&
(UserAgent.isWebkit() ? useWebkitCompositionFlag : true));
}
EditorEventHandler(TimerService criticalTimerService, EditorInteractor interactor,
EditorEventsSubHandler subHandler, NodeEventRouter router,
boolean useWhiteListing, boolean useCompositionEvents) {
this.editorInteractor = interactor;
this.subHandler = subHandler;
this.router = router;
this.useWhiteListing = useWhiteListing;
this.compositionHandler = new CompositionEventHandler<EditorEvent>(
criticalTimerService, compositionListener, logger, weirdComposition);
this.useCompositionEvents = useCompositionEvents;
}
/** Visible for testing */
State getState() {
return state;
}
static int selectionLogCullRotation = 0;
/**
* @param signal
* @return true if its handled
*/
public boolean handleEvent(EditorEvent signal) {
if (editorInteractor.notifyListeners(signal)) {
// The listeners themselves can cancel the event if they wish.
return false;
}
// Wraps handleEventInner to update the selectionAffinity variables.
selectionAffinityMaybeChanged = true;
hadInitialSelection = editorInteractor.hasContentSelection();
boolean retVal = true;
try {
retVal = handleEventInner(signal);
} catch (SelectionLostException e) {
if (e.hasLostSelection() &&
(LogLevel.showDebug() || (selectionLogCullRotation++ % SELECTION_LOG_CULL_FACTOR) == 0)) {
EditorStaticDeps.logger.error().log(e);
}
// NOTE(patcoleman): we assume that if there was no selection to start with, that the
// html selection is inside a part with no corresponding content node (e.g. inside doodad
// or textbox). In this case it's not cancelled, so the browser can deal with it.
retVal = e.hasLostSelection();
}
if (selectionAffinityMaybeChanged) {
needToSetSelectionAffinity = true;
}
return retVal;
}
private boolean handleEventInner(EditorEvent event) throws SelectionLostException {
// TODO(danilatos): IE IME keycode thingy!!
invalidateSelection();
// NOTE(patcoleman): special cases FTW!
// 1) click can be while the editor isn't editing, so needs to avoid needing content selection.
if (event.isMouseEvent()) {
// Flush because the selection location may have changed to somewhere
// else in the same text node. We MUST handle mouse down events for
// this.
editorInteractor.forceFlush();
ContentElement node = editorInteractor.findElementWrapper(event.getTarget());
event.setCaret(new ContentPoint(node, null));
if (node != null && event.isClickEvent()) {
router.handleClick(node, event);
editorInteractor.clearCaretAnnotations();
editorInteractor.rebiasSelection(CursorDirection.NEUTRAL);
return !event.shouldAllowBrowserDefault();
} else {
return false;
}
}
// 2) Only update selection if we know it's needed:
if (checkIfValidSelectionNeeded(event)) {
refreshEditorWithCaret(event);
if (cachedSelection == null) {
// disallow events if we don't know where the selection is - probably something's botched
// lars: only in editing mode; otherwise we block, e.g., keyboard manipulation
// of radio buttons
return editorInteractor.isEditing();
}
}
if (weirdComposition && state == State.COMPOSITION) {
if (!event.isCompositionEvent()) {
compositionHandler.handleOtherEvent();
}
}
// Handle:
if (event.isKeyEvent()) {
return handleKeyEvent(event);
} else if (event.isCompositionEvent()) {
if (useCompositionEvents) {
return handleCompositionEvent(event);
} else {
return false;
}
} else if (event.isClipboardEvent()) {
if (event.isPasteEvent()) {
return subHandler.handlePaste(event);
} else if (event.isCutEvent()) {
return subHandler.handleCut(event);
} else if (event.isCopyEvent()) {
return subHandler.handleCopy(event);
} else {
// These are onbeforecopy/onbeforepaste etc.. We are not currently
// interested, and they are harmless so just allow.
return false;
}
} else if (event.isMutationEvent()) {
selectionAffinityMaybeChanged = false;
if (!editorInteractor.isExpectingMutationEvents()) {
if (DomHelper.isTextNode(event.getTarget())) {
cachedSelection = editorInteractor.getSelectionPoints();
if (cachedSelection != null) {
if (!cachedSelection.isCollapsed()) {
logger.trace().logPlainText("WARNING: Probable IME input on non-collapsed " +
"range not handled!!!");
// TODO(dan/patcoleman): Yeargh, IME killing a range!!! Nooo!!!!
// Handle eeet
}
logger.trace().logPlainText("Notifying typing extractor for " +
"probable IME-caused mutation event");
// Nothing to do with the return value of this method, as mutation
// events are not cancellable.
editorInteractor.notifyTypingExtractor(cachedSelection.getFocus(), false, false);
}
}
}
if (QuirksConstants.LIES_ABOUT_CARET_AT_LINK_END_BOUNDARY) {
checkForWebkitEndOfLinkHack(event);
}
subHandler.handleDomMutation(event);
return false;
} else if (event.isFocusEvent()) {
return false;
} else {
// cancel anything we don't know about
logger.trace().log("Cancelling: " + event.getType());
return true;
}
}
void checkForWebkitEndOfLinkHack(SignalEvent signal) {
// If it's inserting text
if (DomHelper.isTextNode(signal.getTarget()) &&
(signal.getType().equals(BrowserEvents.DOMCharacterDataModified) ||
signal.getType().equals(BrowserEvents.DOMNodeInserted))) {
Text textNode = signal.getTarget().cast();
if (textNode.getLength() > 0) {
Node e = textNode.getPreviousSibling();
if (e != null && !DomHelper.isTextNode(e)
&& e.<Element>cast().getTagName().toLowerCase().equals("a")) {
FocusedPointRange<Node> selection = editorInteractor.getHtmlSelection();
if (selection.isCollapsed() && selection.getFocus().getTextOffset() == 0) {
editorInteractor.noteWebkitEndOfLinkHackOccurred(textNode);
}
}
}
}
}
private boolean handleKeyEvent(EditorEvent event) throws SelectionLostException {
KeySignalType keySignalType = event.getKeySignalType();
switch (state) {
case NORMAL:
if (isAccelerator(event)) {
refreshEditorWithCaret(event);
if (subHandler.handleCommand(event)
|| subHandler.handleBlockLevelCommands(event,
cachedSelection.asOrderedRange(editorInteractor.selectionIsOrdered()))) {
return true;
}
if (cachedSelection.isCollapsed()) {
if (subHandler.handleCollapsedKeyCombo(event, cachedSelection.getFocus())) {
return true;
}
} else {
if (subHandler.handleRangeKeyCombo(event,
cachedSelection.asOrderedRange(editorInteractor.selectionIsOrdered()))) {
return true;
}
}
return shouldCancelAcceleratorBrowserDefault(event);
}
switch(keySignalType) {
case INPUT:
case DELETE:
return handleInputOrDeleteKeyEvent(event, keySignalType);
case NAVIGATION:
return handleNavigationKeyEvents(event);
case NOEFFECT:
return false;
}
throw new RuntimeException("Unhandled signal type");
case COMPOSITION:
// NOTE(danilatos): From my investigations, during IME composition, the browser itself
// pretty much disables all the combos. Or, it has its own strange buggy behaviour
// without us doing anything. Therefore, we can pretty much ignore key events during
// composition mode.
return false;
default:
throw new RuntimeException("Unhandled state");
}
}
private boolean handleCompositionEvent(EditorEvent event) {
return compositionHandler.handleCompositionEvent(event, event.getType());
}
private void compositionStart(EditorEvent event) {
if (state == State.COMPOSITION) {
logger.error().log("State was already IME during a compositionstart event!");
}
Point<ContentNode> caret;
if (cachedSelection == null) {
logger.error().log("No selection during a composition start event? Maybe it's " +
"deep inside some doodad's html?");
caret = null;
} else if (cachedSelection.isCollapsed()) {
caret = cachedSelection.getFocus();
} else {
caret = deleteCachedSelectionRangeAndInvalidate(true);
}
state = State.COMPOSITION;
editorInteractor.compositionStart(caret);
}
private void compositionUpdate() {
editorInteractor.compositionUpdate();
}
private void compositionEnd() {
// We update the cached selection because sometimes we'll immediately get called back
// into compositionStart()
cachedSelection = editorInteractor.compositionEnd();
state = State.NORMAL;
}
private boolean handleInputOrDeleteKeyEvent(EditorEvent event, KeySignalType keySignalType)
throws SelectionLostException {
// !!!!!!!!!
// TODO(danilatos): This caret is in the wrong (full) view, and can die when
// applied to mutable doc!!!! Only OK right now out of sheer luck.
// !!!!!!!!!
Point<ContentNode> caret;
boolean isCollapsed = editorInteractor.getHtmlSelection() != null &&
editorInteractor.getHtmlSelection().isCollapsed();
boolean isReplace = false;
if (isCollapsed) {
MoveUnit moveUnit = event.getMoveUnit();
if (moveUnit != MoveUnit.CHARACTER) {
if (event.getMoveUnit() == MoveUnit.WORD) {
if (event.getKeyCode() == KeyCodes.KEY_BACKSPACE) {
refreshEditorWithCaret(event);
caret = cachedSelection.getFocus();
editorInteractor.deleteWordEndingAt(caret);
} else if (event.getKeyCode() == KeyCodes.KEY_DELETE){
refreshEditorWithCaret(event);
caret = cachedSelection.getFocus();
editorInteractor.deleteWordStartingAt(caret);
}
}
// TODO(user): Manually handle line/other etc. deletes, because
// they might contain formatting, etc. For now, cancelling for safety.
return true;
} else {
// HACK(danilatos/patcoleman): We don't want the caret to get set here,
// because it is not safe unless we continually flush the typing extractor
// which is undesirable.
// NOTE #XYZ (this comment referenced from elsewhere)
// To fix this properly, we need to restructure the control flow, and
// possibly change the types of caret we pass around.
caret = null;
}
} else {
refreshEditorWithCaret(event);
// NOTE: at this point, should be either INPUT or DELETE
boolean isDelete = (keySignalType == KeySignalType.DELETE);
if (event.isImeKeyEvent()) {
// Semi-HACK(danilatos): sometimes during composition, the selection will be reported
// as a range. We want to leave this alone, not delete it. Since we're not handling
// ranged deletions with non-FF ime input properly anyway, this will do.
caret = cachedSelection.getFocus();
} else {
caret = deleteCachedSelectionRangeAndInvalidate(!isDelete); // keep annotations on insert
}
if (isDelete) {
return true; // Did a range delete already. Do not go on to typing extractor.
} else {
isReplace = true;
}
}
if (keySignalType == KeySignalType.DELETE) {
refreshEditorWithCaret(event);
caret = cachedSelection.getFocus();
ContentNode node = caret.getContainer();
editorInteractor.checkpoint(new FocusedContentRange(caret));
switch (EventWrapper.getKeyCombo(event)) {
case BACKSPACE:
case SHIFT_BACKSPACE:
editorInteractor.rebiasSelection(CursorDirection.FROM_RIGHT);
return router.handleBackspace(node, event);
case SHIFT_DELETE:
if (!QuirksConstants.HAS_OLD_SCHOOL_CLIPBOARD_SHORTCUTS) {
// On a mac, shift+delete is the same as regular delete.
editorInteractor.rebiasSelection(CursorDirection.FROM_LEFT);
return router.handleDelete(node, event);
} else {
// On windows & linux, shift+delete is cut
// It should have been caught earlier by the isAccelerator check
throw new RuntimeException("Shift delete should have been caught"
+ "as an accelerator event!");
}
case DELETE:
editorInteractor.rebiasSelection(CursorDirection.FROM_LEFT);
return router.handleDelete(node, event);
}
} else if (handleEventsManuallyOnNode(event, caret)){
return true;
}
return handleNormalTyping(event, caret, isReplace);
}
private Point<ContentNode> deleteCachedSelectionRangeAndInvalidate(boolean isReplace) {
// !!!!!!!!!
// TODO(danilatos): This caret is in the wrong (full) view, and can die when
// applied to mutable doc!!!! Only OK right now out of sheer luck.
// !!!!!!!!!
editorInteractor.checkpoint(cachedSelection);
Point<ContentNode> start;
Point<ContentNode> end;
if (editorInteractor.selectionIsOrdered()) {
start = cachedSelection.getAnchor();
end = cachedSelection.getFocus();
} else {
end = cachedSelection.getAnchor();
start = cachedSelection.getFocus();
}
Point<ContentNode> caret = null;
caret = editorInteractor.deleteRange(start, end, isReplace);
setCaret(caret);
assert cachedSelection == null;
return caret;
}
private boolean handleNormalTyping(EditorEvent event, Point<ContentNode> caret, boolean isReplace)
throws SelectionLostException {
// Note that caret may be null if this is called during typing extraction
// Normal typing
selectionAffinityMaybeChanged = false;
// NOTE(danilatos): We can't tell if a key event is IME in firefox, so
// we just always do typing extraction instead.
// Additionally, even for normal key strokes, firefox has strange
// behaviour when handling them programmatically. The cursor appears
// to lag a character behind, and there are selection half-disappearing
// issues when deleting around annotation boundaries.
boolean useTypingExtractor = event.isImeKeyEvent() || UserAgent.isFirefox();
if (useTypingExtractor) {
// Just normal typing. Send to typing extractor.
if (editorInteractor.isTyping()) {
// NOTE(patcoleman): Do not change affinity while normal typing, our affinity should
// remain consistent across normal typing.
logger.trace().log("Not notifying typing extractor, already notified");
} else {
if (UserAgent.isFirefox()) {
// NOTE(user): This is one way of handling the affinity problem.
// The other method is to detect where the selection is, and modify
// the behaviour of typing extractor/document such that when the
// typing is extracted, the formatting applied to the content doc
// matches the html impl.
// TODO(user): This doesn't handle the case for persistent inline
// elements where the browser may automatically place the cursor. We
// don't currently have such elements, but we'll need to consider
// this case in the future.
refreshEditorWithCaret(event);
caret = maybeSetSelectionLeftAffinity(event.getCaret().asPoint());
event.setCaret(ContentPoint.fromPoint(caret));
} else {
// Caret might be null
}
logger.trace().log("Notifying typing extractor");
return editorInteractor.notifyTypingExtractor(caret, caret == null, isReplace);
}
return false;
} else {
char c = (char) event.getKeyCode();
refreshEditorWithCaret(event);
caret = cachedSelection.getFocus(); // Is it safe to delete this line?
caret = editorInteractor.insertText(caret, String.valueOf(c), isReplace);
caret = editorInteractor.normalizePoint(caret);
setCaret(caret);
editorInteractor.rebiasSelection(CursorDirection.FROM_LEFT);
return true;
}
}
private boolean handleEventsManuallyOnNode(EditorEvent event, Point<ContentNode> caret)
throws SelectionLostException {
// Note that caret may be null if this is called during typing extraction
// Always handle enter specially, and always cancel the default action.
// TODO(danilatos): This is still a slight anomaly, to call a
// node.handleXYZ method here.
if (event.isOnly(KeyCodes.KEY_ENTER)) {
refreshEditorWithCaret(event);
caret = event.getCaret().asPoint();
editorInteractor.checkpoint(new FocusedContentRange(caret));
router.handleEnter(caret.getContainer(), event);
editorInteractor.rebiasSelection(CursorDirection.FROM_LEFT);
return true;
} else if (event.isCombo(KeyCodes.KEY_ENTER, KeyModifier.SHIFT)) {
// shift+enter inserts a "newline" (such as a <br/>) by default
// TODO(danilatos): Form elements want to handle this.
return true;
}
return false;
}
private boolean handleNavigationKeyEvents(EditorEvent event) {
editorInteractor.checkpoint(null);
editorInteractor.clearCaretAnnotations();
ContentNode node = cachedSelection.getFocus().getContainer();
logger.trace().log("Navigation event");
// Not using key combo, because we want to handle left key with
// any modifiers also applying.
// TODO(danilatos): MoveUnit, and holding down shift for selection.
if (event.getKeyCode() == KeyCodes.KEY_LEFT) {
router.handleLeft(node, event);
editorInteractor.rebiasSelection(CursorDirection.FROM_RIGHT);
return !event.shouldAllowBrowserDefault();
} else if (event.getKeyCode() == KeyCodes.KEY_RIGHT) {
router.handleRight(node, event);
editorInteractor.rebiasSelection(CursorDirection.FROM_LEFT);
return !event.shouldAllowBrowserDefault();
} else {
editorInteractor.rebiasSelection(CursorDirection.NEUTRAL);
}
return false;
}
private Point<ContentNode> maybeSetSelectionLeftAffinity(Point<ContentNode> caret) {
if (!needToSetSelectionAffinity) {
return caret;
}
needToSetSelectionAffinity = false;
Point<ContentNode> newCaret = editorInteractor.normalizePoint(caret);
if (newCaret != caret) {
editorInteractor.setCaret(newCaret);
}
return newCaret;
}
/**
* Tells us if this key event is an "accelerator" key event.
*
* For lack of a better word, basically this means keys & combos that aren't
* used for basic input, deletion, and navigation. See the implementation
* comments for details.
*
* @param event Must be a key event!
* @return true if this event is an accelerator key sequence.
*/
static boolean isAccelerator(SignalEvent event) {
return isAcceleratorInner(event, UserAgent.isMac(),
QuirksConstants.HAS_OLD_SCHOOL_CLIPBOARD_SHORTCUTS);
}
/**
* Parameterised to allow testing different browser/os permuations
* @param event
* @param isMac
* @param quirksHasOldSchoolClipboardShortcuts
*/
@VisibleForTesting
static boolean isAcceleratorInner(SignalEvent event, boolean isMac,
boolean quirksHasOldSchoolClipboardShortcuts) {
switch (event.getKeySignalType()) {
case INPUT:
// Alt on its own is a simple modifier, like shift, on OSX
boolean maybeAltKey = !isMac && event.getAltKey();
// NOTE(user): Perhaps we should create a registry in
// EditorEventSubHandler of non-metesque like command keys such as TAB.
// For now TAB is our only special case, but we may need to allow
// implementers to define arbitrary keys as accelerators.
return event.getCtrlKey() || event.getMetaKey() || event.getKeyCode() == KeyCodes.KEY_TAB
|| maybeAltKey;
case DELETE:
if (quirksHasOldSchoolClipboardShortcuts &&
event.getKeyCode() == KeyCodes.KEY_DELETE && KeyModifier.SHIFT.check(event)) {
// shift+delete on windows/linux is cut
// (shift+insert and ctrl+insert are other clipboard alternatives,
// but that's handled below).
return true;
} else {
return false;
}
case NAVIGATION:
// All navigation does not count
return false;
case NOEFFECT:
// Random special keys like ESC, F7, TAB, INS, etc count
return true;
}
throw new RuntimeException("Unknown KeySignal type");
}
/**
* @param acceleratorEvent Must be a key event AND isAccelerator(event) == true
* @return whether we should cancel the browser's default action
*/
private boolean shouldCancelAcceleratorBrowserDefault(SignalEvent acceleratorEvent) {
// (more verbose name in argument to remind us of the constraint).
SignalEvent event = acceleratorEvent;
// First, handle non-combo events (here they should only be "NOEFFECT" keys)
// We use blacklisting for these.
// TODO(danilatos/mtsui): Switch to whitelisting as well?
if (KeyModifier.NONE.check(event)) {
if (event.getKeyCode() == EventWrapper.KEY_INSERT) {
// Cancel INSERT to prevent overwrite mode, for now
// (Happens in IE).
return true;
} else {
// Other things like ESC, TAB, function keys, etc are OK.
return cancelUnsafeKeyEvents;
}
}
if (isAllowableCombo(event)) {
// We can safely ignore
logger.trace().log("Allowing event");
return false;
}
if (logger.trace().shouldLog()) {
logger.trace().log("unsafe combo: ", event.getType(), event.getKeyCode());
}
return cancelUnsafeKeyEvents;
}
private boolean isAllowableCombo(SignalEvent sEvent) {
// Detect inconsistency between whitelist and blacklist.
checkBlackWhiteListConsistency(sEvent);
if (isWhiteListedCombo(sEvent)) {
return true;
}
if (useWhiteListing) {
// If we are using whitelisting, disallow all events that didn't pass the
// above check.
return false;
} else {
// TODO(user): Log a sample of these combos to the server, so we can
// analyse these and perhaps add a class of keys to the whitelist. Also
// store this string somewhere so in case an exception is thrown later, it
// can be associated with this event.
if (logger.trace().shouldLog()) {
logger.trace().log("not in whitelist: ", sEvent);
}
// Otherwise return allow events that are not in the blacklist.
return !isBlackListedCombo(sEvent);
}
}
private boolean checkBlackWhiteListConsistency(SignalEvent sEvent) {
boolean isConsistent = !(isWhiteListedCombo(sEvent) && isBlackListedCombo(sEvent));
if (!isConsistent) {
String message =
"Combo both whitelisted and blacklisted! " + sEvent.getKeyCode();
assert false : message;
logger.error().logPlainText(message);
}
return isConsistent;
}
/**
* These key combos can be safely ignored. They don't directly modify the
* editable region, but may perform something useful on the browser so we
* don't want to cancel them. i.e. copy/cut/paste key events.
*
* Combos listed here should be accompanied with a comment stating the reason.
*
* Maintaining this whitelist is quite an effort, but at least we shouldn't
* get the browser blowing up if the user entered some keycombo we don't know
* about.
*
*
* References:
* http://support.mozilla.com/en-US/kb/Keyboard+shortcuts
* http://docs.info.apple.com/article.html?artnum=42951
* http://www.microsoft.com/windows/products/winfamily/ie/quickref.mspx
*
* @return true if it is safe to ignore, or false which will result in further
* handling.
*/
private boolean isWhiteListedCombo(SignalEvent signal) {
KeyCombo keyCombo = EventWrapper.getKeyCombo(signal);
switch (keyCombo) {
// Edit actions:
// Allow cut/copy/paste combos and handle the actual clipboard events
// later.
case ORDER_C: // copy
case ORDER_X: // cut
case ORDER_V: // paste
case ORDER_A: // select all
case ORDER_P: // print
case ORDER_L: // navigate to url box
// Page navigation
// On safari, delete/backspace is normally used to go back as well, but
// of course in the editor we won't allow that.
case META_LEFT: // back
case META_RIGHT: // forward
case META_HOME: // home
case ORDER_O: // open file
case ORDER_R: // reload
case ORDER_SHIFT_R: // reload (override cache)
// Search
case ORDER_F: // find
case ORDER_G: // find again
// tools
case ORDER_D: // bookmark this page
// Window and tabs
case ORDER_N: // new window
case ORDER_T: // new tab
case ORDER_W: // close window
case ORDER_Q: // quit
return true;
default:
}
if (QuirksConstants.HAS_OLD_SCHOOL_CLIPBOARD_SHORTCUTS) {
if (isAlternateClipboardCombo(signal)) {
return true;
}
}
if (UserAgent.isSafari() && UserAgent.isMac()) {
// Navigation events for Mac Safari only.
switch (keyCombo) {
case CTRL_A:
case CTRL_B:
case CTRL_E:
case CTRL_F:
return true;
default:
}
}
return false;
}
private boolean isBlackListedCombo(SignalEvent event) {
KeyCombo keyCombo = EventWrapper.getKeyCombo(event);
switch (keyCombo) {
// Disallow undo
case ORDER_Z:
return true;
}
if (UserAgent.isMac()) {
switch (keyCombo) {
case CTRL_D: // Deletes a character, needs to be handled manually
case CTRL_H: // Deletes a character backwards
case CTRL_K: // Deletes to end of line, needs to be handled manually
return true;
}
if (UserAgent.isFirefox()) {
switch (keyCombo) {
case CTRL_W: // Deletes a word backwards
return true;
case CTRL_U: // Kills line
// NOTE(user): Implement this when Firefox updates their selection API.
return true;
}
}
if (UserAgent.isWebkit()) {
switch (keyCombo) {
case CTRL_O: // Inserts a new line
return true;
}
}
}
if (QuirksConstants.PLAINTEXT_PASTE_DOES_NOT_EMIT_PASTE_EVENT
&& keyCombo == KeyCombo.ORDER_ALT_SHIFT_V) {
return true;
}
return false;
}
private boolean isAlternateClipboardCombo(SignalEvent signal) {
switch (EventWrapper.getKeyCombo(signal)) {
// Edit actions:
// Allow cut/copy/paste combos and handle the actual clipboard events
// later.
case SHIFT_DELETE: // cut (win + linux only)
case CTRL_INSERT: // copy (win + linux only)
case SHIFT_INSERT: // paste (win + linux only)
return true;
default:
return false;
}
}
// If any of these abstract methods return true, we stop processing the signal
// We also prevent default for those named with "handleXYZ" if true is returned
private void setCaret(Point<ContentNode> caret) {
invalidateSelection();
editorInteractor.setCaret(caret);
}
private void invalidateSelection() {
cachedSelection = null;
}
/**
* Flushes the editor, and updates the caret of the event to be the new start of selection.
*/
private void refreshEditorWithCaret(EditorEvent event) throws SelectionLostException {
// NOTE(patcoleman): don't call interactor's flush outside here - it is possible the rest of the
// event states will not be updated correctly.
editorInteractor.forceFlush();
cachedSelection = editorInteractor.getSelectionPoints();
if (cachedSelection != null) {
event.setCaret(ContentPoint.fromPoint(cachedSelection.getFocus()));
} else {
throw new SelectionLostException("Null selection after force flushing editor, "
+ "event = " + event.getType(), hadInitialSelection);
}
}
/**
* A check extracted out, to see whether a particular event requires a valid refreshed selection.
*/
private boolean checkIfValidSelectionNeeded(EditorEvent event) {
if (event.isMutationEvent() || event.isFocusEvent()) {
return false; // mutations or focus don't mutate the document at this stage.
} else if (event.isKeyEvent() && state == State.NORMAL) {
if (event.isImeKeyEvent()) {
return false; // ime typing can be extracted not on firefox
} else if(event.getKeySignalType() == KeySignalType.INPUT) {
return false; // normal typing can be extracted on firefox
}
}
return true;
}
/**
* This may not be always correct, but may be useful when the selection is
* not otherwise available, i.e. when the editor is blurred.
*/
public FocusedContentRange getCachedSelection() {
return cachedSelection;
}
/**
* Sets whether unsafe combos are cancelled.
*/
public static void setCancelUnsafeCombos(boolean shouldCancel) {
cancelUnsafeKeyEvents = shouldCancel;
}
/**
* Gets whether unsafe combos are cancelled.
*/
public static boolean getCancelUnsafeCombos() {
return cancelUnsafeKeyEvents;
}
/**
* Checked exception for finding any places the editor unexpectedly
* has no selection - as this probably indicates a bug.
*/
private static class SelectionLostException extends Exception {
private final boolean lostSelection;
public SelectionLostException(String message, boolean lost) {
super(message + ". Selection was " + (lost ? "" : "not ") + "lost.");
this.lostSelection = lost;
}
public boolean hasLostSelection() {
return lostSelection;
}
}
}