Merge branch 'master' into next
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / js / WoltLabSuite / Core / Ui / Redactor / Mention.js
1 define(['Ajax', 'Environment', 'EventHandler', 'Ui/Alignment'], function(Ajax, Environment, EventHandler, UiAlignment) {
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._caret = null;
11 this._dropdownActive = false;
12 this._dropdownMenu = null;
13 this._itemIndex = 0;
14 this._lineHeight = null;
15 this._mentionStart = '';
16 this._redactor = redactor;
17 this._timer = null;
18
19 redactor.WoltLabEvent.register('keydown', this._keyDown.bind(this));
20 redactor.WoltLabEvent.register('keyup', this._keyUp.bind(this));
21 },
22
23 _keyDown: function(data) {
24 if (!this._dropdownActive) {
25 return;
26 }
27
28 /** @var Event event */
29 var event = data.event;
30
31 switch (event.which) {
32 // enter
33 case 13:
34 this._setUsername(null, this._dropdownMenu.children[this._itemIndex].children[0]);
35 break;
36
37 // arrow up
38 case 38:
39 this._selectItem(-1);
40 break;
41
42 // arrow down
43 case 40:
44 this._selectItem(1);
45 break;
46
47 default:
48 return;
49 break;
50 }
51
52 event.preventDefault();
53 data.cancel = true;
54 },
55
56 _keyUp: function(data) {
57 /** @var Event event */
58 var event = data.event;
59
60 // ignore return key
61 if (event.which === 13) {
62 this._active = false;
63
64 return;
65 }
66
67 var text = this._getTextLineInFrontOfCaret();
68 if (text.length) {
69 var match = text.match(/@([^,]{3,})$/);
70 if (match) {
71 // if mentioning is at text begin or there's a whitespace character
72 // before the '@', everything is fine
73 if (!match.index || text[match.index - 1].match(/\s/)) {
74 this._mentionStart = match[1];
75
76 if (this._timer !== null) {
77 window.clearTimeout(this._timer);
78 this._timer = null;
79 }
80
81 this._timer = window.setTimeout((function() {
82 Ajax.api(this, {
83 parameters: {
84 data: {
85 searchString: this._mentionStart
86 }
87 }
88 });
89
90 this._timer = null;
91 }).bind(this), 500);
92 }
93 }
94 else {
95 this._hideDropdown();
96 }
97 }
98 else {
99 this._hideDropdown();
100 }
101 },
102
103 _setUsername: function(event, item) {
104 if (event) {
105 event.preventDefault();
106 item = event.currentTarget;
107 }
108
109 /*if (this._timer !== null) {
110 this._timer.stop();
111 this._timer = null;
112 }
113 this._proxy.abortPrevious();*/
114
115 var selection = window.getSelection();
116
117 // restore caret position
118 selection.removeAllRanges();
119 selection.addRange(this._caret);
120
121 var orgRange = selection.getRangeAt(0).cloneRange();
122
123 // allow redactor to undo this
124 this._redactor.buffer.set();
125
126 var startContainer = orgRange.startContainer;
127 var startOffset = orgRange.startOffset - (this._mentionStart.length + 1);
128
129 // navigating with the keyboard before hitting enter will cause the text node to be split
130 if (startOffset < 0) {
131 startContainer = startContainer.previousSibling;
132 startOffset = startContainer.length - (this._mentionStart.length + 1) - (orgRange.startOffset - 1);
133 }
134
135 var newRange = document.createRange();
136 newRange.setStart(startContainer, startOffset);
137 newRange.setEnd(orgRange.startContainer, orgRange.startOffset);
138
139 selection.removeAllRanges();
140 selection.addRange(newRange);
141
142 var range = getSelection().getRangeAt(0);
143 range.deleteContents();
144 range.collapse(true);
145
146 var text = document.createTextNode('@' + elData(item, 'username') + '\u00A0');
147 range.insertNode(text);
148
149 newRange = document.createRange();
150 newRange.selectNode(text);
151 newRange.collapse(false);
152
153 selection.removeAllRanges();
154 selection.addRange(newRange);
155
156 this._redactor.selection.save();
157
158 this._hideDropdown();
159 },
160
161 _getTextLineInFrontOfCaret: function() {
162 /** @var Range range */
163 var range = window.getSelection().getRangeAt(0);
164 if (!range.collapsed) {
165 return '';
166 }
167
168 // in Firefox, blurring and refocusing the browser creates separate text nodes
169 if (Environment.browser() === 'firefox' && range.startContainer.nodeType === Node.TEXT_NODE) {
170 range.startContainer.parentNode.normalize();
171 }
172
173 var text = range.startContainer.textContent.substr(0, range.startOffset);
174
175 // remove unicode zero-width space and non-breaking space
176 var textBackup = text;
177 text = '';
178 var hadSpace = false;
179 for (var i = 0; i < textBackup.length; i++) {
180 var byte = textBackup.charCodeAt(i).toString(16);
181 if (byte !== '200b' && (!/\s/.test(textBackup[i]) || ((byte === 'a0' || byte === '20') && !hadSpace))) {
182 if (byte === 'a0' || byte === '20') {
183 hadSpace = true;
184 }
185
186 if (textBackup[i] === '@' && i && /\s/.test(textBackup[i - 1])) {
187 hadSpace = false;
188 text = '';
189 }
190
191 text += textBackup[i];
192 }
193 else {
194 hadSpace = false;
195 text = '';
196 }
197 }
198
199 return text;
200 },
201
202 _ajaxSetup: function() {
203 return {
204 data: {
205 actionName: 'getSearchResultList',
206 className: 'wcf\\data\\user\\UserAction',
207 interfaceName: 'wcf\\data\\ISearchAction',
208 parameters: {
209 data: {
210 includeUserGroups: false
211 }
212 }
213 }
214 };
215 },
216
217 _ajaxSuccess: function(data) {
218 if (!Array.isArray(data.returnValues) || !data.returnValues.length) {
219 this._hideDropdown();
220
221 return;
222 }
223
224 if (this._dropdownMenu === null) {
225 this._dropdownMenu = elCreate('ol');
226 this._dropdownMenu.className = 'dropdownMenu';
227
228 if (_dropdownContainer === null) {
229 _dropdownContainer = elCreate('div');
230 _dropdownContainer.className = 'dropdownMenuContainer';
231 document.body.appendChild(_dropdownContainer);
232 }
233
234 _dropdownContainer.appendChild(this._dropdownMenu);
235 }
236
237 this._dropdownMenu.innerHTML = '';
238
239 var callbackClick = this._setUsername.bind(this), link, listItem, user;
240 for (var i = 0, length = data.returnValues.length; i < length; i++) {
241 user = data.returnValues[i];
242
243 listItem = elCreate('li');
244 link = elCreate('a');
245 link.addEventListener(WCF_CLICK_EVENT, callbackClick);
246 link.className = 'box16';
247 link.innerHTML = '<span>' + user.icon + '</span> <span>' + user.label + '</span>';
248 elData(link, 'user-id', user.objectID);
249 elData(link, 'username', user.label);
250
251 listItem.appendChild(link);
252 this._dropdownMenu.appendChild(listItem);
253 }
254
255 this._dropdownMenu.classList.add('dropdownOpen');
256 this._dropdownActive = true;
257
258 this._updateDropdownPosition();
259 },
260
261 _getDropdownMenuPosition: function() {
262 this._redactor.selection.save();
263
264 var selection = window.getSelection();
265 var orgRange = selection.getRangeAt(0).cloneRange();
266
267 // mark the entire text, starting from the '@' to the current cursor position
268 var newRange = document.createRange();
269 newRange.setStart(orgRange.startContainer, orgRange.startOffset - (this._mentionStart.length + 1));
270 newRange.setEnd(orgRange.startContainer, orgRange.startOffset);
271
272 selection.removeAllRanges();
273 selection.addRange(newRange);
274
275 // get the offsets of the bounding box of current text selection
276 var rect = selection.getRangeAt(0).getBoundingClientRect();
277 var offsets = {
278 top: Math.round(rect.bottom) + window.scrollY,
279 left: Math.round(rect.left) + document.body.scrollLeft
280 };
281
282 if (this._lineHeight === null) {
283 this._lineHeight = Math.round(rect.bottom - rect.top - window.scrollY);
284 }
285
286 // restore caret position
287 this._redactor.selection.restore();
288
289 this._caret = orgRange;
290
291 return offsets;
292 },
293
294 _updateDropdownPosition: function() {
295 try {
296 var offset = this._getDropdownMenuPosition();
297 offset.top += 7; // add a little vertical gap
298
299 this._dropdownMenu.style.setProperty('left', offset.left + 'px', '');
300 this._dropdownMenu.style.setProperty('top', offset.top + 'px', '');
301
302 this._selectItem(0);
303
304 if (offset.top + this._dropdownMenu.offsetHeight + 10 > window.innerHeight + window.scrollY) {
305 this._dropdownMenu.classList.add('dropdownArrowBottom');
306
307 this._dropdownMenu.style.setProperty('top', offset.top - this._dropdownMenu.offsetHeight - 2 * this._lineHeight + 7 + 'px', '');
308 }
309 else {
310 this._dropdownMenu.classList.remove('dropdownArrowBottom');
311 }
312 }
313 catch (e) {
314 console.debug(e);
315 // ignore errors that are caused by pressing enter to
316 // often in a short period of time
317 }
318 },
319
320 _selectItem: function(step) {
321 // find currently active item
322 var item = elBySel('.active', this._dropdownMenu);
323 if (item !== null) {
324 item.classList.remove('active');
325 }
326
327 this._itemIndex += step;
328 if (this._itemIndex === -1) {
329 this._itemIndex = this._dropdownMenu.childElementCount - 1;
330 }
331 else if (this._itemIndex === this._dropdownMenu.childElementCount) {
332 this._itemIndex = 0;
333 }
334
335 this._dropdownMenu.children[this._itemIndex].classList.add('active');
336 },
337
338 _hideDropdown: function() {
339 if (this._dropdownMenu !== null) this._dropdownMenu.classList.remove('dropdownOpen');
340 this._dropdownActive = false;
341 }
342 };
343
344 return UiRedactorMention;
345 });