annotate core/modules/quickedit/js/views/EntityToolbarView.es6.js @ 0:c75dbcec494b

Initial commit from drush-created site
author Chris Cannam
date Thu, 05 Jul 2018 14:24:15 +0000
parents
children a9cd425dd02b
rev   line source
Chris@0 1 /**
Chris@0 2 * @file
Chris@0 3 * A Backbone View that provides an entity level toolbar.
Chris@0 4 */
Chris@0 5
Chris@0 6 (function ($, _, Backbone, Drupal, debounce) {
Chris@0 7 Drupal.quickedit.EntityToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityToolbarView# */{
Chris@0 8
Chris@0 9 /**
Chris@0 10 * @type {jQuery}
Chris@0 11 */
Chris@0 12 _fieldToolbarRoot: null,
Chris@0 13
Chris@0 14 /**
Chris@0 15 * @return {object}
Chris@0 16 * A map of events.
Chris@0 17 */
Chris@0 18 events() {
Chris@0 19 const map = {
Chris@0 20 'click button.action-save': 'onClickSave',
Chris@0 21 'click button.action-cancel': 'onClickCancel',
Chris@0 22 mouseenter: 'onMouseenter',
Chris@0 23 };
Chris@0 24 return map;
Chris@0 25 },
Chris@0 26
Chris@0 27 /**
Chris@0 28 * @constructs
Chris@0 29 *
Chris@0 30 * @augments Backbone.View
Chris@0 31 *
Chris@0 32 * @param {object} options
Chris@0 33 * Options to construct the view.
Chris@0 34 * @param {Drupal.quickedit.AppModel} options.appModel
Chris@0 35 * A quickedit `AppModel` to use in the view.
Chris@0 36 */
Chris@0 37 initialize(options) {
Chris@0 38 const that = this;
Chris@0 39 this.appModel = options.appModel;
Chris@0 40 this.$entity = $(this.model.get('el'));
Chris@0 41
Chris@0 42 // Rerender whenever the entity state changes.
Chris@0 43 this.listenTo(this.model, 'change:isActive change:isDirty change:state', this.render);
Chris@0 44 // Also rerender whenever a different field is highlighted or activated.
Chris@0 45 this.listenTo(this.appModel, 'change:highlightedField change:activeField', this.render);
Chris@0 46 // Rerender when a field of the entity changes state.
Chris@0 47 this.listenTo(this.model.get('fields'), 'change:state', this.fieldStateChange);
Chris@0 48
Chris@0 49 // Reposition the entity toolbar as the viewport and the position within
Chris@0 50 // the viewport changes.
Chris@0 51 $(window).on('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit', debounce($.proxy(this.windowChangeHandler, this), 150));
Chris@0 52
Chris@0 53 // Adjust the fence placement within which the entity toolbar may be
Chris@0 54 // positioned.
Chris@0 55 $(document).on('drupalViewportOffsetChange.quickedit', (event, offsets) => {
Chris@0 56 if (that.$fence) {
Chris@0 57 that.$fence.css(offsets);
Chris@0 58 }
Chris@0 59 });
Chris@0 60
Chris@0 61 // Set the entity toolbar DOM element as the el for this view.
Chris@0 62 const $toolbar = this.buildToolbarEl();
Chris@0 63 this.setElement($toolbar);
Chris@0 64 this._fieldToolbarRoot = $toolbar.find('.quickedit-toolbar-field').get(0);
Chris@0 65
Chris@0 66 // Initial render.
Chris@0 67 this.render();
Chris@0 68 },
Chris@0 69
Chris@0 70 /**
Chris@0 71 * @inheritdoc
Chris@0 72 *
Chris@0 73 * @return {Drupal.quickedit.EntityToolbarView}
Chris@0 74 * The entity toolbar view.
Chris@0 75 */
Chris@0 76 render() {
Chris@0 77 if (this.model.get('isActive')) {
Chris@0 78 // If the toolbar container doesn't exist, create it.
Chris@0 79 const $body = $('body');
Chris@0 80 if ($body.children('#quickedit-entity-toolbar').length === 0) {
Chris@0 81 $body.append(this.$el);
Chris@0 82 }
Chris@0 83 // The fence will define a area on the screen that the entity toolbar
Chris@0 84 // will be position within.
Chris@0 85 if ($body.children('#quickedit-toolbar-fence').length === 0) {
Chris@0 86 this.$fence = $(Drupal.theme('quickeditEntityToolbarFence'))
Chris@0 87 .css(Drupal.displace())
Chris@0 88 .appendTo($body);
Chris@0 89 }
Chris@0 90 // Adds the entity title to the toolbar.
Chris@0 91 this.label();
Chris@0 92
Chris@0 93 // Show the save and cancel buttons.
Chris@0 94 this.show('ops');
Chris@0 95 // If render is being called and the toolbar is already visible, just
Chris@0 96 // reposition it.
Chris@0 97 this.position();
Chris@0 98 }
Chris@0 99
Chris@0 100 // The save button text and state varies with the state of the entity
Chris@0 101 // model.
Chris@0 102 const $button = this.$el.find('.quickedit-button.action-save');
Chris@0 103 const isDirty = this.model.get('isDirty');
Chris@0 104 // Adjust the save button according to the state of the model.
Chris@0 105 switch (this.model.get('state')) {
Chris@0 106 // Quick editing is active, but no field is being edited.
Chris@0 107 case 'opened':
Chris@0 108 // The saving throbber is not managed by AJAX system. The
Chris@0 109 // EntityToolbarView manages this visual element.
Chris@0 110 $button
Chris@0 111 .removeClass('action-saving icon-throbber icon-end')
Chris@0 112 .text(Drupal.t('Save'))
Chris@0 113 .removeAttr('disabled')
Chris@0 114 .attr('aria-hidden', !isDirty);
Chris@0 115 break;
Chris@0 116
Chris@0 117 // The changes to the fields of the entity are being committed.
Chris@0 118 case 'committing':
Chris@0 119 $button
Chris@0 120 .addClass('action-saving icon-throbber icon-end')
Chris@0 121 .text(Drupal.t('Saving'))
Chris@0 122 .attr('disabled', 'disabled');
Chris@0 123 break;
Chris@0 124
Chris@0 125 default:
Chris@0 126 $button.attr('aria-hidden', true);
Chris@0 127 break;
Chris@0 128 }
Chris@0 129
Chris@0 130 return this;
Chris@0 131 },
Chris@0 132
Chris@0 133 /**
Chris@0 134 * @inheritdoc
Chris@0 135 */
Chris@0 136 remove() {
Chris@0 137 // Remove additional DOM elements controlled by this View.
Chris@0 138 this.$fence.remove();
Chris@0 139
Chris@0 140 // Stop listening to additional events.
Chris@0 141 $(window).off('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit');
Chris@0 142 $(document).off('drupalViewportOffsetChange.quickedit');
Chris@0 143
Chris@0 144 Backbone.View.prototype.remove.call(this);
Chris@0 145 },
Chris@0 146
Chris@0 147 /**
Chris@0 148 * Repositions the entity toolbar on window scroll and resize.
Chris@0 149 *
Chris@0 150 * @param {jQuery.Event} event
Chris@0 151 * The scroll or resize event.
Chris@0 152 */
Chris@0 153 windowChangeHandler(event) {
Chris@0 154 this.position();
Chris@0 155 },
Chris@0 156
Chris@0 157 /**
Chris@0 158 * Determines the actions to take given a change of state.
Chris@0 159 *
Chris@0 160 * @param {Drupal.quickedit.FieldModel} model
Chris@0 161 * The `FieldModel` model.
Chris@0 162 * @param {string} state
Chris@0 163 * The state of the associated field. One of
Chris@0 164 * {@link Drupal.quickedit.FieldModel.states}.
Chris@0 165 */
Chris@0 166 fieldStateChange(model, state) {
Chris@0 167 switch (state) {
Chris@0 168 case 'active':
Chris@0 169 this.render();
Chris@0 170 break;
Chris@0 171
Chris@0 172 case 'invalid':
Chris@0 173 this.render();
Chris@0 174 break;
Chris@0 175 }
Chris@0 176 },
Chris@0 177
Chris@0 178 /**
Chris@0 179 * Uses the jQuery.ui.position() method to position the entity toolbar.
Chris@0 180 *
Chris@0 181 * @param {HTMLElement} [element]
Chris@0 182 * The element against which the entity toolbar is positioned.
Chris@0 183 */
Chris@0 184 position(element) {
Chris@0 185 clearTimeout(this.timer);
Chris@0 186
Chris@0 187 const that = this;
Chris@0 188 // Vary the edge of the positioning according to the direction of language
Chris@0 189 // in the document.
Chris@0 190 const edge = (document.documentElement.dir === 'rtl') ? 'right' : 'left';
Chris@0 191 // A time unit to wait until the entity toolbar is repositioned.
Chris@0 192 let delay = 0;
Chris@0 193 // Determines what check in the series of checks below should be
Chris@0 194 // evaluated.
Chris@0 195 let check = 0;
Chris@0 196 // When positioned against an active field that has padding, we should
Chris@0 197 // ignore that padding when positioning the toolbar, to not unnecessarily
Chris@0 198 // move the toolbar horizontally, which feels annoying.
Chris@0 199 let horizontalPadding = 0;
Chris@0 200 let of;
Chris@0 201 let activeField;
Chris@0 202 let highlightedField;
Chris@0 203 // There are several elements in the page that the entity toolbar might be
Chris@0 204 // positioned against. They are considered below in a priority order.
Chris@0 205 do {
Chris@0 206 switch (check) {
Chris@0 207 case 0:
Chris@0 208 // Position against a specific element.
Chris@0 209 of = element;
Chris@0 210 break;
Chris@0 211
Chris@0 212 case 1:
Chris@0 213 // Position against a form container.
Chris@0 214 activeField = Drupal.quickedit.app.model.get('activeField');
Chris@0 215 of = activeField && activeField.editorView && activeField.editorView.$formContainer && activeField.editorView.$formContainer.find('.quickedit-form');
Chris@0 216 break;
Chris@0 217
Chris@0 218 case 2:
Chris@0 219 // Position against an active field.
Chris@0 220 of = activeField && activeField.editorView && activeField.editorView.getEditedElement();
Chris@0 221 if (activeField && activeField.editorView && activeField.editorView.getQuickEditUISettings().padding) {
Chris@0 222 horizontalPadding = 5;
Chris@0 223 }
Chris@0 224 break;
Chris@0 225
Chris@0 226 case 3:
Chris@0 227 // Position against a highlighted field.
Chris@0 228 highlightedField = Drupal.quickedit.app.model.get('highlightedField');
Chris@0 229 of = highlightedField && highlightedField.editorView && highlightedField.editorView.getEditedElement();
Chris@0 230 delay = 250;
Chris@0 231 break;
Chris@0 232
Chris@0 233 default: {
Chris@0 234 const fieldModels = this.model.get('fields').models;
Chris@0 235 let topMostPosition = 1000000;
Chris@0 236 let topMostField = null;
Chris@0 237 // Position against the topmost field.
Chris@0 238 for (let i = 0; i < fieldModels.length; i++) {
Chris@0 239 const pos = fieldModels[i].get('el').getBoundingClientRect().top;
Chris@0 240 if (pos < topMostPosition) {
Chris@0 241 topMostPosition = pos;
Chris@0 242 topMostField = fieldModels[i];
Chris@0 243 }
Chris@0 244 }
Chris@0 245 of = topMostField.get('el');
Chris@0 246 delay = 50;
Chris@0 247 break;
Chris@0 248 }
Chris@0 249 }
Chris@0 250 // Prepare to check the next possible element to position against.
Chris@0 251 check++;
Chris@0 252 } while (!of);
Chris@0 253
Chris@0 254 /**
Chris@0 255 * Refines the positioning algorithm of jquery.ui.position().
Chris@0 256 *
Chris@0 257 * Invoked as the 'using' callback of jquery.ui.position() in
Chris@0 258 * positionToolbar().
Chris@0 259 *
Chris@0 260 * @param {*} view
Chris@0 261 * The view the positions will be calculated from.
Chris@0 262 * @param {object} suggested
Chris@0 263 * A hash of top and left values for the position that should be set. It
Chris@0 264 * can be forwarded to .css() or .animate().
Chris@0 265 * @param {object} info
Chris@0 266 * The position and dimensions of both the 'my' element and the 'of'
Chris@0 267 * elements, as well as calculations to their relative position. This
Chris@0 268 * object contains the following properties:
Chris@0 269 * @param {object} info.element
Chris@0 270 * A hash that contains information about the HTML element that will be
Chris@0 271 * positioned. Also known as the 'my' element.
Chris@0 272 * @param {object} info.target
Chris@0 273 * A hash that contains information about the HTML element that the
Chris@0 274 * 'my' element will be positioned against. Also known as the 'of'
Chris@0 275 * element.
Chris@0 276 */
Chris@0 277 function refinePosition(view, suggested, info) {
Chris@0 278 // Determine if the pointer should be on the top or bottom.
Chris@0 279 const isBelow = suggested.top > info.target.top;
Chris@0 280 info.element.element.toggleClass('quickedit-toolbar-pointer-top', isBelow);
Chris@0 281 // Don't position the toolbar past the first or last editable field if
Chris@0 282 // the entity is the target.
Chris@0 283 if (view.$entity[0] === info.target.element[0]) {
Chris@0 284 // Get the first or last field according to whether the toolbar is
Chris@0 285 // above or below the entity.
Chris@0 286 const $field = view.$entity.find('.quickedit-editable').eq((isBelow) ? -1 : 0);
Chris@0 287 if ($field.length > 0) {
Chris@0 288 suggested.top = (isBelow) ? ($field.offset().top + $field.outerHeight(true)) : $field.offset().top - info.element.element.outerHeight(true);
Chris@0 289 }
Chris@0 290 }
Chris@0 291 // Don't let the toolbar go outside the fence.
Chris@0 292 const fenceTop = view.$fence.offset().top;
Chris@0 293 const fenceHeight = view.$fence.height();
Chris@0 294 const toolbarHeight = info.element.element.outerHeight(true);
Chris@0 295 if (suggested.top < fenceTop) {
Chris@0 296 suggested.top = fenceTop;
Chris@0 297 }
Chris@0 298 else if ((suggested.top + toolbarHeight) > (fenceTop + fenceHeight)) {
Chris@0 299 suggested.top = (fenceTop + fenceHeight) - toolbarHeight;
Chris@0 300 }
Chris@0 301 // Position the toolbar.
Chris@0 302 info.element.element.css({
Chris@0 303 left: Math.floor(suggested.left),
Chris@0 304 top: Math.floor(suggested.top),
Chris@0 305 });
Chris@0 306 }
Chris@0 307
Chris@0 308 /**
Chris@0 309 * Calls the jquery.ui.position() method on the $el of this view.
Chris@0 310 */
Chris@0 311 function positionToolbar() {
Chris@0 312 that.$el
Chris@0 313 .position({
Chris@0 314 my: `${edge} bottom`,
Chris@0 315 // Move the toolbar 1px towards the start edge of the 'of' element,
Chris@0 316 // plus any horizontal padding that may have been added to the
Chris@0 317 // element that is being added, to prevent unwanted horizontal
Chris@0 318 // movement.
Chris@0 319 at: `${edge}+${1 + horizontalPadding} top`,
Chris@0 320 of,
Chris@0 321 collision: 'flipfit',
Chris@0 322 using: refinePosition.bind(null, that),
Chris@0 323 within: that.$fence,
Chris@0 324 })
Chris@0 325 // Resize the toolbar to match the dimensions of the field, up to a
Chris@0 326 // maximum width that is equal to 90% of the field's width.
Chris@0 327 .css({
Chris@0 328 'max-width': (document.documentElement.clientWidth < 450) ? document.documentElement.clientWidth : 450,
Chris@0 329 // Set a minimum width of 240px for the entity toolbar, or the width
Chris@0 330 // of the client if it is less than 240px, so that the toolbar
Chris@0 331 // never folds up into a squashed and jumbled mess.
Chris@0 332 'min-width': (document.documentElement.clientWidth < 240) ? document.documentElement.clientWidth : 240,
Chris@0 333 width: '100%',
Chris@0 334 });
Chris@0 335 }
Chris@0 336
Chris@0 337 // Uses the jQuery.ui.position() method. Use a timeout to move the toolbar
Chris@0 338 // only after the user has focused on an editable for 250ms. This prevents
Chris@0 339 // the toolbar from jumping around the screen.
Chris@0 340 this.timer = setTimeout(() => {
Chris@0 341 // Render the position in the next execution cycle, so that animations
Chris@0 342 // on the field have time to process. This is not strictly speaking, a
Chris@0 343 // guarantee that all animations will be finished, but it's a simple
Chris@0 344 // way to get better positioning without too much additional code.
Chris@0 345 _.defer(positionToolbar);
Chris@0 346 }, delay);
Chris@0 347 },
Chris@0 348
Chris@0 349 /**
Chris@0 350 * Set the model state to 'saving' when the save button is clicked.
Chris@0 351 *
Chris@0 352 * @param {jQuery.Event} event
Chris@0 353 * The click event.
Chris@0 354 */
Chris@0 355 onClickSave(event) {
Chris@0 356 event.stopPropagation();
Chris@0 357 event.preventDefault();
Chris@0 358 // Save the model.
Chris@0 359 this.model.set('state', 'committing');
Chris@0 360 },
Chris@0 361
Chris@0 362 /**
Chris@0 363 * Sets the model state to candidate when the cancel button is clicked.
Chris@0 364 *
Chris@0 365 * @param {jQuery.Event} event
Chris@0 366 * The click event.
Chris@0 367 */
Chris@0 368 onClickCancel(event) {
Chris@0 369 event.preventDefault();
Chris@0 370 this.model.set('state', 'deactivating');
Chris@0 371 },
Chris@0 372
Chris@0 373 /**
Chris@0 374 * Clears the timeout that will eventually reposition the entity toolbar.
Chris@0 375 *
Chris@0 376 * Without this, it may reposition itself, away from the user's cursor!
Chris@0 377 *
Chris@0 378 * @param {jQuery.Event} event
Chris@0 379 * The mouse event.
Chris@0 380 */
Chris@0 381 onMouseenter(event) {
Chris@0 382 clearTimeout(this.timer);
Chris@0 383 },
Chris@0 384
Chris@0 385 /**
Chris@0 386 * Builds the entity toolbar HTML; attaches to DOM; sets starting position.
Chris@0 387 *
Chris@0 388 * @return {jQuery}
Chris@0 389 * The toolbar element.
Chris@0 390 */
Chris@0 391 buildToolbarEl() {
Chris@0 392 const $toolbar = $(Drupal.theme('quickeditEntityToolbar', {
Chris@0 393 id: 'quickedit-entity-toolbar',
Chris@0 394 }));
Chris@0 395
Chris@0 396 $toolbar
Chris@0 397 .find('.quickedit-toolbar-entity')
Chris@0 398 // Append the "ops" toolgroup into the toolbar.
Chris@0 399 .prepend(Drupal.theme('quickeditToolgroup', {
Chris@0 400 classes: ['ops'],
Chris@0 401 buttons: [
Chris@0 402 {
Chris@0 403 label: Drupal.t('Save'),
Chris@0 404 type: 'submit',
Chris@0 405 classes: 'action-save quickedit-button icon',
Chris@0 406 attributes: {
Chris@0 407 'aria-hidden': true,
Chris@0 408 },
Chris@0 409 },
Chris@0 410 {
Chris@0 411 label: Drupal.t('Close'),
Chris@0 412 classes: 'action-cancel quickedit-button icon icon-close icon-only',
Chris@0 413 },
Chris@0 414 ],
Chris@0 415 }));
Chris@0 416
Chris@0 417 // Give the toolbar a sensible starting position so that it doesn't
Chris@0 418 // animate on to the screen from a far off corner.
Chris@0 419 $toolbar
Chris@0 420 .css({
Chris@0 421 left: this.$entity.offset().left,
Chris@0 422 top: this.$entity.offset().top,
Chris@0 423 });
Chris@0 424
Chris@0 425 return $toolbar;
Chris@0 426 },
Chris@0 427
Chris@0 428 /**
Chris@0 429 * Returns the DOM element that fields will attach their toolbars to.
Chris@0 430 *
Chris@0 431 * @return {jQuery}
Chris@0 432 * The DOM element that fields will attach their toolbars to.
Chris@0 433 */
Chris@0 434 getToolbarRoot() {
Chris@0 435 return this._fieldToolbarRoot;
Chris@0 436 },
Chris@0 437
Chris@0 438 /**
Chris@0 439 * Generates a state-dependent label for the entity toolbar.
Chris@0 440 */
Chris@0 441 label() {
Chris@0 442 // The entity label.
Chris@0 443 let label = '';
Chris@0 444 const entityLabel = this.model.get('label');
Chris@0 445
Chris@0 446 // Label of an active field, if it exists.
Chris@0 447 const activeField = Drupal.quickedit.app.model.get('activeField');
Chris@0 448 const activeFieldLabel = activeField && activeField.get('metadata').label;
Chris@0 449 // Label of a highlighted field, if it exists.
Chris@0 450 const highlightedField = Drupal.quickedit.app.model.get('highlightedField');
Chris@0 451 const highlightedFieldLabel = highlightedField && highlightedField.get('metadata').label;
Chris@0 452 // The label is constructed in a priority order.
Chris@0 453 if (activeFieldLabel) {
Chris@0 454 label = Drupal.theme('quickeditEntityToolbarLabel', {
Chris@0 455 entityLabel,
Chris@0 456 fieldLabel: activeFieldLabel,
Chris@0 457 });
Chris@0 458 }
Chris@0 459 else if (highlightedFieldLabel) {
Chris@0 460 label = Drupal.theme('quickeditEntityToolbarLabel', {
Chris@0 461 entityLabel,
Chris@0 462 fieldLabel: highlightedFieldLabel,
Chris@0 463 });
Chris@0 464 }
Chris@0 465 else {
Chris@0 466 // @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437
Chris@0 467 label = Drupal.checkPlain(entityLabel);
Chris@0 468 }
Chris@0 469
Chris@0 470 this.$el
Chris@0 471 .find('.quickedit-toolbar-label')
Chris@0 472 .html(label);
Chris@0 473 },
Chris@0 474
Chris@0 475 /**
Chris@0 476 * Adds classes to a toolgroup.
Chris@0 477 *
Chris@0 478 * @param {string} toolgroup
Chris@0 479 * A toolgroup name.
Chris@0 480 * @param {string} classes
Chris@0 481 * A string of space-delimited class names that will be applied to the
Chris@0 482 * wrapping element of the toolbar group.
Chris@0 483 */
Chris@0 484 addClass(toolgroup, classes) {
Chris@0 485 this._find(toolgroup).addClass(classes);
Chris@0 486 },
Chris@0 487
Chris@0 488 /**
Chris@0 489 * Removes classes from a toolgroup.
Chris@0 490 *
Chris@0 491 * @param {string} toolgroup
Chris@0 492 * A toolgroup name.
Chris@0 493 * @param {string} classes
Chris@0 494 * A string of space-delimited class names that will be removed from the
Chris@0 495 * wrapping element of the toolbar group.
Chris@0 496 */
Chris@0 497 removeClass(toolgroup, classes) {
Chris@0 498 this._find(toolgroup).removeClass(classes);
Chris@0 499 },
Chris@0 500
Chris@0 501 /**
Chris@0 502 * Finds a toolgroup.
Chris@0 503 *
Chris@0 504 * @param {string} toolgroup
Chris@0 505 * A toolgroup name.
Chris@0 506 *
Chris@0 507 * @return {jQuery}
Chris@0 508 * The toolgroup DOM element.
Chris@0 509 */
Chris@0 510 _find(toolgroup) {
Chris@0 511 return this.$el.find(`.quickedit-toolbar .quickedit-toolgroup.${toolgroup}`);
Chris@0 512 },
Chris@0 513
Chris@0 514 /**
Chris@0 515 * Shows a toolgroup.
Chris@0 516 *
Chris@0 517 * @param {string} toolgroup
Chris@0 518 * A toolgroup name.
Chris@0 519 */
Chris@0 520 show(toolgroup) {
Chris@0 521 this.$el.removeClass('quickedit-animate-invisible');
Chris@0 522 },
Chris@0 523
Chris@0 524 });
Chris@0 525 }(jQuery, _, Backbone, Drupal, Drupal.debounce));