utils.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  1. import { ReactiveCache } from '/imports/reactiveCache';
  2. import { Session } from 'meteor/session';
  3. import { FlowRouter } from 'meteor/kadira:flow-router';
  4. import { Tracker } from 'meteor/tracker';
  5. import { $ } from 'meteor/jquery';
  6. import { Meteor } from 'meteor/meteor';
  7. // Initialize global Utils first
  8. if (typeof window.Utils === 'undefined') {
  9. window.Utils = {};
  10. }
  11. // Create Utils object
  12. const Utils = {
  13. setBackgroundImage(url) {
  14. const currentBoard = Utils.getCurrentBoard();
  15. if (currentBoard.backgroundImageURL !== undefined) {
  16. $(".board-wrapper").css({"background":"url(" + currentBoard.backgroundImageURL + ")","background-size":"cover"});
  17. $(".swimlane,.swimlane .list,.swimlane .list .list-body,.swimlane .list:first-child .list-body").css({"background-color":"transparent"});
  18. $(".minicard").css({"opacity": "0.9"});
  19. } else if (currentBoard["background-color"]) {
  20. currentBoard.setColor(currentBoard["background-color"]);
  21. }
  22. },
  23. // Normalize non-Western (Persian/Arabic) digits to Western Arabic (0-9)
  24. // This helps with date parsing in non-English languages
  25. normalizeDigits(str) {
  26. if (!str) return str;
  27. // Convert Persian and Arabic numbers to English
  28. const persianNumbers = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
  29. const arabicNumbers = ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩'];
  30. return str.split('')
  31. .map(c => {
  32. const pIndex = persianNumbers.indexOf(c);
  33. const aIndex = arabicNumbers.indexOf(c);
  34. if (pIndex >= 0) return pIndex.toString();
  35. if (aIndex >= 0) return aIndex.toString();
  36. return c;
  37. })
  38. .join('');
  39. },
  40. /** returns the current board id
  41. * <li> returns the current board id or the board id of the popup card if set
  42. */
  43. getCurrentBoardId() {
  44. let popupCardBoardId = Session.get('popupCardBoardId');
  45. let currentBoard = Session.get('currentBoard');
  46. let ret = currentBoard;
  47. if (popupCardBoardId) {
  48. ret = popupCardBoardId;
  49. }
  50. return ret;
  51. },
  52. getCurrentCardId(ignorePopupCard) {
  53. let ret = Session.get('currentCard');
  54. if (!ret && !ignorePopupCard) {
  55. ret = Utils.getPopupCardId();
  56. }
  57. return ret;
  58. },
  59. getPopupCardId() {
  60. const ret = Session.get('popupCardId');
  61. return ret;
  62. },
  63. getCurrentListId() {
  64. const ret = Session.get('currentList');
  65. return ret;
  66. },
  67. /** returns the current board
  68. * <li> returns the current board or the board of the popup card if set
  69. */
  70. getCurrentBoard() {
  71. const boardId = Utils.getCurrentBoardId();
  72. const ret = ReactiveCache.getBoard(boardId);
  73. return ret;
  74. },
  75. getCurrentCard(ignorePopupCard) {
  76. const cardId = Utils.getCurrentCardId(ignorePopupCard);
  77. const ret = ReactiveCache.getCard(cardId);
  78. return ret;
  79. },
  80. getCurrentList() {
  81. const listId = this.getCurrentListId();
  82. let ret = null;
  83. if (listId) {
  84. ret = ReactiveCache.getList(listId);
  85. }
  86. return ret;
  87. },
  88. getPopupCard() {
  89. const cardId = Utils.getPopupCardId();
  90. const ret = ReactiveCache.getCard(cardId);
  91. return ret;
  92. },
  93. canModifyCard() {
  94. const currentUser = ReactiveCache.getCurrentUser();
  95. const ret = (
  96. currentUser &&
  97. currentUser.isBoardMember() &&
  98. !currentUser.isCommentOnly() &&
  99. !currentUser.isWorker()
  100. );
  101. return ret;
  102. },
  103. canModifyBoard() {
  104. const currentUser = ReactiveCache.getCurrentUser();
  105. const ret = (
  106. currentUser &&
  107. currentUser.isBoardMember() &&
  108. !currentUser.isCommentOnly()
  109. );
  110. return ret;
  111. },
  112. reload() {
  113. // we move all window.location.reload calls into this function
  114. // so we can disable it when running tests.
  115. // This is because we are not allowed to override location.reload but
  116. // we can override Utils.reload to prevent reload during tests.
  117. window.location.reload();
  118. },
  119. setBoardView(view) {
  120. currentUser = ReactiveCache.getCurrentUser();
  121. if (currentUser) {
  122. ReactiveCache.getCurrentUser().setBoardView(view);
  123. } else if (view === 'board-view-swimlanes') {
  124. window.localStorage.setItem('boardView', 'board-view-swimlanes'); //true
  125. Utils.reload();
  126. } else if (view === 'board-view-lists') {
  127. window.localStorage.setItem('boardView', 'board-view-lists'); //true
  128. Utils.reload();
  129. } else if (view === 'board-view-cal') {
  130. window.localStorage.setItem('boardView', 'board-view-cal'); //true
  131. Utils.reload();
  132. } else {
  133. window.localStorage.setItem('boardView', 'board-view-swimlanes'); //true
  134. Utils.reload();
  135. }
  136. },
  137. unsetBoardView() {
  138. window.localStorage.removeItem('boardView');
  139. window.localStorage.removeItem('collapseSwimlane');
  140. },
  141. boardView() {
  142. currentUser = ReactiveCache.getCurrentUser();
  143. if (currentUser) {
  144. return (currentUser.profile || {}).boardView;
  145. } else if (
  146. window.localStorage.getItem('boardView') === 'board-view-swimlanes'
  147. ) {
  148. return 'board-view-swimlanes';
  149. } else if (
  150. window.localStorage.getItem('boardView') === 'board-view-lists'
  151. ) {
  152. return 'board-view-lists';
  153. } else if (window.localStorage.getItem('boardView') === 'board-view-cal') {
  154. return 'board-view-cal';
  155. } else {
  156. window.localStorage.setItem('boardView', 'board-view-swimlanes'); //true
  157. Utils.reload();
  158. return 'board-view-swimlanes';
  159. }
  160. },
  161. myCardsSort() {
  162. let sort = window.localStorage.getItem('myCardsSort');
  163. if (!sort || !['board', 'dueAt'].includes(sort)) {
  164. sort = 'board';
  165. }
  166. return sort;
  167. },
  168. myCardsSortToggle() {
  169. if (this.myCardsSort() === 'board') {
  170. this.setMyCardsSort('dueAt');
  171. } else {
  172. this.setMyCardsSort('board');
  173. }
  174. },
  175. setMyCardsSort(sort) {
  176. window.localStorage.setItem('myCardsSort', sort);
  177. Utils.reload();
  178. },
  179. archivedBoardIds() {
  180. const ret = ReactiveCache.getBoards({ archived: false }).map(board => board._id);
  181. return ret;
  182. },
  183. dueCardsView() {
  184. let view = window.localStorage.getItem('dueCardsView');
  185. if (!view || !['me', 'all'].includes(view)) {
  186. view = 'me';
  187. }
  188. return view;
  189. },
  190. setDueCardsView(view) {
  191. window.localStorage.setItem('dueCardsView', view);
  192. Utils.reload();
  193. },
  194. myCardsView() {
  195. let view = window.localStorage.getItem('myCardsView');
  196. if (!view || !['boards', 'table'].includes(view)) {
  197. view = 'boards';
  198. }
  199. return view;
  200. },
  201. setMyCardsView(view) {
  202. window.localStorage.setItem('myCardsView', view);
  203. Utils.reload();
  204. },
  205. // XXX We should remove these two methods
  206. goBoardId(_id) {
  207. const board = ReactiveCache.getBoard(_id);
  208. return (
  209. board &&
  210. FlowRouter.go('board', {
  211. id: board._id,
  212. slug: board.slug,
  213. })
  214. );
  215. },
  216. goCardId(_id) {
  217. const card = ReactiveCache.getCard(_id);
  218. const board = ReactiveCache.getBoard(card.boardId);
  219. return (
  220. board &&
  221. FlowRouter.go('card', {
  222. cardId: card._id,
  223. boardId: board._id,
  224. slug: board.slug,
  225. })
  226. );
  227. },
  228. getCommonAttachmentMetaFrom(card) {
  229. const meta = {};
  230. if (card.isLinkedCard()) {
  231. meta.boardId = ReactiveCache.getCard(card.linkedId).boardId;
  232. meta.cardId = card.linkedId;
  233. } else {
  234. meta.boardId = card.boardId;
  235. meta.swimlaneId = card.swimlaneId;
  236. meta.listId = card.listId;
  237. meta.cardId = card._id;
  238. }
  239. return meta;
  240. },
  241. MAX_IMAGE_PIXEL: Meteor.settings.public.MAX_IMAGE_PIXEL,
  242. COMPRESS_RATIO: Meteor.settings.public.IMAGE_COMPRESS_RATIO,
  243. shrinkImage(options) {
  244. // shrink image to certain size
  245. const dataurl = options.dataurl,
  246. callback = options.callback,
  247. toBlob = options.toBlob;
  248. let canvas = document.createElement('canvas'),
  249. image = document.createElement('img');
  250. const maxSize = options.maxSize || 1024;
  251. const ratio = options.ratio || 1.0;
  252. const next = function (result) {
  253. image = null;
  254. canvas = null;
  255. if (typeof callback === 'function') {
  256. callback(result);
  257. }
  258. };
  259. image.onload = function () {
  260. let width = this.width,
  261. height = this.height;
  262. let changed = false;
  263. if (width > height) {
  264. if (width > maxSize) {
  265. height *= maxSize / width;
  266. width = maxSize;
  267. changed = true;
  268. }
  269. } else if (height > maxSize) {
  270. width *= maxSize / height;
  271. height = maxSize;
  272. changed = true;
  273. }
  274. canvas.width = width;
  275. canvas.height = height;
  276. canvas.getContext('2d').drawImage(this, 0, 0, width, height);
  277. if (changed === true) {
  278. const type = 'image/jpeg';
  279. if (toBlob) {
  280. canvas.toBlob(next, type, ratio);
  281. } else {
  282. next(canvas.toDataURL(type, ratio));
  283. }
  284. } else {
  285. next(changed);
  286. }
  287. };
  288. image.onerror = function () {
  289. next(false);
  290. };
  291. image.src = dataurl;
  292. },
  293. capitalize(string) {
  294. return string.charAt(0).toUpperCase() + string.slice(1);
  295. },
  296. windowResizeDep: new Tracker.Dependency(),
  297. // in fact, what we really care is screen size
  298. // large mobile device like iPad or android Pad has a big screen, it should also behave like a desktop
  299. // in a small window (even on desktop), Wekan run in compact mode.
  300. // we can easily debug with a small window of desktop browser. :-)
  301. isMiniScreen() {
  302. // OLD WINDOW WIDTH DETECTION:
  303. this.windowResizeDep.depend();
  304. return $(window).width() <= 800;
  305. },
  306. isTouchScreen() {
  307. // NEW TOUCH DEVICE DETECTION:
  308. // https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
  309. var hasTouchScreen = false;
  310. if ("maxTouchPoints" in navigator) {
  311. hasTouchScreen = navigator.maxTouchPoints > 0;
  312. } else if ("msMaxTouchPoints" in navigator) {
  313. hasTouchScreen = navigator.msMaxTouchPoints > 0;
  314. } else {
  315. var mQ = window.matchMedia && matchMedia("(pointer:coarse)");
  316. if (mQ && mQ.media === "(pointer:coarse)") {
  317. hasTouchScreen = !!mQ.matches;
  318. } else if ('orientation' in window) {
  319. hasTouchScreen = true; // deprecated, but good fallback
  320. } else {
  321. // Only as a last resort, fall back to user agent sniffing
  322. var UA = navigator.userAgent;
  323. hasTouchScreen = (
  324. /\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) ||
  325. /\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA)
  326. );
  327. }
  328. }
  329. return hasTouchScreen;
  330. },
  331. // returns if desktop drag handles are enabled
  332. isShowDesktopDragHandles() {
  333. //const currentUser = ReactiveCache.getCurrentUser();
  334. //if (currentUser) {
  335. // return (currentUser.profile || {}).showDesktopDragHandles;
  336. //} else if (window.localStorage.getItem('showDesktopDragHandles')) {
  337. if (window.localStorage.getItem('showDesktopDragHandles')) {
  338. return true;
  339. } else {
  340. return false;
  341. }
  342. },
  343. // returns if mini screen or desktop drag handles
  344. isTouchScreenOrShowDesktopDragHandles() {
  345. //return this.isTouchScreen() || this.isShowDesktopDragHandles();
  346. return this.isShowDesktopDragHandles();
  347. },
  348. calculateIndexData(prevData, nextData, nItems = 1) {
  349. let base, increment;
  350. // If we drop the card to an empty column
  351. if (!prevData && !nextData) {
  352. base = 0;
  353. increment = 1;
  354. // If we drop the card in the first position
  355. } else if (!prevData) {
  356. const nextSortIndex = nextData.sort;
  357. const ceil = Math.ceil(nextSortIndex - 1);
  358. if (ceil < nextSortIndex) {
  359. increment = nextSortIndex - ceil;
  360. base = nextSortIndex - increment;
  361. } else {
  362. base = nextData.sort - 1;
  363. increment = -1;
  364. }
  365. // If we drop the card in the last position
  366. } else if (!nextData) {
  367. const prevSortIndex = prevData.sort;
  368. const floor = Math.floor(prevSortIndex + 1);
  369. if (floor > prevSortIndex) {
  370. increment = prevSortIndex - floor;
  371. base = prevSortIndex - increment;
  372. } else {
  373. base = prevData.sort + 1;
  374. increment = 1;
  375. }
  376. }
  377. // In the general case take the average of the previous and next element
  378. // sort indexes.
  379. else {
  380. const prevSortIndex = prevData.sort;
  381. const nextSortIndex = nextData.sort;
  382. if (nItems == 1 ) {
  383. if (prevSortIndex < 0 ) {
  384. const ceil = Math.ceil(nextSortIndex - 1);
  385. if (ceil < nextSortIndex && ceil > prevSortIndex) {
  386. increment = ceil - prevSortIndex;
  387. }
  388. } else {
  389. const floor = Math.floor(nextSortIndex - 1);
  390. if (floor < nextSortIndex && floor > prevSortIndex) {
  391. increment = floor - prevSortIndex;
  392. }
  393. }
  394. }
  395. if (!increment) {
  396. increment = (nextSortIndex - prevSortIndex) / (nItems + 1);
  397. }
  398. if (!base) {
  399. base = prevSortIndex + increment;
  400. }
  401. }
  402. // XXX Return a generator that yield values instead of a base with a
  403. // increment number.
  404. return {
  405. base,
  406. increment,
  407. };
  408. },
  409. // Determine the new sort index
  410. calculateIndex(prevCardDomElement, nextCardDomElement, nCards = 1) {
  411. let prevData = null;
  412. let nextData = null;
  413. if (prevCardDomElement) {
  414. prevData = Blaze.getData(prevCardDomElement)
  415. }
  416. if (nextCardDomElement) {
  417. nextData = Blaze.getData(nextCardDomElement);
  418. }
  419. const ret = Utils.calculateIndexData(prevData, nextData, nCards);
  420. return ret;
  421. },
  422. manageCustomUI() {
  423. Meteor.call('getCustomUI', (err, data) => {
  424. if (err && err.error[0] === 'var-not-exist') {
  425. Session.set('customUI', false); // siteId || address server not defined
  426. }
  427. if (!err) {
  428. Utils.setCustomUI(data);
  429. }
  430. });
  431. },
  432. setCustomUI(data) {
  433. const currentBoard = Utils.getCurrentBoard();
  434. if (currentBoard) {
  435. DocHead.setTitle(`${currentBoard.title} - ${data.productName}`);
  436. } else {
  437. DocHead.setTitle(`${data.productName}`);
  438. }
  439. },
  440. setMatomo(data) {
  441. window._paq = window._paq || [];
  442. window._paq.push(['setDoNotTrack', data.doNotTrack]);
  443. if (data.withUserName) {
  444. window._paq.push(['setUserId', ReactiveCache.getCurrentUser().username]);
  445. }
  446. window._paq.push(['trackPageView']);
  447. window._paq.push(['enableLinkTracking']);
  448. (function () {
  449. window._paq.push(['setTrackerUrl', `${data.address}piwik.php`]);
  450. window._paq.push(['setSiteId', data.siteId]);
  451. const script = document.createElement('script');
  452. Object.assign(script, {
  453. id: 'scriptMatomo',
  454. type: 'text/javascript',
  455. async: 'true',
  456. defer: 'true',
  457. src: `${data.address}piwik.js`,
  458. });
  459. const s = document.getElementsByTagName('script')[0];
  460. s.parentNode.insertBefore(script, s);
  461. })();
  462. Session.set('matomo', true);
  463. },
  464. manageMatomo() {
  465. const matomo = Session.get('matomo');
  466. if (matomo === undefined) {
  467. Meteor.call('getMatomoConf', (err, data) => {
  468. if (err && err.error[0] === 'var-not-exist') {
  469. Session.set('matomo', false); // siteId || address server not defined
  470. }
  471. if (!err) {
  472. Utils.setMatomo(data);
  473. }
  474. });
  475. } else if (matomo) {
  476. window._paq.push(['trackPageView']);
  477. }
  478. },
  479. getTriggerActionDesc(event, tempInstance) {
  480. const jqueryEl = tempInstance.$(event.currentTarget.parentNode);
  481. const triggerEls = jqueryEl.find('.trigger-content').children();
  482. let finalString = '';
  483. for (let i = 0; i < triggerEls.length; i++) {
  484. const element = tempInstance.$(triggerEls[i]);
  485. if (element.hasClass('trigger-text')) {
  486. finalString += element.text().toLowerCase();
  487. } else if (element.hasClass('user-details')) {
  488. let username = element.find('input').val();
  489. if (username === undefined || username === '') {
  490. username = '*';
  491. }
  492. finalString += `${element
  493. .find('.trigger-text')
  494. .text()
  495. .toLowerCase()} ${username}`;
  496. } else if (element.find('select').length > 0) {
  497. finalString += element
  498. .find('select option:selected')
  499. .text()
  500. .toLowerCase();
  501. } else if (element.find('input').length > 0) {
  502. let inputvalue = element.find('input').val();
  503. if (inputvalue === undefined || inputvalue === '') {
  504. inputvalue = '*';
  505. }
  506. finalString += inputvalue;
  507. }
  508. // Add space
  509. if (i !== length - 1) {
  510. finalString += ' ';
  511. }
  512. }
  513. return finalString;
  514. },
  515. fallbackCopyTextToClipboard(text) {
  516. var textArea = document.createElement("textarea");
  517. textArea.value = text;
  518. // Avoid scrolling to bottom
  519. textArea.style.top = "0";
  520. textArea.style.left = "0";
  521. textArea.style.position = "fixed";
  522. document.body.appendChild(textArea);
  523. textArea.focus();
  524. textArea.select();
  525. try {
  526. document.execCommand('copy');
  527. return Promise.resolve(true);
  528. } catch (e) {
  529. return Promise.reject(false);
  530. } finally {
  531. document.body.removeChild(textArea);
  532. }
  533. },
  534. /** copy the text to the clipboard
  535. * @see https://stackoverflow.com/questions/400212/how-do-i-copy-to-the-clipboard-in-javascript/30810322#30810322
  536. * @param string copy this text to the clipboard
  537. * @return Promise
  538. */
  539. copyTextToClipboard(text) {
  540. let ret;
  541. if (navigator.clipboard) {
  542. ret = navigator.clipboard.writeText(text).then(function () {
  543. }, function (err) {
  544. console.error('Async: Could not copy text: ', err);
  545. });
  546. } else {
  547. ret = Utils.fallbackCopyTextToClipboard(text);
  548. }
  549. return ret;
  550. },
  551. /** show the "copied!" message
  552. * @param promise the promise of Utils.copyTextToClipboard
  553. * @param $tooltip jQuery tooltip element
  554. */
  555. showCopied(promise, $tooltip) {
  556. if (promise) {
  557. promise.then(() => {
  558. $tooltip.show(100);
  559. setTimeout(() => $tooltip.hide(100), 1000);
  560. }, (err) => {
  561. console.error("error: ", err);
  562. });
  563. }
  564. },
  565. };
  566. // Update global Utils with all methods
  567. Object.assign(window.Utils, Utils);
  568. // Export for ES modules
  569. export { Utils };
  570. // A simple tracker dependency that we invalidate every time the window is
  571. // resized. This is used to reactively re-calculate the popup position in case
  572. // of a window resize. This is the equivalent of a "Signal" in some other
  573. // programming environments (eg, elm).
  574. $(window).on('resize', () => Utils.windowResizeDep.changed());