[BUGFIX] Access correct event variable in ContextMenu.ts
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Resources / Private / TypeScript / ContextMenu.ts
1 /*
2  * This file is part of the TYPO3 CMS project.
3  *
4  * It is free software; you can redistribute it and/or modify it under
5  * the terms of the GNU General Public License, either version 2
6  * of the License, or any later version.
7  *
8  * For the full copyright and license information, please read the
9  * LICENSE.txt file that was distributed with this source code.
10  *
11  * The TYPO3 project - inspiring people to share!
12  */
13
14 import * as $ from 'jquery';
15 import ContextMenuActions = require('./ContextMenuActions');
16
17 interface MousePosition {
18   X: number;
19   Y: number;
20 }
21
22 interface ActiveRecord {
23   uid: number;
24   table: string;
25 }
26
27 interface MenuItem {
28   type: string;
29   icon: string;
30   label: string;
31   additionalAttributes?: { [key: string]: string };
32   childItems?: MenuItems;
33   callbackAction?: string;
34 }
35
36 interface MenuItems {
37   [key: string]: MenuItem;
38 }
39
40 /**
41  * Module: TYPO3/CMS/Backend/ContextMenu
42  * Container used to load the context menu via AJAX to render the result in a layer next to the mouse cursor
43  */
44 class ContextMenu {
45   private mousePos: MousePosition = {X: null, Y: null};
46   private delayContextMenuHide: boolean = false;
47   private record: ActiveRecord = {uid: null, table: null};
48
49   /**
50    * @param {MenuItem} item
51    * @returns {string}
52    */
53   private static drawActionItem(item: MenuItem): string {
54     const attributes: { [key: string]: string } = item.additionalAttributes || {};
55     let attributesString = '';
56     for (let attribute in attributes) {
57       if (attributes.hasOwnProperty(attribute)) {
58         attributesString += ' ' + attribute + '="' + attributes[attribute] + '"';
59       }
60     }
61
62     return '<a class="list-group-item"'
63       + ' data-callback-action="' + item.callbackAction + '"'
64       + attributesString + '><span class="list-group-item-icon">' + item.icon + '</span> ' + item.label + '</a>';
65   }
66
67   /**
68    * @param {JQuery} $element
69    * @param {number} x
70    * @param {number} y
71    * @returns {boolean}
72    */
73   private static within($element: JQuery, x: number, y: number): boolean {
74     const offset = $element.offset();
75     return (
76       y >= offset.top &&
77       y < offset.top + $element.height() &&
78       x >= offset.left &&
79       x < offset.left + $element.width()
80     );
81   }
82
83   /**
84    * Manipulates the DOM to add the divs needed for context menu the bottom of the <body>-tag
85    */
86   private static initializeContextMenuContainer(): void {
87     if ($('#contentMenu0').length === 0) {
88       const code = '<div id="contentMenu0" class="context-menu"></div>'
89         + '<div id="contentMenu1" class="context-menu" style="display: block;"></div>';
90       $('body').append(code);
91     }
92   }
93
94   constructor() {
95     this.initializeEvents();
96   }
97
98   private initializeEvents(): void {
99     $(document).on('click contextmenu', '.t3js-contextmenutrigger', (e: JQueryEventObject): void => {
100       const $me = $(e.currentTarget);
101       // if there is an other "inline" onclick setting, context menu is not triggered
102       // usually this is the case for the foldertree
103       if ($me.prop('onclick') && e.type === 'click') {
104         return;
105       }
106
107       e.preventDefault();
108       this.show(
109         $me.data('table'),
110         $me.data('uid'),
111         $me.data('context'),
112         $me.data('iteminfo'),
113         $me.data('parameters'),
114       );
115     });
116
117     // register mouse movement inside the document
118     $(document).on('mousemove', this.storeMousePositionEvent);
119   }
120
121   /**
122    * Main function, called from most context menu links
123    *
124    * @param {string} table Table from where info should be fetched
125    * @param {number} uid The UID of the item
126    * @param {string} context Context of the item
127    * @param {string} enDisItems Items to disable / enable
128    * @param {string} addParams Additional params
129    */
130   private show(table: string, uid: number, context: string, enDisItems: string, addParams: string): void {
131     this.record = {table: table, uid: uid};
132
133     let parameters = '';
134
135     if (typeof table !== 'undefined') {
136       parameters += 'table=' + encodeURIComponent(table);
137     }
138     if (typeof uid !== 'undefined') {
139       parameters += (parameters.length > 0 ? '&' : '') + 'uid=' + uid;
140     }
141     if (typeof context !== 'undefined') {
142       parameters += (parameters.length > 0 ? '&' : '') + 'context=' + context;
143     }
144     if (typeof enDisItems !== 'undefined') {
145       parameters += (parameters.length > 0 ? '&' : '') + 'enDisItems=' + enDisItems;
146     }
147     if (typeof addParams !== 'undefined') {
148       parameters += (parameters.length > 0 ? '&' : '') + 'addParams=' + addParams;
149     }
150     this.fetch(parameters);
151   }
152
153   /**
154    * Make the AJAX request
155    *
156    * @param {string} parameters Parameters sent to the server
157    */
158   private fetch(parameters: string): void {
159     let url = TYPO3.settings.ajaxUrls.contextmenu;
160     if (parameters) {
161       url += ((url.indexOf('?') === -1) ? '?' : '&') + parameters;
162     }
163     $.ajax(url).done((response: MenuItems): void => {
164       if (typeof response !== 'undefined' && Object.keys(response).length > 0) {
165         this.populateData(response, 0);
166       }
167     });
168   }
169
170   /**
171    * Fills the context menu with content and displays it correctly
172    * depending on the mouse position
173    *
174    * @param {Array<MenuItem>} items The data that will be put in the menu
175    * @param {number} level The depth of the context menu
176    */
177   private populateData(items: MenuItems, level: number): void {
178     ContextMenu.initializeContextMenuContainer();
179
180     const $obj = $('#contentMenu' + level);
181
182     if ($obj.length && (level === 0 || $('#contentMenu' + (level - 1)).is(':visible'))) {
183       const elements = this.drawMenu(items, level);
184       $obj.html('<div class="list-group">' + elements + '</div>');
185
186       $('a.list-group-item', $obj).click((event: JQueryEventObject): void => {
187         event.preventDefault();
188         const $me = $(event.currentTarget);
189
190         if ($me.hasClass('list-group-item-submenu')) {
191           this.openSubmenu(level, $me);
192           return;
193         }
194
195         const callbackName = $me.data('callback-action');
196         const callbackModule = $me.data('callback-module');
197         if ($me.data('callback-module')) {
198           require([callbackModule], (callbackModuleCallback: any): void => {
199             callbackModuleCallback[callbackName].bind($me)(this.record.table, this.record.uid);
200           });
201         } else if (ContextMenuActions && typeof (ContextMenuActions as any)[callbackName] === 'function') {
202           (ContextMenuActions as any)[callbackName].bind($me)(this.record.table, this.record.uid);
203         } else {
204           console.log('action: ' + callbackName + ' not found');
205         }
206         this.hideAll();
207       });
208
209       $obj.css(this.getPosition($obj)).show();
210     }
211   }
212
213   /**
214    * @param {number} level
215    * @param {JQuery} $item
216    */
217   private openSubmenu(level: number, $item: JQuery): void {
218     const $obj = $('#contentMenu' + (level + 1)).html('');
219     $item.next().find('.list-group').clone(true).appendTo($obj);
220     $obj.css(this.getPosition($obj)).show();
221   }
222
223   private getPosition($obj: JQuery): {[key: string]: string} {
224     let x = this.mousePos.X;
225     let y = this.mousePos.Y;
226     const dimsWindow = {
227       width: $(window).width() - 20, // saving margin for scrollbars
228       height: $(window).height(),
229     };
230
231     // dimensions for the context menu
232     const dims = {
233       width: $obj.width(),
234       height: $obj.height(),
235     };
236
237     const relative = {
238       X: this.mousePos.X - $(document).scrollLeft(),
239       Y: this.mousePos.Y - $(document).scrollTop(),
240     };
241
242     // adjusting the Y position of the layer to fit it into the window frame
243     // if there is enough space above then put it upwards,
244     // otherwise adjust it to the bottom of the window
245     if (dimsWindow.height - dims.height < relative.Y) {
246       if (relative.Y > dims.height) {
247         y -= (dims.height - 10);
248       } else {
249         y += (dimsWindow.height - dims.height - relative.Y);
250       }
251     }
252     // adjusting the X position like Y above, but align it to the left side of the viewport if it does not fit completely
253     if (dimsWindow.width - dims.width < relative.X) {
254       if (relative.X > dims.width) {
255         x -= (dims.width - 10);
256       } else if ((dimsWindow.width - dims.width - relative.X) < $(document).scrollLeft()) {
257         x = $(document).scrollLeft();
258       } else {
259         x += (dimsWindow.width - dims.width - relative.X);
260       }
261     }
262
263     return {left: x + 'px', top: y + 'px'};
264   }
265
266   /**
267    * fills the context menu with content and displays it correctly
268    * depending on the mouse position
269    *
270    * @param {MenuItems} items The data that will be put in the menu
271    * @param {Number} level The depth of the context menu
272    * @return {string}
273    */
274   private drawMenu(items: MenuItems, level: number): string {
275     let elements: string = '';
276     for (let key in items) {
277       if (items.hasOwnProperty(key)) {
278         const item = items[key];
279         if (item.type === 'item') {
280           elements += ContextMenu.drawActionItem(item);
281         } else if (item.type === 'divider') {
282           elements += '<a class="list-group-item list-group-item-divider"></a>';
283         } else if (item.type === 'submenu' || item.childItems) {
284           elements += '<a class="list-group-item list-group-item-submenu">'
285             + '<span class="list-group-item-icon">' + item.icon + '</span> '
286             + item.label + '&nbsp;&nbsp;<span class="fa fa-caret-right"></span>'
287             + '</a>';
288
289           const childElements = this.drawMenu(item.childItems, 1);
290           elements += '<div class="context-menu contentMenu' + (level + 1) + '" style="display:none;">'
291             + '<div class="list-group">' + childElements + '</div>'
292             + '</div>';
293         }
294       }
295     }
296     return elements;
297   }
298
299   /**
300    * event handler function that saves the
301    * actual position of the mouse
302    * in the context menu object
303    *
304    * @param {JQueryEventObject} event The event object
305    */
306   private storeMousePositionEvent = (event: JQueryEventObject): void => {
307     this.mousePos = {X: event.pageX, Y: event.pageY};
308     this.mouseOutFromMenu('#contentMenu0');
309     this.mouseOutFromMenu('#contentMenu1');
310   }
311
312   /**
313    * hides a visible menu if the mouse has moved outside
314    * of the object
315    *
316    * @param {string} obj The identifier of the object to hide
317    */
318   private mouseOutFromMenu(obj: string): void {
319     const $element = $(obj);
320
321     if ($element.length > 0 && $element.is(':visible') && !ContextMenu.within($element, this.mousePos.X, this.mousePos.Y)) {
322       this.hide(obj);
323     } else if ($element.length > 0 && $element.is(':visible')) {
324       this.delayContextMenuHide = true;
325     }
326   }
327
328   /**
329    * @param {string} obj
330    */
331   private hide(obj: string): void {
332     this.delayContextMenuHide = false;
333     window.setTimeout(
334       (): void => {
335         if (!this.delayContextMenuHide) {
336           $(obj).hide();
337         }
338       },
339       500,
340     );
341   }
342
343   /**
344    * Hides all context menus
345    */
346   private hideAll(): void {
347     this.hide('#contentMenu0');
348     this.hide('#contentMenu1');
349   }
350 }
351
352 export = new ContextMenu();