paper.js/src/ui/View.js

663 lines
18 KiB
JavaScript
Raw Normal View History

/*
* Paper.js - The Swiss Army Knife of Vector Graphics Scripting.
* http://paperjs.org/
*
* Copyright (c) 2011 - 2013, Juerg Lehni & Jonathan Puckey
* http://lehni.org/ & http://jonathanpuckey.com/
*
2011-07-01 06:17:45 -04:00
* Distributed under the MIT license. See LICENSE file for details.
*
* All rights reserved.
*/
/**
* @name View
2011-06-27 09:13:24 -04:00
*
2012-12-23 10:01:53 -05:00
* @class The View object wraps an HTML element and handles drawing and user
2011-06-28 03:20:42 -04:00
* interaction through mouse and keyboard for it. It offer means to scroll the
2011-06-27 09:13:24 -04:00
* view, find the currently visible bounds in project coordinates, or the
2011-06-28 05:35:08 -04:00
* center, both useful for constructing artwork that should appear centered on
2011-06-27 09:13:24 -04:00
* screen.
*/
var View = this.View = Base.extend(Callback, /** @lends View# */{
initialize: function(element) {
// Store reference to the currently active global paper scope, and the
// active project, which will be represented by this view
this._scope = paper;
this._project = paper.project;
this._element = element;
var size;
/*#*/ if (options.browser) {
// Generate an id for this view / element if it does not have one
this._id = element.getAttribute('id');
if (this._id == null)
element.setAttribute('id', this._id = 'view-' + View._id++);
// Install event handlers
DomEvent.add(element, this._viewHandlers);
// If the element has the resize attribute, resize the it to fill the
// window and resize it again whenever the user resizes the window.
if (PaperScript.hasAttribute(element, 'resize')) {
// Subtract element' viewport offset from the total size, to
// stretch it in
var offset = DomElement.getOffset(element, true),
that = this;
size = DomElement.getViewportBounds(element)
.getSize().subtract(offset);
this._windowHandlers = {
resize: function(event) {
// Only update element offset if it's not invisible, as
// otherwise the offset would be wrong.
if (!DomElement.isInvisible(element))
offset = DomElement.getOffset(element, true);
// Set the size now, which internally calls onResize
// and redraws the view
that.setViewSize(DomElement.getViewportBounds(element)
.getSize().subtract(offset));
}
};
DomEvent.add(window, this._windowHandlers);
} else {
// If the element is invisible, we cannot directly access
// element.width / height, because they would appear 0. Reading
// the attributes still works though:
size = DomElement.isInvisible(element)
? Size.create(parseInt(element.getAttribute('width')),
parseInt(element.getAttribute('height')))
: DomElement.getSize(element);
}
// Set canvas size even if we just deterined the size from it, since
// it might have been set to a % size, in which case it would use some
// default internal size (300x150 on WebKit) and scale up the pixels.
element.width = size.width;
element.height = size.height;
// TODO: Test this on IE:
if (PaperScript.hasAttribute(element, 'stats')) {
this._stats = new Stats();
// Align top-left to the element
var stats = this._stats.domElement,
style = stats.style,
offset = DomElement.getOffset(element);
style.position = 'absolute';
style.left = offset.x + 'px';
style.top = offset.y + 'px';
document.body.appendChild(stats);
}
/*#*/ } else if (options.server) {
// Generate an id for this view
this._id = 'view-' + View._id++;
size = Size.create(element.width, element.height);
/*#*/ } // options.server
// Keep track of views internally
View._views.push(this);
// Link this id to our view
View._viewsById[this._id] = this;
this._viewSize = LinkedSize.create(this, 'setViewSize',
size.width, size.height);
this._matrix = new Matrix();
this._zoom = 1;
// Make sure the first view is focused for keyboard input straight away
if (!View._focused)
View._focused = this;
// Items that need the onFrame handler called on them
this._frameItems = {};
this._frameItemCount = 0;
},
/**
* Removes this view from and frees the associated element.
*/
remove: function() {
if (!this._project)
return false;
// Clear focus if removed view had it
if (View._focused == this)
View._focused = null;
// Remove view from internal structures
View._views.splice(View._views.indexOf(this), 1);
delete View._viewsById[this._id];
// Unlink from project
if (this._project.view == this)
this._project.view = null;
// Uninstall event handlers again for this view.
DomEvent.remove(this._element, this._viewHandlers);
DomEvent.remove(window, this._windowHandlers);
this._element = this._project = null;
// Removing all onFrame handlers makes the onFrame handler stop
// automatically through its uninstall method.
this.detach('frame');
this._frameItems = {};
return true;
},
2012-12-24 11:20:00 -05:00
/**
* @namespace
* @ignore
*/
_events: {
2012-12-24 11:20:00 -05:00
/**
* @namespace
*/
onFrame: {
install: function() {
/*#*/ if (options.browser) {
// Call the onFrame handler straight away and initialize the
// sequence of onFrame calls.
if (!this._requested) {
this._animate = true;
this._handleFrame(true);
}
/*#*/ } // options.browser
},
uninstall: function() {
this._animate = false;
}
},
onResize: {}
},
// These are default values for event related properties on the prototype.
// Writing item._count++ does not change the defaults, it creates / updates
// the property on the instance. Useful!
_animate: false,
_time: 0,
_count: 0,
_handleFrame: function(request) {
this._requested = false;
// See if we need to stop due to a call to uninstall()
if (!this._animate)
return;
// Set the global paper object to the current scope
paper = this._scope;
if (request) {
// Request next frame already
this._requested = true;
var that = this;
DomEvent.requestAnimationFrame(function() {
that._handleFrame(true);
}, this._element);
}
var now = Date.now() / 1000,
delta = this._before ? now - this._before : 0;
this._before = now;
// Use Base.merge to convert into a Base object, for #toString()
this.fire('frame', Base.merge({
// Time elapsed since last redraw in seconds:
delta: delta,
// Time since first call of frame() in seconds:
time: this._time += delta,
count: this._count++
}));
// Update framerate stats
if (this._stats)
this._stats.update();
// Automatically draw view on each frame.
this.draw(true);
},
_animateItem: function(item, animate) {
var items = this._frameItems;
if (animate) {
items[item._id] = {
item: item,
// Additional information for the event callback
time: 0,
count: 0
};
if (++this._frameItemCount == 1)
this.attach('frame', this._handleFrameItems);
} else {
delete items[item._id];
if (--this._frameItemCount == 0) {
// If this is the last one, just stop animating straight away.
this.detach('frame', this._handleFrameItems);
}
}
},
// An empty callback that's only there so _frameItems can be handled
// through the onFrame callback framework that automatically starts and
// stops the animation for us whenever there's one or more frame handlers
_handleFrameItems: function(event) {
for (var i in this._frameItems) {
var entry = this._frameItems[i];
entry.item.fire('frame', Base.merge(event, {
// Time since first call of frame() in seconds:
time: entry.time += event.delta,
count: entry.count++
}));
}
},
_redraw: function() {
this._redrawNeeded = true;
if (this._animate) {
// If we're animating, call _handleFrame staight away, but without
// requesting another animation frame.
this._handleFrame();
} else {
// Otherwise simply redraw the view now
this.draw();
}
},
2011-12-10 14:05:26 -05:00
_transform: function(matrix) {
2011-08-22 05:13:42 -04:00
this._matrix.preConcatenate(matrix);
// Force recalculation of these values next time they are requested.
this._bounds = null;
this._inverse = null;
this._redraw();
2011-08-22 05:13:42 -04:00
},
2011-06-27 09:13:24 -04:00
/**
* The underlying native element.
2011-06-27 09:13:24 -04:00
*
2011-06-27 09:27:32 -04:00
* @type HTMLCanvasElement
2011-06-27 09:13:24 -04:00
* @bean
*/
getElement: function() {
return this._element;
2011-06-04 10:15:35 -04:00
},
2011-05-23 14:10:25 -04:00
/**
* The size of the view. Changing the view's size will resize it's
* underlying element.
*
* @type Size
2011-05-23 14:10:25 -04:00
* @bean
*/
getViewSize: function() {
return this._viewSize;
},
setViewSize: function(size) {
size = Size.read(arguments);
var delta = size.subtract(this._viewSize);
if (delta.isZero())
return;
this._element.width = size.width;
this._element.height = size.height;
// Update _viewSize but don't notify of change.
this._viewSize.set(size.width, size.height, true);
// Force recalculation
this._bounds = null;
this._redrawNeeded = true;
// Call onResize handler on any size change
this.fire('resize', {
size: size,
delta: delta
});
this._redraw();
},
2011-05-23 14:10:25 -04:00
/**
2011-06-27 09:13:24 -04:00
* The bounds of the currently visible area in project coordinates.
*
* @type Rectangle
2011-05-23 14:10:25 -04:00
* @bean
*/
getBounds: function() {
if (!this._bounds)
this._bounds = this._getInverse()._transformBounds(
new Rectangle(new Point(), this._viewSize));
return this._bounds;
},
2011-05-23 14:10:25 -04:00
/**
2011-06-27 09:13:24 -04:00
* The size of the visible area in project coordinates.
*
2011-05-23 14:10:25 -04:00
* @type Size
* @bean
*/
getSize: function() {
return this.getBounds().getSize();
},
2011-05-23 14:10:25 -04:00
/**
2011-06-27 09:13:24 -04:00
* The center of the visible area in project coordinates.
*
2011-05-23 14:10:25 -04:00
* @type Point
* @bean
*/
getCenter: function() {
return this.getBounds().getCenter();
},
setCenter: function(center) {
this.scrollBy(Point.read(arguments).subtract(this.getCenter()));
},
2011-05-23 14:10:25 -04:00
/**
2011-06-27 09:13:24 -04:00
* The zoom factor by which the project coordinates are magnified.
*
* @type Number
2011-05-23 14:10:25 -04:00
* @bean
*/
getZoom: function() {
return this._zoom;
},
setZoom: function(zoom) {
// TODO: Clamp the view between 1/32 and 64, just like Illustrator?
this._transform(new Matrix().scale(zoom / this._zoom,
this.getCenter()));
this._zoom = zoom;
},
/**
* Checks whether the view is currently visible within the current browser
* viewport.
*
* @return {Boolean} Whether the view is visible.
*/
isVisible: function() {
return DomElement.isInView(this._element);
},
2011-05-23 14:10:25 -04:00
/**
2011-06-27 09:13:24 -04:00
* Scrolls the view by the given vector.
*
2011-05-23 14:10:25 -04:00
* @param {Point} point
*/
scrollBy: function(point) {
2011-05-17 08:43:10 -04:00
this._transform(new Matrix().translate(Point.read(arguments).negate()));
},
/**
* Draws the view.
*
* @name View#draw
* @function
*/
/*
draw: function(checkRedraw) {
},
*/
// TODO: getInvalidBounds
// TODO: invalidate(rect)
// TODO: style: artwork / preview / raster / opaque / ink
// TODO: getShowGrid
// TODO: getMousePoint
// TODO: projectToView(rect)
projectToView: function(point) {
return this._matrix._transformPoint(Point.read(arguments));
},
viewToProject: function(point) {
return this._getInverse()._transformPoint(Point.read(arguments));
},
2011-05-17 08:43:45 -04:00
_getInverse: function() {
if (!this._inverse)
this._inverse = this._matrix.inverted();
2011-05-17 08:43:45 -04:00
return this._inverse;
}
2011-05-17 08:43:45 -04:00
/**
* {@grouptitle Event Handlers}
2011-05-23 14:10:25 -04:00
* Handler function to be called on each frame of an animation.
* The function receives an event object which contains information about
* the frame event:
*
* <b>{@code event.count}</b>: the number of times the frame event was
* fired.
* <b>{@code event.time}</b>: the total amount of time passed since the
* first frame event in seconds.
* <b>{@code event.delta}</b>: the time passed in seconds since the last
* frame event.
*
* @example {@paperscript}
* // Creating an animation:
*
* // Create a rectangle shaped path with its top left point at:
* // {x: 50, y: 25} and a size of {width: 50, height: 50}
* var path = new Path.Rectangle(new Point(50, 25), new Size(50, 50));
* path.fillColor = 'black';
*
* function onFrame(event) {
* // Every frame, rotate the path by 3 degrees:
* path.rotate(3);
* }
*
* @name View#onFrame
2011-11-12 07:27:29 -05:00
* @property
* @type Function
*/
/**
* Handler function that is called whenever a view is resized.
*
* @example
* // Repositioning items when a view is resized:
*
* // Create a circle shaped path in the center of the view:
* var path = new Path.Circle(view.bounds.center, 30);
* path.fillColor = 'red';
*
* function onResize(event) {
* // Whenever the view is resized, move the path to its center:
* path.position = view.center;
* }
*
* @name View#onResize
2011-11-12 07:27:29 -05:00
* @property
* @type Function
*/
/**
* {@grouptitle Event Handling}
*
* Attach an event handler to the view.
*
* @name View#attach
* @function
* @param {String('frame', 'resize')} type the event type
* @param {Function} function The function to be called when the event
* occurs
*/
/**
* Attach one or more event handlers to the view.
*
* @name View#attach^2
* @function
* @param {Object} param An object literal containing one or more of the
* following properties: {@code frame, resize}.
*/
/**
* Detach an event handler from the view.
*
* @name View#detach
* @function
* @param {String('frame', 'resize')} type the event type
* @param {Function} function The function to be detached
*/
/**
* Detach one or more event handlers from the view.
*
* @name View#detach^2
* @function
* @param {Object} param An object literal containing one or more of the
* following properties: {@code frame, resize}
*/
/**
* Fire an event on the view.
*
* @name View#fire
* @function
* @param {String('frame', 'resize')} type the event type
* @param {Object} event An object literal containing properties describing
* the event.
*/
/**
* Check if the view has one or more event handlers of the specified type.
*
* @name View#responds
* @function
* @param {String('frame', 'resize')} type the event type
* @return {Boolean} {@true if the view has one or more event handlers of
* the specified type}
*/
}, {
statics: {
_views: [],
_viewsById: {},
_id: 0,
create: function(element) {
/*#*/ if (options.browser) {
if (typeof element === 'string')
element = document.getElementById(element);
/*#*/ } // options.browser
// Factory to provide the right View subclass for a given element.
// Produces only Canvas-Views for now:
return new CanvasView(element);
}
}
}, new function() {
// Injection scope for mouse events on the browser
/*#*/ if (options.browser) {
var tool,
curPoint,
prevFocus,
tempFocus,
dragging = false;
function getView(event) {
// Get the view from the current event target.
return View._viewsById[DomEvent.getTarget(event).getAttribute('id')];
}
function viewToProject(view, event) {
return view.viewToProject(DomEvent.getOffset(event, view._element));
}
function updateFocus() {
if (!View._focused || !View._focused.isVisible()) {
// Find the first visible view
for (var i = 0, l = View._views.length; i < l; i++) {
var view = View._views[i];
if (view && view.isVisible()) {
View._focused = tempFocus = view;
2011-12-05 20:41:40 -05:00
break;
}
}
}
}
function mousedown(event) {
// Get the view from the event, and store a reference to the view that
// should receive keyboard input.
var view = View._focused = getView(event);
curPoint = viewToProject(view, event);
dragging = true;
// Always first call the view's mouse handlers, as required by
// CanvasView, and then handle the active tool, if any.
if (view._onMouseDown)
view._onMouseDown(event, curPoint);
if (tool = view._scope._tool)
tool._onHandleEvent('mousedown', curPoint, event);
// In the end we always call draw(), but pass checkRedraw = true, so we
// only redraw the view if anything has changed in the above calls.
view.draw(true);
}
function mousemove(event) {
var view;
if (!dragging) {
// See if we can get the view from the current event target, and
// handle the mouse move over it.
view = getView(event);
if (view) {
// Temporarily focus this view without making it sticky, so
// Key events are handled too during the mouse over
prevFocus = View._focused;
View._focused = tempFocus = view;
} else if (tempFocus && tempFocus == View._focused) {
// Clear temporary focus again and update it.
View._focused = prevFocus;
updateFocus();
}
}
if (!(view = view || View._focused))
return;
var point = event && viewToProject(view, event);
if (view._onMouseMove)
view._onMouseMove(event, point);
if (tool = view._scope._tool) {
var onlyMove = !!(!tool.onMouseDrag && tool.onMouseMove);
if (dragging && !onlyMove) {
if ((curPoint = point || curPoint)
&& tool._onHandleEvent('mousedrag', curPoint, event))
DomEvent.stop(event);
} else if ((!dragging || onlyMove)
&& tool._onHandleEvent('mousemove', point, event)) {
DomEvent.stop(event);
}
}
view.draw(true);
}
function mouseup(event) {
var view = View._focused;
if (!view || !dragging)
return;
var point = viewToProject(view, event);
curPoint = null;
dragging = false;
if (view._onMouseUp)
view._onMouseUp(event, point);
// Cancel DOM-event if it was handled by our tool
if (tool && tool._onHandleEvent('mouseup', point, event))
DomEvent.stop(event);
view.draw(true);
}
function selectstart(event) {
// Only stop this even if we're dragging already, since otherwise no
// text whatsoever can be selected on the page.
if (dragging)
DomEvent.stop(event);
}
// mousemove and mouseup events need to be installed on document, not the
// view element, since we want to catch the end of drag events even outside
// our view. Only the mousedown events are installed on the view, as handled
// by _createHandlers below.
DomEvent.add(document, {
mousemove: mousemove,
mouseup: mouseup,
touchmove: mousemove,
touchend: mouseup,
selectstart: selectstart,
scroll: updateFocus
});
DomEvent.add(window, {
load: updateFocus
});
return {
_viewHandlers: {
mousedown: mousedown,
touchstart: mousedown,
selectstart: selectstart
},
statics: {
/**
2011-11-12 18:05:28 -05:00
* Loops through all views and sets the focus on the first
* active one.
*/
updateFocus: updateFocus
}
};
/*#*/ } // options.browser
});