comparison core/modules/quickedit/js/views/EntityToolbarView.es6.js @ 0:4c8ae668cc8c

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