package com.project.website.canvas.client.canvastools.sketch;
import com.google.common.base.Strings;
import com.google.gwt.canvas.client.Canvas;
import com.google.gwt.canvas.dom.client.Context2d;
import com.google.gwt.canvas.dom.client.Context2d.Composite;
import com.google.gwt.canvas.dom.client.Context2d.LineCap;
import com.google.gwt.canvas.dom.client.Context2d.LineJoin;
import com.google.gwt.dom.client.ImageElement;
import com.google.gwt.event.dom.client.HumanInputEvent;
import com.google.gwt.event.dom.client.MouseOutEvent;
import com.google.gwt.event.dom.client.MouseOutHandler;
import com.google.gwt.event.dom.client.MouseOverEvent;
import com.google.gwt.event.dom.client.MouseOverHandler;
import com.google.gwt.event.dom.client.MouseUpEvent;
import com.google.gwt.event.dom.client.TouchEndEvent;
import com.google.gwt.user.client.Event;
import com.google.gwt.user.client.Event.NativePreviewEvent;
import com.google.gwt.user.client.Event.NativePreviewHandler;
import com.google.gwt.user.client.ui.FlowPanel;
import com.google.gwt.user.client.ui.Image;
import com.google.gwt.user.client.ui.IsWidget;
import com.project.shared.client.events.SimpleEvent.Handler;
import com.project.shared.client.handlers.RegistrationsManager;
import com.project.shared.client.utils.CanvasUtils;
import com.project.shared.client.utils.ElementUtils;
import com.project.shared.client.utils.EventUtils;
import com.project.shared.client.utils.widgets.WidgetUtils;
import com.project.shared.data.Pair;
import com.project.shared.data.Point2D;
import com.project.shared.data.Rectangle;
import com.project.shared.utils.GenericUtils;
import com.project.shared.utils.NumberUtils;
import com.project.shared.utils.PointUtils;
import com.project.website.canvas.client.canvastools.base.CanvasToolEvents;
import com.project.website.canvas.client.canvastools.base.ResizeMode;
import com.project.website.canvas.client.canvastools.base.interfaces.CanvasTool;
import com.project.website.canvas.client.canvastools.base.interfaces.ICanvasToolEvents;
import com.project.website.canvas.client.resources.CanvasResources;
import com.project.website.canvas.client.shared.UndoManager;
import com.project.website.canvas.shared.data.ElementData;
import com.project.website.canvas.shared.data.SketchData;
import com.project.website.canvas.shared.data.SketchOptions;
public class SketchTool extends FlowPanel implements CanvasTool<SketchData>
{
public enum SpiroCurveType {
Sine("Wave"),
Circle("Curl");
String friendlyName;
private SpiroCurveType(String friendlyName)
{
this.friendlyName = friendlyName;
}
public String getFriendlyName()
{
return friendlyName;
}
}
private static final double DEFAULT_GLOBAL_ALPHA = 1;
private static final int DEFAULT_SHADOW_BLUR = 0;
private static final double SPIRO_CURVE_WIDTH = 30;
private static final double SPIRO_CURVE_SPEED_Y = 0.4;
private static final int VELOCITY_SMOOTHING = 10;
private static final int POSITION_SMOOTHING = 2;
private static final int PEN_WIDTH_SMOOTHING = 4;
private static final double DEFAULT_SPLINE_TENSION = 0.4;
private static final double DEFAULT_SPIRO_RESOLUTION = 1;
private CanvasToolEvents _toolEvents = new CanvasToolEvents(this);
private SketchData data = null;
private final RegistrationsManager registrationsManager = new RegistrationsManager();
private final RegistrationsManager untilMovementStopRegs = new RegistrationsManager();
private final SketchToolbar _toolbar = new SketchToolbar();
private final PointUtils.MovingAverage _averageVelocity = new PointUtils.MovingAverage(VELOCITY_SMOOTHING);
private final PointUtils.MovingAverage _averageDrawPos = new PointUtils.MovingAverage(POSITION_SMOOTHING);
private final NumberUtils.MovingAverage _averagePenWidth = new NumberUtils.MovingAverage(PEN_WIDTH_SMOOTHING);
private final Canvas _canvas = Canvas.createIfSupported();
private final Canvas _cursorCanvas = Canvas.createIfSupported();
private Canvas _resizeCanvas1 = Canvas.createIfSupported();
private Canvas _resizeCanvas2 = Canvas.createIfSupported();
// private Canvas _undoCanvas = Canvas.createIfSupported();
private boolean _inViewMode = false;
private boolean _active = false;
private boolean _bound = false;
private boolean _drawingPathExists;
private Point2D _prevMousePos = Point2D.zero;
private Point2D _prevDrawPos1 = Point2D.zero;
private Point2D _prevDrawPos2 = Point2D.zero;
private Point2D _prevControlPoint = Point2D.zero;
private Context2d _context = null;
private final ImageElement _imageElement;
private Image _image = new Image();
private double _spiroCurveParameter = 0;
//TODO: Use Widget.isAttached instead (which currently doesn't seem to work - always returns false)
private boolean _isAttached;
public SketchTool(int width, int height)
{
this._imageElement = ImageElement.as(this._image.getElement());
this.add(this._image);
if (Canvas.isSupported()) {
this.add(this._cursorCanvas);
this._context = this._canvas.getContext2d();
// // DEBUGGING
// this.add(this._resizeCanvas1);
// this._resizeCanvas1.getElement().getStyle().setPosition(Position.ABSOLUTE);
// this._resizeCanvas1.getElement().getStyle().setLeft(100, Unit.PCT);
// this._resizeCanvas1.getElement().getStyle().setBorderStyle(BorderStyle.SOLID);
// this.add(this._resizeCanvas2);
// this._resizeCanvas2.getElement().getStyle().setPosition(Position.ABSOLUTE);
// this._resizeCanvas2.getElement().getStyle().setTop(100, Unit.PCT);
// this._resizeCanvas1.getElement().getStyle().setBorderStyle(BorderStyle.DASHED);
// // ---------
}
this.updateImageVisibilty();
this.setWidth(width);
this.setHeight(height);
this.addStyleName(CanvasResources.INSTANCE.main().sketchTool());
}
@Override
public void bind()
{
this._bound = true;
this.updateViewMode();
}
@Override
public boolean canRotate()
{
// TODO: disabled because we don't know how to translate mouse coordinates when the tool is rotated. It needs to
// be done relative to the tool frame, because that is the element that is rotated (not the tool itself).
return true;
}
@Override
public ResizeMode getResizeMode()
{
return ResizeMode.BOTH;
}
@Override
public IsWidget getToolbar()
{
return this._toolbar;
}
@Override
public ICanvasToolEvents getToolEvents()
{
return this._toolEvents;
}
@Override
public SketchData getValue()
{
if (null == this._context) {
// we can't change anything.
return this.data;
}
this.updateDataFromCanvas();
return this.data;
}
@Override
public void onResize()
{
Point2D targetSize = ElementUtils.getElementOffsetSize(this.getElement());
// make sure resizeCanvas1 is big enough for the new canvas size
this.expandBackCanvas(targetSize);
// TODO: it should have been enough to draw with Composite.COPY, and no need to clear-before-draw
// but that seems to screw up the transparency.
CanvasUtils.drawOnto(this._canvas, this._resizeCanvas1, Composite.SOURCE_OVER, true, true);
// this will clear _canvas
this.setWidth(targetSize.getX());
this.setHeight(targetSize.getY());
CanvasUtils.drawOnto(this._resizeCanvas1, this._canvas, Composite.SOURCE_OVER);
this.redraw();
}
@Override
public void setActive(boolean isActive)
{
this._active = isActive;
if (this._inViewMode) {
return;
}
}
@Override
public void setElementData(ElementData data)
{
this.setValue((SketchData) data);
}
@Override
public void setValue(SketchData value)
{
this.data = value;
this._toolbar.setOptions(this.data.sketchOptions);
this.refreshCanvasFromData();
}
@Override
public void setViewMode(boolean isViewMode)
{
this._inViewMode = isViewMode;
this.updateViewMode();
}
@Override
protected void onAttach() {
super.onAttach();
this._isAttached = true;
}
@Override
protected void onDetach() {
this._isAttached = false;
super.onDetach();
}
@Override
protected void onLoad()
{
super.onLoad();
ElementUtils.setTextSelectionEnabled(this.getElement(), false);
this.refreshCanvasFromData();
this.updateViewMode();
}
@Override
protected void onUnload()
{
this.updateDataFromCanvas();
this.registrationsManager.clear();
UndoManager.get().removeOwner(this);
super.onUnload();
}
/**
* Does not check the buttons' states, instead it relies on drawingPathExists.
* Because we can't use getButton or event.getNativeButton from within a MouseMove event handler.
* @see http://code.google.com/p/google-web-toolkit/issues/detail?id=3983
*/
private void handleMovementEvent(boolean movementIsStopping)
{
if (false == isDrawingActive()) {
return;
}
Point2D pos = ElementUtils.getMousePositionRelativeToElement(this.getElement());
if (null == pos) {
return;
}
if (DrawingTool.PAINT != this.data.sketchOptions.drawingTool) {
this.drawLinearInterpolatedSteps(pos);
}
this.applyDrawingTool(pos, pos.minus(GenericUtils.defaultIfNull(this._prevMousePos, pos)), movementIsStopping);
this._prevMousePos = pos;
}
private void applyDrawingTool(final Point2D mousePos, final Point2D velocity, boolean isEndPos)
{
SketchOptions sketchOptions = this.data.sketchOptions;
this._context.setStrokeStyle(sketchOptions.penColor);
this._context.setShadowColor(sketchOptions.penColor);
this._context.setLineWidth(sketchOptions.penWidth);
if (isErasing()) {
// We have to erase in both buffers, because when we copy from the front to the back buffer later when
// resizing, it does not
// overwrite with transparent pixels
this.drawEraser(mousePos, this._context);
this.drawEraser(mousePos, this._resizeCanvas1.getContext2d());
return;
}
this.applyStrokeDrawingTool(mousePos, velocity, sketchOptions, isEndPos);
}
private double velocityToWidthFactor(final Point2D velocity) {
return Math.log10(10 + velocity.getPower() / 3.0);
}
private void applyStrokeDrawingTool(final Point2D mousePos, Point2D velocity, SketchOptions sketchOptions, boolean isEndPos)
{
Point2D finalPos = mousePos;
this._averageVelocity.add(velocity);
Point2D averageVelocity = this._averageVelocity.getAverage();
this._averagePenWidth.add(sketchOptions.penWidth * velocityToWidthFactor(averageVelocity));
this._context.setLineWidth(this._averagePenWidth.getAverage());
if (DrawingTool.SPIRO == sketchOptions.drawingTool) {
if (averageVelocity.getAbs().sumCoords() < 1) {
return;
}
finalPos = this.getSpiroPoint(mousePos, averageVelocity, this.getCurvePointForSpiro(1));
// finalPos = this._averageDrawPos.getAverage();
}
if (false == isEndPos)
{
this._averageDrawPos.add(finalPos);
finalPos = this._averageDrawPos.getAverage();
}
if (sketchOptions.useBezierSmoothing) {
this.drawBezierLine(finalPos);
} else {
if (null == this._prevDrawPos1) {
this._context.moveTo(finalPos.getX(), finalPos.getY());
}
this._context.lineTo(finalPos.getX(), finalPos.getY());
this._context.stroke();
if (DrawingTool.SPIRO == sketchOptions.drawingTool) {
this._context.beginPath();
}
this._context.moveTo(finalPos.getX(), finalPos.getY());
}
this._prevDrawPos2 = this._prevDrawPos1;
this._prevDrawPos1 = finalPos;
}
private void drawBezierLine(Point2D finalPos)
{
if ((null == this._prevDrawPos1) || (null == this._prevDrawPos2)) {
return;
}
// -----------
Pair<Point2D, Point2D> controlPoints = PointUtils.getBezierControlPoints(this._prevDrawPos2, this._prevDrawPos1, finalPos, DEFAULT_SPLINE_TENSION);
Point2D cp0 = this._prevControlPoint;
Point2D cp1 = controlPoints.getA();
Point2D cp2 = controlPoints.getB();
if (null == cp0) {
cp0 = cp1;
}
this._prevControlPoint = cp2;
this._context.beginPath();
this._context.moveTo(this._prevDrawPos2.getX(), this._prevDrawPos2.getY());
this._context.bezierCurveTo(cp0.getX(), cp0.getY(), cp1.getX(), cp1.getY(), this._prevDrawPos1.getX(), this._prevDrawPos1.getY());
this._context.stroke();
}
private void drawCursor()
{
Point2D pos = ElementUtils.getMousePositionRelativeToElement(this.getElement());
final Rectangle sizeRectangle = new Rectangle(Point2D.zero,
ElementUtils.getElementOffsetSize(this.getElement()));
if ((null == pos) || (false == sizeRectangle.contains(pos))) {
return;
}
Context2d cursorContext = this._cursorCanvas.getContext2d();
if (this.isErasing()) {
String eraserStrokeColor = "black";
if (this.isDrawingActive()) {
eraserStrokeColor = "red";
}
cursorContext.setStrokeStyle(eraserStrokeColor);
cursorContext.setFillStyle("rgba(255,255,255,0.5)");
cursorContext.setLineWidth(1);
cursorContext.beginPath();
cursorContext.rect(pos.getX() - this.data.sketchOptions.eraserWidth / 2,
pos.getY() - this.data.sketchOptions.eraserWidth / 2,
this.data.sketchOptions.eraserWidth,
this.data.sketchOptions.eraserWidth);
cursorContext.closePath();
if (this.isDrawingActive()) {
cursorContext.fill();
}
cursorContext.stroke();
} else {
cursorContext.setStrokeStyle("transparent");
cursorContext.setFillStyle(this.data.sketchOptions.penColor);
cursorContext.beginPath();
cursorContext.arc(pos.getX(), pos.getY(), this.data.sketchOptions.penWidth / 2, 0, Math.PI * 2);
cursorContext.closePath();
cursorContext.fill();
}
}
private void drawEraser(Point2D mousePos, Context2d context)
{
context.clearRect(mousePos.getX() - this.data.sketchOptions.eraserWidth / 2,
mousePos.getY() - this.data.sketchOptions.eraserWidth / 2,
this.data.sketchOptions.eraserWidth,
this.data.sketchOptions.eraserWidth);
}
private boolean isDrawingActive()
{
return this._active && this._drawingPathExists;// null != this._currentPath;
}
private void drawLinearInterpolatedSteps(Point2D pos)
{
if (null == this._prevMousePos) {
return;
}
final Point2D offset = pos.minus(this._prevMousePos);
Point2D prevStepPos = this._prevMousePos;
int steps = (int) Math.floor(offset.getRadius() * DEFAULT_SPIRO_RESOLUTION);
for (int i = 0; i < steps; i += Math.max(1, this.data.sketchOptions.penSkip)) {
Point2D stepPos = this._prevMousePos.plus(offset.mul(((double) i) / steps));
this.applyDrawingTool(stepPos, stepPos.minus(prevStepPos), i + 1 == steps);
prevStepPos = stepPos;
}
this._prevMousePos = prevStepPos;
}
/**
* Increases the size of the resize canvases to be at least as big as the given target size, while retaining the
* bitmap data they are storing.
*/
private void expandBackCanvas(Point2D targetSize)
{
Point2D maxSize = Point2D.max(CanvasUtils.getCoorinateSpaceSize(this._resizeCanvas1), targetSize);
CanvasUtils.setCoordinateSpaceSize(this._resizeCanvas2, maxSize);
CanvasUtils.drawOnto(this._resizeCanvas1, this._resizeCanvas2, Composite.COPY);
this.swapResizeCanvases();
}
private Point2D getCurvePointForSpiro(double step)
{
this._spiroCurveParameter += step;
switch (this.data.sketchOptions.spiroCurveType) {
case Sine:
return new Point2D(0, // (int)(this._spiroCurveParameter * SPIRO_CURVE_SPEED_X),
(int) Math.round(SPIRO_CURVE_WIDTH * Math.cos(this._spiroCurveParameter * SPIRO_CURVE_SPEED_Y)));
case Circle:
return new Point2D((int) Math.round(SPIRO_CURVE_WIDTH * Math.sin(this._spiroCurveParameter * SPIRO_CURVE_SPEED_Y)),
(int) Math.round(SPIRO_CURVE_WIDTH * Math.cos(this._spiroCurveParameter * SPIRO_CURVE_SPEED_Y)));
default:
return Point2D.zero;
}
}
/**
* Calculates the point of a "spirograph" - overlay of a curve onto the path of another, which can be seen as
* translating each point of the curve to the coordinate system of the tangent to the target path.
*
* In mathematical terms we are changing the basis of the curve to be the given normalized tangent. It's a matrix
* multiplication.
*
* Let <el><li>(f'x, f'y) be the <em>normalized</em> derivative of the target path f</li> <li>(gx, gy) be the curve
* to be overlayed</li></el> Then the result (rx, ry) is:
*
* <pre>
* (rx) equals (f'x -f'y) (gx)
* (ry) (f'y f'x) (gy)
* </pre>
*
* @param normalizedPathDerivative
* derivative of the target path at the desired point
* @param overlayedCurvePoint
* the vector of the overlayed curve at the desired point
*/
private Point2D getSpiroPoint(Point2D pathPoint, Point2D pathDerivative, Point2D overlayedCurvePoint)
{
double magnitude = pathDerivative.getRadius();
int x = (int) Math.round((overlayedCurvePoint.getX() * pathDerivative.getX() - overlayedCurvePoint.getY()
* pathDerivative.getY())
/ magnitude);
int y = (int) Math.round((overlayedCurvePoint.getX() * pathDerivative.getY() + overlayedCurvePoint.getY()
* pathDerivative.getX())
/ magnitude);
return new Point2D(x, y).plus(pathPoint);
}
private boolean isErasing()
{
return DrawingTool.ERASE == this.data.sketchOptions.drawingTool;
}
private void redraw()
{
this.redraw(true);
}
private void redraw(boolean drawCursor)
{
CanvasUtils.setCoordinateSpaceSize(this._cursorCanvas, CanvasUtils.getCoorinateSpaceSize(this._canvas));
if (drawCursor) {
this.drawCursor();
}
CanvasUtils.drawOnto(this._canvas, this._cursorCanvas, Composite.DESTINATION_OVER);
}
private void refreshCanvasFromData()
{
this._imageElement.setSrc(Strings.nullToEmpty(this.data.imageData));
if (null == this._context) {
return;
}
// For some reason, the canvas does not get updated after a page reload if not using deferred command in IE (at
// least, maybe also others)
if (null != data.transform.size) {
CanvasUtils.setCoordinateSpaceSize(_canvas, data.transform.size);
}
if (this._isAttached){
_context.drawImage(_imageElement, 0, 0);
redraw(false);
}
}
private void setContextConstantProperties()
{
this._context.setGlobalAlpha(DEFAULT_GLOBAL_ALPHA);
this._context.setShadowBlur(DEFAULT_SHADOW_BLUR);
this._context.setFillStyle("transparent");
this._context.setLineJoin(LineJoin.ROUND);
this._context.setLineCap(LineCap.ROUND);
}
private void setHeight(int height)
{
super.setHeight(toPxString(height));
this._canvas.setCoordinateSpaceHeight(height);
}
private void setOptions(SketchOptions options)
{
this.data.sketchOptions = options;
// switch (this.data.sketchOptions.drawingTool)
// {
// case ERASE:
// this._averageDrawPos.setNumBins(POSITION_SMOOTHING_ERASE)
// case SPIRO:
// case PAINT:
// default:
//
// }
}
private void setRegistrations()
{
final SketchTool that = this;
this.registrationsManager.clear();
this.registrationsManager.add(this._toolbar.addOptionsChangedHandler(new Handler<SketchOptions>() {
@Override public void onFire(SketchOptions arg) {
that.setOptions(arg);
}
}));
if (false == Canvas.isSupported()) {
return;
}
this.registrationsManager.add(this.addDomHandler(new MouseOutHandler() {
@Override public void onMouseOut(MouseOutEvent event) {
that.handleMovementEvent(true);
that.redraw(false); // cursor left the canvas area, need to redraw without it
}
}, MouseOutEvent.getType()));
this.registrationsManager.add(this.addDomHandler(new MouseOverHandler() {
@Override public void onMouseOver(MouseOverEvent event) {
if (that.isDrawingActive()) {
// restart the path to prevent drawing line from mouse-out pos to mouse-over pos
that.startPathDraw();
}
}}, MouseOverEvent.getType()));
this.registrationsManager.add(WidgetUtils.addMovementStartHandler(this, new Handler<HumanInputEvent<?>>() {
@Override public void onFire(HumanInputEvent<?> arg) {
that.untilMovementStopRegs.clear();
that.untilMovementStopRegs.add(Event.addNativePreviewHandler(new NativePreviewHandler(){
@Override public void onPreviewNativeEvent(NativePreviewEvent event) {
if (EventUtils.nativePreviewEventTypeIsAny(event, MouseUpEvent.getType(), TouchEndEvent.getType())) {
that.handleMovementEndedEvent();
}
}}));
// TODO request to be activated instead of doing this forcefully?
// we want the tool frame to know it is activated.
that.setActive(true);
that.startPathDraw();
}
}));
this.registrationsManager.add(WidgetUtils.addMovementStopHandler(this, new Handler<HumanInputEvent<?>>() {
@Override public void onFire(HumanInputEvent<?> arg) {
that.handleMovementEndedEvent();
}
}));
this.registrationsManager.add(WidgetUtils.addMovementMoveHandler(this, new Handler<HumanInputEvent<?>>() {
@Override public void onFire(HumanInputEvent<?> arg) {
//that.untilMouseOverRegs.clear();
that.handleMovementEvent(false);
that.redraw(); // to update both the drawn graphics and the cursor
}
}));
}
private void setWidth(int width)
{
super.setWidth(toPxString(width));
this._canvas.setCoordinateSpaceWidth(width);
}
private void startPathDraw()
{
// this.saveToUndoCanvas();
Point2D pos = ElementUtils.getMousePositionRelativeToElement(this.getElement());
this._drawingPathExists = true;
this._context.beginPath();
this._averageDrawPos.clear();
this._averageVelocity.clear();
this._averagePenWidth.clear();
this._prevMousePos = null;
this._prevDrawPos1 = null;
this._prevDrawPos2 = null;
this._prevControlPoint = null;
this.setContextConstantProperties();
this.applyDrawingTool(pos, Point2D.zero, false);
}
private void swapResizeCanvases()
{
Canvas tempCanvas = this._resizeCanvas2;
this._resizeCanvas2 = this._resizeCanvas1;
this._resizeCanvas1 = tempCanvas;
}
private void terminateDrawingPath()
{
this._drawingPathExists = false;
// TODO: handle undo
// // We can only handle one undo step.
// UndoManager.get().removeOwner(this);
// UndoManager.get().add(this, new UndoRedoPair() {
// @Override
// public void undo()
// {
//
// }
//
// @Override
// public void redo()
// {
// }
// });
}
// private void saveToUndoCanvas()
// {
// CanvasUtils.setCoordinateSpaceSize(this._undoCanvas, CanvasUtils.getCoorinateSpaceSize(this._canvas));
// CanvasUtils.drawOnto(this._canvas, this._undoCanvas);
// }
//
// private void restoreFromUndoCanvas()
// {
// CanvasUtils.drawOnto(this._undoCanvas, this._canvas);
// }
//
private String toPxString(int height)
{
return String.valueOf(height) + "px";
}
private void updateDataFromCanvas()
{
if (null != this._canvas) {
// TODO: for some reasno adding a condition about isAttached always returns false here
//&& (this._canvas.isAttached())) {
// Browser bug?
final String dataUrl = Strings.nullToEmpty(this._canvas.toDataUrl());
this.data.imageData = dataUrl;
this._image.setUrl(dataUrl);
}
}
private void updateImageVisibilty()
{
final boolean imageVisible = this._inViewMode || (null == _canvas);
if (imageVisible) {
this.updateDataFromCanvas();
}
this._image.setVisible(imageVisible);
this._cursorCanvas.setVisible((false == this._inViewMode) && (null != _canvas));
}
private void updateViewMode()
{
if (this._inViewMode) {
this.registrationsManager.clear();
} else if (this._bound) {
this.setRegistrations();
this.redraw();
}
this.updateImageVisibilty();
}
private void handleMovementEndedEvent() {
this.untilMovementStopRegs.clear();
if (this.isDrawingActive()) {
// Before terminating the path, draw in the last position
this.handleMovementEvent(true);
this.terminateDrawingPath();
this.redraw();
}
}
}