// -*- mode: java; c-basic-offset: 2; -*-
// Copyright 2009-2011 Google, All Rights reserved
// Copyright 2011-2012 MIT, All rights reserved
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
package com.google.appinventor.client.widgets.dnd;
import com.google.appinventor.client.output.OdeLog;
import com.google.gwt.user.client.Command;
import com.google.gwt.user.client.DOM;
import com.google.gwt.user.client.DeferredCommand;
import com.google.gwt.user.client.Element;
import com.google.gwt.user.client.ui.MouseListener;
import com.google.gwt.user.client.ui.PopupPanel;
import com.google.gwt.user.client.ui.UIObject;
import com.google.gwt.user.client.ui.Widget;
/**
* Provides support for dragging from a {@link DragSource}
* (typically a widget) to a {@link DropTarget}.
*
*/
public final class DragSourceSupport implements MouseListener {
/**
* Interface to functionality provided by the {@link DOM} class.
* Used as a testing seam.
*
*/
// @VisibleForTesting
static interface IDom {
public void setCapture(Element elem);
public void releaseCapture(Element elem);
public void eventPreventDefaultOfCurrentEvent();
public com.google.gwt.dom.client.Element getFromElementOfCurrentEvent();
public com.google.gwt.dom.client.Element getToElementOfCurrentEvent();
}
/**
* Implementation of {@link IDom} that delegates to the real
* {@link DOM} class.
*
*/
private static class RealDom implements IDom {
private static final RealDom INSTANCE = new RealDom();
/**
* Prevent instantiation of static class.
*/
private RealDom() {
// nothing
}
public void setCapture(Element elem) {
DOM.setCapture(elem);
}
public void releaseCapture(Element elem) {
DOM.releaseCapture(elem);
}
public void eventPreventDefaultOfCurrentEvent() {
DOM.eventPreventDefault(DOM.eventGetCurrentEvent());
}
public com.google.gwt.dom.client.Element getFromElementOfCurrentEvent() {
return DOM.eventGetCurrentEvent().getFromElement();
}
public com.google.gwt.dom.client.Element getToElementOfCurrentEvent() {
return DOM.eventGetCurrentEvent().getToElement();
}
}
/**
* This class is used to show a widget while dragging. This could be anything
* from a simple outline to a copy of the {@code DragSource} widget.
*/
private static class DragWidgetPopup extends PopupPanel {
public DragWidgetPopup(Widget w) {
super(true);
setWidget(w);
}
}
/**
* Number of pixels away from the click-point that a drag-source must be
* dragged to initiate a drag action.
*/
// @VisibleForTesting
static final int DRAG_THRESHOLD = 5;
// Provider of the drag widget and the set of permissible drop targets
private final DragSource dragSource;
// DOM implementation
private final IDom dom;
// Location (in the drag-widget coordinate system) where the last mouse-down originated.
// When a drag is in progress, this is the origin of the click that initiated the drag.
private int startX;
private int startY;
private boolean captured;
private boolean mouseIsDown;
private boolean dragInProgress;
// Location (in the drag-widget coordinate system) where the last mouse-move originated
// while the mouse button was down.
private int dragX;
private int dragY;
// Array of widgets that the drag source widget can be dropped on
private DropTarget[] dropTargets;
// Popup containing the widget being shown while dragging
private DragWidgetPopup dragWidgetPopup;
// The drop target that the cursor is hovering over currently
private DropTarget hoverDropTarget;
/**
* Creates a new instance of this class to provide support for dragging
* from the specified drag source to any of the drop targets that it defines.
* <p>
* After creation, the caller must add this {@link DragSourceSupport} as
* a {@link MouseListener} to whatever actual {@link UIObject} will
* receive drag gestures.
*/
public DragSourceSupport(DragSource dragSource) {
this(dragSource, RealDom.INSTANCE);
}
// @VisibleForTesting
DragSourceSupport(DragSource dragSource, IDom dom) {
this.dragSource = dragSource;
this.dom = dom;
startX = -1;
startY = -1;
mouseIsDown = false;
dragInProgress = false;
dragX = -1;
dragY = -1;
dropTargets = null;
dragWidgetPopup = null;
hoverDropTarget = null;
}
// Private utility methods
/**
* Clears any existing selections in the browser.
* <p>
* While we are normally trying to avoid falling back to using embedded Javascript, it seems
* that this cannot currently be done using the GWT APIs.
*/
private static native void clearSelections() /*-{
try {
if ($doc.selection && $doc.selection.empty) {
$doc.selection.empty();
} else if ($wnd.getSelection) {
var sel = $wnd.getSelection();
if (sel) {
if (sel.removeAllRanges) {
sel.removeAllRanges();
}
if (sel.collapse) {
sel.collapse();
}
}
}
} catch (ignore) {
// Well, we tried...
}
}-*/;
/**
* Returns whether the specified widget contains a position given
* by the absolute coordinates.
*
* @param w widget to test
* @param absX absolute x coordinate of position
* @param absY absolute y coordinate of position
* @return {@code true} if the position is within the widget, {@code false}
* otherwise
*/
private static boolean isInside(Widget w, int absX, int absY) {
int wx = w.getAbsoluteLeft();
int wy = w.getAbsoluteTop();
int ww = w.getOffsetWidth();
int wh = w.getOffsetHeight();
return (wx <= absX) && (absX < wx + ww) && (wy <= absY) && (absY < wy + wh);
}
// Drag-widget positioning
/**
* Configures the specified drag-widget (that will be returned by
* {@link DragSource#createDragWidget(int, int)}) so that the cursor's hot spot
* will appear at the point (x,y) in the widget's coordinate system.
*/
public static void configureDragWidgetToAppearWithCursorAt(Widget w, int x, int y) {
Element e = w.getElement();
DOM.setStyleAttribute(e, "position", "absolute");
DOM.setStyleAttribute(e, "left", -x + "px");
DOM.setStyleAttribute(e, "top", -y + "px");
}
/**
* Returns the x-coordinate where the cursor appears in the specified
* drag-widget's coordinate system.
*/
private static int getDragWidgetOffsetX(Widget w) {
return -parsePixelValue(DOM.getStyleAttribute(w.getElement(), "left"));
}
/**
* Returns the y-coordinate where the cursor appears in the specified
* drag-widget's coordinate system.
*/
private static int getDragWidgetOffsetY(Widget w) {
return -parsePixelValue(DOM.getStyleAttribute(w.getElement(), "top"));
}
private static int parsePixelValue(String pixelValueStr) {
if ((pixelValueStr != null) && pixelValueStr.endsWith("px")) {
try {
return Integer.parseInt(pixelValueStr.substring(0, pixelValueStr.length() - "px".length()));
} catch (NumberFormatException e) {
return 0;
}
} else {
return 0;
}
}
// MouseListener implementation
@Override
public void onMouseDown(Widget sender, int x, int y) {
if (mouseIsDown) {
OdeLog.wlog("received onMouseDown event when we thought the mouse was already down");
}
mouseIsDown = true;
startX = x;
startY = y;
if (!captured) {
// Force browser to keep sending us events until the mouse is released
dom.setCapture(sender.getElement());
captured = true;
}
// Prevent default actions like image-dragging and text selections from being triggered
dom.eventPreventDefaultOfCurrentEvent();
// TODO(user): Consider removing this, since it seems to have
// less effect (at least on Firefox 2) than the line above,
// is more complex, and is browser-dependent.
DeferredCommand.addCommand(new Command() {
@Override
public void execute() {
clearSelections();
}
});
}
// NOTE: At least in Firefox 2, if the user drags outside of the browser window,
// mouse-move (and even mouse-down) events will not be received until
// the user drags back inside the window. A workaround for this issue
// exists in the implementation for onMouseLeave().
@Override
public void onMouseMove(Widget sender, int x, int y) {
if (mouseIsDown) {
dragX = x;
dragY = y;
if (dragInProgress) {
onDragContinue(sender, x, y);
} else {
dragInProgress = (manhattanDist(x, y, startX, startY) >= DRAG_THRESHOLD);
if (dragInProgress) {
onDragStart(sender, x, y);
// Check whether we are already hovering over a potential drop target
onDragContinue(sender, x, y);
}
}
// Prevent default actions from being triggered
dom.eventPreventDefaultOfCurrentEvent();
}
}
@Override
public void onMouseUp(Widget sender, int x, int y) {
if (!mouseIsDown) {
OdeLog.wlog("received onMouseUp event when we thought the mouse was already up");
}
mouseIsDown = false;
if (captured) {
// Allow other elements to receive events after the drag/click
dom.releaseCapture(sender.getElement());
captured = false;
}
if (dragInProgress) {
onDragEnd(sender, x, y);
}
startX = -1;
startY = -1;
dragInProgress = false;
// Prevent default actions from being triggered
dom.eventPreventDefaultOfCurrentEvent();
}
@Override
public void onMouseEnter(Widget sender) {
if (dragInProgress) {
// Firefox 2 specific. IE6 does not need this.
if (dom.getFromElementOfCurrentEvent() == getDragWidget().getElement() &&
isRootHtmlElement(dom.getToElementOfCurrentEvent())) {
// The user moved the mouse outside the browser window.
//
// Simulate a mouse-moved event to a position offscreen,
// since this is not done automatically in Firefox 2.
onMouseMove(sender,
/*localX*/ (/*absX*/ -1) - sender.getAbsoluteLeft(),
/*localY*/ (/*absY*/ -1) - sender.getAbsoluteTop());
return;
}
}
}
@Override
public void onMouseLeave(Widget sender) {
if (dragInProgress) {
// Firefox 2 specific. IE6 does not need this.
if (isRootHtmlElement(dom.getFromElementOfCurrentEvent()) &&
dom.getToElementOfCurrentEvent() == null) {
// The user released the mouse button while
// the mouse was outside the browser window.
//
// Simulate a mouse-release event, since this
// is not done automatically in Firefox 2.
onMouseUp(sender, dragX, dragY);
return;
}
}
}
private static int manhattanDist(int x1, int y1, int x2, int y2) {
return Math.abs(x1 - x2) + Math.abs(y1 - y2);
}
/**
* Returns whether the specified element is the root HTML element of the web page.
*/
private static boolean isRootHtmlElement(com.google.gwt.dom.client.Element element) {
return "html".equalsIgnoreCase(element.getTagName());
}
/**
* Returns the drag widget created by the last call to
* {@link DragSource#createDragWidget(int, int)}.
*/
public Widget getDragWidget() {
return dragWidgetPopup.getWidget();
}
// Drag handling
private void onDragStart(Widget sender, int x, int y) {
// Notify drag source of the drag starting
dragSource.onDragStart();
// Cache the set of permissible drop targets
dropTargets = dragSource.getDropTargets();
// Show drag proxy widget
dragWidgetPopup = new DragWidgetPopup(dragSource.createDragWidget(startX, startY));
dragWidgetPopup.setPopupPosition(
/*absX*/ x + sender.getAbsoluteLeft(),
/*absY*/ y + sender.getAbsoluteTop());
dragWidgetPopup.show();
// Initialize hover state
hoverDropTarget = null;
}
private void onDragContinue(Widget sender, int x, int y) {
int absX = x + sender.getAbsoluteLeft();
int absY = y + sender.getAbsoluteTop();
// Move drag proxy to new position
dragWidgetPopup.setPopupPosition(absX, absY);
// Find drop target that the cursor is currently hovering over
for (DropTarget target : dropTargets) {
Widget targetWidget = target.getDropTargetWidget();
if (target == sender) {
// can't drop onto self - only an issue if sender is a container
continue;
}
boolean isInsideTargetWidget = isInside(targetWidget, absX, absY);
if (target == hoverDropTarget) {
if (isInsideTargetWidget) {
// The last identified drop-target "captures" the attention
// of the drag and drop system while the user is still dragging
// within its bounds and no other contained drop target accepts the drag
break;
} else {
// Drag has left the bounds of the current hover-target
hoverDropTarget.onDragLeave(dragSource);
hoverDropTarget = null;
// Continue searching for enclosing and non-intersecting
// drop targets to accept the current drag
continue;
}
}
if (isInsideTargetWidget) {
int localX = absX - targetWidget.getAbsoluteLeft();
int localY = absY - targetWidget.getAbsoluteTop();
if (target.onDragEnter(dragSource, localX, localY)) {
if (hoverDropTarget != null) {
// Drag exits the old hover-target because it has entered
// the bounds of an accepting drop target that is within
// the bounds of the old hover-target
hoverDropTarget.onDragLeave(dragSource);
}
// Drag accepted; current target becomes the new hover-target
hoverDropTarget = target;
// The guaranteed onDragContinue() event that follows all invocations
// of onDragEnter() that accept the drag is fired later in this method
break;
}
}
}
// Inform the hover-target of the continuing drag
if (hoverDropTarget != null) {
Widget targetWidget = hoverDropTarget.getDropTargetWidget();
hoverDropTarget.onDragContinue(dragSource,
/*localX*/ absX - targetWidget.getAbsoluteLeft(),
/*localY*/ absY - targetWidget.getAbsoluteTop());
}
}
private void onDragEnd(Widget sender, int x, int y) {
// Make sure the current hover-target is still valid,
// and send the guaranteed onDragContinue() prior to onDrop()
onDragContinue(sender, x, y);
// Hide drag widget popup
dragWidgetPopup.hide();
// Inform the hover-target of the drop
if (hoverDropTarget != null) {
Widget targetWidget = hoverDropTarget.getDropTargetWidget();
Widget dragWidget = getDragWidget();
hoverDropTarget.onDrop(dragSource,
/*localX*/ (/*absX*/ x + sender.getAbsoluteLeft()) - targetWidget.getAbsoluteLeft(),
/*localY*/ (/*absY*/ y + sender.getAbsoluteTop()) - targetWidget.getAbsoluteTop(),
getDragWidgetOffsetX(dragWidget),
getDragWidgetOffsetY(dragWidget));
}
// Notify drag source of the drag end
dragSource.onDragEnd();
// Clean up
dropTargets = null;
dragWidgetPopup = null;
hoverDropTarget = null;
}
}