shaarli.js 24 KB

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