// 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.ClientConfig;
import com.google.collide.client.ui.menu.PositionController.HorizontalAlign;
import com.google.collide.client.ui.menu.PositionController.Position;
import com.google.collide.client.ui.menu.PositionController.Positioner;
import com.google.collide.client.ui.menu.PositionController.VerticalAlign;
import com.google.collide.client.ui.tooltip.Tooltip;
import com.google.collide.client.util.Elements;
import com.google.collide.client.util.dom.MouseMovePauseDetector;
import com.google.collide.client.util.dom.eventcapture.MouseCaptureListener;
import com.google.collide.clientlibs.model.Workspace;
import com.google.collide.dto.Revision;
import com.google.collide.dto.Revision.RevisionType;
import com.google.collide.mvp.CompositeView;
import com.google.collide.mvp.UiComponent;
import com.google.common.collect.Lists;
import com.google.gwt.i18n.shared.DateTimeFormat;
import com.google.gwt.i18n.shared.DateTimeFormat.PredefinedFormat;
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.EventListener;
import elemental.events.MouseEvent;
import elemental.html.DivElement;
import java.util.Date;
import java.util.List;
/**
* Representation of a timeline node ("dot") on the timeline widget, and its
* tooltip with node information
*/
public class TimelineNode extends UiComponent<TimelineNode.View> {
/**
* Static factory method for obtaining an instance of the TimelineNode.
*/
public static TimelineNode create(
TimelineNode.View view, int index, Revision revision, Timeline timeline) {
return new TimelineNode(view, index, revision, timeline);
}
/**
* Style names used by the TimelineNode.
*/
public interface Css extends CssResource {
String base();
String currentLeft();
String currentRight();
String nodeWrapper();
String largeNodeWrapper();
String node();
String nodeRange();
String nodeBranch();
String nodeBranchRange();
String nodeSync();
String nodeSyncRange();
String nodeIndicator();
String conflictIcon();
String conflictResolvedIcon();
// TODO: add deleted icon.
String label();
}
/**
* CSS and images used by the TimelineNode.
*/
public interface Resources extends Tooltip.Resources {
// Regular Node
@Source("node.png")
ImageResource node();
@Source("conflictIcon.png")
ImageResource conflictIcon();
@Source("conflictResolvedIcon.png")
ImageResource conflictResolvedIcon();
@Source("nodeHover.png")
ImageResource nodeHover();
@Source("nodeRange.png")
ImageResource nodeRange();
// Branch Node
@Source("nodeBranch.png")
ImageResource nodeBranch();
@Source("nodeBranchHover.png")
ImageResource nodeBranchHover();
@Source("nodeBranchRange.png")
ImageResource nodeBranchRange();
// Sync Node
@Source("nodeSync.png")
ImageResource nodeSync();
@Source("nodeSyncHover.png")
ImageResource nodeSyncHover();
@Source("nodeSyncRange.png")
ImageResource nodeSyncRange();
// Current Node
@Source("nodeCurrent.png")
ImageResource nodeCurrent();
@Source("clear.png")
ImageResource clear();
@Source("TimelineNode.css")
Css timelineNodeCss();
}
/**
* The View for the TimelineNode.
*/
public static class View extends CompositeView<ViewEvents> {
private final Resources res;
private final Css css;
private DivElement nodeIndicator;
private DivElement node;
private DivElement nodeWrapper;
private DivElement label;
View(TimelineNode.Resources res) {
super(Elements.createDivElement(res.timelineNodeCss().base()));
this.res = res;
this.css = res.timelineNodeCss();
createDom();
attachHandlers();
}
protected void createDom() {
getElement().setAttribute("draggable", "true");
node = Elements.createDivElement(css.node());
nodeIndicator = Elements.createDivElement(css.nodeIndicator());
nodeWrapper = Elements.createDivElement(css.nodeWrapper());
label = Elements.createDivElement(css.label());
nodeWrapper.appendChild(node);
nodeWrapper.appendChild(nodeIndicator);
getElement().appendChild(nodeWrapper);
}
protected void attachHandlers() {
nodeWrapper.setOnDblClick(new EventListener() {
@Override
public void handleEvent(Event evt) {
ViewEvents delegate = getDelegate();
if (delegate == null) {
return;
}
delegate.onNodeDblClick();
}
});
nodeWrapper.setOnClick(new EventListener() {
@Override
public void handleEvent(Event evt) {
ViewEvents delegate = getDelegate();
if (delegate == null) {
return;
}
delegate.onNodeClick(((MouseEvent) evt).isCtrlKey());
}
});
}
public void attachDragHandler(MouseCaptureListener mouseCaptureListener) {
nodeWrapper.addEventListener(Event.MOUSEDOWN, mouseCaptureListener, false);
}
public void setNodeType(NodeType nodeType) {
node.addClassName(nodeType.getBaseClassName());
nodeIndicator.addClassName(nodeType.getIndicatorClassName());
nodeWrapper.addClassName(nodeType.getWrapperClassName());
}
public void addRangeStyles(NodeType nodeType, boolean left) {
node.addClassName(nodeType.getRangeClassName());
if (left) {
getElement().addClassName(css.currentLeft());
} else {
getElement().addClassName(css.currentRight());
}
}
public void clearRangeStyles(NodeType nodeType) {
node.removeClassName(nodeType.getRangeClassName());
getElement().removeClassName(css.currentLeft());
getElement().removeClassName(css.currentRight());
}
public void setAsCurrentNode() {
node.setAttribute("current", "true");
nodeWrapper.removeClassName(css.largeNodeWrapper());
}
}
/**
* Events reported by the TimelineNode's View.
*/
private interface ViewEvents {
void onNodeDblClick();
void onNodeClick(boolean isCtrlKey);
}
/**
* The delegate implementation for handling events reported by the View.
*/
private class ViewEventsImpl implements ViewEvents {
/**
* On node double click, the range should adjust to be this node -> last
* node
*/
@Override
public void onNodeDblClick() {
setTempLeftRange(true);
timeline.nodes.get(timeline.nodes.size() - 1).setTempRightRange(true);
// Update current range = temp range
timeline.resetLeftRange();
timeline.resetRightRange();
timeline.adjustRangeLine();
}
/**
* On node click, the range should shorten appropriately
*/
@Override
public void onNodeClick(boolean isCtrlKey) {
// Check if dot inside the range line or not
if (index > timeline.currentLeftRange.index && index < timeline.currentRightRange.index) {
// If clicked inside the range line,
if (isCtrlKey) {
// act as dragging the right side
setTempRightRange(true);
} else {
// act as dragging the left side
setTempLeftRange(true);
}
} else {
// If clicked outside the range line, find which side it is closest
// to and update the range line
if (index < timeline.currentLeftRange.index) {
setTempLeftRange(true);
} else if (index > timeline.currentRightRange.index) {
setTempRightRange(true);
}
}
// Update current range = temp range
timeline.resetLeftRange();
timeline.resetRightRange();
timeline.adjustRangeLine();
}
}
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 (timeline.closeEnoughToDot()) {
timeline.adjustRangeLine();
timeline.setDiffForRevisions();
}
}
});
private boolean dragging = false;
private void onNodeDragStart() {
// Record original x-coordinate
timeline.setCurrentDragX(0);
// Can only drag edge nodes
TimelineNode that = getNode();
if (that == timeline.currentLeftRange) {
timeline.setDrag(true);
timeline.forceCursor("col-resize");
} else if (that == timeline.currentRightRange) {
timeline.setDrag(true);
timeline.forceCursor("col-resize");
}
mouseMovePauseDetector.start();
}
private void onNodeDragMove(int delta) {
if (timeline.getDrag()) {
timeline.moveRangeEdge(getNode(), delta);
}
}
public void onNodeDragEnd() {
mouseMovePauseDetector.stop();
timeline.setDrag(false);
timeline.removeCursor();
timeline.resetCatchUp();
// Update current range = temp range
timeline.resetLeftRange();
timeline.resetRightRange();
timeline.adjustRangeLine();
timeline.setDiffForRevisions();
}
// Timeline Node types (sync, branch)
static class NodeType {
/**
* Static factory method for a NodeType.
*/
public static NodeType create(Revision revision, Css css) {
String indicatorClassName = css.nodeIndicator();
switch (revision.getRevisionType()) {
case AUTO_SAVE:
if (revision.getHasUnresolvedConflicts()) {
indicatorClassName = css.conflictIcon();
} else if (revision.getIsFinalResolution()) {
indicatorClassName = css.conflictResolvedIcon();
}
return new NodeType(revision.getRevisionType(), css.node(), css.nodeRange(),
css.nodeWrapper(), indicatorClassName);
case SYNC_SOURCE:
case SYNC_MERGED:
if (revision.getHasUnresolvedConflicts()) {
indicatorClassName = css.conflictIcon();
}
// not possible to be a final conflict resolution node.
return new NodeType(revision.getRevisionType(), css.nodeSync(), css.nodeSyncRange(),
css.largeNodeWrapper(), indicatorClassName);
case BRANCH:
return new NodeType(revision.getRevisionType(), css.nodeBranch(), css.nodeBranchRange(),
css.largeNodeWrapper(), indicatorClassName);
case DELETE:
// TODO need a DELETE node type or indicator.
return new NodeType(revision.getRevisionType(), css.node(), css.nodeRange(),
css.nodeWrapper(), indicatorClassName);
case MOVE:
// TODO need a MOVE node type or indicator.
return new NodeType(revision.getRevisionType(), css.node(), css.nodeRange(),
css.nodeWrapper(), indicatorClassName);
case COPY:
// TODO need a COPY node type or indicator.
return new NodeType(revision.getRevisionType(), css.node(), css.nodeRange(),
css.nodeWrapper(), indicatorClassName);
default:
throw new IllegalArgumentException("Attempted to create a non-existent NodeType!");
}
}
private final RevisionType type;
private final String baseClassName;
private final String rangeClassName;
private final String wrapperClassName;
private final String indicatorClassName; //displayed at top-right to indicate node states.
NodeType(
RevisionType type, String baseClassName, String rangeClassName, String wrapperClassName,
String indicatorClassName) {
this.type = type;
this.baseClassName = baseClassName;
this.rangeClassName = rangeClassName;
this.wrapperClassName = wrapperClassName;
this.indicatorClassName = indicatorClassName;
}
RevisionType getType(){
return type;
}
String getBaseClassName() {
return baseClassName;
}
String getRangeClassName() {
return rangeClassName;
}
String getWrapperClassName() {
return wrapperClassName;
}
String getIndicatorClassName(){
return indicatorClassName;
}
}
private final Tooltip tooltip;
private final Timeline timeline;
private final Revision revision;
// file path is discovered during file diff.
private String filePath = "";
public final int index;
public final NodeType nodeType;
public boolean currentNode;
protected TimelineNode(View view, int index, Revision revision, Timeline timeline) {
super(view);
this.timeline = timeline;
this.revision = revision;
this.index = index;
this.nodeType = NodeType.create(revision, getView().css);
setLabelText();
setNodeOffset();
setNodeType();
Positioner positioner = new Tooltip.TooltipPositionerBuilder().setVerticalAlign(
VerticalAlign.TOP).setHorizontalAlign(HorizontalAlign.MIDDLE).setPosition(Position.OVERLAP)
.buildAnchorPositioner(getView().nodeWrapper);
tooltip = new Tooltip.Builder(getView().res, getView().nodeWrapper, positioner).setTooltipText(
"").build();
tooltip.setTitle(getTooltipTitle());
view.setDelegate(new ViewEventsImpl());
view.attachDragHandler(mouseCaptureListener);
}
private TimelineNode getNode() {
return this;
}
public Revision getRevision() {
return revision;
}
void setFilePath(String filePath) {
this.filePath = filePath;
}
String getFilePath() {
return filePath;
}
public String getRevisionTitle() {
return filePath + " @ " + getFormattedFullDate();
}
void updateTooltipTitle() {
tooltip.setTitle(getTooltipTitle());
}
private String getTooltipTitle() {
String type = revision.getRevisionType().name();
if (revision.getRevisionType() == RevisionType.AUTO_SAVE) {
type = "EDIT";
}
Workspace workspaceInfo = timeline.getFileHistoryApi().getWorkspace();
if (workspaceInfo != null /*&& workspaceInfo.getWorkspaceType() == WorkspaceType.TRUNK*/) {
type = "SUBMITTED_" + type;
}
return type + " " + getFormattedFullDate();
}
private String[] getTooltipText() {
List<String> text = Lists.newArrayList();
if (revision.getHasUnresolvedConflicts()) {
text.add("Has conflicts.");
} else if (revision.getIsFinalResolution()) {
text.add("Conflicts resolved.");
}
if (ClientConfig.isDebugBuild()) {
if (revision.getPreviousNodesSkipped() != 0) {
text.add("Hide " + (revision.getPreviousNodesSkipped() == -1
? " unkown # of" : revision.getPreviousNodesSkipped()) + " previous nodes.");
}
text.add("Root ID: " + revision.getRootId());
text.add("ID:" + revision.getNodeId());
}
return text.toArray(new String[0]);
}
private String getFormattedDate() {
// If today, only put the time. Else, only put the date.
// TODO: Figure out what's the best way to display dates like this
PredefinedFormat format;
if (dateIsToday(new Date(Long.valueOf(revision.getTimestamp())))) {
format = PredefinedFormat.TIME_SHORT;
} else {
format = PredefinedFormat.DATE_SHORT;
}
return getFormattedDate(format);
}
private String getFormattedFullDate() {
return getFormattedDate(PredefinedFormat.DATE_TIME_SHORT);
}
private boolean dateIsToday(Date date) {
Date today = new Date();
return today.getYear() == date.getYear() && today.getDate() == date.getDate();
}
private String getFormattedDate(PredefinedFormat format) {
String timestamp = revision.getTimestamp();
Date date = new Date(Long.valueOf(revision.getTimestamp()));
return DateTimeFormat.getFormat(format).format(date);
}
protected void setLabelText() {
getView().label.setTextContent(getFormattedDate());
}
protected void setNodeOffset() {
getView().getElement().getStyle().setLeft(getNodeOffset(), CSSStyleDeclaration.Unit.PCT);
}
public double getNodeOffset() {
return (index * 100.0 / (timeline.numNodes - 1));
}
public void setNodeType() {
getView().setNodeType(nodeType);
}
public void setAsCurrentNode() {
currentNode = true;
getView().setAsCurrentNode();
}
/*
* Methods to set current and temp ranges. Temp ranges needed to preserve
* original (before dragging) range as the "current"
*/
/**
* Set the current node to be the new temporary left edge node of the range
* line, and adjusts range styles. The temporary node becomes the current node
* upon calling resetLeftRange();
*/
public void setTempLeftRange(boolean updateDiff) {
TimelineNode old = timeline.tempLeftRange;
if (old != null) {
old.getView().clearRangeStyles(old.nodeType);
}
timeline.tempLeftRange = this;
getView().addRangeStyles(nodeType, true);
if (updateDiff) {
timeline.setDiffForRevisions();
}
}
/**
* Set the current node to be the new temporary right edge node of the range
* line, and adjusts range styles. The temporary node becomes the current node
* upon calling resetRightRange();
*/
public void setTempRightRange(boolean updateDiff) {
TimelineNode old = timeline.tempRightRange;
// Clear range styles from the old node
if (old != null) {
old.getView().clearRangeStyles(old.nodeType);
}
timeline.tempRightRange = this;
getView().addRangeStyles(nodeType, false);
if (updateDiff) {
timeline.setDiffForRevisions();
}
}
}