// Copyright 2012 Google Inc. All Rights Reserved. // // 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.ui; import elemental.dom.Element; import elemental.dom.Node; import elemental.events.Event; import elemental.events.EventListener; import elemental.events.EventRemover; import elemental.events.EventTarget; import elemental.events.MouseEvent; import elemental.util.Timer; import com.google.gwt.core.client.GWT; import com.google.gwt.resources.client.ClientBundle; import com.google.gwt.resources.client.CssResource; import org.eclipse.che.ide.ui.menu.AutoHideComponent; import org.eclipse.che.ide.ui.menu.AutoHideView; import org.eclipse.che.ide.ui.menu.PositionController; import org.eclipse.che.ide.util.AnimationController; import org.eclipse.che.ide.util.HoverController; import org.eclipse.che.ide.util.dom.Elements; import org.eclipse.che.ide.util.loging.Log; import java.util.ArrayList; import java.util.List; /** * Represents a single tooltip instance attached to any element, activated by * hovering. */ /* * TODO: oh, my god this thing has become a monster. Might be nice to * get a list of requirements and start from the top... especially if we need * some coach marks as well for the landing page. */ public class Tooltip extends AutoHideComponent<AutoHideView<Void>, AutoHideComponent.AutoHideModel> { private static final Resources RESOURCES = GWT.create(Resources.class); private static final int SHOW_DELAY = 600; private static final int HIDE_DELAY = 600; /** The singleton view instance that all tooltips use. */ private static AutoHideView<Void> tooltipViewInstance; /** The currently active tooltip that is bound to the view. */ private static Tooltip activeTooltip; /** Holds a reference to the css. */ private final Css css; private final List<Element> targetElements; private final Timer showTimer; private final TooltipRenderer renderer; private final PositionController positionController; private final List<EventRemover> eventRemovers; private final PositionController.Positioner positioner; private Element contentElement; private String title; private String maxWidth; private boolean isEnabled = true; private boolean isShowDelayDisabled; static { RESOURCES.tooltipCss().ensureInjected(); } private Tooltip(AutoHideView<Void> view, Resources res, List<Element> targetElements, PositionController.Positioner positioner, TooltipRenderer renderer, boolean shouldShowOnHover) { super(view, new AutoHideModel()); this.positioner = positioner; this.renderer = renderer; this.css = res.tooltipCss(); this.targetElements = targetElements; this.eventRemovers = shouldShowOnHover ? attachToTargetElement() : new ArrayList<EventRemover>(); getView().setAnimationController(AnimationController.FADE_ANIMATION_CONTROLLER); positionController = new PositionController(positioner, getView().getElement()); showTimer = new Timer() { @Override public void run() { show(); } }; setDelay(HIDE_DELAY); setCaptureOutsideClickOnClose(false); getHoverController().setHoverListener(new HoverController.HoverListener() { @Override public void onHover() { if (isEnabled && !isShowing()) { deferredShow(); } } }); } /** Static factory method for creating a simple tooltip. */ public static Tooltip create(Element targetElement, PositionController.VerticalAlign vAlign, PositionController.HorizontalAlign hAlign, String... tooltipText) { return new Builder(targetElement, new TooltipPositionerBuilder().setVerticalAlign(vAlign) .setHorizontalAlign(hAlign) .buildAnchorPositioner(targetElement)).setTooltipRenderer( new SimpleStringRenderer(tooltipText)).build(); } /** Static factory method for creating a simple tooltip with given element as content. */ public static Tooltip create(Element targetElement, PositionController.VerticalAlign vAlign, PositionController.HorizontalAlign hAlign, final Element tooltipContent) { return new Builder(targetElement, new TooltipPositionerBuilder().setVerticalAlign(vAlign) .setHorizontalAlign(hAlign) .buildAnchorPositioner(targetElement)).setTooltipRenderer( new TooltipRenderer() { @Override public Element renderDom() { return tooltipContent; } } ).build(); } /** The Tooltip is a flyweight that uses a singleton View base element. */ private static AutoHideView<Void> getViewInstance(Css css) { if (tooltipViewInstance == null) { tooltipViewInstance = new AutoHideView<Void>(Elements.createDivElement()); Elements.addClassName(css.tooltipPosition(), tooltipViewInstance.getElement()); } return tooltipViewInstance; } @Override public void show() { // Nothing to do if it is showing. if (isShowing()) { return; } /* * Hide the old Tooltip. This will not actually hide the View because we set * activeTooltip to null. */ if (activeTooltip != null) { activeTooltip.hide(); } ensureContent(); // Bind to the singleton view. getView().getElement().setInnerHTML(""); getView().getElement().appendChild(contentElement); positionController.updateElementPosition(); activeTooltip = this; super.show(); } @Override public void forceHide() { super.forceHide(); activeTooltip = null; } @Override protected void hideView() { // If another tooltip is being shown, do not hide the shared view. if (activeTooltip == this) { super.hideView(); } } public void setTitle(String title) { this.title = title; } public void setMaxWidth(String maxWidth) { this.maxWidth = maxWidth; // Update the content element if it is already created. if (contentElement != null) { if (maxWidth == null) { contentElement.getStyle().removeProperty("max-width"); } else { contentElement.getStyle().setProperty("max-width", maxWidth); } } } /** * Enables or disables the show delay. If disabled, the tooltip will appear * instantly on hover. Defaults to enabled. * * @param isDisabled * true to disable the show delay */ public void setShowDelayDisabled(boolean isDisabled) { this.isShowDelayDisabled = isDisabled; } /** Enable or disable this tooltip */ public void setEnabled(boolean isEnabled) { this.isEnabled = isEnabled; } private void setPositionStyle() { PositionController.HorizontalAlign hAlign = positioner.getHorizontalAlignment(); switch (positioner.getVerticalAlignment()) { case TOP: Elements.addClassName(css.tooltipAbove(), contentElement); break; case BOTTOM: if (hAlign == PositionController.HorizontalAlign.RIGHT) { Elements.addClassName(css.tooltipBelowRightAligned(), contentElement); } else { Elements.addClassName(css.tooltipBelow(), contentElement); } break; case MIDDLE: if (hAlign == PositionController.HorizontalAlign.LEFT) { Elements.addClassName(css.tooltipLeft(), contentElement); } else if (hAlign == PositionController.HorizontalAlign.RIGHT) { Elements.addClassName(css.tooltipRight(), contentElement); } break; } } /** * Adds event handlers to the target element for the tooltip to show it on * hover, and update position on mouse move. */ private List<EventRemover> attachToTargetElement() { List<EventRemover> removers = new ArrayList<>(); for (int i = 0; i < targetElements.size(); i++) { final Element targetElement = targetElements.get(i); addPartner(targetElement); removers.add(targetElement.addEventListener(Event.MOUSEOUT, new EventListener() { @Override public void handleEvent(Event evt) { MouseEvent mouseEvt = (MouseEvent)evt; EventTarget relatedTarget = mouseEvt.getRelatedTarget(); // Ignore the event unless we mouse completely out of the target element. if (relatedTarget == null || !targetElement.contains((Node)relatedTarget)) { cancelPendingShow(); } } }, false)); removers.add(targetElement.addEventListener(Event.MOUSEDOWN, new EventListener() { @Override public void handleEvent(Event evt) { cancelPendingShow(); hide(); } }, false)); } return removers; } /** Removes event handlers from the target element for the tooltip. */ private void detachFromTargetElement() { for (int i = 0; i < targetElements.size(); i++) { removePartner(targetElements.get(i)); } for (int i = 0, n = eventRemovers.size(); i < n; ++i) { eventRemovers.get(i).remove(); } eventRemovers.clear(); } /** * Creates the dom for this tooltip's content. * <p/> * <code> * <div class="tooltipPosition"> * <div class="tooltip tooltipAbove/Below/Left/Right"> * tooltipText * <div class="tooltipTriangle"></div> * </div> * </div> * </code> */ private void ensureContent() { if (contentElement == null) { contentElement = renderer.renderDom(); if (contentElement == null) { // Guard against malformed renderers. Log.warn(getClass(), "Renderer for tooltip returned a null content element"); contentElement = Elements.createDivElement(); contentElement.setTextContent("An empty Tooltip!"); } if (title != null) { // Insert a title if one is set. Element titleElem = Elements.createElement("b"); titleElem.setTextContent(title); Element breakElem = Elements.createBRElement(); contentElement.insertBefore(breakElem, contentElement.getFirstChild()); contentElement.insertBefore(titleElem, contentElement.getFirstChild()); } // Set the maximum width. setMaxWidth(maxWidth); Elements.addClassName(css.tooltip(), contentElement); Element triangle = Elements.createDivElement(css.triangle()); contentElement.appendChild(triangle); setPositionStyle(); } } public void destroy() { showTimer.cancel(); forceHide(); detachFromTargetElement(); } private void deferredShow() { if (isShowDelayDisabled || activeTooltip != null) { /* * If there is already a tooltip showing and the user mouses over an item * that has it's own tooltip, move the tooltip immediately. We don't want * to leave a lingering tooltip on the old item. */ showTimer.cancel(); showTimer.run(); } else { showTimer.schedule(SHOW_DELAY); } } private void cancelPendingShow() { showTimer.cancel(); } /** Interface for specifying an arbitrary renderer for tooltips. */ public interface TooltipRenderer { Element renderDom(); } public interface Css extends CssResource { String tooltipPosition(); String tooltip(); String triangle(); String tooltipAbove(); String tooltipRight(); String tooltipBelow(); String tooltipLeft(); String tooltipBelowRightAligned(); } public interface Resources extends ClientBundle { @Source({"org/eclipse/che/ide/ui/constants.css", "Tooltip.css", "org/eclipse/che/ide/api/ui/style.css"}) Css tooltipCss(); } /** A builder used to construct a new Tooltip. */ public static class Builder { private final Resources res; private final List<Element> targetElements; private final PositionController.Positioner positioner; private boolean shouldShowOnHover = true; private TooltipRenderer renderer; /** @see TooltipPositionerBuilder */ public Builder(Element targetElement, PositionController.Positioner positioner) { this.res = RESOURCES; this.positioner = positioner; this.targetElements = new ArrayList<>(); this.targetElements.add(targetElement); } /** * Adds additional target elements. If the user hovers over any of the target elements, the * tooltip will appear. */ public Builder addTargetElements(Element... additionalTargets) { for (int i = 0; i < additionalTargets.length; i++) { targetElements.add(additionalTargets[i]); } return this; } /** * Sets the tooltip text. Each item in the array appears on a new line. This * method overwrites the tooltip renderer. */ public Builder setTooltipText(String... tooltipText) { return setTooltipRenderer(new SimpleStringRenderer(tooltipText)); } public Builder setTooltipRenderer(TooltipRenderer renderer) { this.renderer = renderer; return this; } /** If false, will prevent the tooltip from automatically showing on hover. */ public Builder setShouldListenToHover(boolean shouldShowOnHover) { this.shouldShowOnHover = shouldShowOnHover; return this; } public Tooltip build() { return new Tooltip(getViewInstance(res.tooltipCss()), res, targetElements, positioner, renderer, shouldShowOnHover); } } /** * A {@link org.eclipse.che.ide.ui.menu.PositionController.PositionerBuilder} which uses some more convenient defaults for tooltips. This * builder * defaults to {@link org.eclipse.che.ide.ui.menu.PositionController.VerticalAlign#BOTTOM} {@link * org.eclipse.che.ide.ui.menu.PositionController.HorizontalAlign#MIDDLE} and * {@link org.eclipse.che.ide.ui.menu.PositionController.Position#NO_OVERLAP}. */ public static class TooltipPositionerBuilder extends PositionController.PositionerBuilder { public TooltipPositionerBuilder() { setVerticalAlign(PositionController.VerticalAlign.BOTTOM); setHorizontalAlign(PositionController.HorizontalAlign.MIDDLE); setPosition(PositionController.Position.NO_OVERLAP); } } /** Default renderer that simply renders the tooltip text with no other DOM. */ private static class SimpleStringRenderer implements TooltipRenderer { private final String[] tooltipText; SimpleStringRenderer(String... tooltipText) { this.tooltipText = tooltipText; } @Override public Element renderDom() { Element content = Elements.createDivElement(); int i = 0; for (String p : tooltipText) { content.appendChild(Elements.createTextNode(p)); if (i < tooltipText.length - 1) { content.appendChild(Elements.createBRElement()); } i++; } return content; } } }