diff --git a/Gruntfile.js b/Gruntfile.js index 578539a1..1a23e6d0 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -4,11 +4,50 @@ module.exports = function (grunt) { require('bsp-grunt')(grunt, { bsp: { + bower: { + // Gallery uses Masonry javascript, which has a few dependencies + // BEGIN dependencies for Masonry + 'outlayer': [ + { + src: 'outlayer.js', + dest: 'outlayer/outlayer.js' + }, + { + src: 'item.js', + dest: 'outlayer/item.js' + } + ], + 'ev-emitter': [ + { + src: 'ev-emitter.js', + dest: 'ev-emitter/ev-emitter.js' + } + ], + 'get-size': [ + { + src: 'get-size.js', + dest: 'get-size/get-size.js' + } + ], + 'fizzy-ui-utils': [ + { + src: 'utils.js', + dest: 'fizzy-ui-utils/utils.js' + } + ], + 'desandro-matches-selector': [ + { + src: 'matches-selector.js', + dest: 'desandro-matches-selector/matches-selector.js' + } + ] + // END dependencies for Masonry + }, + styles: { dir: '', less: [ - 'base/All.less', - 'sample/All.less' + 'base/All.less' ], options : { autoprefixer: true diff --git a/bower.json b/bower.json index 633a33e6..3f783f48 100644 --- a/bower.json +++ b/bower.json @@ -3,9 +3,13 @@ "version": "3.0.0", "homepage": "https://github.com/perfectsense/brightspot-base", "dependencies": { - "bsp-utils": "perfectsense/brightspot-js-utils#^3.1.0", - "jquery": "^2.1.4", - "normalize-css": "necolas/normalize.css#^3.0.3" + "bsp-utils": "perfectsense/brightspot-js-utils#3.1.0", + "jquery": "2.1.4", + "normalize-css": "necolas/normalize.css#3.0.3", + "masonry": "masonry#4.1.0", + "bsp-modal": "perfectsense/brightspot-js-modal#1.0.2", + "bsp-carousel": "perfectsense/brightspot-js-carousel#1.4.1", + "vex": "2.3.2" }, "main": "src/main/webapp/main.js", "ignore": [ @@ -36,10 +40,10 @@ } ], "normalize-css": [ - { - "src": "normalize.css", - "dest": "bower/normalize-css/normalize.css" - } + { + "src": "normalize.css", + "dest": "bower/normalize-css/normalize.css" + } ] } } diff --git a/src/main/webapp/base/All.less b/src/main/webapp/base/All.less index 1af23f15..b8837a08 100644 --- a/src/main/webapp/base/All.less +++ b/src/main/webapp/base/All.less @@ -13,6 +13,10 @@ @import "main/ArticleMain.less"; @import "main/CreativeWorkMain.less"; @import "main/Main.less"; +@import "main/GalleryMain.less"; + +@import "GalleryIntro.less"; +@import "GallerySlide.less"; @import "page/Page.less"; @import "page/PageFooter.less"; diff --git a/src/main/webapp/base/GalleryIntro.hbs b/src/main/webapp/base/GalleryIntro.hbs new file mode 100644 index 00000000..2d495670 --- /dev/null +++ b/src/main/webapp/base/GalleryIntro.hbs @@ -0,0 +1,27 @@ +{{#defineBlock "GalleryIntro"}} + + {{#defineElement "titleShort"}}data-shorttext="{{this}}"{{/defineElement}} + {{#defineElement "title" noWith=true}} +
+ {{title}} +
+ {{/defineElement}} + + {{#defineElement "descriptionShort"}}data-shorttext="{{this}}"{{/defineElement}} + {{#defineElement "description" noWith=true}} +
+ {{description}} +
+ {{/defineElement}} + + {{#defineBlockContainer}} +
+ + {{#defineBlockBody}} + {{element "title"}} + {{element "description"}} + {{/defineBlockBody}} + +
+ {{/defineBlockContainer}} +{{/defineBlock}} diff --git a/src/main/webapp/base/GalleryIntro.less b/src/main/webapp/base/GalleryIntro.less new file mode 100644 index 00000000..66a5ef95 --- /dev/null +++ b/src/main/webapp/base/GalleryIntro.less @@ -0,0 +1,11 @@ +.GalleryIntro { + + &-title { + &:extend(.GalleryIntro-title all); + } + + &-description { + &:extend(.GalleryIntro-description all); + } + +} diff --git a/src/main/webapp/base/GallerySlide.hbs b/src/main/webapp/base/GallerySlide.hbs new file mode 100644 index 00000000..2e50633c --- /dev/null +++ b/src/main/webapp/base/GallerySlide.hbs @@ -0,0 +1,60 @@ +{{#defineBlock "GallerySlide"}} + + {{#defineElement "mediaContent"}} +
{{this}}
+ {{/defineElement}} + + {{#defineElement "mediaControlsZoom" noWith=true}} + + {{/defineElement}} + + {{#defineElement "mediaControls" noWith=true}} +
+ {{element "mediaControlsZoom"}} +
+ {{/defineElement}} + + {{#defineElement "media" noWith=true}} +
+ {{element "mediaControls"}} + {{element "mediaContent"}} +
+ {{/defineElement}} + + {{#defineElement "infoTitleShort"}}data-shorttext="{{this}}"{{/defineElement}} + {{#defineElement "infoTitle" noWith=true}} +
+ {{infoTitle}} +
+ {{/defineElement}} + + {{#defineElement "infoDescriptionShort"}}data-shorttext="{{this}}"{{/defineElement}} + {{#defineElement "infoDescription" noWith=true}} +
+ {{infoDescription}} +
+ {{/defineElement}} + + {{#defineElement "infoAttribution"}} +
{{this}}
+ {{/defineElement}} + + {{#defineElement "info" noWith=true}} +
+ {{element "infoTitle"}} + {{element "infoDescription"}} + {{element "infoAttribution"}} +
+ {{/defineElement}} + + {{#defineBlockContainer}} +
+ + {{#defineBlockBody}} + {{element "media"}} + {{element "info"}} + {{/defineBlockBody}} + +
+ {{/defineBlockContainer}} +{{/defineBlock}} diff --git a/src/main/webapp/base/GallerySlide.less b/src/main/webapp/base/GallerySlide.less new file mode 100644 index 00000000..3dc65425 --- /dev/null +++ b/src/main/webapp/base/GallerySlide.less @@ -0,0 +1,23 @@ +.GallerySlide { + + &-image { + &:extend(.GallerySlide-image all); + } + + &-info { + &:extend(.GallerySlide-info all); + } + + &-infoTitle { + &:extend(.GallerySlide-infoTitle all); + } + + &-infoDescription { + &:extend(.GallerySlide-infoDescription all); + } + + &-infoAttribution { + &:extend(.GallerySlide-infoAttribution all); + } + +} diff --git a/src/main/webapp/base/main/GalleryMain.hbs b/src/main/webapp/base/main/GalleryMain.hbs new file mode 100644 index 00000000..45769b39 --- /dev/null +++ b/src/main/webapp/base/main/GalleryMain.hbs @@ -0,0 +1,85 @@ +{{#defineBlock "Gallery"}} + + {{#defineElement "intro"}} +
{{this}}
+ {{/defineElement}} + + {{#defineElement "controls" noWith=true}} +
+ + + + + +
+ {{/defineElement}} + + {{#defineElement "slides" noWith=true}} +
+ {{#each slides}}{{this}}{{/each}} +
+ {{/defineElement}} + + {{#defineElement "modalControlsClose" noWith=true}} + + {{/defineElement}} + {{#defineElement "modalControlsFullscreen" noWith=true}} + + {{/defineElement}} + + {{#defineElement "modalControlsPrev" noWith=true}} + + {{/defineElement}} + + {{#defineElement "modalControlsNext" noWith=true}} + + {{/defineElement}} + + {{#defineElement "modalControlsCount" noWith=true}} + + {{/defineElement}} + + {{#defineElement "modalControlsInfo" noWith=true}} + + {{/defineElement}} + + {{#defineElement "modalControls" noWith=true}} +
+ {{element "modalControlsClose"}} + {{element "modalControlsFullscreen"}} + {{element "modalControlsPrev"}} + {{element "modalControlsCount"}} + {{element "modalControlsNext"}} + {{element "modalControlsInfo"}} +
+ {{/defineElement}} + + {{#defineElement "modalCarousel" noWith=true}} +
+ {{/defineElement}} + + {{#defineElement "modal" noWith=true}} +
+ {{element "modalControls"}} + {{element "modalCarousel"}} +
+ {{/defineElement}} + + {{#defineBlockContainer}} +
+ + {{#defineBlockBody}} + {{#unless singleton}} + {{element "intro"}} + {{element "controls"}} + {{/unless}} + {{element "slides"}} + {{element "modal"}} + {{/defineBlockBody}} + +
+ {{/defineBlockContainer}} +{{/defineBlock}} diff --git a/src/main/webapp/base/main/GalleryMain.js b/src/main/webapp/base/main/GalleryMain.js new file mode 100644 index 00000000..ee85eccb --- /dev/null +++ b/src/main/webapp/base/main/GalleryMain.js @@ -0,0 +1,792 @@ +import $ from 'jquery'; +import bspUtils from 'bsp-utils'; +import Masonry from 'masonry'; +import bspModal from 'bsp-modal'; +import bspCarousel from 'bsp-carousel'; + +class Gallery { + + /** + * Constructor for the gallery. Does not actually initialize the gallery, + * you should call .init() when ready to initialize. + * + * @param {Element|jQuery} el + * Main element for the gallery. + * + * @param {Object} [options] + * Optional list of key:value pairs to set options. + * @param {Boolean} [options.introBackground=true] + * Set to true (the default) to add a background to the intro. + * Set to false to turn off the background for the intro. + * @param {String} [options.introBackgroundImage] + * If an image URL is specified, the gallery will use this as the image background for the intro block. + * If not specified, the gallery uses a randomly-selected montage of images as a background. + * @param {Number} [options.introBackgroundMontageCount=7] + * Maximum number of images to use for the montage background. + */ + constructor(el, options) { + + this.$el = $(el); + + this.settings = Object.assign({}, { + + // Allow classnames to be overridden easily + classNameMain: 'Gallery', + classNameSlide: 'GallerySlide', + attrNameMain: 'data-gallery', + + // Make a background from the intro block? + // Set to false if you don't want to add a background. + introBackground: true, + + // Set to image url if you want a single image instead of montage + introBackgroundImage: '', + + // Max images to include in intro montage + introBackgroundMontageCount: 7, + + // When in modal view, attempt to go fullscreen immediately + fullscreen: false + + }, options); + + // Selectors used to find various elements within the gallery HTML + let cn = this.settings.classNameMain; + let cnSlide = this.settings.classNameSlide; + this.selectors = { + main: `.${cn}`, + intro: `.${cn}-intro`, + slidesContainer: `.${cn}-slides`, + slide: `.${cnSlide}`, + slideMedia: `.${cnSlide}-media`, + slideMediaImg: `.${cnSlide}-mediaContent > img`, + slideMediaZoom: `.${cnSlide}-mediaControlsZoom`, + controlsCount: `.${cn}-controls-count`, + controlsButtonsList: `.${cn}-controls-buttons-list`, + controlsButtonsTiles: `.${cn}-controls-buttons-tiles`, + masonryItem: `.${cnSlide}`, + modal: `.${cn}-modal`, + modalClose: `.${cn}-modalControlsClose`, + modalFullscreen: `.${cn}-modalControlsFullscreen`, + modalPrev: `.${cn}-modalControlsPrev`, + modalNext: `.${cn}-modalControlsNext`, + modalInfo: `.${cn}-modalControlsInfo`, + modalCount: `.${cn}-modalControlsCount`, + modalCarousel: `.${cn}-modalCarousel` + }; + + // Class names for elements added by the javascript + this.classNames = { + // A background div is added behind the gallery info so we can display + // a single image or a montage of images + introBackground: `${cn}-intro-background` + }; + + // Attributes added to certain elements to affect the layout + let attr = this.settings.attrNameMain; // "data-gallery" by default + this.attr = { + + // If this is a "singleton" gallery set an attribute + singleton: `${attr}-singleton`, + + // The gallery background can be 'single' or 'montage' + introBackground: `${attr}-intro-background`, + + // The gallery view can be "list" or "tiles" or "modal" + view: `${attr}-view`, + + // When a view is active, this attribute is added so the view can be shown + viewActive: `${attr}-view-active`, + + // When a button is active this attribute is added so the button can be styled differently + buttonActive: `${attr}-button-active`, + + // In modal view, the info can be toggled. + // This attribute is added when the info is toggled on. + showInfo: `${attr}-showinfo`, + + // In modal view, the navigation should be displayed only if there are multiple slides + hideNav: `${attr}-hidenav`, + + // In modal view, the fullscreen icon should be displayed only if fullscreen is supported by the browser + fullscreenIsSupported: `${attr}-fullscreen-is-supported` + }; + + // After constructing the object, run init() to set up the gallery + } + + + /** + * Initialize the gallery. + */ + init() { + this.initSlides(); + if (this.$slides.length < 2) { + this.initSingleton(); + } else { + this.initIntro(); + this.initViewList(); + this.initViewTiles(); + this.initViewControls(); + } + this.modalInit(); + } + + + /** + * Initialize the gallery slides. + */ + initSlides() { + this.$slidesContainer = this.$el.find(this.selectors.slidesContainer); + } + + + /** + * Initialize the gallery in "singleton" mode where this is only one slide, + * but there could be multiple singleton galleries on the page tied together + * by a common id. + */ + initSingleton() { + // Set an attribute 'data-gallery-singleton' to the gallery can be styled differently. + // Also set it to the optional id for the singleton, so if the user opens the modal + // we can gather up all the slides that have the same id and let the user navigate + // between them all. + this.settings.singleton = true; + this.$el.attr(this.attr.singleton, this.settings.id || ''); + } + + + /** + * Get a jQuery Object containing all the slides. + * + * Though we do not currently support adding or removing slides, + * making this a dynamic function that finds the slides each time + * just in case we add functionality in the future. + * + * @return {jQuery Object} A jQuery object containing all the slides. + */ + get $slides() { + return this.$slidesContainer.find(this.selectors.slide); + } + + + /** + * Initialize the intro block for the gallery. + * This adds more DOM elements to display a background image (see initIntroBackground). + */ + initIntro() { + let $intro = this.$el.find(this.selectors.intro); + + // Cache the intro element so we don't have to find it again + this.$intro = $intro; + + if ($intro.length) { + this.initIntroBackground(); + } + } + + + /** + * Initialize the background for the intro block. + * This adds background image(s) to the intro block (that must be styled to become a background) + * + * The intro starts like this: + * + * + * Then after the background is added: + * + * + * The Gallery-intro-background will have one of the following additional class names + * which you can use for styling: Gallery-intro-background-single, Gallery-intro-background-montage + */ + initIntroBackground() { + + // Only add a background if settings.introBackground is true + if (!this.settings.introBackground) { + return; + } + + // Create a background image div within the intro + this.$introBackground = $('
', {'class': this.classNames.introBackground}).prependTo(this.$intro); + + // Set a single background image, or a montage of random images from the slides + if (this.settings.introBackgroundImage) { + this.initIntroBackgroundSingle(); + } else { + this.initIntroBackgroundMontage(); + } + } + + + /** + * Initialize the intro background with a single image. + */ + initIntroBackgroundSingle() { + + // Add an attribute so the image can be styled + this.$introBackground.attr(this.attr.introBackground, 'single'); + + // Add a single image to the background + $('', {src:this.settings.introBackgroundImage, alt:''}).appendTo(this.$introBackground); + } + + + /** + * Initialize the intro background with a montage of images randomly chosen from the slides. + */ + initIntroBackgroundMontage() { + + // Get the image urls from the slides and randomize the order + let urls = this.slideImages; + urls = this.shuffle(urls); + + // If there are not enough images for the montage, switch to single image with a random image + if (urls.length && urls.length < this.settings.introBackgroundMontageCount) { + this.settings.introBackgroundImage = urls[0]; + this.initIntroBackgroundSingle(); + return; + } + + // Add an attribute so the images can be styled + this.$introBackground.attr(this.attr.introBackground, 'montage'); + + // Add all the slide images to the background so they can be styled in a montage + for (let i=0; i < urls.length && i < this.settings.introBackgroundMontageCount; i++) { + $('', {src:urls[i], alt:''}).appendTo(this.$introBackground); + } + } + + + /** + * Initialize the gallery controls to switch from list to tile mode. + * The gallery controls consist of the following parts: + * Slide count ("64 Photos") + * Button to switch to List view + * Button to switch to Tile view + * + * The HTML is expected to look like this: + * + */ + initViewControls() { + + // Find the control hooks within the DOM + this.$controlsCount = this.$el.find(this.selectors.controlsCount); + this.$controlsButtonsList = this.$el.find(this.selectors.controlsButtonsList); + this.$controlsButtonsTiles = this.$el.find(this.selectors.controlsButtonsTiles); + + // Set up click events for the buttons + this.$controlsButtonsList.on('click', event => { + this.modeSetList(); + return false; + }); + this.$controlsButtonsTiles.on('click', event => { + this.modeSetTiles(); + return false; + }); + + // Set the initial mode to "List" + this.modeSetList(); + + // Update the slide count + this.controlsUpdateCount(); + } + + + initViewList() { + let $viewList; + + // Duplicate the slide container + // Note: must use CSS to show and hide the list! + $viewList = this.$slidesContainer.clone() + .attr(this.attr.view, 'list') + .insertAfter(this.$slidesContainer); + + // Cache for later use + this.$viewList = $viewList; + } + + + initViewTiles() { + let $viewTiles; + + // Duplicate the slide container + // Note: must use CSS to show and hide the tiles! + $viewTiles = this.$slidesContainer.clone() + .attr(this.attr.view, 'tiles') + .insertAfter(this.$slidesContainer); + + // Cache for later use + this.$viewTiles = $viewTiles; + + // Set up masonry layout + this.masonry = new Masonry($viewTiles[0], { + + itemSelector: this.selectors.masonryItem, + + // Turn off animations + transitionDuration: 0, + + // The left-right gutter between masonry tiles can only be set in the javascript. + // Hardcoded for now but should be some kind of option. + gutter: 20 + }); + } + + + /** + * Initialize the event handlers to show modal view when user clicks image, + * plus set up the modal popup. + */ + modalInit() { + + // Find the modal container + this.$modal = this.$el.find(this.selectors.modal); + if (!this.$modal.length) { + // If we can't find the modal container don't bother continuing + // because we cannot support zooming + return; + } + this.$modal.detach(); + + // Add an attribute to the modal to indicate if fullscreen mode is supported + // so we can use CSS to display or hide the fullscreen icons + if (this.fullscreenIsSupported()) { + this.$modal.attr(this.attr.fullscreenIsSupported, ''); + } + + // Turn the modal container into a modal but don't open it. + // Note this will remove the modal container from the DOM. + this.modal = Object.create(bspModal); + this.modal.init(this.$modal, {theme: 'Gallery', id: 'Gallery'}); + + // Create a single event handler for all clicks on slideMedia containers. + // This container contains the modal controls, the slide image, and possibly other + // things like social media share buttons. + this.$el.on('click', this.selectors.slideMedia, (event) => { + + let $target = $(event.target); + + // Only do something if clicking on the slide image or the modal control + // Do not do anything if clicking on other things in the image container + // (like social media share buttons) + if ($target.is(this.selectors.slideMediaImg) || $target.is(this.selectors.slideMediaZoom)) { + // Get the start of the slide that was clicked + let $slide = $target.closest(this.selectors.slide); + let $siblings = $slide.parent().find(this.selectors.slide); + let slideIndex = $siblings.index($slide); + + // Display the modal and start on the slide that was clicked + this.modalOpen(slideIndex); + } + return false; + }); + } + + + /** + * Open the modal to display a slide carousel. + * + * @param {Number} index + * The index of the slide to zoom (0=first slide, n-1=last slide) + */ + modalOpen(index) { + + let $modalCarousel; + let $modalSlides; + + // Because the vex modal calls jQuery.remove() when the modal is closed, + // all the jQuery events are destroyed, so we need to create the events + // each time we open the modal. + this.$modal.find(this.selectors.modalClose).on('click', event => { + this.modalClose(); + return false; + }); + this.$modal.find(this.selectors.modalPrev).on('click', event => { + this.modalPrev(); + return false; + }); + this.$modal.find(this.selectors.modalNext).on('click', event => { + this.modalNext(); + return false; + }); + this.$modal.find(this.selectors.modalInfo).on('click', event => { + this.modalInfoToggle(); + return false; + }); + this.$modal.find(this.selectors.modalFullscreen).on('click', event => { + this.modalFullscreen(); + return false; + }); + + // Open the modal, using class 'modal-theme-gallery' + this.modal.open(this.$modal, {theme: 'gallery', id: 'gallery'}); + + // Because the vex modal calls jQuery.remove() when the modal is closed, + // all the jQuery events are destroyed, so we need to recreate the carousel. + $modalCarousel = this.$modal.find(this.selectors.modalCarousel); + $modalCarousel.empty(); + + // If this is a singleton gallery, and it has an id that will be used to tie multiple galleries together, + // grab all the slides on the page that share the same id. + if (this.settings.singleton && this.settings.id) { + + $modalSlides = this.$slidesContainer.clone().empty().attr(this.attr.view, 'modal').appendTo($modalCarousel); + + // Find all the galleries that match our gallery id + let $galleries = $(this.selectors.main).filter((index, el) => { + let id = $(el).attr(this.attr.singleton) || ''; + return id === this.settings.id; + }); + + // Get all the slides within the matching galleries + let $slides = $galleries.find(this.selectors.slide); + + // Update the index number for the slide we want to show first + index = $slides.index( this.$slides[0] ) || 0; + + // Clone the slides and add them to the modal + $slides.clone().appendTo($modalSlides); + + } else { + // This is not a singleton gallery with an id, so just get the slides from this gallery + $modalSlides = this.$slidesContainer.clone().attr(this.attr.view, 'modal').appendTo($modalCarousel); + } + + // Whenever the carousel slide changes update the count + $modalCarousel.on('afterChange', event => { + this.modalUpdateCount(); + }); + + // Create the carousel within the modal + this.carousel = Object.create(bspCarousel); + this.carousel.init($modalSlides, { + // theme: '', + themeConfig: { + initialSlide: index, + arrows: false + } + }); + + // Focus on the carousel the keyboard navigation will work + this.$modal.find('.slick-list').focus(); + + // Intialize the count so it shows the initial slide number + this.modalUpdateCount(); + + // Hide the info by default + this.modalInfoHide(); + + // Optionally go to full screen mode + if (this.settings.fullscreen) { + this.modalFullscreen(); + } + } + + + /** + * When in modal view, close the modal and return to the gallery. + */ + modalClose() { + if (this.modal) { + this.modal.close(); + } + } + + + /** + * When in modal view, go to the next slide in the carousel. + */ + modalNext() { + if (this.carousel) { + this.carousel.next(); + } + } + + + /** + * When in modal view, go to the previous slide in the carousel. + */ + modalPrev() { + if (this.carousel) { + this.carousel.prev(); + } + } + + + /** + * Toggle show or hide the slide info in the modal view. + * @param {Boolean} [show=toggle] + * Use true to show the info, or false to hide the info. + * If not specied, the default is to toggle the current state. + */ + modalInfoToggle(flag) { + let show; + show = (flag === undefined) ? !this.modalInfoIsShowing() : flag; + if (show) { + this.modalInfoShow(); + } else { + this.modalInfoHide(); + } + } + + + /** + * Show the slide info when in modal mode. + */ + modalInfoShow() { + this.$modal.attr(this.attr.showInfo, ''); + } + + + /** + * Hide the slide info when in modal view. + */ + modalInfoHide() { + this.$modal.removeAttr(this.attr.showInfo); + } + + + /** + * Determine if the slide info is currently showing in modal view. + * @return {Boolean} Returns true if the info is currently showing. + */ + modalInfoIsShowing() { + return this.$modal[0].hasAttribute(this.attr.showInfo); + } + + + /** + * Show the slide navigation when in modal mode. + */ + modalNavShow() { + this.$modal.removeAttr(this.attr.hideNav); + } + + + /** + * Hide the slide navigation when in modal view. + */ + modalNavHide() { + this.$modal.attr(this.attr.hideNav, ''); + } + + + /** + * When in modal view, update the slide count (current slide and total number of slides). + */ + modalUpdateCount() { + let $count; + let currentSlide; + let total; + if (this.carousel) { + $count = this.$modal.find(this.selectors.modalCount); + currentSlide = this.carousel.currentSlide() + 1; + total = this.carousel.slideCount(); + $count.text(currentSlide + '/' + total); + + // Add an attribute so the navigation can be hidden if there is only one slide + if (total > 1) { + this.modalNavShow(); + } else { + this.modalNavHide(); + } + } + } + + + /** + * Take the modal view to full screen width. + * + * Note this can be called only from a user-intiated event (like a click event handler) + * and may not work in all browsers or in other situations (like when running in an iframe). + */ + modalFullscreen() { + + let el = this.$modal[0]; + + if (el) { + this.fullscreenElement(el); + } + } + + + /** + * Get a list of slide images. + * @return {Array of Strings} Array of slide image urls. + */ + get slideImages() { + let urls = []; + this.$el.find(this.selectors.slideMedia).each((i, el) => { + // Find the first img element within the slideMedia block + let src = $(el).find('img').attr('src'); + if (src) { + urls.push(src) + } + }); + return urls; + } + + + /** + * Return the number of slides in the gallery. + * @return {Number} Number of slides in the gallery. + */ + get count() { + return this.$slides.length; + } + + + /** + * Set the gallery count display ("13 Photos"). + */ + controlsUpdateCount() { + // Template "13 Photos" is hardcoded. + // Should probably make an option to configure the template. + this.$controlsCount.html(`${this.count} Photos`); + } + + + /** + * Set the mode to List mode. + * Hides the tile display, shows the list display, and makes the List button active. + */ + modeSetList() { + this._modeClear(); + this.$controlsButtonsList.attr(this.attr.buttonActive, ''); + this.$viewList.attr(this.attr.viewActive, ''); + } + + + /** + * Set the mode to Tiles mode. + * Hides the list display, shows the tiles display, and makes the Tiles button active. + */ + modeSetTiles() { + this._modeClear(); + this.$controlsButtonsTiles.attr(this.attr.buttonActive, ''); + this.$viewTiles.attr(this.attr.viewActive, ''); + this.masonry.layout(); + } + + + /** + * Clear the mode display (list or tile mode). This unsets the active button and hides all modes. + */ + _modeClear() { + + this.$controlsButtonsTiles.removeAttr(this.attr.buttonActive); + this.$viewTiles.removeAttr(this.attr.viewActive); + + this.$controlsButtonsList.removeAttr(this.attr.buttonActive); + this.$viewList.removeAttr(this.attr.viewActive); + } + + + /** + * Determine if fullscreen is supported in this browser, and if so add an attribute to the + * modal so we can use CSS to style and display a fullscreen icon. + */ + fullscreenInit() { + if (this.fullscreenIsSupported()) { + this.$modal.attr(this.attr.fullscreenIsSupported, ''); + } + } + + + /** + * Determine if this browser supports full screen mode. + * @return {Boolean} + */ + fullscreenIsSupported() { + let el = this.$el[0]; + return Boolean( + el.requestFullscreen + || el.webkitRequestFullscreen + || el.mozRequestFullScreen + || el.msRequestFullscreen + ); + } + + + /** + * Set an element to fullscreen mode. + * + * Note this can be called only from a user-intiated event (like a click event handler) + * and may not work in all browsers or in other situations (like when running in an iframe). + * + * @param {Element} el + */ + fullscreenElement(el) { + + if (el.requestFullscreen) { + el.requestFullscreen(); + } else if (el.webkitRequestFullscreen) { + el.webkitRequestFullscreen(); + } else if (el.mozRequestFullScreen) { + el.mozRequestFullScreen(); + } else if (el.msRequestFullscreen) { + el.msRequestFullscreen(); + } + } + + + /** + * Randomly shuffle an array in place. + * @param {Array} array + * @return {Array} + */ + shuffle(array) { + let currentIndex = array.length; + let temporaryValue; + let randomIndex; + + // While there remain elements to shuffle... + while (0 !== currentIndex) { + + // Pick a remaining element... + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex -= 1; + + // And swap it with the current element. + temporaryValue = array[currentIndex]; + array[currentIndex] = array[randomIndex]; + array[randomIndex] = temporaryValue; + } + + return array; + } +} + + +// Set up a bspUtils.plugin so an element with data-bsp-gallery-list +// will automatically create a Gallery list +export default bspUtils.plugin(false, 'bsp-gallery', 'list', { + + '_each': function(item) { + + // Get options from the data-bsp-gallery-options attribute + let options = this.option(item); + + // Create the gallery object + let gallery = new Gallery(item, options); + + // Save the Gallery object on the element so it can be accessed later if necessary + $(item).data('bsp-gallery', gallery); + + // Run it! + gallery.init(); + } +}); diff --git a/src/main/webapp/base/main/GalleryMain.less b/src/main/webapp/base/main/GalleryMain.less new file mode 100644 index 00000000..f2352818 --- /dev/null +++ b/src/main/webapp/base/main/GalleryMain.less @@ -0,0 +1,86 @@ +.Gallery { + + //--------------- + // Gallery-intro + // Javascript also adds either Gallery-intro-background-single or Gallery-intro-background-montage + // to display images as a background behind the intro content. + //--------------- + &-intro { + &:extend(.Gallery-intro all); + + &-background { // Optional element added by javascript + &:extend(.Gallery-intro-background all); + } + + &-background-single { // Optional element added by javascript + &:extend(.Gallery-intro-background-single all); + } + + &-background-montage { // Optional element added by javascript + &:extend(.Gallery-intro-background-montage all); + } + } + + //------------------ + // Gallery-controls + // Contains a photo count, plus buttons to switch between list and tiles views. + //------------------ + &-controls { + &:extend(.Gallery-controls all); + + &-count { + &:extend(.Gallery-controls-count all); + } + + &-buttons { + &:extend(.Gallery-controls-buttons all); + + // List button + &-list { + &:extend(.Gallery-controls-buttons-list all); + } + + // Tiles button + &-tiles { + &:extend(.Gallery-controls-buttons-tiles all); + } + + // Make a button appear selected + &-active { + &:extend(.Gallery-controls-buttons-active all); + } + } + } + + //---------------- + // Gallery-slides + // Container for the gallery slides. + // However note that this is hidden because=, but the javascript duplicates the content + // into Gallery-view-list and Gallery-view-tiles + // Contains GallerySlide blocks. + // --------------- + &-slides { + &:extend(.Gallery-slides all); + // Refer to GallerySlide.less + } + + //------------------- + // Gallery-view-list + // Added by javascript to represent slides in a list view. + // Contains GallerySlide blocks. + //------------------- + &-view-list { // Elements added by javascript + &:extend(.Gallery-view-list all); + // Refer to GallerySlide.less + } + + //------------------- + // Gallery-view-tiles + // Added by javascript to represent slides in a tile view. + // Contains GallerySlide blocks. + //------------------- + &-view-tiles { // Elements added by javascript + &:extend(.Gallery-view-tiles all); + // Refer to GallerySlide.less + } +} diff --git a/src/main/webapp/main.js b/src/main/webapp/main.js index 6db5a4d2..737439db 100644 --- a/src/main/webapp/main.js +++ b/src/main/webapp/main.js @@ -18,4 +18,6 @@ import Comment from "community/commenting/Comment.js"; import CommentEntry from "community/commenting/CommentEntry.js"; import Commenting from "community/commenting/Commenting.js"; +import Gallery from "base/main/GalleryMain.js"; + export default {}; diff --git a/src/main/webapp/sample/All.less b/src/main/webapp/sample/All.less deleted file mode 100644 index 192c19fc..00000000 --- a/src/main/webapp/sample/All.less +++ /dev/null @@ -1 +0,0 @@ -@import "../base/All.less"; diff --git a/styleguide/_config.json b/styleguide/_config.json index 13287a89..31b1c4e3 100644 --- a/styleguide/_config.json +++ b/styleguide/_config.json @@ -3,10 +3,6 @@ { "name": "Base", "href": "/base/All.min.css" - }, - { - "name": "Samples", - "href": "/sample/All.min.css" } ] } diff --git a/styleguide/main/gallery-main/_gallery-slide.json b/styleguide/main/gallery-main/_gallery-slide.json new file mode 100644 index 00000000..4ba8e8d2 --- /dev/null +++ b/styleguide/main/gallery-main/_gallery-slide.json @@ -0,0 +1,16 @@ +{ + "_template": "/base/GallerySlide", + + "mediaContent": { + "_template": "/base/util/Image", + "src": "{{image([200, 800], [200, 640])}}" + }, + + "infoTitle": "{{words(8,12)}}", + "infoTitleShort": "{{words(3,6)}}", + + "infoDescription": "{{words(60,120)}}", + "infoDescriptionShort": "{{words(20,40)}}", + + "infoAttribution": "Photo by {{words(6,10)}}" +} diff --git a/styleguide/main/gallery-main/gallery-main-single-intro-image.json b/styleguide/main/gallery-main/gallery-main-single-intro-image.json new file mode 100644 index 00000000..1b188855 --- /dev/null +++ b/styleguide/main/gallery-main/gallery-main-single-intro-image.json @@ -0,0 +1,18 @@ +{ + "_template": "/base/main/GalleryMain", + + "intro": { + "_template": "/base/GalleryIntro", + "title": "{{words(8,12)}}", + "description": "{{words(60,120)}}" + }, + + "introBackgroundImage": "{{image(640, 400)}}", + + "slides": [ + { + "_dataUrl": "/main/gallery-main/_gallery-slide.json", + "_repeat": [8,20] + } + ] +} diff --git a/styleguide/main/gallery-main/gallery-main-single-intro-image.md b/styleguide/main/gallery-main/gallery-main-single-intro-image.md new file mode 100644 index 00000000..ff26d8ae --- /dev/null +++ b/styleguide/main/gallery-main/gallery-main-single-intro-image.md @@ -0,0 +1,4 @@ +## Gallery with single intro background image + +This shows the gallery if you specify the `introBackgroundImage` property. +Instead of a matrix of images, you get a single image background. diff --git a/styleguide/main/gallery-main/gallery-main-single-slide.json b/styleguide/main/gallery-main/gallery-main-single-slide.json new file mode 100644 index 00000000..309133cb --- /dev/null +++ b/styleguide/main/gallery-main/gallery-main-single-slide.json @@ -0,0 +1,47 @@ +{ + "_template": "/base/util/Concatenated", + "items" : [ + { + "_template": "/base/util/RawHtml", + "html": "{{paragraphs(1)}}" + }, + { + "_template": "/base/main/GalleryMain", + "slides": [ + { + "_dataUrl": "/main/gallery-main/_gallery-slide.json" + } + ] + }, + { + "_template": "/base/util/RawHtml", + "html": "{{paragraphs(1)}}" + }, + { + "_template": "/base/main/GalleryMain", + "id": "my-singleton-unique-id-2", + "slides": [ + { + "_dataUrl": "/main/gallery-main/_gallery-slide.json" + } + ] + }, + { + "_template": "/base/util/RawHtml", + "html": "{{paragraphs(1)}}" + }, + { + "_template": "/base/main/GalleryMain", + "id": "my-singleton-unique-id-2", + "slides": [ + { + "_dataUrl": "/main/gallery-main/_gallery-slide.json" + } + ] + }, + { + "_template": "/base/util/RawHtml", + "html": "{{paragraphs(1)}}" + } + ] +} diff --git a/styleguide/main/gallery-main/gallery-main-single-slide.md b/styleguide/main/gallery-main/gallery-main-single-slide.md new file mode 100644 index 00000000..47f4a59c --- /dev/null +++ b/styleguide/main/gallery-main/gallery-main-single-slide.md @@ -0,0 +1,19 @@ +## Gallery singleton image + +You can display a single image embedded in other content (for example, an image in an article), and still get +the gallery experience. Clicking the image opens the modal carousel as usual. The modal can display just a single +image, or you can associate multiple gallery singletons to be displayed in the same modal carousel (by specifying the +same id for each gallery singleton). + +In this demonstration, the first image opens by itself. The second and third images are associated, so if you open the +modal carousel you can navigate between those two slides. + +### Blocks and properties +The singleton gallery experience is triggered automatically when the gallery contains a only a single slide. +The singleton gallery uses the same properties as the main gallery, but it does not display the intro block +or the list/tiles view. It has an additional (optional) property: +* `Gallery`: + * `id`: (String/Optional) An id can be used to associate multiple singletons, so when the user clicks the singleton image, + the modal carousel lets the user navigate between all the images that have the same id. If this is not specified + then the modal carousel will show only the image that was clicked, and will not have navigation controls. + diff --git a/styleguide/main/gallery-main/gallery-main.json b/styleguide/main/gallery-main/gallery-main.json new file mode 100644 index 00000000..6f9c5519 --- /dev/null +++ b/styleguide/main/gallery-main/gallery-main.json @@ -0,0 +1,20 @@ +{ + "_template": "/base/main/GalleryMain", + + "intro": { + "_template": "/base/GalleryIntro", + "title": "{{words(8,12)}}", + "titleShort": "{{words(4,8)}}", + "description": "{{words(60,80)}}", + "descriptionShort": "{{words(20,50)}}" + }, + + "introBackgroundImage": "", + + "slides": [ + { + "_dataUrl": "/main/gallery-main/_gallery-slide.json", + "_repeat": [8,12] + } + ] +} diff --git a/styleguide/main/gallery-main/gallery-main.md b/styleguide/main/gallery-main/gallery-main.md new file mode 100644 index 00000000..c7c6bee1 --- /dev/null +++ b/styleguide/main/gallery-main/gallery-main.md @@ -0,0 +1,30 @@ +## Gallery with random image montage + +### Requirements +* [Jira Ticket and Requirements](https://perfectsense.atlassian.net/browse/BSP-1571) +* Designs: [Zeplin](https://zpl.io/Z11ulFh), [PNG](https://www.dropbox.com/sh/n1ljua526dyo6l3/AAB43ovSiTFXz4LxKuIARrGfa?dl=0), [Sketch](https://www.dropbox.com/s/bz9w6u8mn742rjt/Base%20Photo%20Gallery.sketch?dl=0) + +### Blocks and properties +* `Gallery`: + * `intro`: (Block) A single `GalleryIntro` block. + * `introBackgroundImage`: (URL/Optional) By default, the gallery displays a randomized matrix of images + as a background. However, you can specify a single image here to use as the background. This URL will + be placed in the `data-gallery-intro-background-image` HTML parameter and used by the JavaScript. + * `slides`: (Array) List of `GallerySlide` blocks. + * `modal` (HTML) Placeholder for the gallery modal, including additional properties for adding controls to the modal. +* `GalleryIntro`: Introductory content for the gallery. + * `title` (String|HTML) + * `titleShort` (String|HTML) Optional short version of the title. + * `description` (HTML) + * `descriptionShort` (HTML) Optional short version of the description. +* `GallerySlide`: A single slide for the gallery. + * `media` + * `mediaContent`: (Image Element) Image or other content for the slide. + * `mediaControls` (HTML) Controls to overlay on top of the media. + * `mediaControlsZoom` (HTML) Control to zoom the image (display the modal) + * info: (HTML) Descriptive content for the image. By default contains infoTitle, infoDescription, infoAttribution. + * `infoTitle` (HTML) Title for the image. + * `infoTitleShort` (HTML) Optional short title for the image. + * `infoDescription` (HTML) Description for the image. + * `infoDescriptionShort` (HTML) Optional short description for the image. + * `infoAttribution` (HTML) Attribution for the image.