]> git.immae.eu Git - github/shaarli/Shaarli.git/blob - tpl/default/js/shaarli.js
Tag list: use awesomplete for tag auto completion
[github/shaarli/Shaarli.git] / tpl / default / js / shaarli.js
1 /** @licstart The following is the entire license notice for the
2 * JavaScript code in this page.
3 *
4 * Copyright: (c) 2011-2015 Sébastien SAUVAGE <sebsauvage@sebsauvage.net>
5 * (c) 2011-2017 The Shaarli Community, see AUTHORS
6 *
7 * This software is provided 'as-is', without any express or implied warranty.
8 * In no event will the authors be held liable for any damages arising from
9 * the use of this software.
10 *
11 * Permission is granted to anyone to use this software for any purpose,
12 * including commercial applications, and to alter it and redistribute it
13 * freely, subject to the following restrictions:
14 *
15 * 1. The origin of this software must not be misrepresented; you must not
16 * claim that you wrote the original software. If you use this software
17 * in a product, an acknowledgment in the product documentation would
18 * be appreciated but is not required.
19 *
20 * 2. Altered source versions must be plainly marked as such, and must
21 * not be misrepresented as being the original software.
22 *
23 * 3. This notice may not be removed or altered from any source distribution.
24 *
25 * @licend The above is the entire license notice
26 * for the JavaScript code in this page.
27 */
28
29 window.onload = function () {
30
31 /**
32 * Retrieve an element up in the tree from its class name.
33 */
34 function getParentByClass(el, className) {
35 var p = el.parentNode;
36 if (p == null || p.classList.contains(className)) {
37 return p;
38 }
39 return getParentByClass(p, className);
40 }
41
42
43 /**
44 * Handle responsive menu.
45 * Source: http://purecss.io/layouts/tucked-menu-vertical/
46 */
47 (function (window, document) {
48 var menu = document.getElementById('shaarli-menu'),
49 WINDOW_CHANGE_EVENT = ('onorientationchange' in window) ? 'orientationchange':'resize';
50
51 function toggleHorizontal() {
52 [].forEach.call(
53 document.getElementById('shaarli-menu').querySelectorAll('.menu-transform'),
54 function(el){
55 el.classList.toggle('pure-menu-horizontal');
56 }
57 );
58 };
59
60 function toggleMenu() {
61 // set timeout so that the panel has a chance to roll up
62 // before the menu switches states
63 if (menu.classList.contains('open')) {
64 setTimeout(toggleHorizontal, 500);
65 }
66 else {
67 toggleHorizontal();
68 }
69 menu.classList.toggle('open');
70 document.getElementById('menu-toggle').classList.toggle('x');
71 };
72
73 function closeMenu() {
74 if (menu.classList.contains('open')) {
75 toggleMenu();
76 }
77 }
78
79 var menuToggle = document.getElementById('menu-toggle');
80 if (menuToggle != null) {
81 menuToggle.addEventListener('click', function (e) {
82 toggleMenu();
83 });
84 }
85
86 window.addEventListener(WINDOW_CHANGE_EVENT, closeMenu);
87 })(this, this.document);
88
89 /**
90 * Fold/Expand shaares description and thumbnail.
91 */
92 var foldAllButtons = document.getElementsByClassName('fold-all');
93 var foldButtons = document.getElementsByClassName('fold-button');
94
95 [].forEach.call(foldButtons, function (foldButton) {
96 // Retrieve description
97 var description = null;
98 var thumbnail = null;
99 var linklistItem = getParentByClass(foldButton, 'linklist-item');
100 if (linklistItem != null) {
101 description = linklistItem.querySelector('.linklist-item-description');
102 thumbnail = linklistItem.querySelector('.linklist-item-thumbnail');
103 if (description != null || thumbnail != null) {
104 foldButton.style.display = 'inline';
105 }
106 }
107
108 foldButton.addEventListener('click', function (event) {
109 event.preventDefault();
110 toggleFold(event.target, description, thumbnail);
111 });
112 });
113
114 if (foldAllButtons != null) {
115 [].forEach.call(foldAllButtons, function (foldAllButton) {
116 foldAllButton.addEventListener('click', function (event) {
117 event.preventDefault();
118 var state = foldAllButton.firstElementChild.getAttribute('class').indexOf('down') != -1 ? 'down' : 'up';
119 [].forEach.call(foldButtons, function (foldButton) {
120 if (foldButton.firstElementChild.classList.contains('fa-chevron-up') && state == 'down'
121 || foldButton.firstElementChild.classList.contains('fa-chevron-down') && state == 'up'
122 ) {
123 return;
124 }
125 // Retrieve description
126 var description = null;
127 var thumbnail = null;
128 var linklistItem = getParentByClass(foldButton, 'linklist-item');
129 if (linklistItem != null) {
130 description = linklistItem.querySelector('.linklist-item-description');
131 thumbnail = linklistItem.querySelector('.linklist-item-thumbnail');
132 if (description != null || thumbnail != null) {
133 foldButton.style.display = 'inline';
134 }
135 }
136
137 toggleFold(foldButton.firstElementChild, description, thumbnail);
138 });
139 foldAllButton.firstElementChild.classList.toggle('fa-chevron-down');
140 foldAllButton.firstElementChild.classList.toggle('fa-chevron-up');
141 });
142 });
143 }
144
145 function toggleFold(button, description, thumb)
146 {
147 // Switch fold/expand - up = fold
148 if (button.classList.contains('fa-chevron-up')) {
149 button.title = 'Expand';
150 if (description != null) {
151 description.style.display = 'none';
152 }
153 if (thumb != null) {
154 thumb.style.display = 'none';
155 }
156 }
157 else {
158 button.title = 'Fold';
159 if (description != null) {
160 description.style.display = 'block';
161 }
162 if (thumb != null) {
163 thumb.style.display = 'block';
164 }
165 }
166 button.classList.toggle('fa-chevron-down');
167 button.classList.toggle('fa-chevron-up');
168 }
169
170 /**
171 * Confirmation message before deletion.
172 */
173 var deleteLinks = document.querySelectorAll('.confirm-delete');
174 [].forEach.call(deleteLinks, function(deleteLink) {
175 deleteLink.addEventListener('click', function(event) {
176 if(! confirm('Are you sure you want to delete this link ?')) {
177 event.preventDefault();
178 }
179 });
180 });
181
182 /**
183 * Close alerts
184 */
185 var closeLinks = document.querySelectorAll('.pure-alert-close');
186 [].forEach.call(closeLinks, function(closeLink) {
187 closeLink.addEventListener('click', function(event) {
188 var alert = getParentByClass(event.target, 'pure-alert-closable');
189 alert.style.display = 'none';
190 });
191 });
192
193 /**
194 * New version dismiss.
195 * Hide the message for one week using localStorage.
196 */
197 var newVersionDismiss = document.getElementById('new-version-dismiss');
198 var newVersionMessage = document.querySelector('.new-version-message');
199 if (newVersionMessage != null
200 && localStorage.getItem('newVersionDismiss') != null
201 && parseInt(localStorage.getItem('newVersionDismiss')) + 7*24*60*60*1000 > (new Date()).getTime()
202 ) {
203 newVersionMessage.style.display = 'none';
204 }
205 if (newVersionDismiss != null) {
206 newVersionDismiss.addEventListener('click', function () {
207 localStorage.setItem('newVersionDismiss', (new Date()).getTime());
208 });
209 }
210
211 var hiddenReturnurl = document.getElementsByName('returnurl');
212 if (hiddenReturnurl != null) {
213 hiddenReturnurl.value = window.location.href;
214 }
215
216 /**
217 * Autofocus text fields
218 */
219 var autofocusElements = document.querySelectorAll('.autofocus');
220 var breakLoop = false;
221 [].forEach.call(autofocusElements, function(autofocusElement) {
222 if (autofocusElement.value == '' && ! breakLoop) {
223 autofocusElement.focus();
224 breakLoop = true;
225 }
226 });
227
228 /**
229 * Handle sub menus/forms
230 */
231 var openers = document.getElementsByClassName('subheader-opener');
232 if (openers != null) {
233 [].forEach.call(openers, function(opener) {
234 opener.addEventListener('click', function(event) {
235 event.preventDefault();
236
237 var id = opener.getAttribute('data-open-id');
238 var sub = document.getElementById(id);
239
240 if (sub != null) {
241 [].forEach.call(document.getElementsByClassName('subheader-form'), function (element) {
242 if (element != sub) {
243 removeClass(element, 'open')
244 }
245 });
246
247 sub.classList.toggle('open');
248 }
249 });
250 });
251 }
252
253 function removeClass(element, classname) {
254 element.className = element.className.replace(new RegExp('(?:^|\\s)'+ classname + '(?:\\s|$)'), ' ');
255 }
256
257 /**
258 * Remove CSS target padding (for fixed bar)
259 */
260 if (location.hash != '') {
261 var anchor = document.getElementById(location.hash.substr(1));
262 if (anchor != null) {
263 var padsize = anchor.clientHeight;
264 this.window.scroll(0, this.window.scrollY - padsize);
265 anchor.style.paddingTop = 0;
266 }
267 }
268
269 /**
270 * Text area resizer
271 */
272 var description = document.getElementById('lf_description');
273 var observe = function (element, event, handler) {
274 element.addEventListener(event, handler, false);
275 };
276 function init () {
277 function resize () {
278 description.style.height = 'auto';
279 description.style.height = description.scrollHeight+10+'px';
280 }
281 /* 0-timeout to get the already changed text */
282 function delayedResize () {
283 window.setTimeout(resize, 0);
284 }
285 observe(description, 'change', resize);
286 observe(description, 'cut', delayedResize);
287 observe(description, 'paste', delayedResize);
288 observe(description, 'drop', delayedResize);
289 observe(description, 'keydown', delayedResize);
290
291 resize();
292 }
293
294 if (description != null) {
295 init();
296 // Submit editlink form with CTRL + Enter in the text area.
297 description.addEventListener('keydown', function (event) {
298 if (event.ctrlKey && event.keyCode === 13) {
299 document.getElementById('button-save-edit').click();
300 }
301 });
302 }
303
304 /**
305 * Awesomplete trigger.
306 */
307 var tags = document.getElementById('lf_tags');
308 if (tags != null) {
309 awesompleteUniqueTag('#lf_tags');
310 }
311
312 /**
313 * bLazy trigger
314 */
315 var picwall = document.getElementById('picwall_container');
316 if (picwall != null) {
317 var bLazy = new Blazy();
318 }
319
320 /**
321 * Bookmarklet alert
322 */
323 var bookmarkletLinks = document.querySelectorAll('.bookmarklet-link');
324 var bkmMessage = document.getElementById('bookmarklet-alert');
325 [].forEach.call(bookmarkletLinks, function(link) {
326 link.addEventListener('click', function(event) {
327 event.preventDefault();
328 alert(bkmMessage.value);
329 });
330 });
331
332 /**
333 * Firefox Social
334 */
335 var ffButton = document.getElementById('ff-social-button');
336 if (ffButton != null) {
337 ffButton.addEventListener('click', function(event) {
338 activateFirefoxSocial(event.target);
339 });
340 }
341
342 /**
343 * Plugin admin order
344 */
345 var orderPA = document.querySelectorAll('.order');
346 [].forEach.call(orderPA, function(link) {
347 link.addEventListener('click', function(event) {
348 event.preventDefault();
349 if (event.target.classList.contains('order-up')) {
350 return orderUp(event.target.parentNode.parentNode.getAttribute('data-order'));
351 } else if (event.target.classList.contains('order-down')) {
352 return orderDown(event.target.parentNode.parentNode.getAttribute('data-order'));
353 }
354 });
355 });
356
357 var continent = document.getElementById('continent');
358 var city = document.getElementById('city');
359 if (continent != null && city != null) {
360 continent.addEventListener('change', function (event) {
361 hideTimezoneCities(city, continent.options[continent.selectedIndex].value, true);
362 });
363 hideTimezoneCities(city, continent.options[continent.selectedIndex].value, false);
364 }
365
366 /**
367 * Bulk actions
368 */
369 var linkCheckboxes = document.querySelectorAll('.delete-checkbox');
370 var bar = document.getElementById('actions');
371 [].forEach.call(linkCheckboxes, function(checkbox) {
372 checkbox.style.display = 'block';
373 checkbox.addEventListener('click', function(event) {
374 var count = 0;
375 var linkCheckedCheckboxes = document.querySelectorAll('.delete-checkbox:checked');
376 [].forEach.call(linkCheckedCheckboxes, function(checkbox) {
377 count++;
378 });
379 if (count == 0 && bar.classList.contains('open')) {
380 bar.classList.toggle('open');
381 } else if (count > 0 && ! bar.classList.contains('open')) {
382 bar.classList.toggle('open');
383 }
384 });
385 });
386
387 var deleteButton = document.getElementById('actions-delete');
388 var token = document.querySelector('input[type="hidden"][name="token"]');
389 if (deleteButton != null && token != null) {
390 deleteButton.addEventListener('click', function(event) {
391 event.preventDefault();
392
393 var links = [];
394 var linkCheckedCheckboxes = document.querySelectorAll('.delete-checkbox:checked');
395 [].forEach.call(linkCheckedCheckboxes, function(checkbox) {
396 links.push({
397 'id': checkbox.value,
398 'title': document.querySelector('.linklist-item[data-id="'+ checkbox.value +'"] .linklist-link').innerHTML
399 });
400 });
401
402 var message = 'Are you sure you want to delete '+ links.length +' links?\n';
403 message += 'This action is IRREVERSIBLE!\n\nTitles:\n';
404 var ids = '';
405 links.forEach(function(item) {
406 message += ' - '+ item['title'] +'\n';
407 ids += item['id'] +'+';
408 });
409
410 if (window.confirm(message)) {
411 window.location = '?delete_link&lf_linkdate='+ ids +'&token='+ token.value;
412 }
413 });
414 }
415
416 /**
417 * Tag list operations
418 *
419 * TODO: support error code in the backend for AJAX requests
420 */
421 var existingTags = document.querySelector('input[name="taglist"]').value.split(' ');
422 var awesomepletes = [];
423
424 // Display/Hide rename form
425 var renameTagButtons = document.querySelectorAll('.rename-tag');
426 [].forEach.call(renameTagButtons, function(rename) {
427 rename.addEventListener('click', function(event) {
428 event.preventDefault();
429 var block = findParent(event.target, 'div', {'class': 'tag-list-item'});
430 var form = block.querySelector('.rename-tag-form');
431 if (form.style.display == 'none' || form.style.display == '') {
432 form.style.display = 'block';
433 } else {
434 form.style.display = 'none';
435 }
436 block.querySelector('input').focus();
437 });
438 });
439
440 // Rename a tag with an AJAX request
441 var renameTagSubmits = document.querySelectorAll('.validate-rename-tag');
442 [].forEach.call(renameTagSubmits, function(rename) {
443 rename.addEventListener('click', function(event) {
444 event.preventDefault();
445 var block = findParent(event.target, 'div', {'class': 'tag-list-item'});
446 var input = block.querySelector('.rename-tag-input');
447 var totag = input.value.replace('/"/g', '\\"');
448 if (totag.trim() == '') {
449 return;
450 }
451 var fromtag = block.getAttribute('data-tag');
452 var token = document.getElementById('token').value;
453
454 xhr = new XMLHttpRequest();
455 xhr.open('POST', '?do=changetag');
456 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
457 xhr.onload = function() {
458 if (xhr.status !== 200) {
459 alert('An error occurred. Return code: '+ xhr.status);
460 location.reload();
461 } else {
462 block.setAttribute('data-tag', totag);
463 input.setAttribute('name', totag);
464 input.setAttribute('value', totag);
465 findParent(input, 'div', {'class': 'rename-tag-form'}).style.display = 'none';
466 block.querySelector('a.tag-link').innerHTML = htmlEntities(totag);
467 block.querySelector('a.tag-link').setAttribute('href', '?searchtags='+ encodeURIComponent(totag));
468 block.querySelector('a.rename-tag').setAttribute('href', '?do=changetag&fromtag='+ encodeURIComponent(totag));
469
470 // Refresh awesomplete values
471 for (var key in existingTags) {
472 if (existingTags[key] == fromtag) {
473 existingTags[key] = totag;
474 }
475 }
476 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
477 }
478 };
479 xhr.send('renametag=1&fromtag='+ encodeURIComponent(fromtag) +'&totag='+ encodeURIComponent(totag) +'&token='+ token);
480 refreshToken();
481 });
482 });
483
484 // Validate input with enter key
485 var renameTagInputs = document.querySelectorAll('.rename-tag-input');
486 [].forEach.call(renameTagInputs, function(rename) {
487
488 rename.addEventListener('keypress', function(event) {
489 if (event.keyCode === 13) { // enter
490 findParent(event.target, 'div', {'class': 'tag-list-item'}).querySelector('.validate-rename-tag').click();
491 }
492 });
493 });
494
495 // Delete a tag with an AJAX query (alert popup confirmation)
496 var deleteTagButtons = document.querySelectorAll('.delete-tag');
497 [].forEach.call(deleteTagButtons, function(rename) {
498 rename.style.display = 'inline';
499 rename.addEventListener('click', function(event) {
500 event.preventDefault();
501 var block = findParent(event.target, 'div', {'class': 'tag-list-item'});
502 var tag = block.getAttribute('data-tag');
503 var token = document.getElementById('token').value;
504
505 if (confirm('Are you sure you want to delete the tag "'+ tag +'"?')) {
506 xhr = new XMLHttpRequest();
507 xhr.open('POST', '?do=changetag');
508 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
509 xhr.onload = function() {
510 block.remove();
511 };
512 xhr.send(encodeURI('deletetag=1&fromtag='+ tag +'&token='+ token));
513 refreshToken();
514 }
515 });
516 });
517
518 updateAwesompleteList('.rename-tag-input', document.querySelector('input[name="taglist"]').value.split(' '), awesomepletes);
519 };
520
521 /**
522 * Find a parent element according to its tag and its attributes
523 *
524 * @param element Element where to start the search
525 * @param tagName Expected parent tag name
526 * @param attributes Associative array of expected attributes (name=>value).
527 *
528 * @returns Found element or null.
529 */
530 function findParent(element, tagName, attributes)
531 {
532 while (element) {
533 if (element.tagName.toLowerCase() == tagName) {
534 var match = true;
535 for (var key in attributes) {
536 if (! element.hasAttribute(key)
537 || (attributes[key] != '' && element.getAttribute(key).indexOf(attributes[key]) == -1)
538 ) {
539 match = false;
540 break;
541 }
542 }
543
544 if (match) {
545 return element;
546 }
547 }
548 element = element.parentElement;
549 }
550 return null;
551 }
552
553 /**
554 * Ajax request to refresh the CSRF token.
555 */
556 function refreshToken()
557 {
558 var xhr = new XMLHttpRequest();
559 xhr.open('GET', '?do=token');
560 xhr.onload = function() {
561 var token = document.getElementById('token');
562 token.setAttribute('value', xhr.responseText);
563 };
564 xhr.send();
565 }
566
567 /**
568 * Update awesomplete list of tag for all elements matching the given selector
569 *
570 * @param selector CSS selector
571 * @param tags Array of tags
572 * @param instances List of existing awesomplete instances
573 */
574 function updateAwesompleteList(selector, tags, instances)
575 {
576 // First load: create Awesomplete instances
577 if (instances.length == 0) {
578 var elements = document.querySelectorAll(selector);
579 [].forEach.call(elements, function (element) {
580 instances.push(new Awesomplete(
581 element,
582 {'list': tags}
583 ));
584 });
585 } else {
586 // Update awesomplete tag list
587 for (var key in instances) {
588 instances[key].list = tags;
589 }
590 }
591 return instances;
592 }
593
594 /**
595 * html_entities in JS
596 *
597 * @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript
598 */
599 function htmlEntities(str)
600 {
601 return str.replace(/[\u00A0-\u9999<>\&]/gim, function(i) {
602 return '&#'+i.charCodeAt(0)+';';
603 });
604 }
605
606 function activateFirefoxSocial(node) {
607 var loc = location.href;
608 var baseURL = loc.substring(0, loc.lastIndexOf("/"));
609
610 // Keeping the data separated (ie. not in the DOM) so that it's maintainable and diffable.
611 var data = {
612 name: "{$shaarlititle}",
613 description: "The personal, minimalist, super-fast, database free, bookmarking service by the Shaarli community.",
614 author: "Shaarli",
615 version: "1.0.0",
616
617 iconURL: baseURL + "/images/favicon.ico",
618 icon32URL: baseURL + "/images/favicon.ico",
619 icon64URL: baseURL + "/images/favicon.ico",
620
621 shareURL: baseURL + "{noparse}?post=%{url}&title=%{title}&description=%{text}&source=firefoxsocialapi{/noparse}",
622 homepageURL: baseURL
623 };
624 node.setAttribute("data-service", JSON.stringify(data));
625
626 var activate = new CustomEvent("ActivateSocialFeature");
627 node.dispatchEvent(activate);
628 }
629
630 /**
631 * Add the class 'hidden' to city options not attached to the current selected continent.
632 *
633 * @param cities List of <option> elements
634 * @param currentContinent Current selected continent
635 * @param reset Set to true to reset the selected value
636 */
637 function hideTimezoneCities(cities, currentContinent) {
638 var first = true;
639 if (reset == null) {
640 reset = false;
641 }
642 [].forEach.call(cities, function (option) {
643 if (option.getAttribute('data-continent') != currentContinent) {
644 option.className = 'hidden';
645 } else {
646 option.className = '';
647 if (reset === true && first === true) {
648 option.setAttribute('selected', 'selected');
649 first = false;
650 }
651 }
652 });
653 }