Недавно на одном из наших проектов возникла необходимость демонстрировать посетителям продукцию клиента — бутылки с напитками. У наших дизайнеров родилась идея дать посетителям сайта возможность вращать бутылки, чтобы рассмотреть их со всех сторон. Тут-то они и обратились ко мне с требованием немедленно реализовать их задумку. А результатами нашей работы мы решили поделиться с вами. Естественно, одним осевым вращением дело не ограничилось, но остальные возможности лично мне были не столь интересны, поэтому сегодня о них писать не буду.

Вкратце о том, что в конечном итоге будет уметь наш скрипт. Первое требование — собственно вращение. Объект должен вращаться вокруг вертикальной оси, проходящей через его центр. Вращать его мы будем с помощью перетаскивания мышью. Очевидно, нужна поддержка сенсорных экранов. Ну и, конечно, нужно красивое модальное окошко с предзагрузкой, чтоб пользователь не отвлекался от всей этой красоты на всякую ерунду вроде текстов, контактных данных и прочей бесполезной информации :)

Теперь давайте разберемся, как все это будет работать с технической точки зрения.

Рассматривался вариант с полноценной 3D моделью и ее отображением посредством трехмерных JavaScript-библиотек. Однако при таком подходе возникают проблемы с детализацией моделей, а ведь хочется в полной мере продемонстрировать труды нашей команды! Поэтому было принято решение отрендерить продукт в нескольких ракурсах и затем просто подменять их друг другом, создавая эффект вращения.

Для создания модального окошка, которое будет красивенько появляться и не менее красивенько скрываться при закрытии, воспользуемся удобным скриптом transition.js из пакета Twitter Bootstrap. Этот скрипт добавляет кросс-браузерную поддержку события transitionEnd в jQuery и используется практически во всех других скриптах пакета Bootstrap.

Теперь переходим непосредственно к написанию кода. JavaScript составляющую я, по традиции, оформлю в качестве плагина для jQuery в стиле Twitter Bootstrap.

Начнем с создания HTML-разметки нашего модального окна:

<a href="#" data-target="#my-rotate-modal" data-toggle="pi-rotate">Просмотр объекта</a>

<div class="pi-rotate fade" id="my-rotate-modal" tabindex="-1" role="dialog" aria-hidden="true">
    <div class="pi-window">
        <div class="pi-preloader fade in active"><div class="pi-preloader-icon"></div></div>
        <div class="pi-image fade">
            <div class="pi-sprite" style="background-image: url('./img/sprite-object.png'); width: 114px; height: 400px;"></div>
        </div>
    </div>

    <button type="button" class="pi-close">&times;</button>
</div>

<script type='text/javascript' src='js/vendor/transition.min.js'></script>
<script type="text/javascript" src="js/pi-rotate.js"></script>

Элемент .pi-rotate затемняет фон и используется для центрирования модального окна. Спрайт продукта мы помещаем в элемент .pi-sprite, в нем же задаем высоту и ширину кадра. Количество кадров в спрайте будет вычисляться автоматически на основе ширины спрайта.

Теперь пропишем стили для нашего модального окошка:

.pi-rotate {
    text-align: center;

    background: #000;
    background: rgba(0,0,0,.85);

    display: none;

    position: fixed;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    z-index: 1000;
}

.pi-rotate.fade {
    opacity: 0;
    -webkit-transition: opacity .15s linear;
       -moz-transition: opacity .15s linear;
        -ms-transition: opacity .15s linear;
         -o-transition: opacity .15s linear;
            transition: opacity .15s linear;
}
.pi-rotate.fade.in {
    opacity: 1;
}

.pi-rotate:after,
.pi-rotate .pi-window {
    display: inline-block;
    vertical-align: middle;
}

.pi-rotate:after {
    content: '';
    height: 100%;
}

.pi-rotate .pi-window {
    text-align: left;

    background: #fff;

    padding: 25px;

    width: 400px;

    position: relative;
}

.pi-rotate .pi-preloader.fade,
.pi-rotate .pi-image.fade {
    opacity: 0;
    -webkit-transition: opacity .15s linear;
       -moz-transition: opacity .15s linear;
        -ms-transition: opacity .15s linear;
         -o-transition: opacity .15s linear;
            transition: opacity .15s linear;
}

.pi-rotate .pi-preloader.fade.in,
.pi-rotate .pi-image.fade.in {
    opacity: 1;
}

.pi-rotate .pi-preloader {
    display: none;
}

.pi-rotate .pi-preloader.active {
    display: block;
}

@-webkit-keyframes preload-spinner {
    0% { background-position: 0; }
    100% { background-position: -660px; }
}

@keyframes preload-spinner {
    0% { background-position: 0; }
    100% { background-position: -660px; }
}

.pi-preloader-icon {
    background: url('../img/sprite-preloader.jpg') no-repeat 0 0;

    margin-top: -26px;
    margin-left: -27px;

    display: block;
    width: 55px;
    height: 52px;

    position: absolute;
    top: 50%;
    left: 50%;

    -webkit-animation: preload-spinner 0.5s steps(12) infinite;
            animation: preload-spinner 0.5s steps(12) infinite;
}

.pi-sprite {
    background-position: 0 0;
    background-repeat: no-repeat;

    margin: 0 auto;

    display: block;
}

.pi-rotate .pi-close {
    color: #fff;
    font-size: 36px;
    line-height: 36px;

    background-color: transparent;

    margin: 0;
    border: 0;
    padding: 0;
    overflow: visible;
    outline: 0;

    display: block;
    width: 36px;
    height: 36px;

    position: fixed;
    top: 20px;
    right: 20px;
}

Для вертикального выравнивания элемента .pi-window я использую псевдоэлемент .pi-rotate:after со свойством height: 100%, а также задаю элементу .pi-window свойства display: inline-block и vertical-align: middle.

Можно приступать к написанию кода. Начнем с инициализации плагина:

(function ($) {
    "use strict";

    var pluginName = 'piRotate';

    var pluginDefaults = {
        preloaderSelector: '.pi-preloader',
        imageSelector: '.pi-image',
        spriteSelector: '.pi-sprite',
        closeSelector: '.pi-close',
        keyboard: true,
        preload: true
        // length
    };

    function Plugin (element, options) {
        this.init(pluginName, element, options);
    }

    Plugin.prototype = {

        constructor: Plugin,

        init: function (name, element, options) {

            // Plugin name is passed for extendability
            this.name     = name;
            this.$element = $(element);
            this.options  = $.extend({}, $.fn[this.name].defaults, this.$element.data(), options);

            // Init elements
            this.$element.attr('data-pi-rotate-element', 'root');
            this.$element.find(this.options.closeSelector).attr('data-pi-rotate-element', 'close');

            this.$preloader = this.$element.find(this.options.preloaderSelector);
            this.$image     = this.$element.find(this.options.imageSelector).attr('data-pi-rotate-element', 'image');
            this.$sprite    = this.$element.find(this.options.spriteSelector);
        }
    };

    $.fn[pluginName] = function (option) {
        return this.each(function () {
            var $this   = $(this),
                data    = $this.data(pluginName),
                options = typeof option == 'object' && option;
            if (!data) $this.data(pluginName, (data = new Plugin(this, options)));
            if (typeof option == 'string') data[option]();
            else data.show();
        });
    };

    $.fn[pluginName].Constructor = Plugin;

    $.fn[pluginName].defaults = pluginDefaults;

    // Product toggle
    $(document).on('click', '[data-toggle="pi-rotate"]', function (event) {
        var $this   = $(this),
            href    = $this.attr('href'),
            $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))),
            option  = $target.data(pluginName) ? 'toggle' : {};

        if ($this.is('a')) event.preventDefault();

        $target[pluginName](option).one('hide', function () {
            $this.is(':visible') && $this.focus();
        });
    });

})(window.jQuery);

Плагин инициализируется, когда пользователь нажимает на ссылку с атрибутом data-toggle="pi-rotate". Внутри метода init() мы задаем нескольким элементам атрибуты data-pi-rotate-element. Они нам понадобятся для корректной работы событий.

Далее напишем код для скрытия и отображения модального окошка, а также предзагрузки спрайта. Вместо того, чтобы изобретать велосипед, адаптируем под свои нужды функционал плагина модальных окон из Twitter Bootstrap:

    Plugin.prototype = {

        // ...

        toggle: function () {
            return this[!this._shown ? 'show' : 'hide']();
        },

        show: function () {
            var event = $.Event('show.rotate');

            this.$element.trigger(event);

            if (this._shown || event.isDefaultPrevented()) return;

            this._shown = true;

            this.escape();
            this.$element.on('click.dismiss.rotate', '[data-pi-rotate-element="close"]', $.proxy(this.hide, this));

            this.$element.on('click.dismiss.rotate', $.proxy(function (event) {
                if (event.target !== event.currentTarget) return;
                this.hide(this);
            }, this));

            var transition = $.support.transition && this.$element.hasClass('fade');

            if (!this.$element.parent().length) this.$element.appendTo(document.body); // don't move modals dom position

            this.$element.show().scrollTop(0);

            if (transition) this.$element[0].offsetWidth; // force reflow

            this.$element.addClass('in').attr('aria-hidden', false);
            this.enforceFocus();

            var fn_onshow = $.proxy(function (e) {
                if (!e || !this.$element.is(e.target)) return true;

                this.$element.trigger('focus');

                if (this.options.preload && !this._loaded) this.preload($.proxy(function () {
                    this.$element.trigger('shown.rotate');
                }, this));
                else this.$element.trigger('shown.rotate');

            }, this);

            transition ? this.$element.one($.support.transition.end, fn_onshow).emulateTransitionEnd(300) : fn_onshow();
        },

        hide: function () {
            var event = $.Event('hide.rotate');

            this.$element.trigger(event);

            if (!this._shown || event.isDefaultPrevented()) return;

            this._shown = false;

            this.escape();

            var transition = $.support.transition && this.$element.hasClass('fade');

            $(document).off('focusin.rotate');

            this.$element.removeClass('in').attr('aria-hidden', true).off('click.dismiss.rotate');

            var fn_onhide = $.proxy(function () {
                this.$element.hide();
                this.reset();
                this.$element.trigger('hidden.rotate');
            }, this);

            transition ? this.$element.one($.support.transition.end, fn_onhide).emulateTransitionEnd(300) : fn_onhide();
        },

        preload: function (callback) {
            if (this._loaded) {
                callback && callback();
                return;
            }

            var url = this.$sprite.css('background-image');
            url = /^url\((['"]?)(.*)\1\)$/.exec(url);
            url = url ? url[2] : '';

            var img     = new Image(),
                promise = $.Deferred();

            img.onload  = function () { promise.resolve(); };
            img.onabort = img.onerror = function () { promise.reject(); };
            img.src     = url;

            promise.done($.proxy(function () {
                this._loaded        = true;
                img.naturalWidth    = img.naturalWidth || img.width;
                this.options.length = img.naturalWidth / this.$sprite.width();

                this.$sprite.css('background-size', img.naturalWidth + 'px ' + this.$sprite.height() + 'px');

                var transition = $.support.transition && this.$preloader.hasClass('fade');
                var fn         = $.proxy(function () {
                    this.$preloader.removeClass('active');
                    this.$image.addClass('active');

                    if (transition) {
                        this.$image[0].offsetWidth;
                        this.$image.addClass('in');
                    } else this.$image.removeClass('fade');

                    callback && callback();
                }, this);

                transition ? this.$preloader.one($.support.transition.end, fn).emulateTransitionEnd(150) : fn();

                this.$preloader.removeClass('in');
            }, this));
        },

        enforceFocus: function () {
            $(document).off('focusin.product').on('focusin.product', $.proxy(function (e) {
                if (this.$element[0] !== e.target && !this.$element.has(e.target).length) {
                    this.$element.trigger('focus');
                }
            }, this));
        },

        escape: function () {
            if (this._shown && this.options.keyboard) {
                this.$element.on('keyup.dismiss.product', $.proxy(function (e) {
                    e.which == 27 && this.hide();
                }, this));
            } else if (!this._shown) {
                this.$element.off('keyup.dismiss.product');
            }
        }

    };

При первом отображении нашего модального окошка пользователь увидит предзагрузчик, который будет отображаться до полной загрузки спрайта. При последующих отображениях модального окошка объект будет отображаться сразу.

Осталось только написать функцию вращения:

    Plugin.prototype = {

        // ...

        imageRotate: function (delta) {
            var length = this.options.length,
                pos    = this._rotatePosition || 0,
                index  = pos + delta;

            if (index >= length) index = index % length;
            if (index < 0) index = length + index;

            this._rotatePosition = index;

            // Set sprite position
            var width = this.$sprite.innerWidth(),
                x     = width * index;

            this.$sprite.css('background-position', -x + 'px 0');
        },

        reset: function () {
            // Reset image rotate
            this._rotatePosition = 0;
            this.$sprite.css('background-position', '0 0');
        }

    };

    // ...

    // Image rotate
    var _drag = null;

    $(document).on('mousedown touchstart', '[data-pi-rotate-element="image"]', function (event) {
        if (event.type == 'touchstart' && event.originalEvent.touches.length > 1) return true; // Single touch only
        if (_drag !== null) return true; // Already tracking

        // Get root element
        var $element = $(this).closest('[data-pi-rotate-element="root"]');
        if ($element.length < 1) return true;

        // Get plugin instance
        var data = $element.data(pluginName);
        if (!data) return true;

        // Get coords source
        var e = event.type == 'touchstart' ? event.originalEvent.touches[0] : event;

        _drag = {
            start:   e.pageX,
            $target: $element,
            offset:  data._dragOffset || 0
        };
    });

    $(document).on('mousemove touchmove', function (event) {
        if (_drag === null) return true;
        if (event.type == 'touchmove' && event.originalEvent.touches.length > 1) return true; // Single touch only

        // Get plugin instance
        var data = _drag.$target.data(pluginName);
        if (!data) return true;

        event.preventDefault();

        // Get coords source
        var e = event.type == 'touchmove' ? event.originalEvent.touches[0] : event;

        // Get delta
        var delta = Math.round((e.pageX - _drag.start) / 20);

        if (delta !== 0) _drag.start = e.pageX;

        return (data.imageRotate(delta) || true);
    });

    $(document).on('mouseup touchend', function (event) {
        if (_drag === null) return true;

        // Clear drag
        _drag = null;
    });

При перетаскивании картинки бутылки влево или вправо скрипт устанавливает новое смещение спрайта каждые 20 пикселов. Метод reset() обнуляет смещение спрайта при скрытии модального окна.

 

В результате мы получили решение, которое подходит для самых разных сайтов — от презентационных сайтов конкретных производителей и вплоть до интернет-магазинов. Правда, представить себе интернет-магазин, дотошно отрисовывающий каждый из сотен или даже тысяч товаров, я не могу. Но это уже реалии, неподвластные даже магии JavaScript.

Демо-страница с работающим скриптом