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