2 * Bittorrent Client using Qt and libtorrent.
3 * Copyright (C) 2009 Christophe Dumez <chris@qbittorrent.org>
5 * This program is free software; you can redistribute it and/or
6 * modify it under the terms of the GNU General Public License
7 * as published by the Free Software Foundation; either version 2
8 * of the License, or (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License
16 * along with this program; if not, write to the Free Software
17 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * In addition, as a special exception, the copyright holders give permission to
20 * link this program with the OpenSSL project's "OpenSSL" library (or with
21 * modified versions of it that use the same license as the "OpenSSL" library),
22 * and distribute the linked executables. You must obey the GNU General Public
23 * License in all respects for all of the code used other than "OpenSSL". If you
24 * modify file(s), you may extend this exception to your version of the file(s),
25 * but you are not obligated to do so. If you do not wish to do so, delete this
26 * exception statement from your version.
31 window.qBittorrent ??= {};
32 window.qBittorrent.ContextMenu ??= (() => {
33 const exports = () => {
35 ContextMenu: ContextMenu,
36 TorrentsTableContextMenu: TorrentsTableContextMenu,
37 StatusesFilterContextMenu: StatusesFilterContextMenu,
38 CategoriesFilterContextMenu: CategoriesFilterContextMenu,
39 TagsFilterContextMenu: TagsFilterContextMenu,
40 TrackersFilterContextMenu: TrackersFilterContextMenu,
41 SearchPluginsTableContextMenu: SearchPluginsTableContextMenu,
42 RssFeedContextMenu: RssFeedContextMenu,
43 RssArticleContextMenu: RssArticleContextMenu,
44 RssDownloaderRuleContextMenu: RssDownloaderRuleContextMenu
48 let lastShownContextMenu = null;
49 const ContextMenu = new Class({
51 Implements: [Options, Events],
71 initialize: function(options) {
73 this.setOptions(options);
76 this.menu = $(this.options.menu);
77 this.targets = $$(this.options.targets);
80 this.fx = new Fx.Tween(this.menu, {
82 duration: this.options.fadeSpeed,
84 this.menu.style.visibility = (getComputedStyle(this.menu).opacity > 0) ? "visible" : "hidden";
88 // hide and begin the listener
89 this.hide().startListener();
92 this.menu.style.position = "absolute";
93 this.menu.style.top = "-900000px";
94 this.menu.style.display = "block";
97 adjustMenuPosition: function(e) {
98 this.updateMenuItems();
100 const scrollableMenuMaxHeight = document.documentElement.clientHeight * 0.75;
102 if (this.menu.hasClass("scrollableMenu"))
103 this.menu.style.maxHeight = `${scrollableMenuMaxHeight}px`;
105 // draw the menu off-screen to know the menu dimensions
106 this.menu.style.left = "-999em";
107 this.menu.style.top = "-999em";
110 let xPosMenu = e.pageX + this.options.offsets.x;
111 let yPosMenu = e.pageY + this.options.offsets.y;
112 if ((xPosMenu + this.menu.offsetWidth) > document.documentElement.clientWidth)
113 xPosMenu -= this.menu.offsetWidth;
114 if ((yPosMenu + this.menu.offsetHeight) > document.documentElement.clientHeight)
115 yPosMenu = document.documentElement.clientHeight - this.menu.offsetHeight;
120 this.menu.style.left = `${xPosMenu}px`;
121 this.menu.style.top = `${yPosMenu}px`;
122 this.menu.style.position = "absolute";
123 this.menu.style.zIndex = "2000";
125 // position the sub-menu
126 const uls = this.menu.getElementsByTagName("ul");
127 for (let i = 0; i < uls.length; ++i) {
129 if (ul.hasClass("scrollableMenu"))
130 ul.style.maxHeight = `${scrollableMenuMaxHeight}px`;
131 const rectParent = ul.parentNode.getBoundingClientRect();
132 const xPosOrigin = rectParent.left;
133 const yPosOrigin = rectParent.bottom;
134 let xPos = xPosOrigin + rectParent.width - 1;
135 let yPos = yPosOrigin - rectParent.height - 1;
136 if ((xPos + ul.offsetWidth) > document.documentElement.clientWidth)
137 xPos -= (ul.offsetWidth + rectParent.width - 2);
138 if ((yPos + ul.offsetHeight) > document.documentElement.clientHeight)
139 yPos = document.documentElement.clientHeight - ul.offsetHeight;
144 ul.style.marginLeft = `${xPos - xPosOrigin}px`;
145 ul.style.marginTop = `${yPos - yPosOrigin}px`;
149 setupEventListeners: function(elem) {
150 elem.addEventListener("contextmenu", (e) => {
151 this.triggerMenu(e, elem);
153 elem.addEventListener("click", (e) => {
157 elem.addEventListener("touchstart", (e) => {
159 this.touchStartAt = performance.now();
160 this.touchStartEvent = e;
161 }, { passive: true });
162 elem.addEventListener("touchend", (e) => {
163 const now = performance.now();
164 const touchStartAt = this.touchStartAt;
165 const touchStartEvent = this.touchStartEvent;
167 this.touchStartAt = null;
168 this.touchStartEvent = null;
170 const isTargetUnchanged = (Math.abs(e.event.pageX - touchStartEvent.event.pageX) <= 10) && (Math.abs(e.event.pageY - touchStartEvent.event.pageY) <= 10);
171 if (((now - touchStartAt) >= this.options.touchTimer) && isTargetUnchanged)
172 this.triggerMenu(touchStartEvent, elem);
173 }, { passive: true });
176 addTarget: function(t) {
177 // prevent long press from selecting this text
178 t.style.userSelect = "none";
180 this.targets[this.targets.length] = t;
181 this.setupEventListeners(t);
184 searchAndAddTargets: function() {
185 document.querySelectorAll(this.options.targets).forEach((target) => { this.addTarget(target); });
188 triggerMenu: function(e, el) {
189 if (this.options.disabled)
192 // prevent default, if told to
193 if (this.options.stopEvent) {
197 // record this as the trigger
198 this.options.element = $(el);
199 this.adjustMenuPosition(e);
204 // get things started
205 startListener: function() {
207 this.targets.each((el) => {
208 this.setupEventListeners(el);
212 this.menu.addEventListener("click", (e) => {
213 const menuItem = e.target.closest("li");
218 if (!menuItem.classList.contains("disabled")) {
219 const anchor = menuItem.firstElementChild;
220 this.execute(anchor.href.split("#")[1], this.options.element);
221 this.fireEvent("click", [anchor, e]);
228 // hide on body click
229 $(document.body).addEventListener("click", () => {
234 updateMenuItems: function() {},
237 show: function(trigger) {
238 if (lastShownContextMenu && (lastShownContextMenu !== this))
239 lastShownContextMenu.hide();
241 this.fireEvent("show");
242 lastShownContextMenu = this;
247 hide: function(trigger) {
248 if (lastShownContextMenu && (lastShownContextMenu.menu.style.visibility !== "hidden")) {
250 // this.menu.fade('out');
251 this.fireEvent("hide");
256 setItemChecked: function(item, checked) {
257 this.menu.getElement("a[href$=" + item + "]").firstChild.style.opacity =
262 getItemChecked: function(item) {
263 return this.menu.getElement("a[href$=" + item + "]").firstChild.style.opacity !== "0";
267 hideItem: function(item) {
268 this.menu.getElement("a[href$=" + item + "]").parentNode.addClass("invisible");
273 showItem: function(item) {
274 this.menu.getElement("a[href$=" + item + "]").parentNode.removeClass("invisible");
278 // enable/disable an item
279 setEnabled: function(item, enabled) {
280 this.menu.querySelector(`:scope a[href$="${item}"]`).parentElement.classList.toggle("disabled", !enabled);
284 // disable the entire menu
285 disable: function() {
286 this.options.disabled = true;
290 // enable the entire menu
292 this.options.disabled = false;
297 execute: function(action, element) {
298 if (this.options.actions[action])
299 this.options.actions[action](element, this, action);
304 const FilterListContextMenu = new Class({
305 Extends: ContextMenu,
306 initialize: function(options) {
307 this.parent(options);
308 this.torrentObserver = new MutationObserver((records, observer) => {
309 this.updateTorrentActions();
313 startTorrentObserver: function() {
314 this.torrentObserver.observe(torrentsTable.tableBody, { childList: true });
317 stopTorrentObserver: function() {
318 this.torrentObserver.disconnect();
321 updateTorrentActions: function() {
322 const torrentsVisible = torrentsTable.tableBody.children.length > 0;
323 this.setEnabled("startTorrents", torrentsVisible)
324 .setEnabled("stopTorrents", torrentsVisible)
325 .setEnabled("deleteTorrents", torrentsVisible);
329 const TorrentsTableContextMenu = new Class({
330 Extends: ContextMenu,
332 updateMenuItems: function() {
333 let all_are_seq_dl = true;
334 let there_are_seq_dl = false;
335 let all_are_f_l_piece_prio = true;
336 let there_are_f_l_piece_prio = false;
337 let all_are_downloaded = true;
338 let all_are_stopped = true;
339 let there_are_stopped = false;
340 let all_are_force_start = true;
341 let there_are_force_start = false;
342 let all_are_super_seeding = true;
343 let all_are_auto_tmm = true;
344 let there_are_auto_tmm = false;
345 let thereAreV1Hashes = false;
346 let thereAreV2Hashes = false;
347 const tagCount = new Map();
348 const categoryCount = new Map();
350 const selectedRows = torrentsTable.selectedRowsIds();
351 selectedRows.forEach((item, index) => {
352 const data = torrentsTable.getRow(item).full_data;
354 if (data["seq_dl"] !== true)
355 all_are_seq_dl = false;
357 there_are_seq_dl = true;
359 if (data["f_l_piece_prio"] !== true)
360 all_are_f_l_piece_prio = false;
362 there_are_f_l_piece_prio = true;
364 if (data["progress"] !== 1.0) // not downloaded
365 all_are_downloaded = false;
366 else if (data["super_seeding"] !== true)
367 all_are_super_seeding = false;
369 if ((data["state"] !== "stoppedUP") && (data["state"] !== "stoppedDL"))
370 all_are_stopped = false;
372 there_are_stopped = true;
374 if (data["force_start"] !== true)
375 all_are_force_start = false;
377 there_are_force_start = true;
379 if (data["auto_tmm"] === true)
380 there_are_auto_tmm = true;
382 all_are_auto_tmm = false;
384 if (data["infohash_v1"] !== "")
385 thereAreV1Hashes = true;
387 if (data["infohash_v2"] !== "")
388 thereAreV2Hashes = true;
390 const torrentTags = data["tags"].split(", ");
391 for (const tag of torrentTags) {
392 const count = tagCount.get(tag);
393 tagCount.set(tag, ((count !== undefined) ? (count + 1) : 1));
396 const torrentCategory = data["category"];
397 const count = categoryCount.get(torrentCategory);
398 categoryCount.set(torrentCategory, ((count !== undefined) ? (count + 1) : 1));
401 // hide renameFiles when more than 1 torrent is selected
402 if (selectedRows.length === 1) {
403 const data = torrentsTable.getRow(selectedRows[0]).full_data;
404 const metadata_downloaded = !((data["state"] === "metaDL") || (data["state"] === "forcedMetaDL") || (data["total_size"] === -1));
406 // hide renameFiles when metadata hasn't been downloaded yet
408 ? this.showItem("renameFiles")
409 : this.hideItem("renameFiles");
412 this.hideItem("renameFiles");
415 if (all_are_downloaded) {
416 this.hideItem("downloadLimit");
417 this.menu.getElement("a[href$=uploadLimit]").parentNode.addClass("separator");
418 this.hideItem("sequentialDownload");
419 this.hideItem("firstLastPiecePrio");
420 this.showItem("superSeeding");
421 this.setItemChecked("superSeeding", all_are_super_seeding);
424 const show_seq_dl = (all_are_seq_dl || !there_are_seq_dl);
425 const show_f_l_piece_prio = (all_are_f_l_piece_prio || !there_are_f_l_piece_prio);
427 if (!show_seq_dl && show_f_l_piece_prio)
428 this.menu.getElement("a[href$=firstLastPiecePrio]").parentNode.addClass("separator");
430 this.menu.getElement("a[href$=firstLastPiecePrio]").parentNode.removeClass("separator");
433 this.showItem("sequentialDownload");
435 this.hideItem("sequentialDownload");
437 if (show_f_l_piece_prio)
438 this.showItem("firstLastPiecePrio");
440 this.hideItem("firstLastPiecePrio");
442 this.setItemChecked("sequentialDownload", all_are_seq_dl);
443 this.setItemChecked("firstLastPiecePrio", all_are_f_l_piece_prio);
445 this.showItem("downloadLimit");
446 this.menu.getElement("a[href$=uploadLimit]").parentNode.removeClass("separator");
447 this.hideItem("superSeeding");
450 this.showItem("start");
451 this.showItem("stop");
452 this.showItem("forceStart");
454 this.hideItem("stop");
455 else if (all_are_force_start)
456 this.hideItem("forceStart");
457 else if (!there_are_stopped && !there_are_force_start)
458 this.hideItem("start");
460 if (!all_are_auto_tmm && there_are_auto_tmm) {
461 this.hideItem("autoTorrentManagement");
464 this.showItem("autoTorrentManagement");
465 this.setItemChecked("autoTorrentManagement", all_are_auto_tmm);
468 this.setEnabled("copyInfohash1", thereAreV1Hashes);
469 this.setEnabled("copyInfohash2", thereAreV2Hashes);
471 const contextTagList = $("contextTagList");
472 tagList.forEach((tag, tagHash) => {
473 const checkbox = contextTagList.getElement(`a[href="#Tag/${tag.name}"] input[type="checkbox"]`);
474 const count = tagCount.get(tag.name);
475 const hasCount = (count !== undefined);
476 const isLesser = (count < selectedRows.length);
477 checkbox.indeterminate = (hasCount ? isLesser : false);
478 checkbox.checked = (hasCount ? !isLesser : false);
481 const contextCategoryList = document.getElementById("contextCategoryList");
482 category_list.forEach((category, categoryHash) => {
483 const categoryIcon = contextCategoryList.querySelector(`a[href$="#Category/${category.name}"] img`);
484 const count = categoryCount.get(category.name);
485 const isEqual = ((count !== undefined) && (count === selectedRows.length));
486 categoryIcon.classList.toggle("highlightedCategoryIcon", isEqual);
490 updateCategoriesSubMenu: function(categoryList) {
491 const contextCategoryList = $("contextCategoryList");
492 contextCategoryList.getChildren().each(c => c.destroy());
494 const createMenuItem = (text, imgURL, clickFn) => {
495 const anchor = document.createElement("a");
496 anchor.textContent = text;
497 anchor.addEventListener("click", () => { clickFn(); });
499 const img = document.createElement("img");
504 const item = document.createElement("li");
505 item.appendChild(anchor);
509 contextCategoryList.appendChild(createMenuItem("QBT_TR(New...)QBT_TR[CONTEXT=TransferListWidget]", "images/list-add.svg", torrentNewCategoryFN));
510 contextCategoryList.appendChild(createMenuItem("QBT_TR(Reset)QBT_TR[CONTEXT=TransferListWidget]", "images/edit-clear.svg", () => { torrentSetCategoryFN(0); }));
512 const sortedCategories = [];
513 categoryList.forEach((category, hash) => sortedCategories.push({
514 categoryName: category.name,
517 sortedCategories.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(
518 left.categoryName, right.categoryName));
521 for (const { categoryName, categoryHash } of sortedCategories) {
522 const anchor = document.createElement("a");
523 anchor.href = `#Category/${categoryName}`;
524 anchor.textContent = categoryName;
525 anchor.addEventListener("click", (event) => {
526 event.preventDefault();
527 torrentSetCategoryFN(categoryHash);
530 const img = document.createElement("img");
531 img.src = "images/view-categories.svg";
534 const setCategoryItem = document.createElement("li");
535 setCategoryItem.appendChild(anchor);
537 setCategoryItem.addClass("separator");
541 contextCategoryList.appendChild(setCategoryItem);
545 updateTagsSubMenu: function(tagList) {
546 const contextTagList = $("contextTagList");
547 while (contextTagList.firstChild !== null)
548 contextTagList.removeChild(contextTagList.firstChild);
550 const createMenuItem = (text, imgURL, clickFn) => {
551 const anchor = document.createElement("a");
552 anchor.textContent = text;
553 anchor.addEventListener("click", () => { clickFn(); });
555 const img = document.createElement("img");
560 const item = document.createElement("li");
561 item.appendChild(anchor);
565 contextTagList.appendChild(createMenuItem("QBT_TR(Add...)QBT_TR[CONTEXT=TransferListWidget]", "images/list-add.svg", torrentAddTagsFN));
566 contextTagList.appendChild(createMenuItem("QBT_TR(Remove All)QBT_TR[CONTEXT=TransferListWidget]", "images/edit-clear.svg", torrentRemoveAllTagsFN));
568 const sortedTags = [];
569 tagList.forEach((tag, hash) => sortedTags.push({
573 sortedTags.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(left.tagName, right.tagName));
575 for (let i = 0; i < sortedTags.length; ++i) {
576 const { tagName, tagHash } = sortedTags[i];
578 const input = document.createElement("input");
579 input.type = "checkbox";
580 input.addEventListener("click", (event) => {
581 input.checked = !input.checked;
584 const anchor = document.createElement("a");
585 anchor.href = `#Tag/${tagName}`;
586 anchor.textContent = tagName;
587 anchor.addEventListener("click", (event) => {
588 event.preventDefault();
589 torrentSetTagsFN(tagHash, !input.checked);
591 anchor.prepend(input);
593 const setTagItem = document.createElement("li");
594 setTagItem.appendChild(anchor);
596 setTagItem.addClass("separator");
598 contextTagList.appendChild(setTagItem);
603 const StatusesFilterContextMenu = new Class({
604 Extends: FilterListContextMenu,
605 updateMenuItems: function() {
606 this.updateTorrentActions();
610 const CategoriesFilterContextMenu = new Class({
611 Extends: FilterListContextMenu,
612 updateMenuItems: function() {
613 const id = Number(this.options.element.id);
614 if ((id !== CATEGORIES_ALL) && (id !== CATEGORIES_UNCATEGORIZED)) {
615 this.showItem("editCategory");
616 this.showItem("deleteCategory");
617 if (useSubcategories)
618 this.showItem("createSubcategory");
620 this.hideItem("createSubcategory");
623 this.hideItem("editCategory");
624 this.hideItem("deleteCategory");
625 this.hideItem("createSubcategory");
628 this.updateTorrentActions();
632 const TagsFilterContextMenu = new Class({
633 Extends: FilterListContextMenu,
634 updateMenuItems: function() {
635 const id = Number(this.options.element.id);
636 if ((id !== TAGS_ALL) && (id !== TAGS_UNTAGGED))
637 this.showItem("deleteTag");
639 this.hideItem("deleteTag");
641 this.updateTorrentActions();
645 const TrackersFilterContextMenu = new Class({
646 Extends: FilterListContextMenu,
647 updateMenuItems: function() {
648 const id = Number(this.options.element.id);
649 if ((id !== TRACKERS_ALL) && (id !== TRACKERS_TRACKERLESS))
650 this.showItem("deleteTracker");
652 this.hideItem("deleteTracker");
654 this.updateTorrentActions();
658 const SearchPluginsTableContextMenu = new Class({
659 Extends: ContextMenu,
661 updateMenuItems: function() {
662 const enabledColumnIndex = function(text) {
663 const columns = $("searchPluginsTableFixedHeaderRow").getChildren("th");
664 for (let i = 0; i < columns.length; ++i) {
665 if (columns[i].textContent === "Enabled")
670 this.showItem("Enabled");
671 this.setItemChecked("Enabled", (this.options.element.getChildren("td")[enabledColumnIndex()].textContent === "Yes"));
673 this.showItem("Uninstall");
677 const RssFeedContextMenu = new Class({
678 Extends: ContextMenu,
679 updateMenuItems: function() {
680 const selectedRows = window.qBittorrent.Rss.rssFeedTable.selectedRowsIds();
681 this.menu.getElement("a[href$=newSubscription]").parentNode.addClass("separator");
682 switch (selectedRows.length) {
684 // remove separator on top of newSubscription entry to avoid double line
685 this.menu.getElement("a[href$=newSubscription]").parentNode.removeClass("separator");
686 // menu when nothing selected
687 this.hideItem("update");
688 this.hideItem("markRead");
689 this.hideItem("rename");
690 this.hideItem("edit");
691 this.hideItem("delete");
692 this.showItem("newSubscription");
693 this.showItem("newFolder");
694 this.showItem("updateAll");
695 this.hideItem("copyFeedURL");
698 if (selectedRows[0] === "0") {
699 // menu when "unread" feed selected
700 this.showItem("update");
701 this.showItem("markRead");
702 this.hideItem("rename");
703 this.hideItem("edit");
704 this.hideItem("delete");
705 this.showItem("newSubscription");
706 this.hideItem("newFolder");
707 this.hideItem("updateAll");
708 this.hideItem("copyFeedURL");
710 else if (window.qBittorrent.Rss.rssFeedTable.getRow(selectedRows[0]).full_data.dataUid === "") {
711 // menu when single folder selected
712 this.showItem("update");
713 this.showItem("markRead");
714 this.showItem("rename");
715 this.hideItem("edit");
716 this.showItem("delete");
717 this.showItem("newSubscription");
718 this.showItem("newFolder");
719 this.hideItem("updateAll");
720 this.hideItem("copyFeedURL");
723 // menu when single feed selected
724 this.showItem("update");
725 this.showItem("markRead");
726 this.showItem("rename");
727 this.showItem("edit");
728 this.showItem("delete");
729 this.showItem("newSubscription");
730 this.hideItem("newFolder");
731 this.hideItem("updateAll");
732 this.showItem("copyFeedURL");
736 // menu when multiple items selected
737 this.showItem("update");
738 this.showItem("markRead");
739 this.hideItem("rename");
740 this.hideItem("edit");
741 this.showItem("delete");
742 this.hideItem("newSubscription");
743 this.hideItem("newFolder");
744 this.hideItem("updateAll");
745 this.showItem("copyFeedURL");
751 const RssArticleContextMenu = new Class({
755 const RssDownloaderRuleContextMenu = new Class({
756 Extends: ContextMenu,
757 adjustMenuPosition: function(e) {
758 this.updateMenuItems();
760 // draw the menu off-screen to know the menu dimensions
761 this.menu.style.left = "-999em";
762 this.menu.style.top = "-999em";
764 let xPosMenu = e.pageX + this.options.offsets.x - $("rssdownloaderpage").offsetLeft;
765 let yPosMenu = e.pageY + this.options.offsets.y - $("rssdownloaderpage").offsetTop;
766 if ((xPosMenu + this.menu.offsetWidth) > document.documentElement.clientWidth)
767 xPosMenu -= this.menu.offsetWidth;
768 if ((yPosMenu + this.menu.offsetHeight) > document.documentElement.clientHeight)
769 yPosMenu = document.documentElement.clientHeight - this.menu.offsetHeight;
770 xPosMenu = Math.max(xPosMenu, 0);
771 yPosMenu = Math.max(yPosMenu, 0);
773 this.menu.style.left = `${xPosMenu}px`;
774 this.menu.style.top = `${yPosMenu}px`;
775 this.menu.style.position = "absolute";
776 this.menu.style.zIndex = "2000";
778 updateMenuItems: function() {
779 const selectedRows = window.qBittorrent.RssDownloader.rssDownloaderRulesTable.selectedRowsIds();
780 this.showItem("addRule");
781 switch (selectedRows.length) {
783 // menu when nothing selected
784 this.hideItem("deleteRule");
785 this.hideItem("renameRule");
786 this.hideItem("clearDownloadedEpisodes");
789 // menu when single item selected
790 this.showItem("deleteRule");
791 this.showItem("renameRule");
792 this.showItem("clearDownloadedEpisodes");
795 // menu when multiple items selected
796 this.showItem("deleteRule");
797 this.hideItem("renameRule");
798 this.showItem("clearDownloadedEpisodes");
806 Object.freeze(window.qBittorrent.ContextMenu);