var _ = require("lodash");
var binder = require("./events").binder;
var hasEmitter = require("./events").hasEmitter;
var DecksEvent = require("./events").DecksEvent;
var dom = require("./ui").dom;
var ItemCollection = require("./itemcollection");
var Item = require("./item");
var Layout = require("./layout");
var Frame = require("./frame");
var Canvas = require("./canvas");
var validate = require("./utils/validate");
//var raf = require("raf");
var GestureHandler = require("./ui/gesturehandler");
var GestureHandlerGroup = require("./ui/gesturehandlergroup");
var logger = require("./utils/logger");
/**
* Viewport - manages visual (DOM) components
*
* @class
* @mixes binder
* @mixes hasEmitter
* @param {!Object} options - options for viewport initialization
* @param {!Object} options.animator - Animator object
* @param {!Object} options.config - Configuration object
* @param {!(Emitter|Object)} options.emitter - Emitter instance or options object
* @param {!ItemCollection} options.itemCollection - ItemCollection instance
* @param {!Layout} options.layout - Layout instance
* @param {!Frame} options.frame - Frame instance
* @param {!Canvas} options.canvas - Canvas instance
*/
function Viewport(options) {
if (!(this instanceof Viewport)) {
return new Viewport(options);
}
options = _.merge({}, this.defaultOptions, options);
/** Whether to run a draw cycle when the deck:ready event is handled */
this.drawOnDeckReady = options.drawOnDeckReady;
/** Debounced version of {@link Viewport#onGestureElementMoved} */
this.debouncedLoadOrUnloadRenders = _.debounce(this.loadOrUnloadRenders, options.debouncedLoadOrUnloadRendersWait);
/**
* Data structure for storing the items and corresponding renders
*
* The data structure is a 2-level tree. At the first level, the key is the item.id.
* The value at the first level is an object with render.ids as keys. The value At
* the second level is the render object, e.g.
*
* @example Internal Viewport renders data structure
* {
* // Item 1
* "item-id-1": {
* // Render "0" for Item 1
* "0": {
* "id": "0",
* "index": 0,
* "transform": {
* "top": 120,
* "left": 140
* ...
* },
* "animateOptions": {
* ...
* }
* },
* // Render "1" for Item 1
* "1": {
* "id": "1",
* "index": 1,
* "transform": {
* "top": 120,
* "left": 140
* ...
* },
* "animateOptions": {
* ...
* }
* }
* },
* // Item 2
* "item-id-2": {
* // Render "0" for Item 2
* "0": {
* "id": "0",
* "index": 1,
* "transform": ...
* }
* }
* }
*/
this.renders = {};
/**
* Keyed object of custom render objects. The key is the custom Render id.
*
* A custom render is an object with an element/transform/etc., which is drawn on the
* {@link Canvas}, but is not associated with an {@link Item} in the {@link ItemCollection}.
*
* This might be a custom divider line, label, etc.
*
* @example Internal Viewport custom renders data structure
* {
* "0": {
* element: ...,
* transform: {
* ...
* },
* animateOptions: {
* ...
* },
* someOtherProperty: {
* ...
* }
* }
* }
*/
this.customRenders = {};
/** Keeps track of how many renders are currently being drawn */
this.renderAnimationCount = 0;
/** Keeps track of the number of custom renders being drawn */
this.customRenderAnimationCount = 0;
/** Keeps track of {@link GestureHandlerGroup}s */
this.gestureHandlerGroups = {};
/** Whether to stop animations when a new cycle starts while one is already running */
this.useAnimationStopping = options.useAnimationStopping;
/**
* Flag that indicates if the deck is ready. Drawing actions are suppressed
* until the deck is signaled as ready
*/
this.isDeckReady = false;
/** Whether drawing is enabled */
this.isDrawingEnabled = options.isDrawingEnabled;
/**
* Object of properties to pass to all {@link Layout} methods invoked by the {@link Viewport}
* This is to provide the {@link Layout} methods with more context for their logic.
*/
this.layoutMethodOptions = {
viewport: this
};
this.setAnimator(options.animator);
this.setConfig(options.config);
this.setEmitter(options.emitter);
this.setDeck(options.deck);
this.setItemCollection(options.itemCollection);
this.setLayout(options.layout);
this.setFrame(options.frame);
this.setCanvas(options.canvas);
this.layoutMethodOptions.emitter = this.emitter;
this.bind();
this.emit(DecksEvent("viewport:ready", this));
}
_.extend(Viewport.prototype, binder, hasEmitter, /** @lends Viewport.prototype */ {
/**
* Default options for instances of Viewport
*/
defaultOptions: {
/** Whether to run a draw cycle on deck:ready */
drawOnDeckReady: true,
/** Whether drawing is enabled */
isDrawingEnabled: true,
/** Whether to stop animations when a draw cycle happens while another cycle is running */
useAnimationStopping: true,
/** Wait time for debounced loadOrUnloadRenders function */
debouncedLoadOrUnloadRendersWait: 400
},
/**
* Event to method mapping for binding to the decks emitter.
*/
getEmitterEvents: function getEmitterEvents() {
return {
// Deck
"deck:ready": "onDeckReady",
"deck:draw": "onDeckDraw",
"deck:layout:setting": "onDeckLayoutSetting",
"deck:layout:set": "onDeckLayoutSet",
// Frame
"frame:bounds:setting": "onFrameBoundsSetting",
"frame:bounds:set": "onFrameBoundsSet",
// Item
"item:changed": "onItemChanged",
// ItemCollection
"item:collection:item:adding": "onItemCollectionItemAdding",
"item:collection:item:added": "onItemCollectionItemAdded",
"item:collection:items:adding": "onItemCollectionItemsAdding",
"item:collection:items:added": "onItemCollectionItemsAdded",
"item:collection:item:removing": "onItemCollectionItemRemoving",
"item:collection:item:removed": "onItemCollectionItemRemoved",
"item:collection:clearing": "onItemCollectionClearing",
"item:collection:cleared": "onItemCollectionCleared",
"item:collection:filter:setting": "onItemCollectionFilterSetting",
"item:collection:sort:by:setting": "onItemCollectionSortBySetting",
"item:collection:reversed:setting": "onItemCollectionReversedSetting",
"item:collection:indexing": "onItemCollectionIndexing",
"item:collection:indexed": "onItemCollectionIndexed",
// Gestures
"gesture:element:moved": "onGestureElementMoved"
};
},
/**
* Binds all {@link Viewport} event handlers
*
* @return {undefined}
*/
bind: function bind() {
this.bindEvents(this.emitter, this.getEmitterEvents());
},
/**
* Unbinds all {@link Viewport} event handlers
*
* @return {undefined}
*/
unbind: function unbind() {
this.unbindEvents(this.emitter, this.getEmitterEvents());
},
/**
* Binds all {@link GestureHandlerGroup}s managed by the {@link Viewport}
*
* @return {undefined}
*/
bindGestures: function bindGestures() {
if (this.gestureHandlerGroups) {
_.each(this.gestureHandlerGroups, function(gestureHandlerGroup) {
gestureHandlerGroup.bind();
}, this);
}
},
/**
* Unbinds all {@link GestureHandlerGroup}s managed by the {@link Viewport}
*
* @return {undefined}
*/
unbindGestures: function bindGestures() {
if (this.gestureHandlerGroups) {
_.each(this.gestureHandlerGroups, function(gestureHandlerGroup) {
gestureHandlerGroup.unbind();
}, this);
}
},
/**
* Destroys the {@link Viewport}
*
* @return {undefined}
*/
destroy: function destroy() {
this.unbind();
if (this.gestureHandlerGroups) {
_.each(this.gestureHandlerGroups, function(gestureHandlerGroup) {
gestureHandlerGroup.destroy();
}, this);
}
},
/**
* Sets the animator instance
*
* @param animator
* @return {undefined}
*/
setAnimator: function setAnimator(animator) {
validate(animator, "animator", { isPlainObject: true, isNotSet: this.animate });
this.animator = this.layoutMethodOptions.animator = animator;
},
/**
* Sets the configuration object
*
* @param config
* @return {undefined}
*/
setConfig: function setConfig(config) {
validate(config, "config", { isPlainObject: true, isNotSet: this.config });
this.config = this.layoutMethodOptions.config = config;
},
/**
* Sets the deck instance.
*
* @param deck
* @return {undefined}
*/
setDeck: function setDeck(deck) {
validate(deck, "Viewport#setDeck: deck", { isRequired: true });
this.deck = this.layoutMethodOptions.deck = deck;
},
/**
* Sets the {@link ItemCollection} instance
*
* @param itemCollection
* @return {undefined}
*/
setItemCollection: function setItemCollection(itemCollection) {
validate(itemCollection, "itemCollection", { isInstanceOf: ItemCollection, isNotSet: this.itemCollection });
this.itemCollection = this.layoutMethodOptions.itemCollection = itemCollection;
},
/**
* Sets the {@link Layout} instance
*
* @param layout
* @return {undefined}
*/
setLayout: function setLayout(layout) {
validate(layout, "layout", { isInstanceOf: Layout });
this.layout = this.layoutMethodOptions.layout = layout;
},
/**
* Sets the {@link Frame} instance
*
* @param frame
* @return {undefined}
*/
setFrame: function setFrame(frame) {
validate(frame, "frame", { isInstanceOf: Frame, isNotSet: this.frame });
this.frame = this.layoutMethodOptions.frame = frame;
},
/**
* Sets the {@link Canvas} instance
*
* @param canvas
* @return {undefined}
*/
setCanvas: function setCanvas(canvas) {
validate(canvas, "canvas", { isInstanceOf: Canvas, isNotSet: this.canvas });
this.canvas = this.layoutMethodOptions.canvas = canvas;
},
/**
* Indicates whether the {@link Viewport} can draw. This check is based on the isDeckReady flag,
* the isDrawingEnabled flag, and possibly other conditions.
*
* @param {boolean} [canDrawCondition=undefined] - extra condition to check
* @return {boolean} - true if drawing can be done, otherwise false
*/
canDraw: function canDraw(canDrawCondition) {
canDrawCondition = _.isBoolean(canDrawCondition) ? canDrawCondition : true;
if (!canDrawCondition) {
if (this.config.debugDrawing) {
console.warn("Viewport#canDraw: not drawing - can draw condition is false");
}
return false;
}
if (!this.isDeckReady) {
if (this.config.debugDrawing) {
console.warn("Viewport#canDraw: not drawing - deck is not ready");
}
return false;
}
if (!this.isDrawingEnabled) {
if (this.config.debugDrawing) {
console.warn("Viewport#canDraw: not drawing - drawing is disabled");
}
return false;
}
return true;
},
/**
* Enables drawing
*
* @return {undefined}
*/
enableDrawing: function enableDrawing() {
if (this.config.debugDrawing) {
console.log("Viewport#enableDrawing: enabling drawing");
}
this.isDrawingEnabled = true;
},
/**
* Disables drawing
*
* @return {undefined}
*/
disableDrawing: function disableDrawing() {
if (this.config.debugDrawing) {
console.log("Viewport#disableDrawing: disabling drawing");
}
this.isDrawingEnabled = false;
},
/**
* Starts the drawing (animation) process for an {@link Item}.
*
* 1. Get one or more "render" objects from the {@link Layout} for the {@link Item}. A "render" is
* an object that specifies where to place an item in the canvas, along with animation
* options to animate the positioning/transform/delay/druation/etc. A Layout can provide zero, one,
* or more renders for an single {@link Item}, if the {@link Item} needs to be displayed multiple times within
* the {@link Canvas} (e.g. if one {@link Item} should have multiple visual representations on the screen).
*
* 2. Initiate the async draw (animation) process for each render.
*
* @param {!Item} item item to draw
* @param {?Object} options - additional options for drawing
* @return {undefined}
*/
drawItem: function drawItem(item, options) {
validate(item, "item", { isInstanceOf: Item });
options = options || {};
if (this.config.debugDrawing) {
console.log("Viewport#drawItem: drawing item", item.id);
}
if (!options.silent) {
this.emit(DecksEvent("viewport:item:drawing", this, item));
}
var layoutRenders = this.layout.getRenders(item, this.layoutMethodOptions);
if (_.isNull(layoutRenders) || _.isUndefined(layoutRenders)) {
layoutRenders = [];
} else if (!_.isArray(layoutRenders)) {
layoutRenders = [layoutRenders];
}
var renders = {};
_.each(layoutRenders, function(render, index) {
// Assign ids to each render (based on the array index), and change it from an array to
// an object with the render id as the key, and the render as the value. Also, add some additional
// data to the render, like the item.
render.id = "" + index;
render.index = index;
render.item = item;
renders[render.id] = render;
});
this.drawRenders(item, renders);
},
/**
* Starts the drawing process for all Items in the ItemCollection.
*
* @return {undefined}
*/
drawItems: function drawItems(items, options) {
items = _.isArray(items) ? items : this.itemCollection.getItems();
options = options || {};
if (items.length === 0) {
return;
}
if (this.config.debugDrawing) {
console.log("Viewport#drawItems: drawing items (length %d)", items.length);
}
if (!options.silent) {
this.emit(DecksEvent("viewport:items:drawing", this, items));
}
_.each(items, function(item) {
this.drawItem(item, { silent: true });
}, this);
},
/**
* Starts the erasing process for an Item. All of the renders for the Item will
* be "erased" (removed from the DOM), possibly with an removal animation. Once
* each render is "erased" the actual render object is removed from the renders data
* structure. Once all of the Item's renders are removed, the item itself will be
* removed from the renders data structure.
*
* @param {Item} item item for which to remove renders
* @return {undefined}
*/
eraseItem: function eraseItem(item, options) {
validate(item, "item", { isInstanceOf: Item });
options = options || {};
if (item.isErasing) {
return;
}
if (this.config.debugDrawing) {
console.log("Viewport#eraseItem: erasing item", item.id);
}
if (!options.silent) {
this.emit(DecksEvent("viewport:item:erasing", this, item));
}
item.isErasing = true;
this.eraseRenders(item);
},
/**
* Starts the erasing process for all the Items in the ItemCollection.
*
* @return {undefined}
*/
eraseItems: function eraseItems(items, options) {
items = _.isArray(items) ? items : this.itemCollection.getItems();
options = options || {};
if (items.length === 0) {
return;
}
if (this.config.debugDrawing) {
console.log("Viewport#eraseItem: erasing items (length %d)", items.length);
}
if (!options.silent) {
this.emit(DecksEvent("viewport:items:erasing", this, items));
}
_.each(items, function(item) {
this.eraseItem(item, { silent: true });
}, this);
},
/**
* Removes an item from the internal items/renders data structure. This is called
* automatically after eraseItem, once all the renders have been erased and removed.
* This should not be called directly.
*
* @param {!Item} item Item to remove
* @return {undefined}
*/
removeItem: function removeItem(item, options) {
validate(item, "item", { isInstanceOf: Item });
options = options || {};
if (this.config.debugDrawing) {
console.log("Viewport#removeItem: removing item", item.id);
}
delete this.renders[item.id];
if (!options.silent) {
this.emit(DecksEvent("viewport:item:erased", this, item));
}
},
/**
* Gets the renders object for the given Item.
*
* This returns the renders currently stored in the Viewport instance,
* it does not request new renders from the Layout.
*
* @param {?Item} item item for which to get renders, or if not specified, get all renders
* @return {Object[]} array of renders for the given item
*/
getRenders: function getRenders(item) {
// If no item specified, return all current renders
if (!item) {
return _(this.renders) // object with item ids as keys, with values of objects with render ids as keys
.map(_.values) // array of objects that have render ids as keys
.map(_.values) // array of arrays of render objects
.flatten() // array of render objects
.value();
}
validate(item, "item", { isInstanceOf: Item });
if (!this.renders[item.id]) {
this.renders[item.id] = {};
}
return this.renders[item.id];
},
/**
* Gets a single render object for an {@link Item}.
*
* @param {!Item} item - Item for which t o get render
* @param {!(String|Number)} [renderIdOrIndex=0] - render id or index
* @return {Object} - the render object (if exists)
*/
getRender: function getRender(item, renderIdOrIndex) {
validate(item, "item", { isInstanceOf: Item });
var renderId = "" + (renderIdOrIndex || 0);
return this.getRenders(item)[renderId];
},
/**
* Checks if an Item currently has any renders stored in the Viewport items/renders
* data structure.
*
* @param {?Item} item - Item for which to check for the existence of renders (or undefined to see if any renders exist)
* @return {boolean} true if the Item has renders, otherwise false
*/
hasRenders: function hasRenders(item) {
return !_.isEmpty(this.getRenders(item));
},
/**
* Stores the given render object in the Viewports internal items/renders data structure.
*
* This is called automatically after a render has been drawn (after the animation completes).
* This should not be called directly.
*
* @param {!Object} render render to store
* @return {undefined}
*/
setRender: function setRender(render) {
validate(render, "render", { isRequired: true });
validate(render.item, "render.item", { isInstanceOf: Item });
if (this.config.debugDrawing) {
console.log("Viewport#setRender: setting render", render.item.id, render.id);
}
var renders = this.getRenders(render.item);
renders[render.id] = render;
},
/**
* Starts the drawing process for a render.
*
* A render is an object which contains a DOM element - the render "container" element,
* a "transform" - a hash of CSS properties and values to animate/set, and an "animateOptions"
* which is a hash of animation properties, like duration, easing, etc. A render is drawn
* by executing the transform on the element, using a compatible animation function like
* VelocityJS. The drawing/animation process is asynchronous - this method starts the process,
* and callbacks are used to track completion of the animation.
*
* @param {!Object} options animation options
* @param {!Object} options.render render object
* @param {!HTMLElement} options.render.element render element
* @param {!Object} options.render.transform hash of CSS style properties to animate
* @param {!Object} options.render.animateOptions animation options
* @return {undefined}
*/
drawRender: function drawRender(render) {
validate(render, "render", { isRequired: true });
validate(render.element, "render.element", { isElement: true });
if (render.isAnimating) {
this.stopRenderAnimation(render);
}
render.isAnimating = true;
this.renderAnimationCount++;
this.setDefaultRenderAnimateOptions(render);
this.setRender(render);
if (this.config.debugDrawing) {
console.log("%cViewport#drawRender: %s render (animations %d)",
logger.GREEN_BG,
render.isErasing ? "erasing" : "drawing",
this.renderAnimationCount,
render.item.id,
render.id);
}
this.animator.animate({
elements: render.element,
properties: render.transform,
options: render.animateOptions
});
},
/**
* Draws the specified renders for the given Item.
*
* The Layout getRenders method does not specify an element in the render object, because the Layout
* has no knowledge of elements - it merely provides the transform and animateOptions that it wants to
* apply the the element(s) for an Item. The Viewport keeps track of the Items and renders in a tree data
* structure. When new renders are retrieved from teh Layout, this method will merge the new renders
* with any existing renders, add new elements where needed, and mark other elements for removal, and applies
* the new render transforms for any existing elements.
*
* @param {!Item} item Item for which to draw renders
* @param {!Object} renders keyed object of renders to draw for the item
* @return {undefined}
*/
drawRenders: function drawRenders(item, renders) {
validate(item, "item", { isInstanceOf: Item });
validate(renders, "renders", { isPlainObject: true });
var newRenderIds = _.keys(renders);
var previousRenders = this.getRenders(item);
var previousRenderIds = _.keys(previousRenders);
var renderIdsToMerge = _.intersection(previousRenderIds, newRenderIds); // renders that existed before, and exist in new set
var renderIdsToRemove = _.difference(previousRenderIds, renderIdsToMerge); // renders that existed before, but don't exist in new set
var renderIdsToAdd = _.difference(newRenderIds, renderIdsToMerge); // renders that did not exist before, but exist in new set
if (this.config.debugDrawing) {
console.log("Viewport#drawRenders: drawing/erasing renders for item", JSON.stringify({
item: item.id,
prevIds: previousRenderIds,
newIds: newRenderIds,
mergeIds: renderIdsToMerge,
removeIds: renderIdsToRemove,
addIds: renderIdsToAdd
}));
}
_.each(renderIdsToMerge, function(renderId) {
var previousRender = previousRenders[renderId];
var newRender = renders[renderId];
var mergedRender = _.merge({}, previousRender, newRender);
// If the transform is the same for the render, don't actually do the animation,
// but fake it as if it happened, so we can still rely on the normal render cycle
// completion logic (like detecting when all items have been processed, even if none
// of them have changed.
if (_.isEqual(previousRender.transform, mergedRender.transform)) {
if (this.config.debugDrawing) {
console.info("Viewport#drawRenders: not animating item render (no change to transform)", item.id, mergedRender.id);
}
return;
}
this.drawRender(mergedRender);
}, this);
_.each(renderIdsToAdd, function(renderId) {
var newRender = renders[renderId];
newRender.element = this.createRenderElement(item, newRender);
this.initializeRender(newRender);
this.canvas.addRender(newRender);
this.drawRender(newRender);
}, this);
_.each(renderIdsToRemove, function(renderId) {
var previousRender = previousRenders[renderId];
this.eraseRender(previousRender);
}, this);
},
/**
* Starts the erasing process for a render. The erasing or hiding of a render is animated, and the actual
* removal of the render is done after the animation completes.
*
* @param {!Object} render render to remove
* @param {?Object} options additional options
* @return {undefined}
*/
eraseRender: function eraseRender(render) {
validate(render, "render", { isRequired: true });
if (render.isErasing) {
return;
}
render.isErasing = true;
this.layout.setHideAnimation(render, this.layoutMethodOptions);
this.drawRender(render);
},
/**
* Removes all the renders for an item, or all renders if no item is specified
*
* @param {?Item} item - item from which to remove all renders, or undefined to remove all renders
* @param {?Number} index index of item, if known
* @param {?Object} options additional options
* @return {undefined}
*/
eraseRenders: function eraseRenders(item) {
var renders = this.getRenders(item);
if (!renders || _.isEmpty(renders)) {
return;
}
if (this.config.debugDrawing) {
console.log("Viewport#eraseRenders: erasing renders (length %d)", _.keys(renders).length);
}
_.each(renders, function(render) {
this.eraseRender(render);
}, this);
},
/**
* Removes the given render from the Viewport's internal items/renders data structure.
*
* This is called automatically after a render has been erased (after the erase animation).
* This should not be called directly.
*
* @param {!Object} render render to remove
* @return {undefined}
*/
removeRender: function removeRender(render) {
validate(render, "render", { isRequired: true });
if (this.config.debugDrawing) {
console.log("Viewport#removeRender: removing render", render.item.id, render.id);
}
var renders = this.getRenders(render.item);
delete renders[render.id];
this.canvas.removeRender(render);
},
/**
* Stops the animation for a render. This does not call the "complete" callback
* for the animation.
*
* @param render
* @return {undefined}
*/
stopRenderAnimation: function stopRenderAnimation(render) {
if (!this.useAnimationStopping) {
return;
}
validate(render, "Viewport#stopRenderAnimation: render", { isRequired: true });
if (this.config.debugDrawing) {
console.log("%cStopping animation for render", logger.RED_BG, render.item.id, render.id);
}
this.animator.animate(render.element, "stop", true);
render.isAnimating = false;
this.renderAnimationCount--;
},
/**
* Finds all renders marked as isErasing and removes them.
* Then if all renders have been removed for an item, and the item is marked
* as isErasing, the Item is removed too.
*/
cleanErasedRendersAndItems: function cleanErasedRendersAndItems() {
// Remove all renders and items that have been successfully erased
_.each(this.getRenders(), function(render) {
if (render.isErasing) {
this.removeRender(render);
if (render.item.isErasing && !this.hasRenders(render.item)) {
this.removeItem(render.item);
}
}
}, this);
},
/**
* Gets the default animation options, extended with the options.render.animateOptions
*
* @param {!Object} options object to pass to callback methods, like complete
* @return {Object} hash of animation options
*/
setDefaultRenderAnimateOptions: function setDefaultRenderAnimateOptions(render) {
var self = this;
validate(render, "render", { isRequired: true });
render.animateOptions = render.animateOptions || {};
render.animateOptions.complete = function() {
self.onRenderAnimationComplete(render);
};
// Don't queue animations. They are stopped if a new animation is needed while
// one is already running (rather than being queued).
render.animateOptions.queue = false;
},
/**
* Creates a container element for an individual render
*
* @return {HTMLElement} detached DOM element which will become the container for a render element.
*/
createRenderElement: function createRenderElement(item, render) {
validate(item, "item", { isInstanceOf: Item });
validate(render, "render", { isRequired: true });
if (this.config.debugDrawing) {
console.log("Viewport#createRenderElement: creating element for render", render.item.id, render.id);
}
var element = dom.create("div");
element.id = this.config.itemClassName + "-" + item.id + "-" + render.id;
dom.addClass(element, this.config.itemClassName);
dom.setStyle(element, "position", "absolute");
dom.setStyle(element, "top", 0);
dom.setStyle(element, "left", 0);
dom.setAttr(element, "data-item-id", item.id);
dom.setAttr(element, "data-render-id", render.id);
return element;
},
/**
* Called when a new element is created for use as a render. This gives the {@link Layout}
* the opportunity to customize the initial state of the render. If an element already exists for
* a previous render, and the element will be re-used for a transition, this method is not called.
*/
initializeRender: function initializeRender(render) {
if (this.config.debugLoading) {
console.log("Viewport#initializeRender: initializing render", render.item.id, render.id);
}
validate(render, "render", { isRequired: true });
this.layout.initializeRender(render, this.layoutMethodOptions);
},
/**
* Delegates to the Layout instance to load the render contents.
*
* @param {!Object} render - render to load
* @return {undefined}
*/
loadRender: function loadRender(render) {
if (this.config.debugLoading) {
console.log("Viewport#loadRender: loading render", render.item.id, render.id);
}
validate(render, "render", { isRequired: true });
this.layout.loadRender(render, this.layoutMethodOptions);
},
/**
* Delegates to the layout instance to unload the render contents.
*
* @param {!Object} render - render to unload
* @return {undefined}
*/
unloadRender: function unloadRender(render) {
if (this.config.debugLoading) {
console.log("Viewport#unloadRender: unloading render", render.item.id, render.id);
}
validate(render, "render", { isRequired: true });
this.layout.unloadRender(render, this.layoutMethodOptions);
},
/**
* Returns a boolean indicating whether this render should be loaded at this time.
*
* The {@link Layout} can implement a method "shouldLoadRender" to specifiy whether
* any given render should be loaded.
*
* If the {@link Layout#shouldLoadRender} method returns false, the {@link Viewport}
* will call the {@link Layout#shouldUnloadRender} method to see if the render should
* be unloaded.
*
* @param {!Object} render - render to check whether it needs to be loaded
* @return {boolean}
*/
shouldLoadRender: function shouldLoadRender(render) {
validate(render, "render", { isRequired: true });
return this.layout.shouldLoadRender(render, this.layoutMethodOptions);
},
/**
* Returns a boolean indicating whether this render should be unloaded at this time.
*
* The {@link Layout} can implement a method "shouldUnloadRender" to specifiy whether
* any given render should be loaded.
*
* {@link Layout#shouldUnloadRender} is only called if {@link Layout#shouldLoadRender} returns
* false;
*
* @param {!Object} render - render to check whether it needs to be loaded
* @return {boolean}
*/
shouldUnloadRender: function shouldUnloadRender(render) {
validate(render, "render", { isRequired: true });
return this.layout.shouldUnloadRender(render, this.layoutMethodOptions);
},
/**
* Loads or unloads a render depending on factors like whether its visible in the
* frame element, etc.
*
* @param {!Object} render render to load or unload
* @return {undefined}
*/
loadOrUnloadRender: function loadOrUnloadRender(render) {
if (this.shouldLoadRender(render)) {
this.loadRender(render);
} else if (this.shouldUnloadRender(render)) {
this.unloadRender(render);
}
},
/**
* Loads or unloads all the renders managed by the Viewport.
*
* @return {undefined}
*/
loadOrUnloadRenders: function loadOrUnloadRenders() {
if (this.config.debugLoading) {
console.log("Viewport#loadOrUnloadRenders: loading or unloading renders");
}
_.each(this.getRenders(), function(render) {
this.loadOrUnloadRender(render);
}, this);
},
/**
* Gets the custom renders
*
* @return {undefined}
*/
getCustomRenders: function getCustomRenders() {
return this.customRenders;
},
/**
* Gets a custom render by id
*
* @param id
* @return {undefined}
*/
getCustomRender: function getCustomRender(id) {
return this.customRenders[id];
},
/**
* Sets a custom render in the internal data structure
*
* @param customRender
* @return {undefined}
*/
setCustomRender: function setCustomRender(customRender) {
validate(customRender, "customRender", { isRequired: true });
this.customRenders[customRender.id] = customRender;
},
/**
* Removes a custom render from the internal data structure.
*
* @param customRender
* @return {undefined}
*/
removeCustomRender: function removeCustomRender(customRender) {
validate(customRender, "customRender", { isRequired: true });
delete this.customRenders[customRender.id];
this.canvas.removeRender(customRender);
},
/**
* Indicates if there are any custom renders.
*
* @return {undefined}
*/
hasCustomRenders: function hasCustomRenders() {
return !_.isEmpty(this.customRenders);
},
/**
* Draws a custom render by initiating its animation.
*
* @param customRender
* @return {undefined}
*/
drawCustomRender: function drawCustomRender(customRender) {
var self = this;
validate(customRender, "customRender", { isRequired: true });
validate(customRender.element, "customRender.element", { isElement: true });
customRender.isAnimating = true;
self.customRenderAnimationCount++;
self.setDefaultCustomRenderAnimateOptions(customRender);
self.setCustomRender(customRender);
self.canvas.addRender(customRender);
if (self.config.debugDrawing) {
console.log("%cViewport#drawCustomRender: %s custom render (animations %d)",
logger.GREEN_FG,
customRender.isErasing ? "erasing" : "drawing",
self.customRenderAnimationCount,
customRender.id);
}
self.animator.animate({
elements: customRender.element,
properties: customRender.transform,
options: customRender.animateOptions
});
},
/**
* Calls the {@link Layout} to get custom renders, and initiates the drawing cycle for all of them.
*
* @return {undefined}
*/
drawCustomRenders: function drawCustomRenders() {
if (this.config.debugDrawing) {
console.log("Viewport#drawCustomRenders: drawing custom renders");
}
var layoutCustomRenders = this.layout.getCustomRenders(this.layoutMethodOptions);
if (!layoutCustomRenders || _.isEmpty(layoutCustomRenders)) {
return;
}
if (!_.isArray(layoutCustomRenders)) {
layoutCustomRenders = [layoutCustomRenders];
}
var customRenders = {};
_.each(layoutCustomRenders, function(customRender, index) {
validate(customRender.element, "Viewport#drawCustomRenders: customRender.element", { isElement: true });
customRender.id = customRender.element.id || ("" + _.uniqueId());
customRender.index = index;
customRenders[customRender.id] = customRender;
this.drawCustomRender(customRender);
}, this);
},
/**
* Marks a custom render as needing to be erased, and starts the erase animation.
*
* @param customRender
* @return {undefined}
*/
eraseCustomRender: function eraseCustomRender(customRender) {
validate(customRender, "customRender", { isRequired: true });
if (customRender.isErasing) {
return;
}
customRender.isErasing = true;
this.layout.setHideAnimation(customRender, this.layoutMethodOptions);
this.drawCustomRender(customRender);
},
/**
* Erases all the custom renders.
*
* @return {undefined}
*/
eraseCustomRenders: function eraseCustomRenders() {
if (this.config.debugDrawing) {
console.log("Viewport#eraseCustomRenders: erasing custom renders (length %d)", _.keys(this.getCustomRenders()).length);
}
_.each(this.getCustomRenders(), function(customRender) {
this.eraseCustomRender(customRender);
}, this);
},
/**
* Removes all custom render elements which have been hidden (completed the hide animation).
*/
cleanErasedCustomRenders: function cleanErasedCustomRenders() {
_.each(this.customRenders, function(customRender) {
if (customRender.isErasing) {
this.canvas.removeRender(customRender);
}
}, this);
},
/**
* Sets the default animation options for a custom render animation.
*
* @param customRender
* @return {undefined}
*/
setDefaultCustomRenderAnimateOptions: function setDefaultCustomRenderAnimateOptions(customRender) {
var self = this;
validate(customRender, "customRender", { isRequired: true });
customRender.animateOptions = customRender.animateOptions || {};
customRender.animateOptions.complete = function() {
self.onCustomRenderAnimationComplete(customRender);
};
customRender.animateOptions.queue = false;
},
/**
* Helper function for creating a custom render element with a default position.
*
* @param customRender
* @return {undefined}
*/
createCustomRenderElement: function createCustomRenderElement() {
var element = dom.create("div");
element.id = this.config.customRenderClassName + "-" + _.uniqueId();
dom.addClass(element, this.config.customRenderClassName);
dom.setStyle(element, "position", "absolute");
dom.setStyle(element, "top", 0);
dom.setStyle(element, "left", 0);
return element;
},
/**
* Pans the {@link Canvas} to the {@link Item}'s render element specified by renderIdOrIndex.
*
* @param {!Item} item - item to pan to
* @param {?(string|number)} [renderIdOrIndex=0] - render id or index to pan to
* @return {undefined}
*/
panToItem: function panToItem(item, renderIdOrIndex) {
validate(item, "Viewport#panToItem: item", { isInstanceOf: Item });
var render = this.getRender(item, renderIdOrIndex);
validate(render, "Viewport#panToItem: render", { isRequired: true });
validate(render.element, "Viewport#panToItem: render.element", { isRequired: true });
this.canvas.panToElement(render.element);
},
/**
* Configures gestures for a single render.
*
* @param render
* @return {undefined}
*/
configureRenderGestures: function configureRenderGestures(render) {
if (render.isErasing) {
this.removeRenderGestureHandler(render);
} else {
this.addRenderGestureHandler(render);
}
},
/**
* Configures gestures for all renders
*
* @return {undefined}
*/
configureAllRenderGestures: function() {
if (this.config.debugGestures) {
console.log("Viewport#configureAllRenderGestures: configuring all render gestures");
}
var renders = this.getRenders();
_.each(renders, function(render) {
this.configureRenderGestures(render);
}, this);
},
/**
* Destroys gestures for all renders.
*
* @return {undefined}
*/
destroyRenderGestures: function() {
if (this.config.debugGestures) {
console.log("Viewport#destroyRenderGestures: destroying all render gestures");
}
_.each(this.gestureHandlerGroups, function(gestureHandlerGroup) {
gestureHandlerGroup.destroy();
}, this);
// Clear the gesture handler group id off all renders
_.each(this.getRenders(), function(render) {
delete render.gestureHandlerGroupId;
});
this.gestureHandlerGroups = {};
},
/**
* Adds a {@link GestureHandler} for the render, and puts it in a {@link GestureHandlerGroup}
* according to the render.gestureHandlerGroupId.
*
* @param render
* @return {undefined}
*/
addRenderGestureHandler: function addRenderGestureHandler(render) {
var gestureHandlerGroupId = render.gestureHandlerGroupId;
if (!gestureHandlerGroupId) {
return;
}
// Get the GestureHandlerGroup options from the Layout
var layoutGestureHandlerOptions = this.layout.getRenderGestureOptions(render, this.layoutMethodOptions);
// Create the GestureHandlerGroup if it doesn't exist
if (!this.gestureHandlerGroups[gestureHandlerGroupId]) {
var defaultGestureHandlerGroupOptions = {
config: this.config,
emitter: this.emitter,
containerElement: this.canvas.element
};
var gestureHandlerGroupOptions = _.extend(defaultGestureHandlerGroupOptions, layoutGestureHandlerOptions);
this.gestureHandlerGroups[gestureHandlerGroupId] = new GestureHandlerGroup(gestureHandlerGroupOptions);
}
// Get a reference to the group
var gestureHandlerGroup = this.gestureHandlerGroups[gestureHandlerGroupId];
// If the element is already in the group, bail
if (gestureHandlerGroup.hasGestureHandlerForElement(render.element)) {
return;
}
// Create the GestureHandler to add to the GestureHandlerGroup
var defaultGestureHandlerOptions = {
animator: this.animator,
config: this.config,
emitter: this.emitter,
element: render.element,
bounds: this.frame.bounds,
snapping: {
toBounds: false,
hardStopAtBounds: true,
reduceMovementAtBounds: false,
toNearestChildElement: false
}
};
var gestureHandlerOptions = _.merge(defaultGestureHandlerOptions, layoutGestureHandlerOptions);
var gestureHandler = new GestureHandler(gestureHandlerOptions);
if (this.config.debugGestures) {
console.log("Viewport#addRenderGestureHandler: adding gesture handler", gestureHandlerGroupId, render);
}
gestureHandlerGroup.addGestureHandler(gestureHandler);
},
/**
* Removes the {@link GestureHandler} for a render.
*
* @param render
* @return {undefined}
*/
removeRenderGestureHandler: function removeRenderGestureHandler(render) {
var gestureHandlerGroupId = render.gestureHandlerGroupId;
if (!gestureHandlerGroupId) {
// No gesture handler group id specified
return;
}
if (!this.gestureHandlerGroups[gestureHandlerGroupId]) {
// No gesture handler group exists for this id
return;
}
var gestureHandlerGroup = this.gestureHandlerGroups[gestureHandlerGroupId];
if (this.config.debugGestures) {
console.log("Viewport#removeRenderGestureHandler: removing gesture handler", gestureHandlerGroupId, render);
}
gestureHandlerGroup.removeGestureHandlerForElement(render.element);
},
/**
* Called when the {@link Deck} is ready. Triggers a draw/load cycle.
*
* This draw cycle can be prevented from happening by setting the drawOnDeckReady to false in the
* {@link Viewport} options.
*
* @return {undefined}
*/
onDeckReady: function onDeckReady() {
if (this.config.debugDrawing) {
console.log("Viewport#onDeckReady");
}
this.isDeckReady = true;
if (!this.canDraw(this.drawOnDeckReady)) {
return;
}
this.drawItems();
},
/**
* Called when a draw request is made on the {@link Deck}. Triggers a redraw/reload cycle.
*
* @return {undefined}
*/
onDeckDraw: function onDeckDraw() {
if (this.config.debugDrawing) {
console.log("Viewport#onDeckDraw");
}
if (!this.canDraw()) {
return;
}
this.eraseCustomRenders();
this.drawItems();
},
/**
* Called before the {@link Deck} {@link Layout} is about to be set.
*
* @return {undefined}
*/
onDeckLayoutSetting: function onDeckLayoutSetting() {
if (this.config.debugDrawing) {
console.log("Viewport#onDeckLayoutSetting");
}
if (!this.canDraw()) {
return;
}
this.eraseCustomRenders();
this.destroyRenderGestures();
},
/**
* Called when a new layout is set on the {@link Deck}. Triggers a redraw/reload cycle.
*
* @return {undefined}
*/
onDeckLayoutSet: function onDeckLayoutSet(e) {
if (this.config.debugDrawing) {
console.log("Viewport#onDeckLayoutSet");
}
var layout = e.data;
this.setLayout(layout);
if (!this.canDraw()) {
return;
}
this.drawItems();
},
/**
* Called before the {@link Frame} bounds are about to be set.
*
* @return {undefined}
*/
onFrameBoundsSetting: function onFrameBoundsSetting() {
if (this.config.debugDrawing) {
console.log("Viewport#onFrameBoundsSetting");
}
if (!this.canDraw()) {
return;
}
this.eraseCustomRenders();
},
/**
* Called when the {@link Frame} bounds are set. Triggers a redraw cycle.
*
* @return {undefined}
*/
onFrameBoundsSet: function onFrameBoundsSet() {
if (this.config.debugDrawing) {
console.log("Viewport#onFrameBoundsSet");
}
if (!this.canDraw()) {
return;
}
this.drawItems();
},
/**
* Called when an {@link Item} is changed
*
* @param e
* @return {undefined}
*/
onItemChanged: function onItemChanged(e) {
if (this.config.debugDrawing) {
console.log("Viewport#onItemChanged");
}
if (!this.canDraw()) {
return;
}
var item = e.sender;
this.eraseCustomRenders();
this.drawItem(item);
},
onItemCollectionItemAdding: function onItemCollectionItemAdding() {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionItemAdding");
}
if (!this.canDraw()) {
return;
}
this.eraseCustomRenders();
},
onItemCollectionItemAdded: function onItemCollectionItemAdded() {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionItemAdded");
}
if (!this.canDraw()) {
return;
}
this.drawItems();
},
onItemCollectionItemsAdding: function onItemCollectionItemsAdding() {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionItemAdding");
}
if (!this.canDraw()) {
return;
}
this.eraseCustomRenders();
},
onItemCollectionItemsAdded: function onItemCollectionItemsAdded() {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionItemsAdded");
}
if (!this.canDraw()) {
return;
}
this.drawItems();
},
onItemCollectionItemRemoving: function onItemCollectionItemRemoving() {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionItemRemoving");
}
if (!this.canDraw()) {
return;
}
this.eraseCustomRenders();
},
onItemCollectionItemRemoved: function onItemCollectionItemRemoved(e) {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionItemRemoved");
}
if (!this.canDraw()) {
return;
}
var item = e.data;
// Start the erase animations for all the renders for the item that was removed
this.eraseItem(item);
// Re-draw all the other items - this is needed because the removal of an item might
// require other items to be re-drawn too.
// The Item is already removed from the ItemCollection when this is called, so calling
// drawItems() here will draw the remaining items (not including the removed one).
this.drawItems();
},
onItemCollectionClearing: function onItemCollectionClearing() {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionClearing");
}
if (!this.canDraw()) {
return;
}
this.eraseCustomRenders();
},
onItemCollectionCleared: function onItemCollectionCleared(e) {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionCleared");
}
if (!this.canDraw()) {
return;
}
var items = e.data;
this.eraseItems(items);
},
onItemCollectionFilterSetting: function onItemCollectionFilterSetting() {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionFilterSetting");
}
if (!this.canDraw()) {
return;
}
this.destroyRenderGestures();
},
onItemCollectionSortBySetting: function onItemCollectionSortBySetting() {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionSortBySetting");
}
if (!this.canDraw()) {
return;
}
this.destroyRenderGestures();
},
onItemCollectionReversedSetting: function onItemCollectionReversedSetting() {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionReversedSetting");
}
if (!this.canDraw()) {
return;
}
this.destroyRenderGestures();
},
onItemCollectionIndexing: function onItemCollectionIndexing() {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionIndexing");
}
if (!this.canDraw()) {
return;
}
this.eraseCustomRenders();
},
onItemCollectionIndexed: function onItemCollectionIndexed(e) {
if (this.config.debugDrawing) {
console.log("Viewport#onItemCollectionIndexed");
}
if (!this.canDraw()) {
return;
}
var stats = e.data;
if (stats.reason.isSetFilter || stats.reason.isSetSortBy || stats.reason.isSetReversed) {
this.drawItems({ loadNeeded: false });
if (stats.totalCount === 0 || stats.changedCount === 0) {
this.drawCustomRenders();
}
}
},
/**
* Called when an individual render animation completes.
*
* This updates the render data in the internal data structure, or removes the render if it was being
* erased. It also checks for the completion of a full render cycle, which might trigger additional events.
*
* @param render
* @return {undefined}
*/
onRenderAnimationComplete: function onRenderAnimationComplete(render) {
validate(render, "Viewport#onRenderAnimationComplete: render", { isRequired: true });
render.isAnimating = false;
this.renderAnimationCount--;
if (this.config.debugDrawing) {
console.log(
"%cViewport#onRenderAnimationComplete: render %s complete (animations %d)",
logger.BLUE_BG,
render.isErasing ? "erasing" : "drawing",
this.renderAnimationCount,
render.item.id,
render.id);
}
if (this.renderAnimationCount === 0) {
this.onAllRenderAnimationsComplete();
}
},
/**
* Called when all the animations in a render cycle have been completed.
*
* @return {undefined}
*/
onAllRenderAnimationsComplete: function onAllRenderAnimationsComplete() {
var self = this;
if (self.config.debugDrawing) {
console.log("Viewport#onAllRenderAnimationsComplete: all render animations complete");
}
_.defer(function() {
// Notify listeners that all renders have been drawn
// TODO: should this be done last?
self.emit(DecksEvent("viewport:all:renders:drawn", self));
_.defer(function() {
// Start drawing the custom renders (labels, lines, whatever, etc.)
self.drawCustomRenders();
_.defer(function() {
// Start checking if items should be loaded or unloaded
self.loadOrUnloadRenders();
_.defer(function() {
// Configure the render gestures (if layout calls for it)
self.configureAllRenderGestures();
_.defer(function() {
// Remove the erased render elements from the DOM, and Items that are
// marked to be erased.
self.cleanErasedRendersAndItems();
});
});
});
});
});
},
/**
* Called when a custom render animation completes. Adds the custom render to the internal data structure,
* or removes it if the custom render was being erased.
*
* This also checks for the end of the drawing cycle for all custom renders, and may perform additional
* logic at the end of the cycle.
*
* @param customRender
* @return {undefined}
*/
onCustomRenderAnimationComplete: function onCustomRenderAnimationComplete(customRender) {
validate(customRender, "Viewport#onCustomRenderAnimationComplete: customRender", { isRequired: true });
customRender.isAnimating = false;
this.customRenderAnimationCount--;
if (this.config.debugDrawing) {
console.log(
"%cViewport#onCustomRenderAnimationComplete: custom render animation complete (animations %s)",
logger.BLUE_FG,
this.customRenderAnimationCount,
customRender.id);
}
if (this.customRenderAnimationCount === 0) {
this.onAllCustomRenderAnimationsComplete();
}
},
/**
* Called when all the custom render animations have been completed.
*
* @return {undefined}
*/
onAllCustomRenderAnimationsComplete: function onAllCustomRenderAnimationsComplete() {
var self = this;
if (this.config.debugDrawing) {
console.log("Viewport#onAllCustomRenderAnimationsComplete: all custom render animations complete");
}
_.defer(function() {
self.emit(DecksEvent("viewport:all:custom:renders:drawn", self));
_.defer(function() {
self.cleanErasedCustomRenders();
});
});
},
/**
* Called when an element is moved via a gesture.
*
* @param e
* @return {undefined}
*/
onGestureElementMoved: function onGestureElementMoved(/*e*/) {
this.debouncedLoadOrUnloadRenders();
},
/**
* Called when the {@link Canvas} element is moved via a touch gesture, or scrolled.
*
* @return {undefined}
*/
onCanvasElementMoved: function onCanvasElementMoved() {
this.debouncedLoadOrUnloadRenders();
},
/**
* Called when a render element is moved (e.g. row layout where rows scroll independently)
*
* @return {undefined}
*/
onRenderElementMoved: function onRenderElementMoved() {
this.debouncedLoadOrUnloadRenders();
}
});
module.exports = Viewport;