123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525 |
- /**
- * @file
- * A Backbone View that provides an entity level toolbar.
- */
- (function ($, _, Backbone, Drupal, debounce) {
- Drupal.quickedit.EntityToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityToolbarView# */{
- /**
- * @type {jQuery}
- */
- _fieldToolbarRoot: null,
- /**
- * @return {object}
- * A map of events.
- */
- events() {
- const map = {
- 'click button.action-save': 'onClickSave',
- 'click button.action-cancel': 'onClickCancel',
- mouseenter: 'onMouseenter',
- };
- return map;
- },
- /**
- * @constructs
- *
- * @augments Backbone.View
- *
- * @param {object} options
- * Options to construct the view.
- * @param {Drupal.quickedit.AppModel} options.appModel
- * A quickedit `AppModel` to use in the view.
- */
- initialize(options) {
- const that = this;
- this.appModel = options.appModel;
- this.$entity = $(this.model.get('el'));
- // Rerender whenever the entity state changes.
- this.listenTo(this.model, 'change:isActive change:isDirty change:state', this.render);
- // Also rerender whenever a different field is highlighted or activated.
- this.listenTo(this.appModel, 'change:highlightedField change:activeField', this.render);
- // Rerender when a field of the entity changes state.
- this.listenTo(this.model.get('fields'), 'change:state', this.fieldStateChange);
- // Reposition the entity toolbar as the viewport and the position within
- // the viewport changes.
- $(window).on('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit', debounce($.proxy(this.windowChangeHandler, this), 150));
- // Adjust the fence placement within which the entity toolbar may be
- // positioned.
- $(document).on('drupalViewportOffsetChange.quickedit', (event, offsets) => {
- if (that.$fence) {
- that.$fence.css(offsets);
- }
- });
- // Set the entity toolbar DOM element as the el for this view.
- const $toolbar = this.buildToolbarEl();
- this.setElement($toolbar);
- this._fieldToolbarRoot = $toolbar.find('.quickedit-toolbar-field').get(0);
- // Initial render.
- this.render();
- },
- /**
- * @inheritdoc
- *
- * @return {Drupal.quickedit.EntityToolbarView}
- * The entity toolbar view.
- */
- render() {
- if (this.model.get('isActive')) {
- // If the toolbar container doesn't exist, create it.
- const $body = $('body');
- if ($body.children('#quickedit-entity-toolbar').length === 0) {
- $body.append(this.$el);
- }
- // The fence will define a area on the screen that the entity toolbar
- // will be position within.
- if ($body.children('#quickedit-toolbar-fence').length === 0) {
- this.$fence = $(Drupal.theme('quickeditEntityToolbarFence'))
- .css(Drupal.displace())
- .appendTo($body);
- }
- // Adds the entity title to the toolbar.
- this.label();
- // Show the save and cancel buttons.
- this.show('ops');
- // If render is being called and the toolbar is already visible, just
- // reposition it.
- this.position();
- }
- // The save button text and state varies with the state of the entity
- // model.
- const $button = this.$el.find('.quickedit-button.action-save');
- const isDirty = this.model.get('isDirty');
- // Adjust the save button according to the state of the model.
- switch (this.model.get('state')) {
- // Quick editing is active, but no field is being edited.
- case 'opened':
- // The saving throbber is not managed by AJAX system. The
- // EntityToolbarView manages this visual element.
- $button
- .removeClass('action-saving icon-throbber icon-end')
- .text(Drupal.t('Save'))
- .removeAttr('disabled')
- .attr('aria-hidden', !isDirty);
- break;
- // The changes to the fields of the entity are being committed.
- case 'committing':
- $button
- .addClass('action-saving icon-throbber icon-end')
- .text(Drupal.t('Saving'))
- .attr('disabled', 'disabled');
- break;
- default:
- $button.attr('aria-hidden', true);
- break;
- }
- return this;
- },
- /**
- * @inheritdoc
- */
- remove() {
- // Remove additional DOM elements controlled by this View.
- this.$fence.remove();
- // Stop listening to additional events.
- $(window).off('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit');
- $(document).off('drupalViewportOffsetChange.quickedit');
- Backbone.View.prototype.remove.call(this);
- },
- /**
- * Repositions the entity toolbar on window scroll and resize.
- *
- * @param {jQuery.Event} event
- * The scroll or resize event.
- */
- windowChangeHandler(event) {
- this.position();
- },
- /**
- * Determines the actions to take given a change of state.
- *
- * @param {Drupal.quickedit.FieldModel} model
- * The `FieldModel` model.
- * @param {string} state
- * The state of the associated field. One of
- * {@link Drupal.quickedit.FieldModel.states}.
- */
- fieldStateChange(model, state) {
- switch (state) {
- case 'active':
- this.render();
- break;
- case 'invalid':
- this.render();
- break;
- }
- },
- /**
- * Uses the jQuery.ui.position() method to position the entity toolbar.
- *
- * @param {HTMLElement} [element]
- * The element against which the entity toolbar is positioned.
- */
- position(element) {
- clearTimeout(this.timer);
- const that = this;
- // Vary the edge of the positioning according to the direction of language
- // in the document.
- const edge = (document.documentElement.dir === 'rtl') ? 'right' : 'left';
- // A time unit to wait until the entity toolbar is repositioned.
- let delay = 0;
- // Determines what check in the series of checks below should be
- // evaluated.
- let check = 0;
- // When positioned against an active field that has padding, we should
- // ignore that padding when positioning the toolbar, to not unnecessarily
- // move the toolbar horizontally, which feels annoying.
- let horizontalPadding = 0;
- let of;
- let activeField;
- let highlightedField;
- // There are several elements in the page that the entity toolbar might be
- // positioned against. They are considered below in a priority order.
- do {
- switch (check) {
- case 0:
- // Position against a specific element.
- of = element;
- break;
- case 1:
- // Position against a form container.
- activeField = Drupal.quickedit.app.model.get('activeField');
- of = activeField && activeField.editorView && activeField.editorView.$formContainer && activeField.editorView.$formContainer.find('.quickedit-form');
- break;
- case 2:
- // Position against an active field.
- of = activeField && activeField.editorView && activeField.editorView.getEditedElement();
- if (activeField && activeField.editorView && activeField.editorView.getQuickEditUISettings().padding) {
- horizontalPadding = 5;
- }
- break;
- case 3:
- // Position against a highlighted field.
- highlightedField = Drupal.quickedit.app.model.get('highlightedField');
- of = highlightedField && highlightedField.editorView && highlightedField.editorView.getEditedElement();
- delay = 250;
- break;
- default: {
- const fieldModels = this.model.get('fields').models;
- let topMostPosition = 1000000;
- let topMostField = null;
- // Position against the topmost field.
- for (let i = 0; i < fieldModels.length; i++) {
- const pos = fieldModels[i].get('el').getBoundingClientRect().top;
- if (pos < topMostPosition) {
- topMostPosition = pos;
- topMostField = fieldModels[i];
- }
- }
- of = topMostField.get('el');
- delay = 50;
- break;
- }
- }
- // Prepare to check the next possible element to position against.
- check++;
- } while (!of);
- /**
- * Refines the positioning algorithm of jquery.ui.position().
- *
- * Invoked as the 'using' callback of jquery.ui.position() in
- * positionToolbar().
- *
- * @param {*} view
- * The view the positions will be calculated from.
- * @param {object} suggested
- * A hash of top and left values for the position that should be set. It
- * can be forwarded to .css() or .animate().
- * @param {object} info
- * The position and dimensions of both the 'my' element and the 'of'
- * elements, as well as calculations to their relative position. This
- * object contains the following properties:
- * @param {object} info.element
- * A hash that contains information about the HTML element that will be
- * positioned. Also known as the 'my' element.
- * @param {object} info.target
- * A hash that contains information about the HTML element that the
- * 'my' element will be positioned against. Also known as the 'of'
- * element.
- */
- function refinePosition(view, suggested, info) {
- // Determine if the pointer should be on the top or bottom.
- const isBelow = suggested.top > info.target.top;
- info.element.element.toggleClass('quickedit-toolbar-pointer-top', isBelow);
- // Don't position the toolbar past the first or last editable field if
- // the entity is the target.
- if (view.$entity[0] === info.target.element[0]) {
- // Get the first or last field according to whether the toolbar is
- // above or below the entity.
- const $field = view.$entity.find('.quickedit-editable').eq((isBelow) ? -1 : 0);
- if ($field.length > 0) {
- suggested.top = (isBelow) ? ($field.offset().top + $field.outerHeight(true)) : $field.offset().top - info.element.element.outerHeight(true);
- }
- }
- // Don't let the toolbar go outside the fence.
- const fenceTop = view.$fence.offset().top;
- const fenceHeight = view.$fence.height();
- const toolbarHeight = info.element.element.outerHeight(true);
- if (suggested.top < fenceTop) {
- suggested.top = fenceTop;
- }
- else if ((suggested.top + toolbarHeight) > (fenceTop + fenceHeight)) {
- suggested.top = (fenceTop + fenceHeight) - toolbarHeight;
- }
- // Position the toolbar.
- info.element.element.css({
- left: Math.floor(suggested.left),
- top: Math.floor(suggested.top),
- });
- }
- /**
- * Calls the jquery.ui.position() method on the $el of this view.
- */
- function positionToolbar() {
- that.$el
- .position({
- my: `${edge} bottom`,
- // Move the toolbar 1px towards the start edge of the 'of' element,
- // plus any horizontal padding that may have been added to the
- // element that is being added, to prevent unwanted horizontal
- // movement.
- at: `${edge}+${1 + horizontalPadding} top`,
- of,
- collision: 'flipfit',
- using: refinePosition.bind(null, that),
- within: that.$fence,
- })
- // Resize the toolbar to match the dimensions of the field, up to a
- // maximum width that is equal to 90% of the field's width.
- .css({
- 'max-width': (document.documentElement.clientWidth < 450) ? document.documentElement.clientWidth : 450,
- // Set a minimum width of 240px for the entity toolbar, or the width
- // of the client if it is less than 240px, so that the toolbar
- // never folds up into a squashed and jumbled mess.
- 'min-width': (document.documentElement.clientWidth < 240) ? document.documentElement.clientWidth : 240,
- width: '100%',
- });
- }
- // Uses the jQuery.ui.position() method. Use a timeout to move the toolbar
- // only after the user has focused on an editable for 250ms. This prevents
- // the toolbar from jumping around the screen.
- this.timer = setTimeout(() => {
- // Render the position in the next execution cycle, so that animations
- // on the field have time to process. This is not strictly speaking, a
- // guarantee that all animations will be finished, but it's a simple
- // way to get better positioning without too much additional code.
- _.defer(positionToolbar);
- }, delay);
- },
- /**
- * Set the model state to 'saving' when the save button is clicked.
- *
- * @param {jQuery.Event} event
- * The click event.
- */
- onClickSave(event) {
- event.stopPropagation();
- event.preventDefault();
- // Save the model.
- this.model.set('state', 'committing');
- },
- /**
- * Sets the model state to candidate when the cancel button is clicked.
- *
- * @param {jQuery.Event} event
- * The click event.
- */
- onClickCancel(event) {
- event.preventDefault();
- this.model.set('state', 'deactivating');
- },
- /**
- * Clears the timeout that will eventually reposition the entity toolbar.
- *
- * Without this, it may reposition itself, away from the user's cursor!
- *
- * @param {jQuery.Event} event
- * The mouse event.
- */
- onMouseenter(event) {
- clearTimeout(this.timer);
- },
- /**
- * Builds the entity toolbar HTML; attaches to DOM; sets starting position.
- *
- * @return {jQuery}
- * The toolbar element.
- */
- buildToolbarEl() {
- const $toolbar = $(Drupal.theme('quickeditEntityToolbar', {
- id: 'quickedit-entity-toolbar',
- }));
- $toolbar
- .find('.quickedit-toolbar-entity')
- // Append the "ops" toolgroup into the toolbar.
- .prepend(Drupal.theme('quickeditToolgroup', {
- classes: ['ops'],
- buttons: [
- {
- label: Drupal.t('Save'),
- type: 'submit',
- classes: 'action-save quickedit-button icon',
- attributes: {
- 'aria-hidden': true,
- },
- },
- {
- label: Drupal.t('Close'),
- classes: 'action-cancel quickedit-button icon icon-close icon-only',
- },
- ],
- }));
- // Give the toolbar a sensible starting position so that it doesn't
- // animate on to the screen from a far off corner.
- $toolbar
- .css({
- left: this.$entity.offset().left,
- top: this.$entity.offset().top,
- });
- return $toolbar;
- },
- /**
- * Returns the DOM element that fields will attach their toolbars to.
- *
- * @return {jQuery}
- * The DOM element that fields will attach their toolbars to.
- */
- getToolbarRoot() {
- return this._fieldToolbarRoot;
- },
- /**
- * Generates a state-dependent label for the entity toolbar.
- */
- label() {
- // The entity label.
- let label = '';
- const entityLabel = this.model.get('label');
- // Label of an active field, if it exists.
- const activeField = Drupal.quickedit.app.model.get('activeField');
- const activeFieldLabel = activeField && activeField.get('metadata').label;
- // Label of a highlighted field, if it exists.
- const highlightedField = Drupal.quickedit.app.model.get('highlightedField');
- const highlightedFieldLabel = highlightedField && highlightedField.get('metadata').label;
- // The label is constructed in a priority order.
- if (activeFieldLabel) {
- label = Drupal.theme('quickeditEntityToolbarLabel', {
- entityLabel,
- fieldLabel: activeFieldLabel,
- });
- }
- else if (highlightedFieldLabel) {
- label = Drupal.theme('quickeditEntityToolbarLabel', {
- entityLabel,
- fieldLabel: highlightedFieldLabel,
- });
- }
- else {
- // @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437
- label = Drupal.checkPlain(entityLabel);
- }
- this.$el
- .find('.quickedit-toolbar-label')
- .html(label);
- },
- /**
- * Adds classes to a toolgroup.
- *
- * @param {string} toolgroup
- * A toolgroup name.
- * @param {string} classes
- * A string of space-delimited class names that will be applied to the
- * wrapping element of the toolbar group.
- */
- addClass(toolgroup, classes) {
- this._find(toolgroup).addClass(classes);
- },
- /**
- * Removes classes from a toolgroup.
- *
- * @param {string} toolgroup
- * A toolgroup name.
- * @param {string} classes
- * A string of space-delimited class names that will be removed from the
- * wrapping element of the toolbar group.
- */
- removeClass(toolgroup, classes) {
- this._find(toolgroup).removeClass(classes);
- },
- /**
- * Finds a toolgroup.
- *
- * @param {string} toolgroup
- * A toolgroup name.
- *
- * @return {jQuery}
- * The toolgroup DOM element.
- */
- _find(toolgroup) {
- return this.$el.find(`.quickedit-toolbar .quickedit-toolgroup.${toolgroup}`);
- },
- /**
- * Shows a toolgroup.
- *
- * @param {string} toolgroup
- * A toolgroup name.
- */
- show(toolgroup) {
- this.$el.removeClass('quickedit-animate-invisible');
- },
- });
- }(jQuery, _, Backbone, Drupal, Drupal.debounce));
|