Merge remote-tracking branch 'origin/master'
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / js / WoltLabSuite / Core / Ui / Redactor / Mention.js
1 define(['Ajax', 'Environment', 'Ui/CloseOverlay'], function(Ajax, Environment, UiCloseOverlay) {
2 "use strict";
3
4 var _dropdownContainer = null;
5
6 function UiRedactorMention(redactor) { this.init(redactor); }
7 UiRedactorMention.prototype = {
8 init: function(redactor) {
9 this._active = false;
10 this._dropdownActive = false;
11 this._dropdownMenu = null;
12 this._itemIndex = 0;
13 this._lineHeight = null;
14 this._mentionStart = '';
15 this._redactor = redactor;
16 this._timer = null;
17
18 redactor.WoltLabEvent.register('keydown', this._keyDown.bind(this));
19 redactor.WoltLabEvent.register('keyup', this._keyUp.bind(this));
20
21 UiCloseOverlay.add('UiRedactorMention-' + redactor.core.element()[0].id, this._hideDropdown.bind(this));
22 },
23
24 _keyDown: function(data) {
25 if (!this._dropdownActive) {
26 return;
27 }
28
29 /** @var Event event */
30 var event = data.event;
31
32 switch (event.which) {
33 // enter
34 case 13:
35 this._setUsername(null, this._dropdownMenu.children[this._itemIndex].children[0]);
36 break;
37
38 // arrow up
39 case 38:
40 this._selectItem(-1);
41 break;
42
43 // arrow down
44 case 40:
45 this._selectItem(1);
46 break;
47
48 default:
49 this._hideDropdown();
50 return;
51 break;
52 }
53
54 event.preventDefault();
55 data.cancel = true;
56 },
57
58 _keyUp: function(data) {
59 /** @var Event event */
60 var event = data.event;
61
62 // ignore return key
63 if (event.which === 13) {
64 this._active = false;
65
66 return;
67 }
68
69 if (this._dropdownActive) {
70 data.cancel = true;
71
72 // ignore arrow up/down
73 if (event.which === 38 || event.which === 40) {
74 return;
75 }
76 }
77
78 var text = this._getTextLineInFrontOfCaret();
79 if (text.length > 0 && text.length < 25) {
80 var match = text.match(/@([^,]{3,})$/);
81 if (match) {
82 // if mentioning is at text begin or there's a whitespace character
83 // before the '@', everything is fine
84 if (!match.index || text[match.index - 1].match(/\s/)) {
85 this._mentionStart = match[1];
86
87 if (this._timer !== null) {
88 window.clearTimeout(this._timer);
89 this._timer = null;
90 }
91
92 this._timer = window.setTimeout((function() {
93 Ajax.api(this, {
94 parameters: {
95 data: {
96 searchString: this._mentionStart
97 }
98 }
99 });
100
101 this._timer = null;
102 }).bind(this), 500);
103 }
104 }
105 else {
106 this._hideDropdown();
107 }
108 }
109 else {
110 this._hideDropdown();
111 }
112 },
113
114 _getTextLineInFrontOfCaret: function() {
115 var data = this._selectMention(false);
116 if (data !== null) {
117 return data.range.cloneContents().textContent.replace(/\u200B/g, '').replace(/\u00A0/g, ' ').trim();
118 }
119
120 return '';
121 },
122
123 _getDropdownMenuPosition: function() {
124 var data = this._selectMention();
125 if (data === null) {
126 return null;
127 }
128
129 this._redactor.selection.save();
130
131 data.selection.removeAllRanges();
132 data.selection.addRange(data.range);
133
134 // get the offsets of the bounding box of current text selection
135 var rect = data.selection.getRangeAt(0).getBoundingClientRect();
136 var offsets = {
137 top: Math.round(rect.bottom) + window.scrollY,
138 left: Math.round(rect.left) + document.body.scrollLeft
139 };
140
141 if (this._lineHeight === null) {
142 this._lineHeight = Math.round(rect.bottom - rect.top - window.scrollY);
143 }
144
145 // restore caret position
146 this._redactor.selection.restore();
147
148 return offsets;
149 },
150
151 _setUsername: function(event, item) {
152 if (event) {
153 event.preventDefault();
154 item = event.currentTarget;
155 }
156
157 var data = this._selectMention();
158 if (data === null) {
159 this._hideDropdown();
160
161 return;
162 }
163
164 // allow redactor to undo this
165 this._redactor.buffer.set();
166
167 data.selection.removeAllRanges();
168 data.selection.addRange(data.range);
169
170 var range = getSelection().getRangeAt(0);
171 range.deleteContents();
172 range.collapse(true);
173
174 var text = document.createTextNode('@' + elData(item, 'username') + '\u00A0');
175 range.insertNode(text);
176
177 range = document.createRange();
178 range.selectNode(text);
179 range.collapse(false);
180
181 data.selection.removeAllRanges();
182 data.selection.addRange(range);
183
184 this._hideDropdown();
185 },
186
187 _selectMention: function (skipCheck) {
188 var selection = window.getSelection();
189 if (!selection.rangeCount || !selection.isCollapsed) {
190 return null;
191 }
192
193 // check if there is an '@' within the current range
194 if (selection.anchorNode.textContent.indexOf('@') === -1) {
195 return null;
196 }
197
198 // check if we're inside code or quote blocks
199 var container = selection.anchorNode, editor = this._redactor.core.editor()[0];
200 while (container && container !== editor) {
201 if (['PRE', 'WOLTLAB-QUOTE'].indexOf(container.nodeName) !== -1) {
202 return null;
203 }
204
205 container = container.parentNode;
206 }
207
208 var range = selection.getRangeAt(0);
209 var endContainer = range.startContainer;
210 var endOffset = range.startOffset;
211
212 // find the appropriate end location
213 while (endContainer.nodeType === Node.ELEMENT_NODE) {
214 if (endOffset === 0 && endContainer.childNodes.length === 0) {
215 // invalid start location
216 return null;
217 }
218
219 // startOffset for elements will always be after a node index
220 // or at the very start, which means if there is only text node
221 // and the caret is after it, startOffset will equal `1`
222 endContainer = endContainer.childNodes[(endOffset ? endOffset - 1 : 0)];
223 if (endOffset > 0) {
224 if (endContainer.nodeType === Node.TEXT_NODE) {
225 endOffset = endContainer.textContent.length;
226 }
227 else {
228 endOffset = endContainer.childNodes.length;
229 }
230 }
231 }
232
233 var startContainer = endContainer;
234 var startOffset = -1;
235 while (startContainer !== null) {
236 if (startContainer.nodeType !== Node.TEXT_NODE) {
237 return null;
238 }
239
240 if (startContainer.textContent.indexOf('@') !== -1) {
241 startOffset = startContainer.textContent.lastIndexOf('@');
242
243 break;
244 }
245
246 startContainer = startContainer.previousSibling;
247 }
248
249 if (startOffset === -1) {
250 // there was a non-text node that was in our way
251 return null;
252 }
253
254 try {
255 // mark the entire text, starting from the '@' to the current cursor position
256 range = document.createRange();
257 range.setStart(startContainer, startOffset);
258 range.setEnd(endContainer, endOffset);
259 }
260 catch (e) {
261 window.console.debug(e);
262 return null;
263 }
264
265 if (skipCheck === false) {
266 // check if the `@` occurs at the very start of the container
267 // or at least has a whitespace in front of it
268 var text = '';
269 if (startOffset) {
270 text = startContainer.textContent.substr(0, startOffset);
271 }
272
273 while (startContainer = startContainer.previousSibling) {
274 if (startContainer.nodeType === Node.TEXT_NODE) {
275 text = startContainer.textContent + text;
276 }
277 else {
278 break;
279 }
280 }
281
282 if (text.replace(/\u200B/g, '').match(/\S$/)) {
283 return null;
284 }
285 }
286 else {
287 // check if new range includes the mention text
288 if (range.cloneContents().textContent.replace(/\u200B/g, '').replace(/\u00A0/g, '').trim().replace(/^@/, '') !== this._mentionStart) {
289 // string mismatch
290 return null;
291 }
292 }
293
294 return {
295 range: range,
296 selection: selection
297 };
298 },
299
300 _updateDropdownPosition: function() {
301 var offset = this._getDropdownMenuPosition();
302 if (offset === null) {
303 this._hideDropdown();
304
305 return;
306 }
307 offset.top += 7; // add a little vertical gap
308
309 this._dropdownMenu.style.setProperty('left', offset.left + 'px', '');
310 this._dropdownMenu.style.setProperty('top', offset.top + 'px', '');
311
312 this._selectItem(0);
313
314 if (offset.top + this._dropdownMenu.offsetHeight + 10 > window.innerHeight + window.scrollY) {
315 this._dropdownMenu.style.setProperty('top', offset.top - this._dropdownMenu.offsetHeight - 2 * this._lineHeight + 7 + 'px', '');
316 }
317 },
318
319 _selectItem: function(step) {
320 // find currently active item
321 var item = elBySel('.active', this._dropdownMenu);
322 if (item !== null) {
323 item.classList.remove('active');
324 }
325
326 this._itemIndex += step;
327 if (this._itemIndex === -1) {
328 this._itemIndex = this._dropdownMenu.childElementCount - 1;
329 }
330 else if (this._itemIndex === this._dropdownMenu.childElementCount) {
331 this._itemIndex = 0;
332 }
333
334 this._dropdownMenu.children[this._itemIndex].classList.add('active');
335 },
336
337 _hideDropdown: function() {
338 if (this._dropdownMenu !== null) this._dropdownMenu.classList.remove('dropdownOpen');
339 this._dropdownActive = false;
340 },
341
342 _ajaxSetup: function() {
343 return {
344 data: {
345 actionName: 'getSearchResultList',
346 className: 'wcf\\data\\user\\UserAction',
347 interfaceName: 'wcf\\data\\ISearchAction',
348 parameters: {
349 data: {
350 includeUserGroups: false
351 }
352 }
353 }
354 };
355 },
356
357 _ajaxSuccess: function(data) {
358 if (!Array.isArray(data.returnValues) || !data.returnValues.length) {
359 this._hideDropdown();
360
361 return;
362 }
363
364 if (this._dropdownMenu === null) {
365 this._dropdownMenu = elCreate('ol');
366 this._dropdownMenu.className = 'dropdownMenu';
367
368 if (_dropdownContainer === null) {
369 _dropdownContainer = elCreate('div');
370 _dropdownContainer.className = 'dropdownMenuContainer';
371 document.body.appendChild(_dropdownContainer);
372 }
373
374 _dropdownContainer.appendChild(this._dropdownMenu);
375 }
376
377 this._dropdownMenu.innerHTML = '';
378
379 var callbackClick = this._setUsername.bind(this), link, listItem, user;
380 for (var i = 0, length = data.returnValues.length; i < length; i++) {
381 user = data.returnValues[i];
382
383 listItem = elCreate('li');
384 link = elCreate('a');
385 link.addEventListener('mousedown', callbackClick);
386 link.className = 'box16';
387 link.innerHTML = '<span>' + user.icon + '</span> <span>' + user.label + '</span>';
388 elData(link, 'user-id', user.objectID);
389 elData(link, 'username', user.label);
390
391 listItem.appendChild(link);
392 this._dropdownMenu.appendChild(listItem);
393 }
394
395 this._dropdownMenu.classList.add('dropdownOpen');
396 this._dropdownActive = true;
397
398 this._updateDropdownPosition();
399 }
400 };
401
402 return UiRedactorMention;
403 });