From 2393abc50e21ece713bf524f3fb9292cc0c060aa Mon Sep 17 00:00:00 2001 From: Yuriy Nemtsov Date: Tue, 11 Oct 2016 20:38:34 -0400 Subject: [PATCH 1/5] add slide prefetching on hover and on next / prev --- package.json | 6 +- sass/lightbox.scss | 18 ++++-- src/ChromeView.js | 82 +++++++++++++++++++----- src/Controller.js | 64 ++++++++++++++---- src/templates.js | 6 +- test/fixtures/css/disable_animations.css | 7 -- test/karma.conf.js | 3 +- test/specs/ChromeView.js | 6 +- test/specs/index.js | 29 +++------ test/webpack.config.js | 2 +- 10 files changed, 154 insertions(+), 69 deletions(-) diff --git a/package.json b/package.json index b83883b..47225f2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "test": "npm run eslint && npm run karma -- --single-run", "eslint": "eslint src/**/*.js test/**/*.js", - "karma": "karma start test/karma.conf.js", + "karma": "env NODE_ENV=test karma start test/karma.conf.js", "examples-simple": "webpack-dev-server --content-base examples/simple --config ./test/webpack.config.js --entry ./examples/simple" }, "repository": { @@ -20,15 +20,17 @@ }, "homepage": "https://github.com/behance/lightbox#readme", "dependencies": { + "hoverintent": "^1.0.3", "idle-timer": "git+https://github.com/nemtsov/idle-timer#8fc0007", "jquery": "~2.1.1", "tinycolor2": "^1.4.1" }, "devDependencies": { + "@claviska/jquery-offscreen": "^1.0.1", "babel": "^6.5.2", "babel-core": "^6.17.0", "babel-loader": "^6.2.5", - "babel-preset-behance": "^3.0.0", + "babel-preset-behance": "^3.1.0", "css-loader": "^0.25.0", "es6-shim": "^0.35.1", "eslint": "^3.7.0", diff --git a/sass/lightbox.scss b/sass/lightbox.scss index 0828651..c6d4e70 100644 --- a/sass/lightbox.scss +++ b/sass/lightbox.scss @@ -40,10 +40,6 @@ html.lightbox-enabled { transition: .2s; width: 100vw; - &.hidden { - opacity: 0; - } - } // .lightbox-content @@ -65,6 +61,20 @@ html.lightbox-enabled { width: 100vw; z-index: 1001; + .offscreen, + &.offscreen { + height: 1px; + overflow: hidden; + -webkit-transform: translate(-99999px); + -ms-transform: translate(-99999px); + transform: translate(-99999px); + width: 1px; + } + + .hidden { + opacity: 0; + } + .lightbox-contents { @extend %user-select-none; diff --git a/src/ChromeView.js b/src/ChromeView.js index 264f12f..189a28f 100644 --- a/src/ChromeView.js +++ b/src/ChromeView.js @@ -11,13 +11,15 @@ const EXTRAS_HIDDEN_CLASS = 'extras-hidden'; const CONTENT_CLASS = 'lightbox-content'; const ENABLED_CLASS = 'lightbox-enabled'; const HIDDEN_CLASS = 'hidden'; +const OFFSCREEN_CLASS = 'offscreen'; +const TRANSITION_END = 'webkitTransitionEnd ontransitionend msTransitionEnd transitionend'; export default class ChromeView { constructor($context, controller, props) { this._$context = $context; this._controller = controller; this._props = props; - this._$view = $(lightboxTemplate); + this._$view = $(lightboxTemplate).appendTo($context); this._$contents = this._$view.find('.js-contents'); this._$prev = this._$view.find('.js-prev'); this._$next = this._$view.find('.js-next'); @@ -73,24 +75,34 @@ export default class ChromeView { this.showExtras(); - this._$context.append(this._$view); + this._$view.removeClass(OFFSCREEN_CLASS); } renderSlide(slide) { - this._maybeHidePrevNext(slide); + this._maybeHidePrevNext(); + this._appendSlide(slide); - const $next = $(`
`); - $next.html(this._getSlideContent(slide)); + const $current = this._$contents.find('[data-slide-is-active]'); + const $next = this._$contents.find(`[data-slide-id="${slide.id}"]`); - this._$contents.find(`.${HIDDEN_CLASS}`).remove(); - this._$contents.append($next); + $current + .removeAttr('data-slide-is-active') + .find('> div') + .addClass(HIDDEN_CLASS) + .one(TRANSITION_END, () => $current.remove()); - this._$contents.find(`.${CONTENT_CLASS}:lt(1)`).addClass(HIDDEN_CLASS); - setTimeout(() => $next.removeClass(HIDDEN_CLASS), 0); + $next + .attr({ 'data-slide-is-active': true }) + .removeClass(OFFSCREEN_CLASS) + .find('> div') + .removeClass(HIDDEN_CLASS); + + this._appendNext($current, $next); } - destroy() { - this._$view.remove(); + close() { + this._$view.addClass(OFFSCREEN_CLASS); + this._$contents.empty(); $(document) .add(this._$context) @@ -103,6 +115,10 @@ export default class ChromeView { } } + destroy() { + this._$view.remove(); + } + hideExtras() { this._$view.addClass(EXTRAS_HIDDEN_CLASS); } @@ -111,12 +127,38 @@ export default class ChromeView { this._$view.removeClass(EXTRAS_HIDDEN_CLASS); } + _appendSlide(slide) { + if (!slide || this._$contents.find(`[data-slide-id="${slide.id}"]`).size()) { return; } + + const $content = $('
') + .addClass(`${CONTENT_CLASS} ${HIDDEN_CLASS}`) + .html(this._getSlideContent(slide)); + + $('
', { 'data-slide-id': slide.id, class: `${OFFSCREEN_CLASS}` }) + .append($content) + .appendTo(this._$contents); + } + + _appendNext($current, $next) { + if ($current.size() === 0) { + this._appendSlide(this._getPrevSlide()); + this._appendSlide(this._getNextSlide()); + } + else { + this._appendSlide($current.data('slide-id') < $next.data('slide-id') + ? this._getNextSlide() + : this._getPrevSlide()); + } + } + _bindToController() { this._controller.on({ open: slide => { this.init(); this.renderSlide(slide); }, - close: () => this.destroy(), + close: () => this.close(), + destroy: () => this.destroy(), prev: slide => this.renderSlide(slide), - next: slide => this.renderSlide(slide) + next: slide => this.renderSlide(slide), + prefetch: slide => this._appendSlide(slide) }); } @@ -125,11 +167,19 @@ export default class ChromeView { return src ? $('', { src }) : slide.content; } - _maybeHidePrevNext(activeSlide) { - const hasPrev = this._controller.slides[activeSlide.id - 1]; - const hasNext = this._controller.slides[activeSlide.id + 1]; + _maybeHidePrevNext() { + const hasPrev = this._getPrevSlide(); + const hasNext = this._getNextSlide(); if (this._props.isCircular && (hasPrev || hasNext)) { return; } (hasPrev) ? this._$prev.removeClass(HIDDEN_CLASS) : this._$prev.addClass(HIDDEN_CLASS); (hasNext) ? this._$next.removeClass(HIDDEN_CLASS) : this._$next.addClass(HIDDEN_CLASS); } + + _getPrevSlide() { + return this._controller.slides[this._controller.getPrevId()]; + } + + _getNextSlide() { + return this._controller.slides[this._controller.getNextId()]; + } } diff --git a/src/Controller.js b/src/Controller.js index 63cfea5..a7dbfbd 100644 --- a/src/Controller.js +++ b/src/Controller.js @@ -1,6 +1,9 @@ import $ from 'jquery'; +// due to a bug in hoverintent, just import'ing it fails webpack +import hoverintent from 'hoverintent/dist/hoverintent.min'; const SLIDE_ID_ATTR = 'lightbox-slide-id'; +const LINK_CLASS = 'lightbox-link'; export default class Controller { constructor($context, props) { @@ -8,7 +11,9 @@ export default class Controller { this._$context = $context; this._$eventNode = $(''); this._$links = this._$context.find(`:not(a) > ${this._props.slideSelector}`); + this._hoverlisteners = []; this.slides = this._createSlides(this._$links); + this._isOpen = false; this._bind(); } @@ -27,48 +32,69 @@ export default class Controller { open(slideId) { const slide = this.slides[slideId]; this.activeSlide = slide; + this._isOpen = true; this._trigger('open', [slide]); } close() { + this._isOpen = false; this._trigger('close'); } next() { + const nextSlide = this.slides[this.getNextId()]; + if (!nextSlide) { return; } + this.activeSlide = nextSlide; + this._trigger('next', nextSlide); + } + + prev() { + const prevSlide = this.slides[this.getPrevId()]; + if (!prevSlide) { return; } + this.activeSlide = prevSlide; + this._trigger('prev', prevSlide); + } + + getNextId() { const nextId = this.activeSlide.id + 1; const next = this.slides[nextId]; if (!this._props.isCircular && !next) { return; } const firstId = 0; - this.activeSlide = this.slides[next ? nextId : firstId]; - this._trigger('next', this.activeSlide); + return next ? nextId : firstId; } - prev() { + getPrevId() { const prevId = this.activeSlide.id - 1; const prev = this.slides[prevId]; if (!this._props.isCircular && !prev) { return; } const lastId = this.slides.length - 1; - this.activeSlide = this.slides[prev ? prevId : lastId]; - this._trigger('prev', this.activeSlide); + return prev ? prevId : lastId; } destroy() { - this.close(); - this._$eventNode.off(); + this._isOpen && this.close(); + this._removePrefetchOnHover(); this._$links - .removeClass('lightbox-link') + .removeClass(LINK_CLASS) .removeData(SLIDE_ID_ATTR) - .off('click'); + .off('mousedown click'); + this._trigger('destroy'); + this._$eventNode.off(); } _bind() { const self = this; - this._$links.addClass('lightbox-link') - .each((id, el) => $(el).data(SLIDE_ID_ATTR, id)) - .click(function(e) { + this._$links.addClass(LINK_CLASS) + .each((id, el) => { + this._addPrefetchOnHover(el, id); + $(el).data(SLIDE_ID_ATTR, id); + }) + .on('mousedown', function() { + self._trigger('prefetch', self.slides[$(this).data(SLIDE_ID_ATTR)]); + }) + .on('click', function(e) { e.stopPropagation(); - self.open(self.slides.filter( - slide => slide.id === $(this).data(SLIDE_ID_ATTR))[0].id); + self.open($(this).data(SLIDE_ID_ATTR)); }); } @@ -88,4 +114,14 @@ export default class Controller { }; }); } + + _addPrefetchOnHover(el, id) { + this._hoverlisteners.push(hoverintent(el, + () => this._trigger('prefetch', this.slides[id]), + () => {})); + } + + _removePrefetchOnHover() { + this._hoverlisteners.forEach(listener => listener.remove()); + } } diff --git a/src/templates.js b/src/templates.js index 112f7da..7d797cc 100644 --- a/src/templates.js +++ b/src/templates.js @@ -30,16 +30,14 @@ const closeControl = ` `; export const lightbox = ` -