konva/src/Stage.js

1042 lines
30 KiB
JavaScript

///////////////////////////////////////////////////////////////////////
// Stage
///////////////////////////////////////////////////////////////////////
/**
* Stage constructor. A stage is used to contain multiple layers and handle
* animations
* @constructor
* @augments Kinetic.Container
* @augments Kinetic.Node
* @param {String|DomElement} cont Container id or DOM element
* @param {int} width
* @param {int} height
*/
Kinetic.Stage = function(config) {
this.setDefaultAttrs({
width: 400,
height: 200,
throttle: 80
});
/*
* if container is a string, assume it's an id for
* a DOM element
*/
if( typeof config.container === 'string') {
config.container = document.getElementById(config.container);
}
// call super constructors
Kinetic.Container.apply(this, []);
Kinetic.Node.apply(this, [config]);
this._setStageDefaultProperties();
this._id = Kinetic.GlobalObject.idCounter++;
this._buildDOM();
this._bindContentEvents();
//change events
this.on('widthChange.kinetic_reserved', function() {
this._resizeDOM();
});
this.on('heightChange.kinetic_reserved', function() {
this._resizeDOM();
});
var go = Kinetic.GlobalObject;
go.stages.push(this);
this._addId(this);
this._addName(this);
};
/*
* Stage methods
*/
Kinetic.Stage.prototype = {
/**
* sets onFrameFunc for animation
* @param {function} func
*/
onFrame: function(func) {
var go = Kinetic.GlobalObject;
this.anim = {
func: func
};
},
/**
* start animation
*/
start: function() {
if(!this.animRunning) {
var go = Kinetic.GlobalObject;
go._addAnimation(this.anim);
go._handleAnimation();
this.animRunning = true;
}
},
/**
* stop animation
*/
stop: function() {
var go = Kinetic.GlobalObject;
go._removeAnimation(this.anim);
this.animRunning = false;
},
/**
* draw children
*/
draw: function() {
this._drawChildren();
},
/**
* set stage size
*/
setSize: function() {
// set stage dimensions
var size = Kinetic.GlobalObject._getSize(arguments);
this.setAttrs(size);
},
/**
* return stage size
*/
getSize: function() {
return {
width: this.attrs.width,
height: this.attrs.height
};
},
/**
* clear all layers
*/
clear: function() {
var layers = this.children;
for(var n = 0; n < layers.length; n++) {
layers[n].clear();
}
},
/**
* Creates a composite data URL and passes it to a callback. If MIME type is not
* specified, then "image/png" will result. For "image/jpeg", specify a quality
* level as quality (range 0.0 - 1.0)
* @param {function} callback
* @param {String} mimeType (optional)
* @param {Number} quality (optional)
*/
toDataURL: function(callback, mimeType, quality) {
var bufferLayer = this.bufferLayer;
var bufferContext = bufferLayer.getContext();
var layers = this.children;
var that = this;
function addLayer(n) {
var dataURL = layers[n].getCanvas().toDataURL();
var imageObj = new Image();
imageObj.onload = function() {
bufferContext.drawImage(this, 0, 0);
n++;
if(n < layers.length) {
addLayer(n);
}
else {
try {
// If this call fails (due to browser bug, like in Firefox 3.6),
// then revert to previous no-parameter image/png behavior
callback(bufferLayer.getCanvas().toDataURL(mimeType, quality));
}
catch(exception) {
callback(bufferLayer.getCanvas().toDataURL());
}
}
};
imageObj.src = dataURL;
}
bufferLayer.clear();
addLayer(0);
},
/**
* serialize stage and children as a JSON object
*/
toJSON: function() {
var go = Kinetic.GlobalObject;
function addNode(node) {
var obj = {};
var cleanAttrs = node.attrs;
// remove function, image, DOM, and objects with methods
for(var key in cleanAttrs) {
var val = cleanAttrs[key];
if(go._isFunction(val) || go._isElement(val) || go._hasMethods(val)) {
cleanAttrs[key] = undefined;
}
}
obj.attrs = cleanAttrs;
obj.nodeType = node.nodeType;
obj.shapeType = node.shapeType;
if(node.nodeType !== 'Shape') {
obj.children = [];
var children = node.getChildren();
for(var n = 0; n < children.length; n++) {
var child = children[n];
obj.children.push(addNode(child));
}
}
return obj;
}
return JSON.stringify(addNode(this));
},
/**
* reset stage to default state
*/
reset: function() {
// remove children
this.removeChildren();
// defaults
this._setStageDefaultProperties();
this.setAttrs(this.defaultNodeAttrs);
},
/**
* load stage with JSON string. De-serializtion does not generate custom
* shape drawing functions, images, or event handlers (this would make the
* serialized object huge). If your app uses custom shapes, images, and
* event handlers (it probably does), then you need to select the appropriate
* shapes after loading the stage and set these properties via on(), setDrawFunc(),
* and setImage()
* @param {String} JSON string
*/
load: function(json) {
this.reset();
function loadNode(node, obj) {
var children = obj.children;
if(children !== undefined) {
for(var n = 0; n < children.length; n++) {
var child = children[n];
var type;
// determine type
if(child.nodeType === 'Shape') {
// add custom shape
if(child.shapeType === undefined) {
type = 'Shape';
}
// add standard shape
else {
type = child.shapeType;
}
}
else {
type = child.nodeType;
}
var no = new Kinetic[type](child.attrs);
node.add(no);
loadNode(no, child);
}
}
}
var obj = JSON.parse(json);
// copy over stage properties
this.attrs = obj.attrs;
loadNode(this, obj);
this.draw();
},
/**
* get mouse position for desktop apps
* @param {Event} evt
*/
getMousePosition: function(evt) {
return this.mousePos;
},
/**
* get touch position for mobile apps
* @param {Event} evt
*/
getTouchPosition: function(evt) {
return this.touchPos;
},
/**
* get user position (mouse position or touch position)
* @param {Event} evt
*/
getUserPosition: function(evt) {
return this.getTouchPosition() || this.getMousePosition();
},
/**
* get container DOM element
*/
getContainer: function() {
return this.attrs.container;
},
/**
* get content DOM element
*/
getContent: function() {
return this.content;
},
/**
* get stage
*/
getStage: function() {
return this;
},
/**
* get shapes that intersect a point
* @param {Object} point
*/
getIntersections: function() {
var pos = Kinetic.GlobalObject._getXY(arguments);
var arr = [];
var shapes = this.get('Shape');
for(var n = 0; n < shapes.length; n++) {
var shape = shapes[n];
if(shape.intersects(pos)) {
arr.push(shape);
}
}
return arr;
},
/**
* get stage DOM node, which is a div element
* with the class name "kineticjs-content"
*/
getDOM: function() {
return this.content;
},
_resizeDOM: function() {
var width = this.attrs.width;
var height = this.attrs.height;
// set content dimensions
this.content.style.width = width + 'px';
this.content.style.height = height + 'px';
// set buffer layer and path layer sizes
this.bufferLayer.getCanvas().width = width;
this.bufferLayer.getCanvas().height = height;
this.pathLayer.getCanvas().width = width;
this.pathLayer.getCanvas().height = height;
// set user defined layer dimensions
var layers = this.children;
for(var n = 0; n < layers.length; n++) {
var layer = layers[n];
layer.getCanvas().width = width;
layer.getCanvas().height = height;
layer.draw();
}
},
/**
* remove layer from stage
* @param {Layer} layer
*/
_remove: function(layer) {
/*
* remove canvas DOM from the document if
* it exists
*/
try {
this.content.removeChild(layer.canvas);
}
catch(e) {
}
},
/**
* add layer to stage
* @param {Layer} layer
*/
_add: function(layer) {
layer.canvas.width = this.attrs.width;
layer.canvas.height = this.attrs.height;
// draw layer and append canvas to container
layer.draw();
this.content.appendChild(layer.canvas);
/*
* set layer last draw time to zero
* so that throttling doesn't take into account
* the layer draws associated with adding a node
*/
layer.lastDrawTime = 0;
},
/**
* detect event
* @param {Shape} shape
*/
_detectEvent: function(shape, evt) {
var isDragging = Kinetic.GlobalObject.drag.moving;
var go = Kinetic.GlobalObject;
var pos = this.getUserPosition();
var el = shape.eventListeners;
if(this.targetShape && shape._id === this.targetShape._id) {
this.targetFound = true;
}
if(shape.isVisible() && pos !== undefined && shape.intersects(pos)) {
// handle onmousedown
if(!isDragging && this.mouseDown) {
this.mouseDown = false;
this.clickStart = true;
shape._handleEvent('mousedown', evt);
return true;
}
// handle onmouseup & onclick
else if(this.mouseUp) {
this.mouseUp = false;
shape._handleEvent('mouseup', evt);
// detect if click or double click occurred
if(this.clickStart) {
/*
* if dragging and dropping, don't fire click or dbl click
* event
*/
if((!go.drag.moving) || !go.drag.node) {
shape._handleEvent('click', evt);
if(shape.inDoubleClickWindow) {
shape._handleEvent('dblclick', evt);
}
shape.inDoubleClickWindow = true;
setTimeout(function() {
shape.inDoubleClickWindow = false;
}, this.dblClickWindow);
}
}
return true;
}
// handle touchstart
else if(!isDragging && this.touchStart && !this.touchMove) {
this.touchStart = false;
this.tapStart = true;
shape._handleEvent('touchstart', evt);
return true;
}
// handle touchend & tap
else if(this.touchEnd) {
this.touchEnd = false;
shape._handleEvent('touchend', evt);
// detect if tap or double tap occurred
if(this.tapStart) {
/*
* if dragging and dropping, don't fire tap or dbltap
* event
*/
if((!go.drag.moving) || !go.drag.node) {
shape._handleEvent('tap', evt);
if(shape.inDoubleClickWindow) {
shape._handleEvent('dbltap', evt);
}
shape.inDoubleClickWindow = true;
setTimeout(function() {
shape.inDoubleClickWindow = false;
}, this.dblClickWindow);
}
}
return true;
}
/*
* NOTE: these event handlers require target shape
* handling
*/
// handle onmouseover
else if(!isDragging && this._isNewTarget(shape, evt)) {
/*
* check to see if there are stored mouseout events first.
* if there are, run those before running the onmouseover
* events
*/
if(this.mouseoutShape) {
this.mouseoverShape = shape;
this.mouseoutShape._handleEvent('mouseout', evt);
this.mouseoverShape = undefined;
}
shape._handleEvent('mouseover', evt);
this._setTarget(shape);
return true;
}
// handle mousemove and touchmove
else if(!isDragging && this.mouseMove) {
shape._handleEvent('mousemove', evt);
return true;
}
else if(!isDragging && this.touchMove) {
shape._handleEvent('touchmove', evt);
return true;
}
}
// handle mouseout condition
else if(!isDragging && this.targetShape && this.targetShape._id === shape._id) {
this._setTarget(undefined);
this.mouseoutShape = shape;
return true;
}
return false;
},
/**
* set new target
*/
_setTarget: function(shape) {
this.targetShape = shape;
this.targetFound = true;
},
/**
* check if shape should be a new target
*/
_isNewTarget: function(shape, evt) {
if(!this.targetShape || (!this.targetFound && shape._id !== this.targetShape._id)) {
/*
* check if old target has an onmouseout event listener
*/
if(this.targetShape) {
var oldEl = this.targetShape.eventListeners;
if(oldEl) {
this.mouseoutShape = this.targetShape;
}
}
return true;
}
else {
return false;
}
},
/**
* traverse container children
* @param {Container} obj
*/
_traverseChildren: function(obj, evt) {
var children = obj.children;
// propapgate backwards through children
for(var i = children.length - 1; i >= 0; i--) {
var child = children[i];
if(child.attrs.listen) {
if(child.nodeType === 'Shape') {
var exit = this._detectEvent(child, evt);
if(exit) {
return true;
}
}
else {
var exit = this._traverseChildren(child, evt);
if(exit) {
return true;
}
}
}
}
return false;
},
/**
* handle incoming event
* @param {Event} evt
*/
_handleStageEvent: function(evt) {
var date = new Date();
var time = date.getTime();
this.lastEventTime = time;
var go = Kinetic.GlobalObject;
if(!evt) {
evt = window.event;
}
this._setMousePosition(evt);
this._setTouchPosition(evt);
this.pathLayer.clear();
/*
* loop through layers. If at any point an event
* is triggered, break out
*/
this.targetFound = false;
var shapeDetected = false;
for(var n = this.children.length - 1; n >= 0; n--) {
var layer = this.children[n];
if(layer.isVisible() && n >= 0 && layer.attrs.listen) {
if(this._traverseChildren(layer, evt)) {
shapeDetected = true;
break;
}
}
}
/*
* if no shape was detected and a mouseout shape has been stored,
* then run the onmouseout event handlers
*/
if(!shapeDetected && this.mouseoutShape) {
this.mouseoutShape._handleEvent('mouseout', evt);
this.mouseoutShape = undefined;
}
},
/**
* begin listening for events by adding event handlers
* to the container
*/
_bindContentEvents: function() {
var go = Kinetic.GlobalObject;
var that = this;
var events = ['mousedown', 'mousemove', 'mouseup', 'mouseover', 'mouseout', 'touchstart', 'touchmove', 'touchend'];
for(var n = 0; n < events.length; n++) {
var pubEvent = events[n];
// induce scope
( function() {
var event = pubEvent;
that.content.addEventListener(event, function(evt) {
that['_' + event](evt);
}, false);
}());
}
},
_mouseover: function(evt) {
this._handleStageEvent(evt);
},
_mouseout: function(evt) {
// if there's a current target shape, run mouseout handlers
var targetShape = this.targetShape;
if(targetShape) {
targetShape._handleEvent('mouseout', evt);
this.targetShape = undefined;
}
this.mousePos = undefined;
// end drag and drop
this._endDrag(evt);
},
_mousemove: function(evt) {
//throttle mousemove
var throttle = this.attrs.throttle;
var date = new Date();
var time = date.getTime();
var timeDiff = time - this.lastEventTime;
var tt = 1000 / throttle;
if(timeDiff >= tt) {
this.mouseDown = false;
this.mouseUp = false;
this._handleStageEvent(evt);
}
// start drag and drop
this._startDrag(evt);
},
_mousedown: function(evt) {
this.mouseDown = true;
this.mouseUp = false;
this.mouseMove = false;
this._handleStageEvent(evt);
//init stage drag and drop
if(this.attrs.draggable) {
this._initDrag();
}
},
_mouseup: function(evt) {
this.mouseDown = false;
this.mouseUp = true;
this.mouseMove = false;
this._handleStageEvent(evt);
this.clickStart = false;
// end drag and drop
this._endDrag(evt);
},
_touchstart: function(evt) {
evt.preventDefault();
this.touchStart = true;
this.touchEnd = false;
this.touchMove = false;
this._handleStageEvent(evt);
/*
* init stage drag and drop
*/
if(this.attrs.draggable) {
this._initDrag();
}
},
_touchend: function(evt) {
this.touchStart = false;
this.touchEnd = true;
this.touchMove = false;
this._handleStageEvent(evt);
this.tapStart = false;
// end drag and drop
this._endDrag(evt);
},
_touchmove: function(evt) {
//throttle touchmove
var that = this;
var throttle = this.attrs.throttle;
var date = new Date();
var time = date.getTime();
var timeDiff = time - this.lastEventTime;
var tt = 1000 / throttle;
if(timeDiff >= tt) {
/*
* need a setTimeout here because iOS
* sometimes triggers touchStart and touchMove
* simultaenously which causes event detection issues.
* The timeout ensures that touchstart events
* are handled first followed by touchmove
*/
setTimeout(function() {
evt.preventDefault();
that.touchEnd = false;
that.touchMove = true;
that._handleStageEvent(evt);
}, 5);
}
// start drag and drop
this._startDrag(evt);
},
/**
* set mouse positon for desktop apps
* @param {Event} evt
*/
_setMousePosition: function(evt) {
var mouseX = evt.offsetX || (evt.clientX - this._getContentPosition().left + window.pageXOffset);
var mouseY = evt.offsetY || (evt.clientY - this._getContentPosition().top + window.pageYOffset);
this.mousePos = {
x: mouseX,
y: mouseY
};
},
/**
* set touch position for mobile apps
* @param {Event} evt
*/
_setTouchPosition: function(evt) {
if(evt.touches !== undefined && evt.touches.length === 1) {// Only deal with
// one finger
var touch = evt.touches[0];
// Get the information for finger #1
var touchX = touch.clientX - this._getContentPosition().left + window.pageXOffset;
var touchY = touch.clientY - this._getContentPosition().top + window.pageYOffset;
this.touchPos = {
x: touchX,
y: touchY
};
}
},
/**
* get container position
*/
_getContentPosition: function() {
var obj = this.content;
var top = 0;
var left = 0;
while(obj && obj.tagName !== 'BODY') {
top += obj.offsetTop - obj.scrollTop;
left += obj.offsetLeft - obj.scrollLeft;
obj = obj.offsetParent;
}
return {
top: top,
left: left
};
},
/**
* modify path context
* @param {CanvasContext} context
*/
_modifyPathContext: function(context) {
context.stroke = function() {
};
context.fill = function() {
};
context.fillRect = function(x, y, width, height) {
context.rect(x, y, width, height);
};
context.strokeRect = function(x, y, width, height) {
context.rect(x, y, width, height);
};
context.drawImage = function() {
};
context.fillText = function() {
};
context.strokeText = function() {
};
},
/**
* end drag and drop
*/
_endDrag: function(evt) {
var go = Kinetic.GlobalObject;
if(go.drag.node) {
if(go.drag.moving) {
go.drag.moving = false;
go.drag.node._handleEvent('dragend', evt);
}
}
go.drag.node = undefined;
},
/**
* start drag and drop
*/
_startDrag: function(evt) {
var that = this;
var go = Kinetic.GlobalObject;
var node = go.drag.node;
if(node) {
var pos = that.getUserPosition();
var dc = node.attrs.dragConstraint;
var db = node.attrs.dragBounds;
var lastNodePos = {
x: node.attrs.x,
y: node.attrs.y
};
// default
var newNodePos = {
x: pos.x - go.drag.offset.x,
y: pos.y - go.drag.offset.y
};
// bounds overrides
if(db.left !== undefined && newNodePos.x < db.left) {
newNodePos.x = db.left;
}
if(db.right !== undefined && newNodePos.x > db.right) {
newNodePos.x = db.right;
}
if(db.top !== undefined && newNodePos.y < db.top) {
newNodePos.y = db.top;
}
if(db.bottom !== undefined && newNodePos.y > db.bottom) {
newNodePos.y = db.bottom;
}
node.setAbsolutePosition(newNodePos);
// constraint overrides
if(dc === 'horizontal') {
node.attrs.y = lastNodePos.y;
}
else if(dc === 'vertical') {
node.attrs.x = lastNodePos.x;
}
/*
* if dragging and dropping the stage,
* draw all of the layers
*/
if(go.drag.node.nodeType === 'Stage') {
go.drag.node.draw();
}
else {
go.drag.node.getLayer().draw();
}
if(!go.drag.moving) {
go.drag.moving = true;
// execute dragstart events if defined
go.drag.node._handleEvent('dragstart', evt);
}
// execute user defined ondragmove if defined
go.drag.node._handleEvent('dragmove', evt);
}
},
/**
* build dom
*/
_buildDOM: function() {
// content
this.content = document.createElement('div');
this.content.style.position = 'relative';
this.content.style.display = 'inline-block';
this.content.className = 'kineticjs-content';
this.attrs.container.appendChild(this.content);
// default layers
this.bufferLayer = new Kinetic.Layer({
name: 'bufferLayer'
});
this.pathLayer = new Kinetic.Layer({
name: 'pathLayer'
});
// set parents
this.bufferLayer.parent = this;
this.pathLayer.parent = this;
// customize back stage context
this._modifyPathContext(this.pathLayer.context);
// hide canvases
this.bufferLayer.getCanvas().style.display = 'none';
this.pathLayer.getCanvas().style.display = 'none';
// add buffer layer
this.bufferLayer.canvas.className = 'kineticjs-buffer-layer';
this.content.appendChild(this.bufferLayer.canvas);
// add path layer
this.pathLayer.canvas.className = 'kineticjs-path-layer';
this.content.appendChild(this.pathLayer.canvas);
this.setSize(this.attrs.width, this.attrs.height);
this._resizeDOM();
},
_addId: function(node) {
if(node.attrs.id !== undefined) {
this.ids[node.attrs.id] = node;
}
},
_removeId: function(node) {
if(node.attrs.id !== undefined) {
this.ids[node.attrs.id] = undefined;
}
},
_addName: function(node) {
var name = node.attrs.name;
if(name !== undefined) {
if(this.names[name] === undefined) {
this.names[name] = [];
}
this.names[name].push(node);
}
},
_removeName: function(node) {
if(node.attrs.name !== undefined) {
var nodes = this.names[node.attrs.name];
if(nodes !== undefined) {
for(var n = 0; n < nodes.length; n++) {
var no = nodes[n];
if(no._id === node._id) {
nodes.splice(n, 1);
}
}
if(nodes.length === 0) {
delete this.names[node.attrs.name];
}
}
}
},
/**
* bind event listener to container DOM element
* @param {String} typesStr
* @param {function} handler
*/
_onContent: function(typesStr, handler) {
var types = typesStr.split(' ');
for(var n = 0; n < types.length; n++) {
var baseEvent = types[n];
this.content.addEventListener(baseEvent, handler, false);
}
},
/**
* set defaults
*/
_setStageDefaultProperties: function() {
this.nodeType = 'Stage';
this.lastEventTime = 0;
this.dblClickWindow = 400;
this.targetShape = undefined;
this.targetFound = false;
this.mouseoverShape = undefined;
this.mouseoutShape = undefined;
// desktop flags
this.mousePos = undefined;
this.mouseDown = false;
this.mouseUp = false;
this.mouseMove = false;
this.clickStart = false;
// mobile flags
this.touchPos = undefined;
this.touchStart = false;
this.touchEnd = false;
this.touchMove = false;
this.tapStart = false;
this.ids = {};
this.names = {};
this.anim = undefined;
this.animRunning = false;
}
};
// Extend Container and Node
Kinetic.GlobalObject.extend(Kinetic.Stage, Kinetic.Container);
Kinetic.GlobalObject.extend(Kinetic.Stage, Kinetic.Node);
// add setters and getters
Kinetic.GlobalObject.addSetters(Kinetic.Stage, ['width', 'height', 'throttle']);
Kinetic.GlobalObject.addGetters(Kinetic.Stage, ['width', 'height', 'throttle']);
/**
* get width
* @name getWidth
* @methodOf Kinetic.Stage.prototype
*/
/**
* get height
* @name getHeight
* @methodOf Kinetic.Stage.prototype
*/
/**
* get throttle
* @name getThrottle
* @methodOf Kinetic.Stage.prototype
*/
/**
* set width
* @name setWidth
* @methodOf Kinetic.Stage.prototype
* @param {Number} width
*/
/**
* set height
* @name setHeight
* @methodOf Kinetic.Stage.prototype
* @param {Number} height
*/
/**
* set throttle. Increasing the throttle will increase
* the number of mousemove and touchmove event detections,
* and decreasing the throttle will decrease the number
* of mousemove and touchmove events which improves performance
* @name setThrottle
* @methodOf Kinetic.Stage.prototype
* @param {Number} throttle
*/