attachments.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. import { ReactiveCache } from '/imports/reactiveCache';
  2. import { ObjectID } from 'bson';
  3. import DOMPurify from 'dompurify';
  4. const filesize = require('filesize');
  5. const prettyMilliseconds = require('pretty-ms');
  6. // We store current card ID and the ID of currently opened attachment in a
  7. // global var. This is used so that we know what's the next attachment to open
  8. // when the user clicks on the prev/next button in the attachment viewer.
  9. let cardId = null;
  10. let openAttachmentId = null;
  11. // Stores link to the attachment for which attachment actions popup was opened
  12. attachmentActionsLink = null;
  13. Template.attachmentGallery.events({
  14. 'click .open-preview'(event) {
  15. openAttachmentId = $(event.currentTarget).attr("data-attachment-id");
  16. cardId = $(event.currentTarget).attr("data-card-id");
  17. openAttachmentViewer(openAttachmentId);
  18. },
  19. 'click .js-add-attachment': Popup.open('cardAttachments'),
  20. // If we let this event bubble, FlowRouter will handle it and empty the page
  21. // content, see #101.
  22. 'click .js-download'(event) {
  23. event.stopPropagation();
  24. },
  25. 'click .js-open-attachment-menu': Popup.open('attachmentActions'),
  26. 'mouseover .js-open-attachment-menu'(event) { // For some reason I cannot combine handlers for "click .js-open-attachment-menu" and "mouseover .js-open-attachment-menu" events so this is a quick workaround.
  27. attachmentActionsLink = event.currentTarget.getAttribute("data-attachment-link");
  28. },
  29. 'click .js-rename': Popup.open('attachmentRename'),
  30. 'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete', function() {
  31. Attachments.remove(this._id);
  32. Popup.back(2);
  33. }),
  34. });
  35. function getNextAttachmentId(currentAttachmentId) {
  36. const attachments = Attachments.find({'meta.cardId': cardId}).get();
  37. let i = 0;
  38. for (; i < attachments.length; i++) {
  39. if (attachments[i]._id === currentAttachmentId) {
  40. break;
  41. }
  42. }
  43. return attachments[(i + 1 + attachments.length) % attachments.length]._id;
  44. }
  45. function getPrevAttachmentId(currentAttachmentId) {
  46. const attachments = Attachments.find({'meta.cardId': cardId}).get();
  47. let i = 0;
  48. for (; i < attachments.length; i++) {
  49. if (attachments[i]._id === currentAttachmentId) {
  50. break;
  51. }
  52. }
  53. return attachments[(i - 1 + attachments.length) % attachments.length]._id;
  54. }
  55. function openAttachmentViewer(attachmentId){
  56. const attachment = ReactiveCache.getAttachment(attachmentId);
  57. $("#attachment-name").text(attachment.name);
  58. // IMPORTANT: if you ever add a new viewer, make sure you also implement
  59. // cleanup in the closeAttachmentViewer() function
  60. switch(true){
  61. case (attachment.isImage):
  62. $("#image-viewer").attr("src", attachment.link());
  63. $("#image-viewer").removeClass("hidden");
  64. break;
  65. case (attachment.isPDF):
  66. $("#pdf-viewer").attr("data", attachment.link());
  67. $("#pdf-viewer").removeClass("hidden");
  68. break;
  69. case (attachment.isVideo):
  70. // We have to create a new <source> DOM element and append it to the video
  71. // element, otherwise the video won't load
  72. let videoSource = document.createElement('source');
  73. videoSource.setAttribute('src', attachment.link());
  74. $("#video-viewer").append(videoSource);
  75. $("#video-viewer").removeClass("hidden");
  76. break;
  77. case (attachment.isAudio):
  78. // We have to create a new <source> DOM element and append it to the audio
  79. // element, otherwise the audio won't load
  80. let audioSource = document.createElement('source');
  81. audioSource.setAttribute('src', attachment.link());
  82. $("#audio-viewer").append(audioSource);
  83. $("#audio-viewer").removeClass("hidden");
  84. break;
  85. case (attachment.isText):
  86. case (attachment.isJSON):
  87. $("#txt-viewer").attr("data", attachment.link());
  88. $("#txt-viewer").removeClass("hidden");
  89. break;
  90. }
  91. $("#viewer-overlay").removeClass("hidden");
  92. }
  93. function closeAttachmentViewer() {
  94. $("#viewer-overlay").addClass("hidden");
  95. // We need to reset the viewers to avoid showing previous attachments
  96. $("#image-viewer").attr("src", "");
  97. $("#image-viewer").addClass("hidden");
  98. $("#pdf-viewer").attr("data", "");
  99. $("#pdf-viewer").addClass("hidden");
  100. $("#txt-viewer").attr("data", "");
  101. $("#txt-viewer").addClass("hidden");
  102. $("#video-viewer").get(0).pause(); // Stop playback
  103. $("#video-viewer").get(0).currentTime = 0;
  104. $("#video-viewer").empty();
  105. $("#video-viewer").addClass("hidden");
  106. $("#audio-viewer").get(0).pause(); // Stop playback
  107. $("#audio-viewer").get(0).currentTime = 0;
  108. $("#audio-viewer").empty();
  109. $("#audio-viewer").addClass("hidden");
  110. }
  111. Template.attachmentViewer.events({
  112. 'click #viewer-container'(event) {
  113. // Make sure the click was on #viewer-container and not on any of its children
  114. if(event.target !== event.currentTarget) return;
  115. closeAttachmentViewer();
  116. },
  117. 'click #viewer-content'(event) {
  118. // Make sure the click was on #viewer-content and not on any of its children
  119. if(event.target !== event.currentTarget) return;
  120. closeAttachmentViewer();
  121. },
  122. 'click #viewer-close'() {
  123. closeAttachmentViewer();
  124. },
  125. 'click #next-attachment'(event) {
  126. closeAttachmentViewer()
  127. const id = getNextAttachmentId(openAttachmentId);
  128. openAttachmentId = id;
  129. openAttachmentViewer(id);
  130. },
  131. 'click #prev-attachment'(event) {
  132. closeAttachmentViewer()
  133. const id = getPrevAttachmentId(openAttachmentId);
  134. openAttachmentId = id;
  135. openAttachmentViewer(id);
  136. }
  137. });
  138. Template.attachmentGallery.helpers({
  139. isBoardAdmin() {
  140. return ReactiveCache.getCurrentUser().isBoardAdmin();
  141. },
  142. fileSize(size) {
  143. const ret = filesize(size);
  144. return ret;
  145. },
  146. sanitize(value) {
  147. return DOMPurify.sanitize(value);
  148. },
  149. });
  150. Template.cardAttachmentsPopup.onCreated(function() {
  151. this.uploads = new ReactiveVar([]);
  152. });
  153. Template.cardAttachmentsPopup.helpers({
  154. getEstimateTime(upload) {
  155. const ret = prettyMilliseconds(upload.estimateTime.get());
  156. return ret;
  157. },
  158. getEstimateSpeed(upload) {
  159. const ret = filesize(upload.estimateSpeed.get(), {round: 0}) + "/s";
  160. return ret;
  161. },
  162. uploads() {
  163. return Template.instance().uploads.get();
  164. }
  165. });
  166. Template.cardAttachmentsPopup.events({
  167. 'change .js-attach-file'(event, templateInstance) {
  168. const card = this;
  169. const files = event.currentTarget.files;
  170. if (files) {
  171. let uploads = [];
  172. for (const file of files) {
  173. const fileId = new ObjectID().toString();
  174. // If filename is not same as sanitized filename, has XSS, then cancel upload
  175. if (file.name !== DOMPurify.sanitize(file.name)) {
  176. return false;
  177. }
  178. const config = {
  179. file: file,
  180. fileId: fileId,
  181. meta: Utils.getCommonAttachmentMetaFrom(card),
  182. chunkSize: 'dynamic',
  183. };
  184. config.meta.fileId = fileId;
  185. const uploader = Attachments.insert(
  186. config,
  187. false,
  188. );
  189. uploader.on('start', function() {
  190. uploads.push(this);
  191. templateInstance.uploads.set(uploads);
  192. });
  193. uploader.on('uploaded', (error, fileRef) => {
  194. if (!error) {
  195. if (fileRef.isImage) {
  196. card.setCover(fileRef._id);
  197. }
  198. }
  199. });
  200. uploader.on('end', (error, fileRef) => {
  201. uploads = uploads.filter(_upload => _upload.config.fileId != fileRef._id);
  202. templateInstance.uploads.set(uploads);
  203. if (uploads.length == 0 ) {
  204. Popup.back();
  205. }
  206. });
  207. uploader.start();
  208. }
  209. }
  210. },
  211. 'click .js-computer-upload'(event, templateInstance) {
  212. templateInstance.find('.js-attach-file').click();
  213. event.preventDefault();
  214. },
  215. 'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'),
  216. });
  217. const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;
  218. const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;
  219. let pastedResults = null;
  220. Template.previewClipboardImagePopup.onRendered(() => {
  221. // we can paste image from clipboard
  222. const handle = results => {
  223. if (results.dataURL.startsWith('data:image/')) {
  224. const direct = results => {
  225. $('img.preview-clipboard-image').attr('src', results.dataURL);
  226. pastedResults = results;
  227. };
  228. if (MAX_IMAGE_PIXEL) {
  229. // if has size limitation on image we shrink it before uploading
  230. Utils.shrinkImage({
  231. dataurl: results.dataURL,
  232. maxSize: MAX_IMAGE_PIXEL,
  233. ratio: COMPRESS_RATIO,
  234. callback(changed) {
  235. if (changed !== false && !!changed) {
  236. results.dataURL = changed;
  237. }
  238. direct(results);
  239. },
  240. });
  241. } else {
  242. direct(results);
  243. }
  244. }
  245. };
  246. $(document.body).pasteImageReader(handle);
  247. // we can also drag & drop image file to it
  248. $(document.body).dropImageReader(handle);
  249. });
  250. Template.previewClipboardImagePopup.events({
  251. 'click .js-upload-pasted-image'() {
  252. const card = this;
  253. if (pastedResults && pastedResults.file) {
  254. const file = pastedResults.file;
  255. window.oPasted = pastedResults;
  256. const fileId = new ObjectID().toString();
  257. const config = {
  258. file,
  259. fileId: fileId,
  260. meta: Utils.getCommonAttachmentMetaFrom(card),
  261. fileName: file.name || file.type.replace('image/', 'clipboard.'),
  262. chunkSize: 'dynamic',
  263. };
  264. config.meta.fileId = fileId;
  265. const uploader = Attachments.insert(
  266. config,
  267. false,
  268. );
  269. uploader.on('uploaded', (error, fileRef) => {
  270. if (!error) {
  271. if (fileRef.isImage) {
  272. card.setCover(fileRef._id);
  273. }
  274. }
  275. });
  276. uploader.on('end', (error, fileRef) => {
  277. pastedResults = null;
  278. $(document.body).pasteImageReader(() => {});
  279. Popup.back();
  280. });
  281. uploader.start();
  282. }
  283. },
  284. });
  285. BlazeComponent.extendComponent({
  286. isCover() {
  287. const ret = ReactiveCache.getCard(this.data().meta.cardId).coverId == this.data()._id;
  288. return ret;
  289. },
  290. isBackgroundImage() {
  291. //const currentBoard = Utils.getCurrentBoard();
  292. //return currentBoard.backgroundImageURL === $(".attachment-thumbnail-img").attr("src");
  293. return false;
  294. },
  295. events() {
  296. return [
  297. {
  298. 'click .js-add-cover'() {
  299. ReactiveCache.getCard(this.data().meta.cardId).setCover(this.data()._id);
  300. Popup.back();
  301. },
  302. 'click .js-remove-cover'() {
  303. ReactiveCache.getCard(this.data().meta.cardId).unsetCover();
  304. Popup.back();
  305. },
  306. 'click .js-add-background-image'() {
  307. const currentBoard = Utils.getCurrentBoard();
  308. currentBoard.setBackgroundImageURL(attachmentActionsLink);
  309. Utils.setBackgroundImage(attachmentActionsLink);
  310. Popup.back();
  311. event.preventDefault();
  312. },
  313. 'click .js-remove-background-image'() {
  314. const currentBoard = Utils.getCurrentBoard();
  315. currentBoard.setBackgroundImageURL("");
  316. Utils.setBackgroundImage("");
  317. Popup.back();
  318. Utils.reload();
  319. event.preventDefault();
  320. },
  321. 'click .js-move-storage-fs'() {
  322. Meteor.call('moveAttachmentToStorage', this.data()._id, "fs");
  323. Popup.back();
  324. },
  325. 'click .js-move-storage-gridfs'() {
  326. Meteor.call('moveAttachmentToStorage', this.data()._id, "gridfs");
  327. Popup.back();
  328. },
  329. 'click .js-move-storage-s3'() {
  330. Meteor.call('moveAttachmentToStorage', this.data()._id, "s3");
  331. Popup.back();
  332. },
  333. }
  334. ]
  335. }
  336. }).register('attachmentActionsPopup');
  337. BlazeComponent.extendComponent({
  338. getNameWithoutExtension() {
  339. const ret = this.data().name.replace(new RegExp("\." + this.data().extension + "$"), "");
  340. return ret;
  341. },
  342. events() {
  343. return [
  344. {
  345. 'keydown input.js-edit-attachment-name'(evt) {
  346. // enter = save
  347. if (evt.keyCode === 13) {
  348. this.find('button[type=submit]').click();
  349. }
  350. },
  351. 'click button.js-submit-edit-attachment-name'(event) {
  352. // save button pressed
  353. event.preventDefault();
  354. const name = this.$('.js-edit-attachment-name')[0]
  355. .value
  356. .trim() + this.data().extensionWithDot;
  357. if (name === DOMPurify.sanitize(name)) {
  358. Meteor.call('renameAttachment', this.data()._id, name);
  359. }
  360. Popup.back(2);
  361. },
  362. }
  363. ]
  364. }
  365. }).register('attachmentRenamePopup');