]>
git.immae.eu Git - github/shaarli/Shaarli.git/blob - base.js
5cf037c2c99a6ad620a86bee4a88447d1c0a6b12
1 import Awesomplete
from 'awesomplete';
4 * Find a parent element according to its tag and its attributes
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).
10 * @returns Found element or null.
12 function findParent(element
, tagName
, attributes
) {
13 const parentMatch
= key
=> attributes
[key
] !== '' && element
.getAttribute(key
).indexOf(attributes
[key
]) !== -1;
15 if (element
.tagName
.toLowerCase() === tagName
) {
16 if (Object
.keys(attributes
).find(parentMatch
)) {
20 element
= element
.parentElement
;
26 * Ajax request to refresh the CSRF token.
28 function refreshToken() {
29 const xhr
= new XMLHttpRequest();
30 xhr
.open('GET', '?do=token');
32 const token
= document
.getElementById('token');
33 token
.setAttribute('value', xhr
.responseText
);
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} `;
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;
52 awesome
.data
= (item
, input
) => {
53 while ((match
= reg
.exec(input
))) {
54 if (item
=== match
[1]) {
69 * Update awesomplete list of tag for all elements matching the given selector
71 * @param selector CSS selector
72 * @param tags Array of tags
73 * @param instances List of existing awesomplete instances
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
));
83 // Update awesomplete tag list
84 instances
.map((item
) => {
95 * @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript
97 function htmlEntities(str
) {
98 return str
.replace(/[\u00A0-\u9999<>&]/gim, i
=> `&#${i.charCodeAt(0)};`);
101 function activateFirefoxSocial(node
) {
102 const loc
= location
.href
;
103 const baseURL
= loc
.substring(0, loc
.lastIndexOf('/') + 1);
106 name: document
.title
,
107 description: document
.getElementById('translation-delete-link').innerHTML
,
111 iconURL: `${baseURL}/images/favicon.ico`,
112 icon32URL: `${baseURL}/images/favicon.ico`,
113 icon64URL: `${baseURL}/images/favicon.ico`,
115 shareURL: `${baseURL}?post=%{url}&title=%{title}&description=%{text}&source=firefoxsocialapi`,
116 homepageURL: baseURL
,
118 node
.setAttribute('data-service', JSON
.stringify(data
));
120 const activate
= new CustomEvent('ActivateSocialFeature');
121 node
.dispatchEvent(activate
);
125 * Add the class 'hidden' to city options not attached to the current selected continent.
127 * @param cities List of <option> elements
128 * @param currentContinent Current selected continent
129 * @param reset Set to true to reset the selected value
131 function hideTimezoneCities(cities
, currentContinent
, reset
= null) {
136 [...cities
].forEach((option
) => {
137 if (option
.getAttribute('data-continent') !== currentContinent
) {
138 option
.className
= 'hidden';
140 option
.className
= '';
141 if (reset
=== true && first
=== true) {
142 option
.setAttribute('selected', 'selected');
150 * Retrieve an element up in the tree from its class name.
152 function getParentByClass(el
, className
) {
153 const p
= el
.parentNode
;
154 if (p
== null || p
.classList
.contains(className
)) {
157 return getParentByClass(p
, className
);
160 function toggleHorizontal() {
161 [...document
.getElementById('shaarli-menu').querySelectorAll('.menu-transform')].forEach((el
) => {
162 el
.classList
.toggle('pure-menu-horizontal');
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);
174 menu
.classList
.toggle('open');
175 document
.getElementById('menu-toggle').classList
.toggle('x');
178 function closeMenu(menu
) {
179 if (menu
.classList
.contains('open')) {
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';
192 thumb
.style
.display
= 'none';
195 button
.title
= document
.getElementById('translation-fold').innerHTML
;
196 if (description
!= null) {
197 description
.style
.display
= 'block';
200 thumb
.style
.display
= 'block';
203 button
.classList
.toggle('fa-chevron-down');
204 button
.classList
.toggle('fa-chevron-up');
207 function removeClass(element
, classname
) {
208 element
.className
= element
.className
.replace(new RegExp(`(?:^|\\s)${classname}(?:\\s|$)`), ' ');
211 function init(description
) {
213 /* Fix jumpy resizing: https://stackoverflow.com/a/18262927/1484919 */
214 const scrollTop
= window
.pageYOffset
||
215 (document
.documentElement
|| document
.body
.parentNode
|| document
.body
).scrollTop
;
217 description
.style
.height
= 'auto';
218 description
.style
.height
= `${description.scrollHeight + 10}px`;
220 window
.scrollTo(0, scrollTop
);
223 /* 0-timeout to get the already changed text */
224 function delayedResize() {
225 window
.setTimeout(resize
, 0);
228 const observe
= (element
, event
, handler
) => {
229 element
.addEventListener(event
, handler
, false);
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
);
242 * Handle responsive menu.
243 * Source: http://purecss.io/layouts/tucked-menu-vertical/
245 const menu
= document
.getElementById('shaarli-menu');
246 const WINDOW_CHANGE_EVENT
= ('onorientationchange' in window
) ? 'orientationchange' : 'resize';
248 const menuToggle
= document
.getElementById('menu-toggle');
249 if (menuToggle
!= null) {
250 menuToggle
.addEventListener('click', () => toggleMenu(menu
));
253 window
.addEventListener(WINDOW_CHANGE_EVENT
, () => closeMenu(menu
));
256 * Fold/Expand shaares description and thumbnail.
258 const foldAllButtons
= document
.getElementsByClassName('fold-all');
259 const foldButtons
= document
.getElementsByClassName('fold-button');
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';
274 foldButton
.addEventListener('click', (event
) => {
275 event
.preventDefault();
276 toggleFold(event
.target
, description
, thumbnail
);
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')
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';
303 toggleFold(foldButton
.firstElementChild
, description
, thumbnail
);
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
;
315 * Confirmation message before deletion.
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();
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';
338 * New version dismiss.
339 * Hide the message for one week using localStorage.
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()
347 newVersionMessage
.style
.display
= 'none';
349 if (newVersionDismiss
!= null) {
350 newVersionDismiss
.addEventListener('click', () => {
351 localStorage
.setItem('newVersionDismiss', (new Date()).getTime().toString());
355 const hiddenReturnurl
= document
.getElementsByName('returnurl');
356 if (hiddenReturnurl
!= null) {
357 hiddenReturnurl
.value
= window
.location
.href
;
361 * Autofocus text fields
363 const autofocusElements
= document
.querySelectorAll('.autofocus');
364 let breakLoop
= false;
365 [].forEach
.call(autofocusElements
, (autofocusElement
) => {
366 if (autofocusElement
.value
=== '' && !breakLoop
) {
367 autofocusElement
.focus();
373 * Handle sub menus/forms
375 const openers
= document
.getElementsByClassName('subheader-opener');
376 if (openers
!= null) {
377 [...openers
].forEach((opener
) => {
378 opener
.addEventListener('click', (event
) => {
379 event
.preventDefault();
381 const id
= opener
.getAttribute('data-open-id');
382 const sub
= document
.getElementById(id
);
385 [...document
.getElementsByClassName('subheader-form')].forEach((element
) => {
386 if (element
!== sub
) {
387 removeClass(element
, 'open');
391 sub
.classList
.toggle('open');
398 * Remove CSS target padding (for fixed bar)
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';
412 const description
= document
.getElementById('lf_description');
414 if (description
!= null) {
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();
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
);
439 const ffButton
= document
.getElementById('ff-social-button');
440 if (ffButton
!= null) {
441 ffButton
.addEventListener('click', (event
) => {
442 activateFirefoxSocial(event
.target
);
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);
452 hideTimezoneCities(city
, continent
.options
[continent
.selectedIndex
].value
, false);
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');
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();
480 const linkCheckedCheckboxes
= document
.querySelectorAll('.delete-checkbox:checked');
481 [...linkCheckedCheckboxes
].forEach((checkbox
) => {
484 title: document
.querySelector(`.linklist-item[data-id="${checkbox.value}"] .linklist-link`).innerHTML
,
488 let message
= `Are you sure you want to delete ${links.length} links?\n`;
489 message
+= 'This action is IRREVERSIBLE!\n\nTitles:\n';
491 links
.forEach((item
) => {
492 message
+= ` - ${item.title}\n`;
496 if (window
.confirm(message
)) {
497 window
.location
= `?delete_link&lf_linkdate=${ids.join('+')}&token=${token.value}`;
503 * Tag list operations
505 * TODO: support error code in the backend for AJAX requests
507 const tagList
= document
.querySelector('input[name="taglist"]');
508 let existingTags
= tagList
? tagList
.value
.split(' ') : [];
509 let awesomepletes
= [];
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';
521 form
.style
.display
= 'none';
523 block
.querySelector('input').focus();
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() === '') {
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');
544 if (xhr
.status
!== 200) {
545 alert(`An error occurred. Return code: ${xhr.status}`);
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)}`);
556 // Refresh awesomplete values
557 existingTags
= existingTags
.map(tag
=> (tag
=== fromtag
? totag : tag
));
558 awesomepletes
= updateAwesompleteList('.rename-tag-input', existingTags
, awesomepletes
);
561 xhr
.send(`renametag=1&fromtag=${encodeURIComponent(fromtag)}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
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();
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');
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');
593 xhr
.send(encodeURI(`deletetag=1&fromtag=${tag}&token=${refreshedToken}`));
596 existingTags
= existingTags
.filter(tagItem
=> tagItem
!== tag
);
597 awesomepletes
= updateAwesompleteList('.rename-tag-input', existingTags
, awesomepletes
);
602 const autocompleteFields
= document
.querySelectorAll('input[data-multiple]');
603 [...autocompleteFields
].forEach((autocompleteField
) => {
604 awesomepletes
.push(createAwesompleteInstance(autocompleteField
));