UNPKG

decks

Version:

JavaScript UI library for viewing collections of items.

609 lines (512 loc) 17.8 kB
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;