/** * 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.sugg; import com.google.gwt.dom.client.Element; import com.google.gwt.dom.client.Style; import com.google.gwt.dom.client.Style.Unit; import com.google.gwt.dom.client.Style.Visibility; import com.google.gwt.user.client.Command; import com.google.gwt.user.client.ui.impl.FocusImpl; import org.waveprotocol.wave.client.common.util.LinkedPruningSequenceMap; import org.waveprotocol.wave.client.common.util.PruningSequenceMap; import org.waveprotocol.wave.client.common.util.SequenceElement; import org.waveprotocol.wave.client.editor.EditorStaticDeps; import org.waveprotocol.wave.client.editor.content.ContentElement; import org.waveprotocol.wave.client.editor.content.ContentNode; import org.waveprotocol.wave.client.editor.selection.content.SelectionHelper; 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.client.widget.popup.PopupEventListener; import org.waveprotocol.wave.client.widget.popup.PopupEventSourcer; import org.waveprotocol.wave.client.widget.popup.RelativePopupPositioner; import org.waveprotocol.wave.client.widget.popup.UniversalPopup; import org.waveprotocol.wave.model.document.util.FocusedRange; import org.waveprotocol.wave.model.document.util.Point; /** * Interactive implementation with real UI. * * TODO(user): Add a unit test for this class. * * @author danilatos@google.com (Daniel Danilatos) */ public class InteractiveSuggestionsManager implements SuggestionsManager, RelativePopupPositioner, PopupEventListener { /** * Handles events fired by SuggestionMenu */ public interface SuggestionMenuHandler { void handleItemSelected(); void handleLeftRight(boolean b); void beforeItemClicked(); void handleMouseOut(); void handleMouseOver(); } private final SuggestionMenuHandler handler = new SuggestionMenuHandler() { @Override public void handleItemSelected() { // NOTE(user): The previous behaviour here moves to the next item, but we // don't want to do that as it disrupts the user flow. Perhaps enable it // for rare cases. // moveToNextItem(); popupCloser.closeImmediately(); } /** * Resets current to the next (or previous, if isRight is false) element. * Wraps to the beginning (or end), unless there is only one element in the * sequence, in which case current is set to null. */ @Override public void handleLeftRight(boolean isRight) { SequenceElement<HasSuggestions> newCurrent = isRight ? current.getNext() : current.getPrev(); if (newCurrent == current) { newCurrent = null; } setCurrent(newCurrent); } @Override public void beforeItemClicked() { if (savedSelection != null) { try { selectionHelper.setSelectionRange(savedSelection); } finally { savedSelection = null; } } } @Override public void handleMouseOut() { popupCloser.scheduleClose(null); } @Override public void handleMouseOver() { popupCloser.cancelScheduledClose(); } }; /** * Manages the scheduling of hiding the popup. The asynchronous logic enables * us to close the menu a short period after the user mouses off the menu, * but to cancel the scheduled close if the mouse returns onto the menu. */ private final class PopupCloser { private final TimerService timerService; private final Scheduler.Task task = new Scheduler.Task() { @Override public void execute() { closeImmediately(); } }; private Command callback; private PopupCloser(TimerService timerService) { this.timerService = timerService; this.callback = null; } private void closeImmediately() { popup.hide(); if (callback != null) { callback.execute(); callback = null; } } private void scheduleClose(Command callback) { this.callback = callback; timerService.scheduleDelayed(task, closeSuggestionMenuDelayMs); } private void cancelScheduledClose() { timerService.cancel(task); callback = null; } } /** Singleton suggestion menu per manager */ private final SuggestionMenu menu = new SuggestionMenu(handler); private final UniversalPopup popup; /** The popup appears relative to this element. */ private Element popupAnchor; // TODO(danilatos): Implement a binary tree implementation instead of LL. private final PruningSequenceMap<ContentNode, HasSuggestions> suggestables = LinkedPruningSequenceMap.<ContentNode, HasSuggestions>create(); private final SelectionHelper selectionHelper; private FocusedRange savedSelection = null; private SequenceElement<HasSuggestions> current = null; private PopupCloser popupCloser = new PopupCloser( new SchedulerTimerService(SchedulerInstance.get())); private final int closeSuggestionMenuDelayMs; /** Constructor */ public InteractiveSuggestionsManager( SelectionHelper selectionHelper, int closeSuggestionMenuDelayMs) { popup = EditorStaticDeps.createPopup(null, this, true, false, menu, this); this.closeSuggestionMenuDelayMs = closeSuggestionMenuDelayMs; this.selectionHelper = selectionHelper; } @Override public void clear() { suggestables.clear(); } @Override public void registerElement(HasSuggestions element) { suggestables.put(element.getSuggestionElement(), element); } @Override public boolean showSuggestionsNearestTo(Point<ContentNode> location) { popupCloser.cancelScheduledClose(); SequenceElement<HasSuggestions> newCurrent = suggestables.findBefore(location.getContainer()); if (newCurrent == null) { // If null, cursor is before the first one, try the first one. newCurrent = suggestables.getFirst(); } else if (!suggestables.isLast(newCurrent)) { // If it's not null and not the last one, we are between "current" and // the next suggestable. see which is closer. newCurrent.getNext(); // TODO(danilatos): // if (pixel distance to next < dist to current) { current = next; } } if (newCurrent == null) { return false; } setCurrent(getFromKeyboard(newCurrent, false)); return newCurrent != null; } @Override public void showSuggestionsFor(HasSuggestions suggestable) { popupCloser.cancelScheduledClose(); ContentElement element = suggestable.getSuggestionElement(); // NOTE(user): If content is not attached, then at the moment, we don't // bring up any suggestions. In the future, we may decide to look for other // suggestions that are sufficiently near. if (element.isContentAttached()) { setCurrent(suggestables.getElement(element)); } } /** * Schedule the closing of the suggestions menu. The closing may not actually * happen if the user mouses onto the menu before it is scheduled to close. */ @Override public void hideSuggestions(Command callback) { popupCloser.scheduleClose(callback); } /** * Logic for setting the current suggestiable, given a seq element. * The flow of these related methods looks like this: * * {@code * showSuggestionsFor --> setCurrent --> showSuggestionsForInner * showSuggestionsNearestTo ^ * ^ | * Outside _| Local Methods _| * } */ private void setCurrent(SequenceElement<HasSuggestions> newCurrent) { //logic for hiding old one boolean alreadyShown = false; if (current != null) { if (newCurrent == null) { popupCloser.closeImmediately(); } else { changeAwayFromCurrent(); } alreadyShown = true; } // logic for setting up and showing new one if (newCurrent != null) { current = newCurrent; HasSuggestions suggestable = current.value(); menu.clearItems(); suggestable.populateSuggestionMenu(menu); suggestable.handleShowSuggestionMenu(); // HACK(danilatos): I had to patch MenuBar to make this method public. // Getting more and more tempting to write own menu class... // Calling this makes the first item in the menu selected by default, // so just pressing enter will choose it. menu.moveSelectionDown(); ContentElement element = suggestable.getSuggestionElement(); popupAnchor = element.getImplNodelet(); // If savedSelection is null, it should be the first time we are showing a popup (not moving // around). So, we save the selection because it becomes null later when we lose focus, // at least in IE. if (savedSelection == null) { savedSelection = selectionHelper.getSelectionRange(); } if (alreadyShown) { popup.move(); } else { popup.show(); } } } private SequenceElement<HasSuggestions> getFromKeyboard( SequenceElement<HasSuggestions> el, boolean rightWardsFirst) { SequenceElement<HasSuggestions> found = getFromKeyboardSpecifiedDirectionOnly(el, rightWardsFirst); if (found == null) { getFromKeyboardSpecifiedDirectionOnly(el, !rightWardsFirst); } return found; } private SequenceElement<HasSuggestions> getFromKeyboardSpecifiedDirectionOnly( SequenceElement<HasSuggestions> el, boolean rightWards) { assert el != null; SequenceElement<HasSuggestions> start = el; // NOTE(user): SequenceElement.getNext() and getPrev() never returns null if // there are any elements in the SequenceMap at all. These methods return null // if the getNext()/getPrev() returns the original element. SequenceElement<HasSuggestions> seen = null; while (true) { // Went through the entire list, didn't find anything we // should show suggestions for. if (el == seen) { el = null; break; } assert el != null : "Sequence element contract does't allow this."; if (el.value().isAccessibleFromKeyboard()) { break; } if (seen == null) { seen = el; } if (rightWards) { el = el.getNext(); } else { el = el.getPrev(); } } return el; } @Override public void onHide(PopupEventSourcer source) { // Restore selection that we lost // TODO(danilatos): Transform this against operations that came in the meantime try { if (savedSelection != null) { selectionHelper.setSelectionRange(savedSelection); } } finally { savedSelection = null; if (current != null) { changeAwayFromCurrent(); current = null; } } } @Override public void onShow(PopupEventSourcer source) { // NOTE(user): Clear selection so that it doesn't get forcibly restored // when applying operations. In Firefox, that would take focus away from the // suggestion menu. selectionHelper.clearSelection(); FocusImpl.getFocusImplForPanel().focus(menu.getElement()); } @Override public void setPopupPositionAndMakeVisible(Element reference, final Element popup) { Style popupStyle = popup.getStyle(); // TODO(danilatos): Do something more intelligent than arbitrary constants (which might be // susceptible to font size changes, etc) popupStyle.setLeft(popupAnchor.getAbsoluteLeft() - popup.getOffsetWidth() + 26, Unit.PX); popupStyle.setTop(popupAnchor.getAbsoluteBottom() + 5, Unit.PX); popupStyle.setVisibility(Visibility.VISIBLE); } private void changeAwayFromCurrent() { current.value().handleHideSuggestionMenu(); } }