fabric.js/src/canvas.class.js

963 lines
27 KiB
JavaScript

(function() {
var extend = fabric.util.object.extend,
getPointer = fabric.util.getPointer,
degreesToRadians = fabric.util.degreesToRadians,
radiansToDegrees = fabric.util.radiansToDegrees,
atan2 = Math.atan2,
abs = Math.abs,
min = Math.min,
max = Math.max,
STROKE_OFFSET = 0.5;
/**
* Canvas class
* @class fabric.Canvas
* @constructor
* @extends fabric.StaticCanvas
* @param {HTMLElement | String} el <canvas> element to initialize instance on
* @param {Object} [options] Options object
*/
fabric.Canvas = function(el, options) {
options || (options = { });
this._initStatic(el, options);
this._initInteractive();
this._createCacheCanvas();
fabric.Canvas.activeInstance = this;
};
function ProtoProxy(){ }
ProtoProxy.prototype = fabric.StaticCanvas.prototype;
fabric.Canvas.prototype = new ProtoProxy();
var InteractiveMethods = /** @lends fabric.Canvas.prototype */ {
/**
* When true, objects can be transformed by one side (unproportionally)
* @type Boolean
*/
uniScaleTransform: false,
/**
* When true, objects use center point as the origin of transformation
* @type Boolean
*/
centerTransform: false,
/**
* Indicates that canvas is interactive. This property should not be changed.
* @type Boolean
*/
interactive: true,
/**
* Indicates whether group selection should be enabled
* @type Boolean
*/
selection: true,
/**
* Color of selection
* @type String
*/
selectionColor: 'rgba(100, 100, 255, 0.3)', // blue
/**
* Default dash array pattern
* If not empty the selection border is dashed
* @type Array
*/
selectionDashArray: [ ],
/**
* Color of the border of selection (usually slightly darker than color of selection itself)
* @type String
*/
selectionBorderColor: 'rgba(255, 255, 255, 0.3)',
/**
* Width of a line used in object/group selection
* @type Number
*/
selectionLineWidth: 1,
/**
* Default cursor value used when hovering over an object on canvas
* @type String
*/
hoverCursor: 'move',
/**
* Default cursor value used when moving an object on canvas
* @type String
*/
moveCursor: 'move',
/**
* Default cursor value used for the entire canvas
* @type String
*/
defaultCursor: 'default',
/**
* Cursor value used during free drawing
* @type String
*/
freeDrawingCursor: 'crosshair',
/**
* Cursor value used for rotation point
* @type String
*/
rotationCursor: 'crosshair',
/**
* Default element class that's given to wrapper (div) element of canvas
* @type String
*/
containerClass: 'canvas-container',
/**
* When true, object detection happens on per-pixel basis rather than on per-bounding-box
* @type Boolean
*/
perPixelTargetFind: false,
/**
* Number of pixels around target pixel to tolerate (consider active) during object detection
* @type Number
*/
targetFindTolerance: 0,
/**
* @private
*/
_initInteractive: function() {
this._currentTransform = null;
this._groupSelector = null;
this._initWrapperElement();
this._createUpperCanvas();
this._initEvents();
this.freeDrawingBrush = fabric.PencilBrush && new fabric.PencilBrush(this);
this.calcOffset();
},
/**
* Resets the current transform to its original values and chooses the type of resizing based on the event
* @private
* @param e {Event} Event object fired on mousemove
*/
_resetCurrentTransform: function(e) {
var t = this._currentTransform;
t.target.set('scaleX', t.original.scaleX);
t.target.set('scaleY', t.original.scaleY);
t.target.set('left', t.original.left);
t.target.set('top', t.original.top);
if (e.altKey || this.centerTransform) {
if (t.originX !== 'center') {
if (t.originX === 'right') {
t.mouseXSign = -1;
}
else {
t.mouseXSign = 1;
}
}
if (t.originY !== 'center') {
if (t.originY === 'bottom') {
t.mouseYSign = -1;
}
else {
t.mouseYSign = 1;
}
}
t.originX = 'center';
t.originY = 'center';
}
else {
t.originX = t.original.originX;
t.originY = t.original.originY;
}
},
/**
* Applies one implementation of 'point inside polygon' algorithm
* @param e { Event } event object
* @param target { fabric.Object } object to test against
* @return {Boolean} true if point contains within area of given object
*/
containsPoint: function (e, target) {
var pointer = this.getPointer(e),
xy = this._normalizePointer(target, pointer),
x = xy.x,
y = xy.y;
// http://www.geog.ubc.ca/courses/klink/gis.notes/ncgia/u32.html
// http://idav.ucdavis.edu/~okreylos/TAship/Spring2000/PointInPolygon.html
// we iterate through each object. If target found, return it.
var iLines = target._getImageLines(target.oCoords),
xpoints = target._findCrossPoints(x, y, iLines);
// if xcount is odd then we clicked inside the object
// For the specific case of square images xcount === 1 in all true cases
if ((xpoints && xpoints % 2 === 1) || target._findTargetCorner(e, this._offset)) {
return true;
}
return false;
},
/**
* @private
*/
_normalizePointer: function (object, pointer) {
var activeGroup = this.getActiveGroup(),
x = pointer.x,
y = pointer.y;
var isObjectInGroup = (
activeGroup &&
object.type !== 'group' &&
activeGroup.contains(object)
);
if (isObjectInGroup) {
x -= activeGroup.left;
y -= activeGroup.top;
}
return { x: x, y: y };
},
/**
* Returns true if object is transparent at a certain location
* @param {fabric.Object} target Object to check
* @param {Number} x Left coordinate
* @param {Number} y Top coordinate
* @return {Boolean}
*/
isTargetTransparent: function (target, x, y) {
var cacheContext = this.contextCache;
var hasBorders = target.hasBorders,
transparentCorners = target.transparentCorners;
target.hasBorders = target.transparentCorners = false;
this._draw(cacheContext, target);
target.hasBorders = hasBorders;
target.transparentCorners = transparentCorners;
// If tolerance is > 0 adjust start coords to take into account. If moves off Canvas fix to 0
if (this.targetFindTolerance > 0) {
if (x > this.targetFindTolerance) {
x -= this.targetFindTolerance;
}
else {
x = 0;
}
if (y > this.targetFindTolerance) {
y -= this.targetFindTolerance;
}
else {
y = 0;
}
}
var isTransparent = true;
var imageData = cacheContext.getImageData(
x, y, (this.targetFindTolerance * 2) || 1, (this.targetFindTolerance * 2) || 1);
// Split image data - for tolerance > 1, pixelDataSize = 4;
for (var i = 3, l = imageData.data.length; i < l; i += 4) {
var temp = imageData.data[i];
isTransparent = temp <= 0;
if (isTransparent === false) break; //Stop if colour found
}
imageData = null;
this.clearContext(cacheContext);
return isTransparent;
},
/**
* @private
*/
_shouldClearSelection: function (e, target) {
var activeGroup = this.getActiveGroup();
return (
!target || (
target &&
activeGroup &&
!activeGroup.contains(target) &&
activeGroup !== target &&
!e.shiftKey) || (
target &&
!target.selectable)
);
},
/**
* @private
*/
_setupCurrentTransform: function (e, target) {
if (!target) return;
var action = 'drag',
corner,
pointer = getPointer(e, target.canvas.upperCanvasEl);
corner = target._findTargetCorner(e, this._offset);
if (corner) {
action = (corner === 'ml' || corner === 'mr')
? 'scaleX'
: (corner === 'mt' || corner === 'mb')
? 'scaleY'
: corner === 'mtr'
? 'rotate'
: 'scale';
}
var originX = "center", originY = "center";
if (corner === 'ml' || corner === 'tl' || corner === 'bl') {
originX = "right";
}
else if (corner === 'mr' || corner === 'tr' || corner === 'br') {
originX = "left";
}
if (corner === 'tl' || corner === 'mt' || corner === 'tr') {
originY = "bottom";
}
else if (corner === 'bl' || corner === 'mb' || corner === 'br') {
originY = "top";
}
if (corner === 'mtr') {
originX = 'center';
originY = 'center';
}
// var center = target.getCenterPoint();
this._currentTransform = {
target: target,
action: action,
scaleX: target.scaleX,
scaleY: target.scaleY,
offsetX: pointer.x - target.left,
offsetY: pointer.y - target.top,
originX: originX,
originY: originY,
ex: pointer.x,
ey: pointer.y,
left: target.left,
top: target.top,
theta: degreesToRadians(target.angle),
width: target.width * target.scaleX,
mouseXSign: 1,
mouseYSign: 1
};
this._currentTransform.original = {
left: target.left,
top: target.top,
scaleX: target.scaleX,
scaleY: target.scaleY,
originX: originX,
originY: originY
};
this._resetCurrentTransform(e);
},
/**
* @private
* @param e {Event}
* @param target {fabric.Object}
* @return {Boolean}
*/
_shouldHandleGroupLogic: function(e, target) {
var activeObject = this.getActiveObject();
return e.shiftKey &&
(this.getActiveGroup() || (activeObject && activeObject !== target))
&& this.selection;
},
/**
* @private
*/
_handleGroupLogic: function (e, target) {
if (target === this.getActiveGroup()) {
// if it's a group, find target again, this time skipping group
target = this.findTarget(e, true);
// if even object is not found, bail out
if (!target || target.isType('group')) {
return;
}
}
var activeGroup = this.getActiveGroup();
if (activeGroup) {
if (activeGroup.contains(target)) {
activeGroup.removeWithUpdate(target);
this._resetObjectTransform(activeGroup);
target.set('active', false);
if (activeGroup.size() === 1) {
// remove group alltogether if after removal it only contains 1 object
this.discardActiveGroup();
}
}
else {
activeGroup.addWithUpdate(target);
this._resetObjectTransform(activeGroup);
}
this.fire('selection:created', { target: activeGroup, e: e });
activeGroup.set('active', true);
}
else {
// group does not exist
if (this._activeObject) {
// only if there's an active object
if (target !== this._activeObject) {
// and that object is not the actual target
var group = new fabric.Group([ this._activeObject, target ]);
this.setActiveGroup(group);
activeGroup = this.getActiveGroup();
}
}
// activate target object in any case
target.set('active', true);
}
if (activeGroup) {
activeGroup.saveCoords();
}
},
/**
* Translates object by "setting" its left/top
* @private
* @param x {Number} pointer's x coordinate
* @param y {Number} pointer's y coordinate
*/
_translateObject: function (x, y) {
var target = this._currentTransform.target;
if (!target.get('lockMovementX')) {
target.set('left', x - this._currentTransform.offsetX);
}
if (!target.get('lockMovementY')) {
target.set('top', y - this._currentTransform.offsetY);
}
},
/**
* Scales object by invoking its scaleX/scaleY methods
* @private
* @param x {Number} pointer's x coordinate
* @param y {Number} pointer's y coordinate
* @param by {String} Either 'x' or 'y' - specifies dimension constraint by which to scale an object.
* When not provided, an object is scaled by both dimensions equally
*/
_scaleObject: function (x, y, by) {
var t = this._currentTransform,
offset = this._offset,
target = t.target;
var lockScalingX = target.get('lockScalingX'),
lockScalingY = target.get('lockScalingY');
if (lockScalingX && lockScalingY) return;
// Get the constraint point
var constraintPosition = target.translateToOriginPoint(target.getCenterPoint(), t.originX, t.originY);
var localMouse = target.toLocalPoint(new fabric.Point(x - offset.left, y - offset.top), t.originX, t.originY);
if (t.originX === 'right') {
localMouse.x *= -1;
}
else if (t.originX === 'center') {
localMouse.x *= t.mouseXSign * 2;
if (localMouse.x < 0) {
t.mouseXSign = -t.mouseXSign;
}
}
if (t.originY === 'bottom') {
localMouse.y *= -1;
}
else if (t.originY === 'center') {
localMouse.y *= t.mouseYSign * 2;
if (localMouse.y < 0) {
t.mouseYSign = -t.mouseYSign;
}
}
// Actually scale the object
var newScaleX = target.scaleX, newScaleY = target.scaleY;
if (by === 'equally' && !lockScalingX && !lockScalingY) {
var dist = localMouse.y + localMouse.x;
var lastDist = (target.height) * t.original.scaleY +
(target.width) * t.original.scaleX +
(target.padding * 2) -
(target.strokeWidth * 2) + 1 /* additional offset needed probably due to subpixel rendering, and avoids jerk when scaling an object */;
// We use t.scaleX/Y instead of target.scaleX/Y because the object may have a min scale and we'll loose the proportions
newScaleX = t.original.scaleX * dist/lastDist;
newScaleY = t.original.scaleY * dist/lastDist;
target.set('scaleX', newScaleX);
target.set('scaleY', newScaleY);
}
else if (!by) {
newScaleX = localMouse.x/(target.width+target.padding);
newScaleY = localMouse.y/(target.height+target.padding);
lockScalingX || target.set('scaleX', newScaleX);
lockScalingY || target.set('scaleY', newScaleY);
}
else if (by === 'x' && !target.get('lockUniScaling')) {
newScaleX = localMouse.x/(target.width+target.padding);
lockScalingX || target.set('scaleX', newScaleX);
}
else if (by === 'y' && !target.get('lockUniScaling')) {
newScaleY = localMouse.y/(target.height+target.padding);
lockScalingY || target.set('scaleY', newScaleY);
}
// Check if we flipped
if (newScaleX < 0)
{
if (t.originX === 'left')
t.originX = 'right';
else if (t.originX === 'right')
t.originX = 'left';
}
if (newScaleY < 0)
{
if (t.originY === 'top')
t.originY = 'bottom';
else if (t.originY === 'bottom')
t.originY = 'top';
}
// Make sure the constraints apply
target.setPositionByOrigin(constraintPosition, t.originX, t.originY);
},
/**
* Rotates object by invoking its rotate method
* @private
* @param x {Number} pointer's x coordinate
* @param y {Number} pointer's y coordinate
*/
_rotateObject: function (x, y) {
var t = this._currentTransform,
o = this._offset;
if (t.target.get('lockRotation')) return;
var lastAngle = atan2(t.ey - t.top - o.top, t.ex - t.left - o.left),
curAngle = atan2(y - t.top - o.top, x - t.left - o.left);
t.target.angle = radiansToDegrees(curAngle - lastAngle + t.theta);
},
/**
* @private
*/
_setCursor: function (value) {
this.upperCanvasEl.style.cursor = value;
},
/**
* @private
*/
_resetObjectTransform: function (target) {
target.scaleX = 1;
target.scaleY = 1;
target.setAngle(0);
},
/**
* @private
*/
_drawSelection: function () {
var ctx = this.contextTop,
groupSelector = this._groupSelector,
left = groupSelector.left,
top = groupSelector.top,
aleft = abs(left),
atop = abs(top);
ctx.fillStyle = this.selectionColor;
ctx.fillRect(
groupSelector.ex - ((left > 0) ? 0 : -left),
groupSelector.ey - ((top > 0) ? 0 : -top),
aleft,
atop
);
ctx.lineWidth = this.selectionLineWidth;
ctx.strokeStyle = this.selectionBorderColor;
// selection border
if (this.selectionDashArray.length > 1) {
var px = groupSelector.ex + STROKE_OFFSET - ((left > 0) ? 0: aleft);
var py = groupSelector.ey + STROKE_OFFSET - ((top > 0) ? 0: atop);
ctx.beginPath();
fabric.util.drawDashedLine(ctx, px, py, px+aleft, py, this.selectionDashArray);
fabric.util.drawDashedLine(ctx, px, py+atop-1, px+aleft, py+atop-1, this.selectionDashArray);
fabric.util.drawDashedLine(ctx, px, py, px, py+atop, this.selectionDashArray);
fabric.util.drawDashedLine(ctx, px+aleft-1, py, px+aleft-1, py+atop, this.selectionDashArray);
ctx.closePath();
ctx.stroke();
}
else {
ctx.strokeRect(
groupSelector.ex + STROKE_OFFSET - ((left > 0) ? 0 : aleft),
groupSelector.ey + STROKE_OFFSET - ((top > 0) ? 0 : atop),
aleft,
atop
);
}
},
/**
* @private
*/
_findSelectedObjects: function (e) {
var group = [ ],
x1 = this._groupSelector.ex,
y1 = this._groupSelector.ey,
x2 = x1 + this._groupSelector.left,
y2 = y1 + this._groupSelector.top,
currentObject,
selectionX1Y1 = new fabric.Point(min(x1, x2), min(y1, y2)),
selectionX2Y2 = new fabric.Point(max(x1, x2), max(y1, y2));
for (var i = 0, len = this._objects.length; i < len; ++i) {
currentObject = this._objects[i];
if (!currentObject) continue;
if (currentObject.intersectsWithRect(selectionX1Y1, selectionX2Y2) ||
currentObject.isContainedWithinRect(selectionX1Y1, selectionX2Y2)) {
if (this.selection && currentObject.selectable) {
currentObject.set('active', true);
group.push(currentObject);
}
}
}
// do not create group for 1 element only
if (group.length === 1) {
this.setActiveObject(group[0], e);
}
else if (group.length > 1) {
group = new fabric.Group(group);
this.setActiveGroup(group);
group.saveCoords();
this.fire('selection:created', { target: group });
this.renderAll();
}
},
/**
* Method that determines what object we are clicking on
* @param {Event} e mouse event
* @param {Boolean} skipGroup when true, group is skipped and only objects are traversed through
*/
findTarget: function (e, skipGroup) {
var target,
pointer = this.getPointer(e);
if (this.controlsAboveOverlay &&
this.lastRenderedObjectWithControlsAboveOverlay &&
this.lastRenderedObjectWithControlsAboveOverlay.visible &&
this.containsPoint(e, this.lastRenderedObjectWithControlsAboveOverlay) &&
this.lastRenderedObjectWithControlsAboveOverlay._findTargetCorner(e, this._offset)) {
target = this.lastRenderedObjectWithControlsAboveOverlay;
return target;
}
// first check current group (if one exists)
var activeGroup = this.getActiveGroup();
if (activeGroup && !skipGroup && this.containsPoint(e, activeGroup)) {
target = activeGroup;
return target;
}
// then check all of the objects on canvas
// Cache all targets where their bounding box contains point.
var possibleTargets = [];
for (var i = this._objects.length; i--; ) {
if (this._objects[i] && this._objects[i].visible && this.containsPoint(e, this._objects[i])) {
if (this.perPixelTargetFind || this._objects[i].perPixelTargetFind) {
possibleTargets[possibleTargets.length] = this._objects[i];
}
else {
target = this._objects[i];
this.relatedTarget = target;
break;
}
}
}
for (var j = 0, len = possibleTargets.length; j < len; j++) {
pointer = this.getPointer(e);
var isTransparent = this.isTargetTransparent(possibleTargets[j], pointer.x, pointer.y);
if (!isTransparent) {
target = possibleTargets[j];
this.relatedTarget = target;
break;
}
}
return target;
},
/**
* Returns pointer coordinates relative to canvas.
* @param {Event} e
* @return {Object} object with "x" and "y" number values
*/
getPointer: function (e) {
var pointer = getPointer(e, this.upperCanvasEl);
return {
x: pointer.x - this._offset.left,
y: pointer.y - this._offset.top
};
},
/**
* @private
* @param {HTMLElement|String} canvasEl Canvas element
* @throws {CANVAS_INIT_ERROR} If canvas can not be initialized
*/
_createUpperCanvas: function () {
this.upperCanvasEl = this._createCanvasElement();
this.upperCanvasEl.className = 'upper-canvas';
this.wrapperEl.appendChild(this.upperCanvasEl);
this._applyCanvasStyle(this.upperCanvasEl);
this.contextTop = this.upperCanvasEl.getContext('2d');
},
/**
* @private
*/
_createCacheCanvas: function () {
this.cacheCanvasEl = this._createCanvasElement();
this.cacheCanvasEl.setAttribute('width', this.width);
this.cacheCanvasEl.setAttribute('height', this.height);
this.contextCache = this.cacheCanvasEl.getContext('2d');
},
/**
* @private
* @param {Number} width
* @param {Number} height
*/
_initWrapperElement: function () {
this.wrapperEl = fabric.util.wrapElement(this.lowerCanvasEl, 'div', {
'class': this.containerClass
});
fabric.util.setStyle(this.wrapperEl, {
width: this.getWidth() + 'px',
height: this.getHeight() + 'px',
position: 'relative'
});
fabric.util.makeElementUnselectable(this.wrapperEl);
},
/**
* @private
* @param {Element} element
*/
_applyCanvasStyle: function (element) {
var width = this.getWidth() || element.width,
height = this.getHeight() || element.height;
fabric.util.setStyle(element, {
position: 'absolute',
width: width + 'px',
height: height + 'px',
left: 0,
top: 0
});
element.width = width;
element.height = height;
fabric.util.makeElementUnselectable(element);
},
/**
* Returns context of canvas where object selection is drawn
* @return {CanvasRenderingContext2D}
*/
getSelectionContext: function() {
return this.contextTop;
},
/**
* Returns &lt;canvas> element on which object selection is drawn
* @return {HTMLCanvasElement}
*/
getSelectionElement: function () {
return this.upperCanvasEl;
},
/**
* Sets given object as the only active object on canvas
* @param object {fabric.Object} Object to set as an active one
* @return {fabric.Canvas} thisArg
* @chainable
*/
setActiveObject: function (object, e) {
if (this._activeObject) {
this._activeObject.set('active', false);
}
this._activeObject = object;
object.set('active', true);
this.renderAll();
this.fire('object:selected', { target: object, e: e });
object.fire('selected', { e: e });
return this;
},
/**
* Returns currently active object
* @return {fabric.Object} active object
*/
getActiveObject: function () {
return this._activeObject;
},
/**
* Discards currently active object
* @return {fabric.Canvas} thisArg
* @chainable
*/
discardActiveObject: function () {
if (this._activeObject) {
this._activeObject.set('active', false);
}
this._activeObject = null;
return this;
},
/**
* Sets active group to a speicified one
* @param {fabric.Group} group Group to set as a current one
* @return {fabric.Canvas} thisArg
* @chainable
*/
setActiveGroup: function (group) {
this._activeGroup = group;
if (group) {
group.canvas = this;
group.set('active', true);
}
return this;
},
/**
* Returns currently active group
* @return {fabric.Group} Current group
*/
getActiveGroup: function () {
return this._activeGroup;
},
/**
* Removes currently active group
* @return {fabric.Canvas} thisArg
*/
discardActiveGroup: function () {
var g = this.getActiveGroup();
if (g) {
g.destroy();
}
return this.setActiveGroup(null);
},
/**
* Deactivates all objects on canvas, removing any active group or object
* @return {fabric.Canvas} thisArg
*/
deactivateAll: function () {
var allObjects = this.getObjects(),
i = 0,
len = allObjects.length;
for ( ; i < len; i++) {
allObjects[i].set('active', false);
}
this.discardActiveGroup();
this.discardActiveObject();
return this;
},
/**
* Deactivates all objects and dispatches appropriate events
* @return {fabric.Canvas} thisArg
*/
deactivateAllWithDispatch: function () {
var activeObject = this.getActiveGroup() || this.getActiveObject();
if (activeObject) {
this.fire('before:selection:cleared', { target: activeObject });
}
this.deactivateAll();
if (activeObject) {
this.fire('selection:cleared');
}
return this;
}
};
fabric.Canvas.prototype.toString = fabric.StaticCanvas.prototype.toString;
extend(fabric.Canvas.prototype, InteractiveMethods);
// iterating manually to workaround Opera's bug
// where "prototype" property is enumerable and overrides existing prototype
for (var prop in fabric.StaticCanvas) {
if (prop !== 'prototype') {
fabric.Canvas[prop] = fabric.StaticCanvas[prop];
}
}
if (fabric.isTouchSupported) {
/** @ignore */
fabric.Canvas.prototype._setCursorFromEvent = function() { };
}
/**
* @class fabric.Element
* @alias fabric.Canvas
* @deprecated Use {@link fabric.Canvas} instead.
* @constructor
*/
fabric.Element = fabric.Canvas;
})();