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