Package org.waveprotocol.wave.client.editor.event

Source Code of org.waveprotocol.wave.client.editor.event.EditorEventHandler

/**
* 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.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;
    }
  }
}
TOP

Related Classes of org.waveprotocol.wave.client.editor.event.EditorEventHandler

TOP
Copyright © 2018 www.massapi.com. All rights reserved.
All source code are property of their respective owners. Java is a trademark of Sun Microsystems, Inc and owned by ORACLE Inc. Contact coftware#gmail.com.