cms/static/cms/js/modules/cms.toolbar.js
/*
* Copyright https://github.com/divio/django-cms
*/
import $ from 'jquery';
import Class from 'classjs';
import Navigation from './cms.navigation';
import Sideframe from './cms.sideframe';
import Modal from './cms.modal';
import Plugin from './cms.plugins';
import { filter, throttle, uniq } from 'lodash';
import { showLoader, hideLoader } from './loader';
import { Helpers, KEYS } from './cms.base';
var SECOND = 1000;
var TOOLBAR_OFFSCREEN_OFFSET = 10; // required to hide box-shadow
export const getPlaceholderIds = pluginRegistry =>
uniq(filter(pluginRegistry, ([, opts]) => opts.type === 'placeholder').map(([, opts]) => opts.placeholder_id));
/**
* @function hideDropdownIfRequired
* @private
* @param {jQuery} publishBtn
*/
function hideDropdownIfRequired(publishBtn) {
var dropdown = publishBtn.closest('.cms-dropdown');
if (dropdown.length && dropdown.find('li[data-cms-hidden]').length === dropdown.find('li').length) {
dropdown.hide().attr('data-cms-hidden', 'true');
}
}
/**
* The toolbar is the generic element which holds various components
* together and provides several commonly used API methods such as
* show/hide, message display or loader indication.
*
* @class Toolbar
* @namespace CMS
* @uses CMS.API.Helpers
*/
var Toolbar = new Class({
implement: [Helpers],
options: {
toolbarDuration: 200
},
initialize: function initialize(options) {
this.options = $.extend(true, {}, this.options, options);
// elements
this._setupUI();
/**
* @property {CMS.Navigation} navigation
*/
this.navigation = new Navigation();
/**
* @property {Object} _position
* @property {Number} _position.top current position of the toolbar
* @property {Number} _position.top position when toolbar became non-sticky
* @property {Boolean} _position.isSticky is toolbar sticky?
* @see _handleLongMenus
* @private
*/
this._position = {
top: 0,
stickyTop: 0,
isSticky: true
};
// states
this.click = 'click.cms.toolbar';
this.touchStart = 'touchstart.cms.toolbar';
this.pointerUp = 'pointerup.cms.toolbar';
this.pointerOverOut = 'pointerover.cms.toolbar pointerout.cms.toolbar';
this.pointerLeave = 'pointerleave.cms.toolbar';
this.mouseEnter = 'mouseenter.cms.toolbar';
this.mouseLeave = 'mouseleave.cms.toolbar';
this.resize = 'resize.cms.toolbar';
this.scroll = 'scroll.cms.toolbar';
this.key = 'keydown.cms.toolbar keyup.cms.toolbar';
// istanbul ignore next: function is always reassigned
this.timer = function() {};
this.lockToolbar = false;
// setup initial stuff
if (!this.ui.toolbar.data('ready')) {
this._events();
}
this._initialStates();
// set a state to determine if we need to reinitialize this._events();
this.ui.toolbar.data('ready', true);
},
/**
* Stores all jQuery references within `this.ui`.
*
* @method _setupUI
* @private
*/
_setupUI: function _setupUI() {
var container = $('.cms');
this.ui = {
container: container,
body: $('html'),
document: $(document),
window: $(window),
toolbar: container.find('.cms-toolbar'),
navigations: container.find('.cms-toolbar-item-navigation'),
buttons: container.find('.cms-toolbar-item-buttons'),
messages: container.find('.cms-messages'),
structureBoard: container.find('.cms-structure'),
toolbarSwitcher: $('.cms-toolbar-item-cms-mode-switcher'),
revert: $('.cms-toolbar-revert')
};
},
/**
* Sets up all the event handlers, such as closing and resizing.
*
* @method _events
* @private
*/
_events: function _events() {
var that = this;
var LONG_MENUS_THROTTLE = 10;
// attach event to the navigation elements
this.ui.navigations.each(function() {
var navigation = $(this);
var lists = navigation.find('li');
var root = 'cms-toolbar-item-navigation';
var hover = 'cms-toolbar-item-navigation-hover';
var disabled = 'cms-toolbar-item-navigation-disabled';
var children = 'cms-toolbar-item-navigation-children';
var isTouchingTopLevelMenu = false;
var open = false;
var cmdPressed = false;
/**
* Resets all the hover state classes and events
* @function reset
*/
function reset() {
open = false;
cmdPressed = false;
lists.removeClass(hover);
lists.find('ul ul').hide();
navigation.find('> li').off(that.mouseEnter);
that.ui.document.off(that.click);
that.ui.toolbar.off(that.click, reset);
that.ui.structureBoard.off(that.click);
that.ui.window.off(that.resize + '.menu.reset');
that._handleLongMenus();
}
that.ui.window.on('keyup.cms.toolbar', function(e) {
if (e.keyCode === CMS.KEYS.ESC) {
reset();
}
});
navigation
.find('> li > a')
.add(that.ui.toolbar.find('.cms-toolbar-item:not(.cms-toolbar-item-navigation) > a'))
.off('keyup.cms.toolbar.reset')
.on('keyup.cms.toolbar.reset', function(e) {
if (e.keyCode === CMS.KEYS.TAB) {
reset();
}
});
// remove events from first level
navigation
.find('a')
.on(that.click + ' ' + that.key, function(e) {
var el = $(this);
// we need to restore the default behaviour once a user
// presses ctrl/cmd and clicks on the entry. In this
// case a new tab should open. First we determine if
// ctrl/cmd is pressed:
if (
e.keyCode === KEYS.CMD_LEFT ||
e.keyCode === KEYS.CMD_RIGHT ||
e.keyCode === KEYS.CMD_FIREFOX ||
e.keyCode === KEYS.SHIFT ||
e.keyCode === KEYS.CTRL
) {
cmdPressed = true;
}
if (e.type === 'keyup') {
cmdPressed = false;
}
if (el.attr('href') !== '' && el.attr('href') !== '#' && !el.parent().hasClass(disabled)) {
if (cmdPressed && e.type === 'click') {
// control the behaviour when ctrl/cmd is pressed
Helpers._getWindow().open(el.attr('href'), '_blank');
} else if (e.type === 'click') {
// otherwise delegate as usual
that._delegate($(this));
} else {
// tabbing through
return;
}
reset();
return false;
}
})
.on(that.touchStart, function() {
isTouchingTopLevelMenu = true;
});
// handle click states
lists.on(that.click, function(e) {
e.preventDefault();
e.stopPropagation();
var el = $(this);
// close navigation once it's pressed again
if (el.parent().hasClass(root) && open) {
that.ui.body.trigger(that.click);
return false;
}
// close if el does not have children
if (!el.hasClass(children)) {
reset();
}
var isRootNode = el.parent().hasClass(root);
if ((isRootNode && el.hasClass(hover)) || (el.hasClass(disabled) && !isRootNode)) {
return false;
}
el.addClass(hover);
that._handleLongMenus();
// activate hover selection
if (!isTouchingTopLevelMenu) {
// we only set the handler for mouseover when not touching because
// the mouseover actually is triggered on touch devices :/
navigation.find('> li').on(that.mouseEnter, function() {
// cancel if item is already active
if ($(this).hasClass(hover)) {
return false;
}
open = false;
$(this).trigger(that.click);
});
}
isTouchingTopLevelMenu = false;
// create the document event
that.ui.document.on(that.click, reset);
that.ui.structureBoard.on(that.click, reset);
that.ui.toolbar.on(that.click, reset);
that.ui.window.on(that.resize + '.menu.reset', throttle(reset, SECOND));
// update states
open = true;
});
// attach hover
lists
.on(that.pointerOverOut + ' keyup.cms.toolbar', 'li', function(e) {
var el = $(this);
var parent = el
.closest('.cms-toolbar-item-navigation-children')
.add(el.parents('.cms-toolbar-item-navigation-children'));
var hasChildren = el.hasClass(children) || parent.length;
// do not attach hover effect if disabled
// cancel event if element has already hover class
if (el.hasClass(disabled)) {
e.stopPropagation();
return;
}
if (el.hasClass(hover) && e.type !== 'keyup') {
return true;
}
// reset
lists.find('li').removeClass(hover);
// add hover effect
el.addClass(hover);
// handle children elements
if (
(hasChildren && e.type !== 'keyup') ||
(hasChildren && e.type === 'keyup' && e.keyCode === CMS.KEYS.ENTER)
) {
el.find('> ul').show();
// add parent class
parent.addClass(hover);
that._handleLongMenus();
} else if (e.type !== 'keyup') {
lists.find('ul ul').hide();
that._handleLongMenus();
}
// Remove stale submenus
el.siblings().find('> ul').hide();
})
.on(that.click, function(e) {
e.preventDefault();
e.stopPropagation();
});
// fix leave event
lists.on(that.pointerLeave, '> ul', function() {
lists.find('li').removeClass(hover);
});
});
// attach event for first page publish
this.ui.buttons.each(function() {
var btn = $(this);
var links = btn.find('a');
links.each(function(i, el) {
var link = $(el);
// in case the button has a data-rel attribute
if (link.attr('data-rel')) {
link.off(that.click).on(that.click, function(e) {
e.preventDefault();
that._delegate($(this));
});
} else {
link.off(that.click).on(that.click, function(e) {
e.stopPropagation();
});
}
});
// in case of the publish button
btn.find('.cms-publish-page').off(`${that.click}.publishpage`).on(`${that.click}.publishpage`, function(e) {
if (!Helpers.secureConfirm(CMS.config.lang.publish)) {
e.preventDefault();
e.stopImmediatePropagation();
}
});
btn.find('.cms-btn-publish').off(`${that.click}.publish`).on(`${that.click}.publish`, function(e) {
e.preventDefault();
showLoader();
// send post request to prevent xss attacks
$.ajax({
type: 'post',
url: $(this).prop('href'),
data: {
placeholders: getPlaceholderIds(CMS._plugins),
csrfmiddlewaretoken: CMS.config.csrf
},
success: function() {
var url = Helpers.makeURL(Helpers._getWindow().location.href.split('?')[0], [
[CMS.settings.edit_off, 'true']
]);
Helpers.reloadBrowser(url);
hideLoader();
},
error: function(jqXHR) {
hideLoader();
CMS.API.Messages.open({
message: jqXHR.responseText + ' | ' + jqXHR.status + ' ' + jqXHR.statusText,
error: true
});
}
});
});
});
this.ui.window
.off([this.resize, this.scroll].join(' '))
.on(
[this.resize, this.scroll].join(' '),
throttle($.proxy(this._handleLongMenus, this), LONG_MENUS_THROTTLE)
);
},
/**
* We check for various states on load if elements in the toolbar
* should appear or trigger other components. This precedes a timeout
* which is not optimal and should be addressed separately.
*
* @method _initialStates
* @private
* @deprecated this method is deprecated now, it will be removed in > 3.2
*/
// eslint-disable-next-line complexity
_initialStates: function _initialStates() {
var publishBtn = $('.cms-btn-publish').parent();
this._show({ duration: 0 });
// hide publish button
publishBtn.hide().attr('data-cms-hidden', 'true');
if ($('.cms-btn-publish-active').length) {
publishBtn.show().removeAttr('data-cms-hidden');
this.ui.window.trigger('resize');
}
hideDropdownIfRequired(publishBtn);
// check if debug is true
if (CMS.config.debug) {
this._debug();
}
// set color scheme
Helpers.setColorScheme (
localStorage.getItem('theme') || CMS.config.color_scheme || 'auto'
);
// check if there are messages and display them
if (CMS.config.messages) {
CMS.API.Messages.open({
message: CMS.config.messages
});
}
// check if there are error messages and display them
if (CMS.config.error) {
CMS.API.Messages.open({
message: CMS.config.error,
error: true
});
}
// should switcher indicate that there is an unpublished page?
if (CMS.config.publisher) {
CMS.API.Messages.open({
message: CMS.config.publisher,
dir: 'right'
});
}
// open sideframe if it was previously opened
if (CMS.settings.sideframe && CMS.settings.sideframe.url && CMS.config.auth) {
var sideframe = new Sideframe();
sideframe.open({
url: CMS.settings.sideframe.url,
animate: false
});
}
// add toolbar ready class to body and fire event
this.ui.body.addClass('cms-ready');
this.ui.document.trigger('cms-ready');
},
/**
* Animation helper for opening the toolbar.
*
* @method _show
* @private
* @param {Object} [opts]
* @param {Number} [opts.duration] time in milliseconds for toolbar to animate
*/
_show: function _show(opts) {
var that = this;
var speed = opts && opts.duration !== undefined ? opts.duration : this.options.toolbarDuration;
var toolbarHeight = $('.cms-toolbar').height() + TOOLBAR_OFFSCREEN_OFFSET;
this.ui.body.addClass('cms-toolbar-expanding');
// animate html
this.ui.body.animate(
{
'margin-top': toolbarHeight - TOOLBAR_OFFSCREEN_OFFSET
},
speed,
'linear',
function() {
that.ui.body.removeClass('cms-toolbar-expanding');
that.ui.body.addClass('cms-toolbar-expanded');
}
);
// set messages top to toolbar height
this.ui.messages.css('top', toolbarHeight - TOOLBAR_OFFSCREEN_OFFSET);
},
/**
* Makes a request to the given url, runs optional callbacks.
*
* @method openAjax
* @param {Object} opts
* @param {String} opts.url url where the ajax points to
* @param {String} [opts.post] post data to be passed (must be stringified JSON)
* @param {String} [opts.method='POST'] ajax method
* @param {String} [opts.text] message to be displayed
* @param {Function} [opts.callback] custom callback instead of reload
* @param {String} [opts.onSuccess] reload and display custom message
* @returns {Boolean|jQuery.Deferred} either false or a promise
*/
openAjax: function(opts) {
var that = this;
// url, post, text, callback, onSuccess
var url = opts.url;
var post = opts.post || '{}';
var text = opts.text || '';
var callback = opts.callback;
var method = opts.method || 'POST';
var onSuccess = opts.onSuccess;
var question = text ? Helpers.secureConfirm(text) : true;
// cancel if question has been denied
if (!question) {
return false;
}
showLoader();
return $.ajax({
type: method,
url: url,
data: JSON.parse(post)
})
.done(function(response) {
CMS.API.locked = false;
if (callback) {
callback(that, response);
hideLoader();
} else if (onSuccess) {
if (onSuccess === 'FOLLOW_REDIRECT') {
Helpers.reloadBrowser(response.url);
} else {
Helpers.reloadBrowser(onSuccess, false, true);
}
} else {
// reload
Helpers.reloadBrowser(false, false, true);
}
})
.fail(function(jqXHR) {
CMS.API.locked = false;
CMS.API.Messages.open({
message: jqXHR.responseText + ' | ' + jqXHR.status + ' ' + jqXHR.statusText,
error: true
});
});
},
/**
* Public api for `./loader.js`
*/
showLoader: function () {
showLoader();
},
hideLoader: function () {
hideLoader();
},
/**
* Delegates event from element to appropriate functionalities.
*
* @method _delegate
* @param {jQuery} el trigger element
* @private
* @returns {Boolean|void}
*/
_delegate: function _delegate(el) {
// save local vars
var target = el.data('rel');
if (el.hasClass('cms-btn-disabled')) {
return false;
}
switch (target) {
case 'modal':
Plugin._removeAddPluginPlaceholder();
var modal = new Modal({
onClose: el.data('on-close')
});
modal.open({
url: Helpers.updateUrlWithPath(el.attr('href')),
title: el.data('name')
});
break;
case 'message':
CMS.API.Messages.open({
message: el.data('text')
});
break;
case 'sideframe':
var sideframe = new Sideframe({
onClose: el.data('on-close')
});
sideframe.open({
url: el.attr('href'),
animate: true
});
break;
case 'ajax':
this.openAjax({
url: el.attr('href'),
post: JSON.stringify(el.data('post')),
method: el.data('method'),
text: el.data('text'),
onSuccess: el.data('on-success')
});
break;
case 'color-toggle':
Helpers.toggleColorScheme();
break;
default:
Helpers._getWindow().location.href = el.attr('href');
}
},
/**
* Handles the debug bar when `DEBUG=true` on top of the toolbar.
*
* @method _debug
* @private
*/
_debug: function _debug() {
if (!CMS.config.lang.debug) {
return;
}
var timeout = 1000;
// istanbul ignore next: function always reassigned
var timer = function() {};
// bind message event
var debug = this.ui.container.find('.cms-debug-bar');
debug.on(this.mouseEnter + ' ' + this.mouseLeave, function(e) {
clearTimeout(timer);
if (e.type === 'mouseenter') {
timer = setTimeout(function() {
CMS.API.Messages.open({
message: CMS.config.lang.debug
});
}, timeout);
}
});
},
/**
* Handles the case when opened menu doesn't fit the screen.
*
* @method _handleLongMenus
* @private
*/
_handleLongMenus: function _handleLongMenus() {
var openMenus = $('.cms-toolbar-item-navigation-hover > ul');
if (!openMenus.length) {
this._stickToolbar();
return;
}
var positions = openMenus.toArray().map(function(item) {
var el = $(item);
return $.extend({}, el.position(), { height: el.height() });
});
var windowHeight = this.ui.window.height();
this._position.top = this.ui.window.scrollTop();
var shouldUnstickToolbar = positions.some(function(item) {
return item.top + item.height > windowHeight;
});
if (shouldUnstickToolbar && this._position.top >= this._position.stickyTop) {
if (this._position.isSticky) {
this._unstickToolbar();
}
} else {
this._stickToolbar();
}
},
/**
* Resets toolbar to the normal position.
*
* @method _stickToolbar
* @private
*/
_stickToolbar: function _stickToolbar() {
this._position.stickyTop = 0;
this._position.isSticky = true;
this.ui.body.removeClass('cms-toolbar-non-sticky');
this.ui.toolbar.css({
top: 0
});
},
/**
* Positions toolbar absolutely so the long menus can be scrolled
* (toolbar goes away from the screen if required)
*
* @method _unstickToolbar
* @private
*/
_unstickToolbar: function _unstickToolbar() {
this._position.stickyTop = this._position.top;
this.ui.body.addClass('cms-toolbar-non-sticky');
// have to do the !important because of "debug" toolbar
this.ui.toolbar[0].style.setProperty('top', this._position.stickyTop + 'px', 'important');
this._position.isSticky = false;
},
/**
* Show publish button and handle the case when it's in the dropdown.
* Also enable revert to live
*
* @method onPublishAvailable
* @public
* @deprecated since 3.5 due to us reloading the toolbar instead
*/
onPublishAvailable: function showPublishButton() {
// show publish / save buttons
// istanbul ignore next
// eslint-disable-next-line no-console
console.warn('This method is deprecated and will be removed in future versions');
},
_refreshMarkup: function(newToolbar) {
const switcher = this.ui.toolbarSwitcher.detach();
$(this.ui.toolbar).html(newToolbar.children());
$('.cms-toolbar-item-cms-mode-switcher').replaceWith(switcher);
this._setupUI();
// have to clone the nav to eliminate double events
// there must be a better way to do this
var clone = this.ui.navigations.clone();
this.ui.navigations.replaceWith(clone);
this.ui.navigations = clone;
this._events();
this.navigation = new Navigation();
this.navigation.ui.window.trigger('resize');
CMS.API.Clipboard.ui.triggers = $('.cms-clipboard-trigger a');
CMS.API.Clipboard.ui.triggerRemove = $('.cms-clipboard-empty a');
CMS.API.Clipboard._toolbarEvents();
},
/**
* Compatibility shims to be removed CMS 4.0+
*
*/
get_color_scheme: Helpers.getColorScheme,
set_color_scheme: Helpers.setColorScheme
});
export default Toolbar;