var _ = require("lodash");
var binder = require("./events").binder;
var DecksEvent = require("./events").DecksEvent;
var hasEmitter = require("./events").hasEmitter;
var rect = require("./utils").rect;
var dom = require("./ui").dom;
var GestureHandler = require("./ui").GestureHandler;
var Layout = require("./layout");
var Frame = require("./frame");
var validate = require("./utils/validate");
var browser = require("./utils/browser");
/**
* Canvas - manages the main DOM element in which items are rendered, and where
* UI/touch/gesture events are first handled.
*
* @class
* @mixes binder
* @param {Object} options additional options
*/
function Canvas(options) {
if (!(this instanceof Canvas)) {
return new Canvas(options);
}
options = _.merge({}, this.defaultOptions, options);
this.canvasId = _.uniqueId();
this.overflow = options.overflow;
this.watchWindowResize = options.watchWindowResize;
this.watchWindowScroll = options.watchWindowScroll;
this.debouncedOnWindowResize = _.debounce(this.onWindowResize, options.debouncedWindowResizeWait);
this.debouncedOnWindowScroll = _.debounce(this.onWindowScroll, options.debouncedWindowScrollWait);
this.debouncedOnGestureElementMoved = _.debounce(this.onGestureElementMoved, options.debouncedOnGestureElementMovedWait);
this.resetPositionOnFilter = options.resetPositionOnFilter;
this.setAnimator(options.animator);
this.setConfig(options.config);
this.setEmitter(options.emitter);
this.setLayout(options.layout);
this.setElement(options.element || dom.create("div")); // Don't make this a defaultOptions - otherwise all Canvases will share it
this.bind();
this.emit(DecksEvent("canvas:ready", this));
}
_.extend(Canvas.prototype, binder, hasEmitter, /** @lends Canvas.prototype */ {
/**
* Default {@link Canvas} constructor options
*/
defaultOptions: {
overflow: "hidden",
watchWindowScroll: true,
watchWindowResize: true,
debouncedWindowScrollWait: 200,
debouncedWindowResizeWait: 200,
debouncedOnGestureElementMovedWait: 200,
resetPositionOnFilter: true
},
/**
* Default options for the canvas GestureHandler
*/
defaultGestureHandlerOptions: {
gestures: {
pan: {
// Only monitor pan events for desktop - mobile uses native browser touch gestures
enabled: browser.isDesktop,
horizontal: false,
vertical: true
},
swipe: {
// Only monitor swipe events for desktop - mobile uses native browser touch gestures
enabled: browser.isDesktop,
horizontal: false,
vertical: true
},
scroll: {
enabled: true
}
},
movement: {
scroll: true
}
},
/**
* Events to bind to on the main emitter
*/
getEmitterEvents: function getEmitterEvents() {
return {
"deck:layout:set": "onDeckLayoutSet",
"deck:resize": "onDeckResize",
"item:collection:filter:set": "onItemCollectionFilterSet",
"frame:bounds:set": "onFrameBoundsSet",
"viewport:all:renders:drawn": "onViewportAllRendersDrawn",
"gesture:element:moved": "debouncedOnGestureElementMoved"
};
},
/**
* Events to bind to on the window
*/
getWindowEvents: function getWindowEvents() {
var map = {};
if (this.watchWindowResize) {
map.resize = "debouncedOnWindowResize";
}
if (this.watchWindowScroll) {
map.scroll = "debouncedOnWindowScroll";
}
return map;
},
/**
* Binds {@link Canvas} event handlers.
*
* @return {undefined}
*/
bind: function bind() {
this.bindEvents(this.emitter, this.getEmitterEvents());
this.bindEvents(window, this.getWindowEvents());
},
/**
* Unbinds {@link Canvas} event handlers.
*
* @return {undefined}
*/
unbind: function unbind() {
this.unbindEvents(this.emitter, this.getEmitterEvents());
this.unbindEvents(window, this.getWindowEvents());
},
/**
* Binds the {@link GestureHandler} managed by the {@link Canvas}
*
* @return {undefined}
*/
bindGestures: function bindGestureHandler() {
if (this.gestureHandler) {
this.gestureHandler.bind();
}
},
/**
* Unbinds the {@link GestureHandler} managed by the {@link Canvas}
*
* @return {undefined}
*/
unbindGestures: function unbindGestureHandler() {
if (this.gestureHandler) {
this.gestureHandler.unbind();
}
},
/**
* Destroys the {@link Canvas}
*
* @return {undefined}
*/
destroy: function destroy() {
this.unbind();
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
},
/**
* Sets the animator instance
*
* @param animator
* @return {undefined}
*/
setAnimator: function setAnimator(animator) {
validate(animator, "Canvas#setAnimator: animator", { isPlainObject: true, isNotSet: this.animator });
this.animator = animator;
},
/**
* Sets the config object
*
* @param config
* @return {undefined}
*/
setConfig: function setConfig(config) {
validate(config, "Canvas#setConfig: config", { isPlainObject: true, isNotSet: this.config });
this.config = config;
},
/**
* Sets the main container element, where items are rendered. Creates a
* div if no element is provided
*
* @param {?HTMLElement} element element for the container
* @param {?Object} options additional options
* @return {undefined}
*/
setElement: function setElement(element) {
validate(element, "Canvas#setElement: element", { isElement: true, isNotSet: this.element });
if (!element.id) {
element.id = this.config.canvasClassName + "-" + this.canvasId;
}
dom.addClass(element, this.config.canvasClassName);
dom.setStyle(element, "position", "absolute");
dom.setStyle(element, "top", 0);
dom.setStyle(element, "left", 0);
dom.setStyle(element, "overflow", this.overflow);
this.element = element;
this.emit(DecksEvent("canvas:element:set", this, this.element));
},
/**
* Sets the Layout instance, and reconfigures the Canvas based on Layout options
*
* @param layout
* @return {undefined}
*/
setLayout: function setLayout(layout) {
validate(layout, "Canvas#setLayout: layout", { isInstanceOf: Layout });
this.layout = layout;
this.configureGestures();
this.resetPosition();
},
/**
* Sets the bounds of the Canvas (width and height).
*
* This uses the {@link Layout#getCanvasBoundsOptions} to apply some post-processing
* to the bounds. E.g. if the Layout wants extra padding at the right or bottom,
* or wants to prevent overflow (so the {@link Canvas} doesn't create vertical or horizontal scrollbars
* on the {@link Frame}).
*
* @param bounds
* @return {undefined}
*/
setBounds: function setBounds(bounds, options) {
bounds = rect.normalize(bounds || this.element);
options = options || {};
// Ignore empty bounds (this can happen if the decks elements or ancestors become display: none)
if (rect.isEmpty(bounds)) {
return;
}
// Allow the Layout to control how the canvas bounds are set
var layoutBoundsOptions = this.layout.getCanvasBoundsOptions();
if (!options.noResize) {
var applyMarginRight = true;
var applyMarginBottom = true;
if (layoutBoundsOptions.smartMarginRight || layoutBoundsOptions.smartMarginBottom) {
// Smart margin right and bottom - only add margin if child elements are close to the edge
// of the bounds
var renderElementsBounds = this.getRenderElementsBounds();
if (!renderElementsBounds) {
// No child elements or no bounds - don't apply margins
applyMarginRight = false;
applyMarginBottom = false;
} else {
// Only add margin right if the child elements are close to the right edge of the bounds
if (layoutBoundsOptions.smartMarginRight) {
if (bounds.right - renderElementsBounds.right > layoutBoundsOptions.marginRight) {
applyMarginRight = false;
}
}
// Only add margin bottom if the child elements are close to the right edge of the bounds
if (layoutBoundsOptions.smartMarginBottom) {
if (bounds.bottom - renderElementsBounds.bottom > layoutBoundsOptions.marginBottom) {
applyMarginBottom = false;
}
}
}
}
// Add margin right and bottom to the bounds (from the layout canvas bounds options)
if (applyMarginRight) {
bounds = rect.resizeWidth(bounds, layoutBoundsOptions.marginRight);
}
if (applyMarginBottom) {
bounds = rect.resizeHeight(bounds, layoutBoundsOptions.marginBottom);
}
if (this.frameBounds) {
if (layoutBoundsOptions.preventOverflowHorizontal) {
// Resize the canvas back to the frame width to prevent horizontal overflow
bounds = rect.resizeToWidth(bounds, this.frameBounds.width);
}
if (layoutBoundsOptions.preventOverflowVertical) {
// Resize the canvas back to the frame height to prevent vertical overflow
bounds = rect.resizeToHeight(bounds, this.frameBounds.height);
}
}
if (layoutBoundsOptions.preventScrollbarHorizontal) {
// Reduce the width by a scrollbar width, so the presence of a vertical scrollbar
// doesn't cause a horizontal scrollbar to appear
bounds = rect.resizeWidth(bounds, -layoutBoundsOptions.scrollbarSize);
}
if (layoutBoundsOptions.preventScrollbarVertical) {
// Reduce the height by a scrollbar size, so the presence of a horizontal scrollbar
// doesn't cause the vertical scrollbar to appear
bounds = rect.resizeHeight(bounds, -layoutBoundsOptions.scrollbarSize);
}
}
if (rect.isEqual(this.bounds, bounds)) {
return;
}
this.emit(DecksEvent("canvas:bounds:setting", this, { oldBounds: this.bounds, newBounds: bounds }));
this.bounds = bounds;
dom.setStyle(this.element, "width", this.bounds.width);
dom.setStyle(this.element, "height", this.bounds.height);
this.emit(DecksEvent("canvas:bounds:set", this, this.bounds));
},
/**
* Sets the Frame instance on the Canvas
*
* @param frame
* @return {undefined}
*/
setFrame: function setFrame(frame) {
validate(frame, "Canvas#setFrame: frame", { isInstanceOf: Frame, isNotSet: this.frame });
this.frame = frame;
},
/**
* Sets the Frame bounds
*
* @param frameBounds
* @return {undefined}
*/
setFrameBounds: function setFrameBounds(frameBounds) {
validate(frameBounds, "Canvas#setFrameBounds: frameBounds", { isRequired: true });
if (rect.isEqual(this.frameBounds, frameBounds)) {
return;
}
this.frameBounds = frameBounds;
this.emit(DecksEvent("canvas:frame:bounds:set", this, this.frameBounds));
// If the Canvas bounds are not set yet, use the Frame bounds
if (!this.bounds) {
this.setBounds(this.frameBounds);
}
// If a GestureHandler is already created, update it's bounds, otherwise
// configure the GestureHandler now
if (this.gestureHandler) {
this.gestureHandler.setBounds(this.frameBounds);
} else {
this.configureGestures();
}
},
/**
* Adds a render (element) to the canvas, if not already added
*
* @param {Object} render render to remove
* @return {undefined}
*/
addRender: function addRender(render) {
validate(render, "Canvas#addRender: render", { isRequired: true });
if (render.isInCanvas) {
return;
}
validate(render.element, "Canvas#addRender: render.element", { isElement: true });
if (this.element.contains(render.element)) {
// TODO: this shouldn't happen, but seems to be happening with fast layout changes/filter changes/etc.
console.warn("Canvas#addRender: Canvas element already contains render element - not re-adding", render.element);
} else {
dom.append(this.element, render.element);
}
render.isInCanvas = true;
},
/**
* Removes a render (element) from the Canvas, if present.
*
* @param {Object} render render to remove
* @return {undefined}
*/
removeRender: function removeRender(render) {
validate(render, "Canvas#removeRender: render", { isRequired: true });
if (!render.isInCanvas) {
return;
}
validate(render.element, "Canvas#removeRender: render.element", { isElement: true });
if (!this.element.contains(render.element)) {
// TODO: this shouldn't happen, but seems to be happening with fast layout changes/filter changes/etc.
console.warn("Canvas#removeRender: Canvas element does not contain render element - not removing", render.element);
} else {
dom.remove(this.element, render.element);
}
render.isInCanvas = false;
},
/**
* Gets the .decks-item elements inside the canvas as plain array.
*
* @return {HTMLElement[]}
*/
getRenderElements: function() {
var itemSelector = "." + this.config.itemClassName;
// convert NodeList to a plain array
return _.map(this.element.querySelectorAll(itemSelector), _.identity);
},
/**
* Gets a rect that is the union of all the bounding client rects for all render elements.
*
* @return {Object} - the union of all element rects, or null if there are no elements.
*/
getRenderElementsBounds: function() {
var elements = this.getRenderElements();
if (_.isEmpty(elements)) {
return null;
}
return rect.unionAll(elements);
},
/**
* Resizes the {@link Canvas} to fit the specified Element.
*
* @param element
* @return {undefined}
*/
resizeToFitElement: function resizeToFitElement(element) {
validate(element, "Canvas#resizeToFitElement: element", { isElement: true });
var bounds = rect.unionAll([element, this.bounds, this.frameBounds]);
this.setBounds(bounds);
},
/**
* Resizes the canvas to fit all of the .decks-item elements currently in the Canvas.
*
* @return {undefined}
*/
resizeToFitAllElements: function resizeToFitAllElements() {
var renderElementsBounds = this.getRenderElementsBounds();
if (!renderElementsBounds) {
return;
}
// Don't include this.bounds in this union - we want to resize to fit the current elements,
// and don't care about the current canvas size
var bounds = rect.union(renderElementsBounds, this.frameBounds);
this.setBounds(bounds);
},
/**
* Resets the postiion of the {@link Canvas} (top/left or scrollTop/scrollLeft)
* to the default position (0, 0).
*
* This is handled by {@link GestureHandler#resetPosition}
*
* @return {undefined}
*/
resetPosition: function resetPosition() {
if (!this.gestureHandler) {
return;
}
this.gestureHandler.resetPosition();
},
/**
* Moves the {@link Canvas} to bring the specified element into view.
*
* This is handled by {@link GestureHandler#animateMoveToElement}
*
* @param element
* @return {undefined}
*/
panToElement: function panToElement(element) {
validate(element, "Canvas#panToElement: element", { isElement: true });
this.gestureHandler.animateMoveToElement(element);
},
/**
* Configures the {@link Canvas} {@link GestureHandler} options.
*
* This is used to configure how the user can interact with the canvas through touch gestures,
* or natural scrolling, and other Hammer.js or DOM events.
*
* The {@link Canvas} specifies default options, which can be overridden via {@link Layout#getCanvasGestureOptions}
* per {@link Layout}.
*
* @return {undefined}
*/
configureGestures: function configureGestures() {
if (!this.element || !this.frame) {
if (this.config.debugGestures) {
console.warn("Canvas#configureGestures: not configuring gestures - Canvas element or frame not set yet");
}
return;
}
var canvasGestureHandlerOptions = {
animator: this.animator,
config: this.config,
emitter: this.emitter,
element: this.element,
containerElement: this.frame.element,
bounds: this.frameBounds,
getMoveToElementOffsets: _.bind(this.layout.getMoveToElementOffsets, this.layout)
};
var layoutGestureHandlerOptions = this.layout.getCanvasGestureOptions();
var gestureHandlerOptions = _.merge({},
this.defaultGestureHandlerOptions,
canvasGestureHandlerOptions,
layoutGestureHandlerOptions);
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
this.gestureHandler = new GestureHandler(gestureHandlerOptions);
},
onDeckLayoutSet: function onDeckLayoutSet(e) {
var layout = e.data;
this.setLayout(layout);
},
onDeckResize: function onDeckResize() {
this.setBounds();
},
onItemCollectionFilterSet: function onItemCollectionFilterSet() {
var self = this;
if (self.resetPositionOnFilter) {
self.once("viewport:all:renders:drawn", function() {
self.resetPosition();
});
}
},
onFrameBoundsSet: function onFrameBoundsSet(e) {
var bounds = e.data;
this.setFrameBounds(bounds);
},
onViewportAllRendersDrawn: function onViewportAllRendersDrawn() {
this.resizeToFitAllElements();
},
onGestureElementMoved: function onGestureElementMoved(e) {
var element = e.data;
if (element !== this.element) {
return;
}
this.setBounds(null, { noResize: true });
},
onWindowScroll: function onWindowScroll() {
this.setBounds(null, { noResize: true });
},
onWindowResize: function onWindowResize() {
this.setBounds(null, { noResize: true });
}
});
module.exports = Canvas;