From dd5df48e48c709e057cb988e37e1a257780cd9b7 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Tue, 11 Feb 2014 00:56:03 +0100 Subject: [PATCH] Added an advanced image viewer --- wcfsetup/install/files/js/WCF.ImageViewer.js | 705 +++++++++++++++++- .../lib/data/IImageViewerAction.class.php | 68 ++ wcfsetup/install/files/style/imageViewer.less | 266 +++++++ 3 files changed, 1038 insertions(+), 1 deletion(-) create mode 100644 wcfsetup/install/files/lib/data/IImageViewerAction.class.php diff --git a/wcfsetup/install/files/js/WCF.ImageViewer.js b/wcfsetup/install/files/js/WCF.ImageViewer.js index d13381c80f..8618aae01e 100644 --- a/wcfsetup/install/files/js/WCF.ImageViewer.js +++ b/wcfsetup/install/files/js/WCF.ImageViewer.js @@ -106,4 +106,707 @@ WCF.ImageViewer = Class.extend({ } } } -}); \ No newline at end of file +}); + +/** + * Provides a focused image viewer for WCF. + * + * Usage: + * $('.triggerElement').wcfImageViewer({ + * shiftBy: 5, + * + * enableSlideshow: 1, + * speed: 5, + * + * className: 'wcf\\data\\foo\\FooAction' + * }); + */ +$.widget('ui.wcfImageViewer', { + /** + * active image index + * @var integer + */ + _active: -1, + + /** + * active image object id + * @var integer + */ + _activeImage: null, + + /** + * image viewer container object + * @var jQuery + */ + _container: null, + + /** + * initialization state + * @var boolean + */ + _didInit: false, + + /** + * list of available images + * @var array + */ + _images: [ ], + + /** + * true if image viewer is open + * @var boolean + */ + _isOpen: false, + + /** + * number of total images + * @var integer + */ + _items: -1, + + /** + * maximum dimensions for enlarged view + * @var object + */ + _maxDimensions: { + height: 0, + width: 0 + }, + + /** + * action proxy object + * @var WCF.Action.Proxy + */ + _proxy: null, + + /** + * true if slideshow is currently running + * @var boolean + */ + _slideshowEnabled: false, + + /** + * visible width of thumbnail container + * @var integer + */ + _thumbnailContainerWidth: 0, + + /** + * right margin of a thumbnail + * @var integer + */ + _thumbnailMarginRight: 0, + + /** + * left offset of thumbnail list + * @var integer + */ + _thumbnailOffset: 0, + + /** + * outer width of a thumbnail (includes margin) + * @var integer + */ + _thumbnailWidth: 0, + + /** + * slideshow timer object + * @var WCF.PeriodicalExecuter + */ + _timer: null, + + /** + * list of interface elements + * @var object + */ + _ui: { + buttonNext: null, + buttonPrevious: null, + header: null, + image: null, + imageContainer: null, + imageList: null, + slideshow: { + container: null, + enlarge: null, + next: null, + previous: null, + toggle: null + } + }, + + /** + * list of options parsed during init + * @var object + */ + options: { + // navigation + shiftBy: 5, // thumbnail slider control + + // slideshow + enableSlideshow: 1, + speed: 5, // time in seconds + + // ajax + className: '' // must be an instance of \wcf\data\IImageViewerAction + }, + + /** + * Creates a new wcfImageViewer instance. + */ + _create: function() { + this._active = -1; + this._activeImage = null; + this._container = null; + this._didInit = false; + this._images = [ ]; + this._isOpen = false; + this._items = -1; + this._maxDimensions = { + height: document.documentElement.clientHeight, + width: document.documentElement.clientWidth + }; + this._proxy = new WCF.Action.Proxy({ + success: $.proxy(this._success, this) + }); + this._slideshowEnabled = false; + this._thumbnailContainerWidth = 0; + this._thumbnailMarginRight = 0; + this._thumbnailOffset = 0; + this._thumbnaiLWidth = 0; + this._timer = null; + this._ui = { }; + + this.element.click($.proxy(this.open, this)); + }, + + /** + * Opens the image viewer. + * + * @return boolean + */ + open: function() { + if (this._isOpen) { + return false; + } + + if (this._images.length === 0) { + this._loadNextImages(); + } + else { + this._render(); + + if (this._items > 1 && this._slideshowEnabled) { + this.startSlideshow(); + } + } + + this._isOpen = true; + + return true; + }, + + /** + * Closes the image viewer. + * + * @return boolean + */ + close: function() { + if (!this._isOpen) { + return false; + } + + this._container.removeClass('open'); + if (this._timer !== null) { + this._timer.stop(); + } + + this._isOpen = false; + + return true; + }, + + /** + * Enables the slideshow. + * + * @return boolean + */ + startSlideshow: function() { + if (this._slideshowEnabled) { + return false; + } + + this._timer = new WCF.PeriodicalExecuter($.proxy(function() { + var $index = this._active + 1; + if ($index == this._items) { + $index = 0; + } + + this.showImage($index); + }, this), this.options.speed * 1000); + + this._slideshowEnabled = true; + + this._ui.slideshow.toggle.children('span').removeClass('icon-play').addClass('icon-pause'); + + return true; + }, + + /** + * Disables the slideshow. + * + * @return boolean + */ + stopSlideshow: function() { + if (!this._slideshowEnabled) { + return false; + } + + this._timer.stop(); + this._ui.slideshow.toggle.children('span').removeClass('icon-pause').addClass('icon-play'); + + this._slideshowEnabled = false; + + return true; + }, + + /** + * Renders the image viewer UI. + * + * @param boolean initialized + */ + _render: function(initialized) { + this._container.addClass('open'); + + if (initialized) { + var $thumbnail = this._ui.imageList.children('li:eq(0)');; + this._thumbnailMarginRight = parseInt($thumbnail.css('marginRight').replace(/px$/, '')) || 0; + this._thumbnailWidth = $thumbnail.outerWidth(true); + this._thumbnailContainerWidth = this._ui.imageList.parent().innerWidth(); + + $thumbnail.trigger('click'); + + if (this._items > 1 && this.options.enableSlideshow) { + this.startSlideshow(); + } + } + + this._toggleButtons(); + + // check if there is enough space to load more thumbnails + this._preload(); + }, + + /** + * Attempts to load the next images. + */ + _preload: function() { + if (this._images.length < this._items) { + var $thumbnailsWidth = this._images.length * this._thumbnailWidth; + if ($thumbnailsWidth - this._thumbnailOffset < this._thumbnailContainerWidth) { + this._loadNextImages(); + } + } + }, + + /** + * Displays image on thumbnail click. + * + * @param object event + */ + _showImage: function(event) { + this.stopSlideshow(); + this.showImage($(event.currentTarget).data('index')); + }, + + /** + * Displays an image by index. + * + * @param integer index + * @return boolean + */ + showImage: function(index) { + if (this._active == index) { + return false; + } + + // reset active marking + if (this._active != -1) { + this._images[this._active].listItem.removeClass('active'); + } + + this._active = index; + var $image = this._images[index]; + + this._ui.imageList.children('li').removeClass('active'); + $image.listItem.addClass('active'); + + var $dimensions = this._ui.imageContainer.getDimensions('inner'); + + if (this._activeImage === null) { + this._activeImage = 0; + this._renderImage(this._activeImage, $image, $dimensions); + } + else { + var $newImageIndex = (this._activeImage ? 0 : 1); + this._renderImage($newImageIndex, $image, $dimensions); + + this._ui.images[this._activeImage].removeClass('active'); + this._ui.images[$newImageIndex].addClass('active'); + + this._activeImage = $newImageIndex; + } + + // user + var $link = this._ui.header.find('> div > a').prop('href', $image.user.link).prop('title', $image.user.username); + $link.children('img').prop('src', $image.user.avatarURL); + + // meta data + var $title = WCF.String.escapeHTML($image.image.title); + if ($image.image.link) $title = '' + $image.image.title + ''; + this._ui.header.find('> div > h1').html($title); + + var $seriesTitle = ($image.series && $image.series.title ? WCF.String.escapeHTML($image.series.title) : ''); + if ($image.series.link) $seriesTitle = '' + $seriesTitle + ''; + this._ui.header.find('> div > h2').html($seriesTitle); + + this._ui.header.find('> div > h3').text(($image.listItem.data('index') + 1) + " von " + this._items); + + this._ui.slideshow.full.data('link', ($image.image.fullURL ? $image.image.fullURL : $image.image.url)); + + this.moveToImage($image.listItem.data('index')); + + this._toggleButtons(); + + return true; + }, + + /** + * Renders target image, leaving 'imageData' undefined will invoke the rendering process for the currently active image. + * + * @param integer targetIndex + * @param object imageData + * @param object containerDimensions + */ + _renderImage: function(targetIndex, imageData, containerDimensions) { + if (!imageData) { + targetIndex = this._activeImage; + imageData = this._images[this._active]; + + containerDimensions = { + height: $(window).height() - (this._container.hasClass('maximized') ? 0 : 200), + width: this._ui.imageContainer.innerWidth() + }; + } + + // simulate padding + containerDimensions.height -= 22; + containerDimensions.width -= 20; + + this._ui.images[targetIndex].prop('src', imageData.image.url); + + var $height = imageData.image.height; + var $width = imageData.image.width; + var $ratio = 0.0; + + // check if image exceeds dimensions on the Y axis + if ($height > containerDimensions.height) { + $ratio = containerDimensions.height / $height; + $height = containerDimensions.height; + $width = Math.floor($width * $ratio); + } + + // check if image exceeds dimensions on the X axis + if ($width > containerDimensions.width) { + $ratio = containerDimensions.width / $width; + $width = containerDimensions.width; + $height = Math.floor($height * $ratio); + } + + var $left = Math.floor((containerDimensions.width - $width) / 2); + var $top = Math.floor((containerDimensions.height - $height) / 2); + + this._ui.images[targetIndex].css({ + height: $height + 'px', + left: $left + 'px', + marginTop: (Math.round($height / 2) * -1) + 'px', + width: $width + 'px' + }); + }, + + /** + * Initialites the user interface. + * + * @return boolean + */ + _initUI: function() { + if (this._didInit) { + return false; + } + + this._didInit = true; + + this._container = $('
').appendTo(document.body); + var $imageContainer = $('
').appendTo(this._container); + var $imageList = $('
    ').appendTo(this._container); + var $slideshowContainer = $('
      ').appendTo($imageContainer); + var $slideshowButtonPrevious = $('
    • ').appendTo($slideshowContainer); + var $slideshowButtonToggle = $('
    • ').appendTo($slideshowContainer); + var $slideshowButtonNext = $('
    • ').appendTo($slideshowContainer); + var $slideshowButtonEnlarge = $('
    • ').appendTo($slideshowContainer); + var $slideshowButtonFull = $('
    • ').appendTo($slideshowContainer); + + this._ui = { + buttonNext: $imageList.children('span.wcfImageViewerButtonNext'), + buttonPrevious: $imageList.children('span.wcfImageViewerButtonPrevious'), + header: $('

      ').appendTo(this._container), + imageContainer: $imageContainer, + images: [ + $imageContainer.children('img:eq(0)').on('webkitTransitionEnd transitionend msTransitionEnd oTransitionEnd', function() { $(this).removeClass('animateTransformation'); }), + $imageContainer.children('img:eq(1)').on('webkitTransitionEnd transitionend msTransitionEnd oTransitionEnd', function() { $(this).removeClass('animateTransformation'); }) + ], + imageList: $imageList.find('> div > ul'), + slideshow: { + container: $slideshowContainer, + enlarge: $slideshowButtonEnlarge, + full: $slideshowButtonFull, + next: $slideshowButtonNext, + previous: $slideshowButtonPrevious, + toggle: $slideshowButtonToggle + } + }; + + this._ui.buttonNext.click($.proxy(this._next, this)); + this._ui.buttonPrevious.click($.proxy(this._previous, this)); + + $slideshowButtonNext.click($.proxy(this._nextImage, this)); + $slideshowButtonPrevious.click($.proxy(this._previousImage, this)); + $slideshowButtonEnlarge.click($.proxy(this._toggleView, this)); + $slideshowButtonToggle.click($.proxy(function() { + if (this._slideshowEnabled) { + this.stopSlideshow(); + } + else { + this.startSlideshow(); + } + }, this)); + $slideshowButtonFull.click(function(event) { window.location = $(event.currentTarget).data('link'); }); + + // close button + $('').appendTo(this._ui.header).click($.proxy(this.close, this)); + + return true; + }, + + /** + * Toggles between normal and fullscreen view. + */ + _toggleView: function() { + this._ui.images[this._activeImage].addClass('animateTransformation'); + this._container.toggleClass('maximized'); + this._ui.slideshow.enlarge.toggleClass('active').children('span').toggleClass('icon-resize-full').toggleClass('icon-resize-small'); + + this._renderImage(null, undefined, null); + }, + + /** + * Shifts the thumbnail list. + * + * @param object event + * @param integer shiftBy + */ + _next: function(event, shiftBy) { + if (this._ui.buttonNext.hasClass('pointer')) { + if (shiftBy == undefined) { + this.stopSlideshow(); + } + + var $maximumOffset = Math.max((this._items * this._thumbnailWidth) - this._thumbnailContainerWidth - this._thumbnailMarginRight, 0); + this._thumbnailOffset = Math.min(this._thumbnailOffset + (this._thumbnailWidth * (shiftBy ? shiftBy : this.options.shiftBy)), $maximumOffset); + this._ui.imageList.css('marginLeft', (this._thumbnailOffset * -1)); + } + + this._preload(); + + this._toggleButtons(); + }, + + /** + * Unshifts the thumbnail list. + * + * @param object event + * @param integer shiftBy + */ + _previous: function(event, unshiftBy) { + if (this._ui.buttonPrevious.hasClass('pointer')) { + if (unshiftBy == undefined) { + this.stopSlideshow(); + } + + this._thumbnailOffset = Math.max(this._thumbnailOffset - (this._thumbnailWidth * (unshiftBy ? unshiftBy : this.options.shiftBy)), 0); + this._ui.imageList.css('marginLeft', (this._thumbnailOffset * -1)); + } + + this._toggleButtons(); + }, + + /** + * Displays the next image. + * + * @param object event + */ + _nextImage: function(event) { + if (this._ui.slideshow.next.hasClass('pointer')) { + this.stopSlideshow(); + this.showImage(this._active + 1); + } + }, + + /** + * Displays the previous image. + * + * @param object event + */ + _previousImage: function(event) { + if (this._ui.slideshow.previous.hasClass('pointer')) { + this.stopSlideshow(); + this.showImage(this._active - 1); + } + }, + + /** + * Moves thumbnail list to target thumbnail. + * + * @param integer seriesIndex + */ + moveToImage: function(seriesIndex) { + // calculate start and end of thumbnail + var $start = (seriesIndex - 3) * this._thumbnailWidth; + var $end = $start + (this._thumbnailWidth * 5); + + // calculate visible offsets + var $left = this._thumbnailOffset; + var $right = this._thumbnailOffset + this._thumbnailContainerWidth; + + // check if thumbnail is within boundaries + var $shouldMove = false; + if ($start < $left || $end > $right) { + $shouldMove = true; + } + + // try to shift until the thumbnail itself and the next/previous 2 thumbnails are visible + if ($shouldMove) { + var $shiftBy = 0; + + // unshift + if ($start < $left) { + while ($start < $left) { + $shiftBy++; + $left -= this._thumbnailWidth; + } + + this._previous(null, $shiftBy); + } + else { + // shift + while ($end > $right) { + $shiftBy++; + $right += this._thumbnailWidth; + } + + this._next(null, $shiftBy); + } + } + }, + + /** + * Toggles control buttons. + */ + _toggleButtons: function() { + // button 'previous' + if (this._thumbnailOffset > 0) { + this._ui.buttonPrevious.addClass('pointer'); + } + else { + this._ui.buttonPrevious.removeClass('pointer'); + } + + // button 'next' + var $maximumOffset = (this._images.length * this._thumbnailWidth) - this._thumbnailContainerWidth - this._thumbnailMarginRight; + if (this._thumbnailOffset >= $maximumOffset) { + this._ui.buttonNext.removeClass('pointer'); + } + else { + this._ui.buttonNext.addClass('pointer'); + } + + // slideshow controls + if (this._active > 0) { + this._ui.slideshow.previous.addClass('pointer'); + } + else { + this._ui.slideshow.previous.removeClass('pointer'); + } + + if (this._active + 1 < this._images.length) { + this._ui.slideshow.next.addClass('pointer'); + } + else { + this._ui.slideshow.next.removeClass('pointer'); + } + }, + + /** + * Inserts thumbnails. + * + * @param array images + */ + _createThumbnails: function(images) { + for (var $i = 0, $length = images.length; $i < $length; $i++) { + var $image = images[$i]; + + var $listItem = $('
    • ').appendTo(this._ui.imageList); + $listItem.data('index', this._images.length).click($.proxy(this._showImage, this)); + + $image.listItem = $listItem; + this._images.push($image); + } + }, + + /** + * Loads the next images via AJAX. + */ + _loadNextImages: function() { + this._proxy.setOption('data', { + actionName: 'loadNextImages', + className: this.options.className, + interfaceName: 'wcf\\data\\IImageViewerAction', + objectIDs: [ this.element.data('objectID') ], + parameters: { + maximumHeight: this._maxDimensions.height, + maximumWidth: this._maxDimensions.width, + offset: this._images.length + } + }); + this._proxy.sendRequest(); + }, + + /** + * Handles successful AJAX requests. + * + * @param object data + * @param string textStatus + * @param jQuery jqXHR + */ + _success: function(data, textStatus, jqXHR) { + if (data.returnValues.items) { + this._items = data.returnValues.items; + } + + var $initialized = this._initUI(); + + this._createThumbnails(data.returnValues.images); + + this._render($initialized); + } +}); diff --git a/wcfsetup/install/files/lib/data/IImageViewerAction.class.php b/wcfsetup/install/files/lib/data/IImageViewerAction.class.php new file mode 100644 index 0000000000..8e55a8a835 --- /dev/null +++ b/wcfsetup/install/files/lib/data/IImageViewerAction.class.php @@ -0,0 +1,68 @@ + + * @package com.woltlab.wcf + * @subpackage data + * @category Community Framework + */ +interface IImageViewerAction { + /** + * Validates parameters to load the next images. + */ + public function validateLoadNextImages(); + + /** + * Returns a list of images, array indices given for 'images' are discarded (series and series 'url' are optional). + * The first response (offset = 0) must return the total number of images. + * + * Each requests contains three parameters: + * - offset: number of already loaded image + * - maximumHeight: image provided in 'url' must be as close as possible to this value + * - maximumWidth: see above + * + * Each image can specify a link which should not point to the image itself, instead it should provide a viewable + * page directly related to the image (e.g. photo page). The 'fullURL' parameter is optional and results in the + * link "View original image" and should lead to the original exceeding lager than the image specified with 'url'. + * + * Expected return value: + * array( + * 'images' => array( + * array( + * 'image' => array( + * 'height' => 768, + * [ 'fullURL' => 'http://example.com/path/to/full/image.png', ] + * [ 'link' => 'http://example.com/index.php/123-MyImage/', ] + * 'title' => 'My first picture', + * 'url' => 'http://example.com/path/to/large/image.png', + * 'width' => 1024 + * ), + * 'thumbnail' => array( + * 'height' => 148, + * 'url' => 'http://example.com/path/to/thumbnail.png', + * 'width' => 148 + * ), + * [ 'series' => array( + * 'title' => 'My image series, + * [ 'link' => 'http://example.com/link/to/image/series/ ] + * ), ] + * 'user' => array( + * 'avatarURL' => 'http://link/to/avatar.png', + * 'link' => 'http://example.com/index.php/123-FooBar/', + * 'username' => 'FooBar' + * ) + * ), + * // ... + * ), + * [ 'items' => 123 ] + * ) + * + * @return array + */ + public function loadNextImages(); +} diff --git a/wcfsetup/install/files/style/imageViewer.less b/wcfsetup/install/files/style/imageViewer.less index dcef2b9cc2..5f156ed8c4 100644 --- a/wcfsetup/install/files/style/imageViewer.less +++ b/wcfsetup/install/files/style/imageViewer.less @@ -109,3 +109,269 @@ #lbCaption { font-weight: bold; } + +/* ImageViewer for WCF */ +@wcfImageViewerBorderColor: rgba(51, 51, 51, 1); +@wcfImageViewerFontColor: rgba(192, 192, 192, 1); + +.wcfImageViewer { + background-color: rgba(0, 0, 0, 1); + bottom: 0; + display: none; + left: 0; + opacity: 0; + position: fixed; + right: 0; + top: 0; + z-index: 399; + + &.open { + display: block; + opacity: 1; + } + + &.maximized { + > header { + top: -100px; + } + + > div { + bottom: 0; + border-color: fade(@wcfImageViewerBorderColor, 0%); + top: 0; + } + + > footer { + bottom: -100px; + } + } + + > header, + > div, + > footer { + box-sizing: border-box; + left: 0; + position: fixed; + right: 0; + z-index: 400; + } + + > header { + height: 100px; + padding: 1rem; + top: 0; + + transition: top linear .3s; + + > div { + > h1, + > h2, + > h3 { + color: @wcfImageViewerFontColor; + margin-left: 80px !important; + } + + > h1 { + font-size: 1.75rem; + } + + > h2 { + font-size: 1.25rem; + } + + > h3 { + color: rgba(153, 153, 153, 1); + font-size: .85rem; + margin-top: .25rem; + } + } + + > .wcfImageViewerButtonClose { + opacity: .6; + position: absolute; + right: 26px; + top: 26px; + + transition: opacity linear .3s; + + &:hover { + opacity: 1; + } + } + } + + > div { + background-color: rgba(0, 0, 0, 1); + border-bottom: 1px solid @wcfImageViewerBorderColor; + border-top: 1px solid @wcfImageViewerBorderColor; + bottom: 100px; + top: 100px; + z-index: 401; + + -webkit-transition: top .3s, bottom .3s, border-color .3s; + + > img { + opacity: 0; + position: absolute; + top: 50%; + + transition: opacity linear .75s; + + &.animateTransformation { + -webkit-transition: left .3s, margin-top .3s, height .3s, width .3s, opacity .75s; + } + + &.active { + opacity: 1; + } + } + + > ul { + background-color: rgba(0, 0, 0, 1); + border: 1px solid @wcfImageViewerBorderColor; + border-bottom-width: 0; + border-radius: 5px 5px 0 0; + bottom: 0; + display: inline-block; + left: 50%; + margin-left: -122px; + opacity: .4; + position: absolute; + + transition: opacity linear .5s; + + &:hover { + opacity: 1; + } + + > li { + display: inline-block; + opacity: .6; + + transition: opacity linear .5s; + + &.pointer > span.icon { + cursor: pointer; + } + + &.active, + &.pointer:hover { + opacity: 1; + } + + &.wcfImageViewerSlideshowButtonToggle > span, + &.wcfImageViewerSlideshowButtonEnlarge > span, + &.wcfImageViewerSlideshowButtonFull > span { + font-size: 28px; + + &:before { + left: 2px; + position: relative; + top: 9px; + } + } + + .wcfImageViewerSlideshowButtonEnlarge, + .wcfImageViewerSlideshowButtonFull { + border-left: 1px solid @wcfImageViewerBorderColor; + box-sizing: border-box; + } + + > span { + vertical-align: middle; + } + } + } + } + + > footer { + bottom: 0; + height: 100px; + padding: 10px; + + transition: bottom linear .3s; + + &:hover > div > ul > li > a { + filter: none; + -webkit-filter: none; + } + + > span { + bottom: 0; + font-size: 48px; + padding-top: 26px; + opacity: 0; + position: absolute; + top: 0; + width: 30px; + z-index: 2; + + transition: opacity linear .5s; + + &.pointer { + opacity: .6; + + &:hover { + opacity: 1; + } + } + + &.wcfImageViewerButtonPrevious { + left: 5px; + } + + &.wcfImageViewerButtonNext { + right: 5px; + } + } + + > div { + height: 80px; + margin: 0 35px; + overflow: hidden; + white-space: nowrap; + + > ul { + font-size: 0; + height: 80px; + z-index: 1; + + transition: margin-left cubic-bezier(.5, 1.595, .56, .98) .75s; + + > li { + display: inline-block; + opacity: .6; + + transition: opacity linear .5s; + + &.active, + &.hover { + opacity: 1; + } + + &:not(:last-child) { + margin-right: 10px; + } + + &.active > a { + filter: none; + -webkit-filter: none; + } + + > a { + background-position: center; + background-repeat: no-repeat; + background-size: contain; + display: block; + height: 80px; + width: 80px; + + .grayscale; + -webkit-filter: grayscale(100%); + -webkit-transition: filter,-webkit-filter .5s linear; + } + } + } + } + } +} -- 2.20.1