// 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 com.google.collide.client.filehistory;
import com.google.collide.client.AppContext;
import com.google.collide.client.common.BaseResources;
import com.google.collide.client.util.CssUtils;
import com.google.collide.client.util.Elements;
import com.google.collide.client.util.PathUtil;
import com.google.collide.client.util.dom.MouseMovePauseDetector;
import com.google.collide.client.util.dom.eventcapture.MouseCaptureListener;
import com.google.collide.dto.Revision;
import com.google.collide.dto.Revision.RevisionType;
import com.google.collide.json.client.JsoArray;
import com.google.collide.mvp.CompositeView;
import com.google.collide.mvp.UiComponent;
import com.google.gwt.resources.client.CssResource;
import com.google.gwt.resources.client.ImageResource;
import elemental.css.CSSStyleDeclaration;
import elemental.events.Event;
import elemental.events.MouseEvent;
import elemental.html.DivElement;
import elemental.html.StyleElement;
/**
* Representation for the FileHistory timeline widget
*
*/
public class Timeline extends UiComponent<Timeline.View> {
/**
* Static factory method for obtaining an instance of the Timeline.
*/
public static Timeline create(FileHistory fileHistory, AppContext context) {
return new Timeline(fileHistory, fileHistory.getView().timelineView, context);
}
/**
* Style names used by the Timeline.
*/
public interface Css extends CssResource {
String base();
String rangeLine();
String rangeLineWrapper();
String baseLine();
String nodeContainer();
String notice();
}
/**
* CSS and images used by the Timeline.
*/
public interface Resources extends BaseResources.Resources {
@Source("Timeline.css")
Css timelineCss();
@Source("rangeLeft.png")
ImageResource rangeLeft();
@Source("rangeRight.png")
ImageResource rangeRight();
}
/**
* The View for the Timeline.
*/
public static class View extends CompositeView<Void> {
private final Resources res;
private final Css css;
private DivElement baseLine;
private DivElement rangeLine;
private DivElement rangeLineWrapper;
private DivElement nodeContainer;
// Keep range line width and left because in CSS they're stored as Strings
// with unit PCT
private double rangeLineWidth = 100.0;
private double rangeLineLeft = 0.0;
// Base line width for maxNode calculations
private int baseLineWidth = 0;
View(Timeline.Resources res) {
super(Elements.createDivElement(res.timelineCss().base()));
this.res = res;
this.css = res.timelineCss();
// Create DOM and initialize View.
createDom();
}
/**
* Get revision history and create the DOM for the timeline widget.
*/
private void createDom() {
// Instantiate DOM elems.
baseLine = Elements.createDivElement(css.baseLine());
rangeLine = Elements.createDivElement(css.rangeLine());
rangeLineWrapper = Elements.createDivElement(css.rangeLineWrapper());
nodeContainer = Elements.createDivElement(css.nodeContainer());
rangeLineWrapper.appendChild(rangeLine);
getElement().appendChild(baseLine);
getElement().appendChild(rangeLineWrapper);
getElement().appendChild(nodeContainer);
}
/**
* Empty the node container of any previous nodes before we create
* fresh nodes from the getRevisions call
*/
public void emptyNodeContainer() {
nodeContainer.setInnerHTML("");
}
public void setLoading() {
emptyNodeContainer();
toggleTimeline(true);
// Capture length of baseLine for later use before hiding
baseLineWidth = baseLine.getOffsetWidth();
toggleTimeline(false);
}
public void setNotice(String text) {
DivElement notice = Elements.createDivElement(css.notice());
notice.setTextContent(text);
nodeContainer.appendChild(notice);
}
public int getBaseLineWidth() {
return baseLineWidth;
}
public void toggleTimeline(boolean visible) {
CssUtils.setDisplayVisibility(rangeLineWrapper, visible);
CssUtils.setDisplayVisibility(baseLine, visible);
}
/**
* Adjust range line to be between the currentLeftRange and
* currentRightRange. Used for snapping between a specific range of nodes.
*
* @param leftIndex index of the left edge node
* @param rightIndex index of the right edge node
* @param numNodes total number of nodes, used for percentage width calculations
*/
public void adjustRangeLine(int leftIndex, int rightIndex, int numNodes) {
rangeLineWidth = ((rightIndex - leftIndex) * 100.0) / (numNodes - 1);
rangeLineLeft = (leftIndex * 100.0 / (numNodes - 1));
rangeLineWrapper.getStyle().setWidth(rangeLineWidth, CSSStyleDeclaration.Unit.PCT);
rangeLineWrapper.getStyle().setLeft(rangeLineLeft, CSSStyleDeclaration.Unit.PCT);
}
/**
* Adjust the range line during the middle of a drag (NOT strictly between
* two different nodes)
*
* @param widthDelta increase in range line width, in percentage
* @param leftDelta increase in range line left offset, in percentage
*/
public void adjustRangeLineBetween(double widthDelta, double leftDelta) {
rangeLineWidth += widthDelta;
rangeLineLeft += leftDelta;
rangeLineWrapper.getStyle().setWidth(rangeLineWidth, CSSStyleDeclaration.Unit.PCT);
rangeLineWrapper.getStyle().setLeft(rangeLineLeft, CSSStyleDeclaration.Unit.PCT);
}
public void attachDragHandler(MouseCaptureListener mouseCaptureListener) {
rangeLineWrapper.addEventListener(Event.MOUSEDOWN, mouseCaptureListener, false);
}
}
/* Mouse dragging event listener for range Line */
private final MouseCaptureListener mouseCaptureListener = new MouseCaptureListener() {
@Override
protected void onMouseMove(MouseEvent evt) {
mouseMovePauseDetector.handleMouseMove(evt);
if (!dragging) {
onNodeDragStart();
dragging = true;
}
onNodeDragMove(getDeltaX());
}
@Override
protected void onMouseUp(MouseEvent evt) {
onNodeDragEnd();
dragging = false;
}
};
private final MouseMovePauseDetector mouseMovePauseDetector =
new MouseMovePauseDetector(new MouseMovePauseDetector.Callback() {
@Override
public void onMouseMovePaused() {
if (closeEnoughToDot()) {
adjustRangeLine();
setDiffForRevisions();
}
}
});
private boolean dragging = false;
private void onNodeDragStart() {
// Record original x-coordinate
setCurrentDragX(0);
forceCursor("-webkit-grabbing");
setDrag(true);
mouseMovePauseDetector.start();
}
private void onNodeDragMove(int delta) {
if (getDrag()) {
moveRange(delta);
}
}
public void onNodeDragEnd() {
mouseMovePauseDetector.stop();
setDrag(false);
removeCursor();
resetCatchUp();
// Update current range = temp range
resetLeftRange();
resetRightRange();
adjustRangeLine();
setDiffForRevisions();
}
final AppContext context;
FileHistoryApi api;
final FileHistory fileHistory;
PathUtil path;
JsoArray<TimelineNode> nodes;
int numNodes;
// Minimum interval between (used to calculate max number of nodes) in pixels
private final int MIN_NODE_INTERNAL = 70;
// Nodes representing the current left and right sides of the rangeLine
TimelineNode currentLeftRange;
TimelineNode currentRightRange;
// Temporary nodes representing the current left and right sides of the
// range line during the current action (ex. during a drag). These values
// are converted into the currentLeftRange and currentRightRange at the
// end of the drag (see resetLeftRange() and resetRightRange()). We need
// these because the drag offset is calculated from the original rangeLine
// position before a drag.
TimelineNode tempLeftRange;
TimelineNode tempRightRange;
// Current dx dragged so far, needed for snapping calculations
private int currentDragX;
// Amount the mouse needs to catch up to the range line due to snapping
private int catchUp;
private boolean catchUpLeft;
// Whether the current node is draggable (must be an edge node)
private boolean drag;
// Snap-to constants
private static final double SNAP_THRESHOLD = 2.0/3.0;
// Cursor style to force when doing a drag action
private final StyleElement forceDragCursor;
protected Timeline(FileHistory fileHistory, View view, AppContext context) {
super(view);
this.context = context;
this.fileHistory = fileHistory;
this.forceDragCursor = Elements.getDocument().createStyleElement();
this.nodes = JsoArray.create();
view.attachDragHandler(mouseCaptureListener);
}
public void setApi(FileHistoryApi api) {
this.api = api;
}
public void setPath(PathUtil path) {
this.path = path;
}
public void setLoading() {
// Set loading state for timeline
getView().setLoading();
}
void updateNodeTooltips() {
for (int i = 0; i < nodes.size(); i++) {
nodes.get(i).updateTooltipTitle();
}
}
/**
* Return the number of nodes allowed with a minimum node spacing
*/
public int maxNumberOfNodes() {
return (getView().getBaseLineWidth() / MIN_NODE_INTERNAL) + 1;
}
private JsoArray<Revision> removeSyncSource(JsoArray<Revision> revisions) {
JsoArray<Revision> result = JsoArray.create();
for (int i = 0; i < revisions.size(); i++) {
if (revisions.get(i).getRevisionType() != RevisionType.SYNC_SOURCE) {
// For now, we hide SYNC_SOURCE.
result.add(revisions.get(i));
}
}
return result;
}
public void drawNodes(JsoArray<Revision> revisions) {
// Remove any existing nodes
getView().emptyNodeContainer();
nodes.clear();
if (revisions.size() > 1) {
getView().toggleTimeline(true);
// Make file history view default the left side of the diff to the last
// sync point if any.
int leftNodeIndex = -1;
// Draw nodes based on data
numNodes = revisions.size();
for (int i = 0; i < revisions.size(); i++) {
TimelineNode currNode = new TimelineNode(
new TimelineNode.View(context.getResources()), i, revisions.get(i), this);
nodes.add(currNode);
getView().nodeContainer.appendChild(currNode.getView().getElement());
if (revisions.get(i).getRevisionType() == RevisionType.SYNC_SOURCE) {
leftNodeIndex = i;
}
}
// By default, the range goes from the first to last node
TimelineNode leftNode;
if (leftNodeIndex < 0) {
leftNode = nodes.get(0);
} else if (leftNodeIndex == nodes.size() - 1) {
// When leftNode is the same as the right node, move leftNode left.
// This can happen when users sync and this file has NO conflict.
// We have a SYNC_SOURCE and SYNC_MERGED; leftNodeIndex is at
// SYNC_MERGED, which is the last node.
// Since revisions.size() > 1, nodes.size() - 2 is valid.
// To avoid to have left and right diff points to the same revision.
leftNode = nodes.get(nodes.size() - 2);
} else {
leftNode = nodes.get(leftNodeIndex);
}
setActiveRange(leftNode, nodes.get(nodes.size() - 1));
adjustRangeLine();
} else {
api.setUnchangedFile(path);
getView().setNotice("File unchanged.");
}
}
/* Set cursor style */
public void forceCursor(String type) {
forceDragCursor.setTextContent("* { cursor: " + type + " !important; }");
Elements.getBody().appendChild(forceDragCursor);
}
public void removeCursor() {
forceDragCursor.removeFromParent();
}
/* Getter and setter methods for private Timeline fields */
public void setCurrentDragX(int previousDragX) {
this.currentDragX = previousDragX;
}
public int getCurrentDragX() {
return currentDragX;
}
public void setDrag(boolean drag) {
this.drag = drag;
}
public boolean getDrag() {
return drag;
}
public void resetCatchUp() {
catchUp = 0;
}
/**
* If not valid move for left or right, add distance mouse moved to
* catchup to close the gap
* @param dx
*/
public void incrementCatchUp(int dx) {
catchUp += dx;
catchUpLeft = dx < 0;
}
/**
* Update the current drag and catchUp variables to reflect the post autosnap
* state
* @param snap snap threshold in pixels
* @param offset distance we auto-snapped over, need compensate in mouse movements
*/
public void updateSnapVariables(int snap, int offset) {
// Subtract the distance we just "snapped" to
currentDragX += ((currentDragX > 0) ? -snap : snap);
catchUp += ((currentDragX > 0) ? -offset : offset);
// Save which direction you're currently going in. Let changing directions
// be OK.
catchUpLeft = currentDragX > 0;
}
/* Utility methods for snap-to/dragging calculations */
/**
* Return the distance (in pixels) of the distance between two nodes on
* the timeline.
*/
public int intervalInPx() {
return getView().baseLine.getOffsetWidth() / (numNodes - 1);
}
/**
* Return the horizontal delta dragged, as a percent of the length
* of the timeline (because the width of the timeline is recorded
* as a percentage).
*
* @param dx
* @return
*/
public double percentageMoved(int dx) {
return (dx * 100.0) / getView().baseLine.getOffsetWidth();
}
/*
* Reset range methods - encompasses resetting nodes, code, and labels
*/
public void resetLeftRange() {
currentLeftRange = tempLeftRange;
}
public void resetRightRange() {
currentRightRange = tempRightRange;
}
/**
* Adjust rangeline to be between the temp left and right edge nodes.
*/
public void adjustRangeLine() {
getView().adjustRangeLine(tempLeftRange.index, tempRightRange.index, numNodes);
}
// TODO: If moving back and forth really fast, mouse gets out of
// sync with the range line (there's a gap). Maybe this is due to arithmetic
// rounding errors? Investigate and fix.
/**
* Controls the edge of the range line currently being dragged. If it is a
* valid drag, calculates how the rangeline should be moved in terms of width
* changed (percentage) and left offset. Includes auto-snap while dragging if
* dragged more than 2/3 the way to the next node. Also, "catchup" is allocated
* to compensate for the gap between the mouse and the range line after auto-snapping
*
* @param currentNode the node currently being dragged
* @param dx the current drag dx recorded (+ is to the right, - is to the left)
*/
public void moveRangeEdge(TimelineNode currentNode, int dx) {
// Continue recording and calculating snapping as usual if the mouse
// doesn't need to catch up
if (!catchUp(dx)) {
double percentMoved = percentageMoved(dx);
currentDragX += dx;
// Check if dragging left or right edge node
if (currentNode == currentLeftRange && validLeftEdgeMove(percentMoved < 0)) {
// Left: need to set new left and extend width
// Add to left and subtract from width
getView().adjustRangeLineBetween(-percentMoved, percentMoved);
} else if (currentNode == currentRightRange && validRightEdgeMove(percentMoved < 0)) {
// Right: only need to extend width
// Add to width
getView().adjustRangeLineBetween(percentMoved, 0);
} else {
currentDragX -= dx;
incrementCatchUp(dx);
}
int snap = (int) (SNAP_THRESHOLD * intervalInPx());
int offset = (int) ((1 - SNAP_THRESHOLD) * intervalInPx());
// If > snapThreshold away, snapTo the next one
if (currentDragX > snap || -currentDragX > snap) {
snapToDot(currentNode);
adjustRangeLine();
updateSnapVariables(snap, offset);
}
}
}
public void moveRange(int dx) {
if(!catchUp(dx)) {
double percentMoved = percentageMoved(dx);
boolean left = percentMoved < 0;
currentDragX += dx;
if ((left && validLeftMove()) || (!left && validRightMove())) {
// Add percent moved to the left offset, width stays the same
getView().adjustRangeLineBetween(0, percentMoved);
} else {
currentDragX -= dx;
incrementCatchUp(dx);
}
int snap = (int) (SNAP_THRESHOLD * intervalInPx());
int offset = (int) ((1 - SNAP_THRESHOLD) * intervalInPx());
// If > snapThreshold away, snapTo the next one
if (currentDragX > snap || -currentDragX > snap) {
snapToRange();
adjustRangeLine();
updateSnapVariables(snap, offset);
}
}
}
boolean closeEnoughToDot() {
return Math.abs(currentDragX) < Math.min(30, (1 - SNAP_THRESHOLD) * intervalInPx());
}
/**
* Because of auto-snapping, there's an awkward gap between the mouse and
* the edge of the range line. Let the mouse catch up to the next node before
* moving the range line with mouse movements again.
*
* @param dx number of pixels dragged
* @return if the mouse needs to catch up
*/
public boolean catchUp(int dx) {
int error = 1;
boolean goingLeft = dx > 0;
if ((goingLeft && catchUp < -error) || (!goingLeft && catchUp > error)) {
// Reset catchup if decide to move mouse in the opposite direction before
// reaching the next node
if (goingLeft == catchUpLeft) {
catchUp += dx;
} else {
resetCatchUp();
}
// Skip moving the range line, let the mouse catch up to the snapping
return true;
}
return false;
}
/*
* Snap-to methods for dragging the rangeLine
*/
/**
* Snap dragging to the next left or right (depending on which direction you
* are currently dragging) node, if it is a valid drag direction.
*
* @param currentNode node currently being dragged
*/
public void snapToDot(TimelineNode currentNode) {
boolean left = currentDragX < 0;
int passed = !left ? 1 : -1;
// Check if dragging the left or right edge node
if (currentNode == currentLeftRange && validLeftEdgeMove(left)) {
// Set the node we "snapped to" by dragging as the new left
nodes.get(tempLeftRange.index + passed).setTempLeftRange(false);
} else if (currentNode == currentRightRange && validRightEdgeMove(left)) {
// Set the node we "snapped to" by dragging as the new right
nodes.get(tempRightRange.index + passed).setTempRightRange(false);
}
}
public void snapToRange() {
boolean left = currentDragX < 0;
int passed = !left ? 1 : -1;
// Check that dragging is valid depending on the drag direction
if ((left && validLeftMove()) || (!left && validRightMove())) {
// Set new rangeline range
setTempRange(nodes.get(tempLeftRange.index + passed),
nodes.get(tempRightRange.index + passed), false);
}
}
/**
* Set the temporary range left and right edges at the same time. Used
* in snapToRange().
*
* @param nextLeft the next left edge to be set
* @param nextRight the next right edge to be set
*/
private void setTempRange(TimelineNode nextLeft, TimelineNode nextRight, boolean updateDiff) {
TimelineNode oldLeft = tempLeftRange;
TimelineNode oldRight = tempRightRange;
if (oldRight != null && oldLeft != null) {
oldLeft.getView().clearRangeStyles(oldLeft.nodeType);
oldRight.getView().clearRangeStyles(oldRight.nodeType);
}
tempLeftRange = nextLeft;
tempRightRange = nextRight;
nextLeft.getView().addRangeStyles(nextLeft.nodeType, true);
nextRight.getView().addRangeStyles(nextRight.nodeType, false);
if (updateDiff) {
setDiffForRevisions();
}
}
public void setActiveRange(TimelineNode nextLeft, TimelineNode nextRight) {
setTempRange(nextLeft, nextRight, true);
currentLeftRange = nextLeft;
currentRightRange = nextRight;
}
public void setDiffForRevisions() {
fileHistory.changeLeftRevisionTitle(tempLeftRange.getRevisionTitle());
fileHistory.changeRightRevisionTitle(tempRightRange.getRevisionTitle());
api.setFile(path, tempLeftRange.getRevision(), tempRightRange.getRevision());
}
void setDiffFilePaths(String leftFilePath, String rightFilePath) {
if (tempLeftRange != null) {
tempLeftRange.setFilePath(leftFilePath);
fileHistory.changeLeftRevisionTitle(tempLeftRange.getRevisionTitle());
} else {
fileHistory.changeLeftRevisionTitle(leftFilePath);
}
if (tempRightRange != null) {
tempRightRange.setFilePath(rightFilePath);
fileHistory.changeRightRevisionTitle(tempRightRange.getRevisionTitle());
} else {
fileHistory.changeRightRevisionTitle(rightFilePath);
}
}
/**
* Don't allow dragging to the left past the first node
*
* @return if dragging to the left is valid
*/
public boolean validLeftMove() {
return !(currentDragX < 0 && tempLeftRange.index == 0);
}
/**
* Don't allow dragging to the right past the last node
*
* @return if dragging to the right is valid
*/
public boolean validRightMove() {
return !(currentDragX > 0 && tempRightRange.index == numNodes - 1);
}
/**
* Don't allow dragging the left edge right past the first node or
* dragging the left edge to the right if length = 1
*
* @param dragLeft drag direction (true for left, false for right)
* @return if the current direction is a valid direction for the left edge node
*/
public boolean validLeftEdgeMove(boolean dragLeft) {
return (!dragLeft || validLeftMove())
&& !(!dragLeft && currentDragX > 0 && tempRightRange.index - tempLeftRange.index == 1);
}
/**
* Don't allow dragging the right edge right past the last node or
* dragging the right edge to the left if length = 1
*
* @param dragLeft drag direction (true for left, false for right)
* @return if the current direction is a valid direction for the right edge node
*/
public boolean validRightEdgeMove(boolean dragLeft) {
return (dragLeft || validRightMove())
&& !(dragLeft && currentDragX < 0 && tempRightRange.index - tempLeftRange.index == 1);
}
FileHistoryApi getFileHistoryApi() {
return api;
}
}