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;
50 constructor(options) {
69 this.menu = $(this.options.menu);
70 this.targets = $$(this.options.targets);
73 this.fx = new Fx.Tween(this.menu, {
75 duration: this.options.fadeSpeed,
77 this.menu.style.visibility = (getComputedStyle(this.menu).opacity > 0) ? "visible" : "hidden";
81 // hide and begin the listener
82 this.hide().startListener();
85 this.menu.style.position = "absolute";
86 this.menu.style.top = "-900000px";
87 this.menu.style.display = "block";
90 adjustMenuPosition(e) {
91 this.updateMenuItems();
93 const scrollableMenuMaxHeight = document.documentElement.clientHeight * 0.75;
95 if (this.menu.hasClass("scrollableMenu"))
96 this.menu.style.maxHeight = `${scrollableMenuMaxHeight}px`;
98 // draw the menu off-screen to know the menu dimensions
99 this.menu.style.left = "-999em";
100 this.menu.style.top = "-999em";
103 let xPosMenu = e.pageX + this.options.offsets.x;
104 let yPosMenu = e.pageY + this.options.offsets.y;
105 if ((xPosMenu + this.menu.offsetWidth) > document.documentElement.clientWidth)
106 xPosMenu -= this.menu.offsetWidth;
107 if ((yPosMenu + this.menu.offsetHeight) > document.documentElement.clientHeight)
108 yPosMenu = document.documentElement.clientHeight - this.menu.offsetHeight;
113 this.menu.style.left = `${xPosMenu}px`;
114 this.menu.style.top = `${yPosMenu}px`;
115 this.menu.style.position = "absolute";
116 this.menu.style.zIndex = "2000";
118 // position the sub-menu
119 const uls = this.menu.getElementsByTagName("ul");
120 for (let i = 0; i < uls.length; ++i) {
122 if (ul.hasClass("scrollableMenu"))
123 ul.style.maxHeight = `${scrollableMenuMaxHeight}px`;
124 const rectParent = ul.parentNode.getBoundingClientRect();
125 const xPosOrigin = rectParent.left;
126 const yPosOrigin = rectParent.bottom;
127 let xPos = xPosOrigin + rectParent.width - 1;
128 let yPos = yPosOrigin - rectParent.height - 1;
129 if ((xPos + ul.offsetWidth) > document.documentElement.clientWidth)
130 xPos -= (ul.offsetWidth + rectParent.width - 2);
131 if ((yPos + ul.offsetHeight) > document.documentElement.clientHeight)
132 yPos = document.documentElement.clientHeight - ul.offsetHeight;
137 ul.style.marginLeft = `${xPos - xPosOrigin}px`;
138 ul.style.marginTop = `${yPos - yPosOrigin}px`;
142 setupEventListeners(elem) {
143 elem.addEventListener("contextmenu", (e) => {
144 this.triggerMenu(e, elem);
146 elem.addEventListener("click", (e) => {
150 elem.addEventListener("touchstart", (e) => {
152 this.touchStartAt = performance.now();
153 this.touchStartEvent = e;
154 }, { passive: true });
155 elem.addEventListener("touchend", (e) => {
156 const now = performance.now();
157 const touchStartAt = this.touchStartAt;
158 const touchStartEvent = this.touchStartEvent;
160 this.touchStartAt = null;
161 this.touchStartEvent = null;
163 const isTargetUnchanged = (Math.abs(e.event.pageX - touchStartEvent.event.pageX) <= 10) && (Math.abs(e.event.pageY - touchStartEvent.event.pageY) <= 10);
164 if (((now - touchStartAt) >= this.options.touchTimer) && isTargetUnchanged)
165 this.triggerMenu(touchStartEvent, elem);
166 }, { passive: true });
170 // prevent long press from selecting this text
171 t.style.userSelect = "none";
173 this.targets[this.targets.length] = t;
174 this.setupEventListeners(t);
177 searchAndAddTargets() {
178 document.querySelectorAll(this.options.targets).forEach((target) => { this.addTarget(target); });
182 if (this.options.disabled)
185 // prevent default, if told to
186 if (this.options.stopEvent) {
190 // record this as the trigger
191 this.options.element = $(el);
192 this.adjustMenuPosition(e);
197 // get things started
200 this.targets.each((el) => {
201 this.setupEventListeners(el);
205 this.menu.addEventListener("click", (e) => {
206 const menuItem = e.target.closest("li");
211 if (!menuItem.classList.contains("disabled")) {
212 const anchor = menuItem.firstElementChild;
213 this.execute(anchor.href.split("#")[1], this.options.element);
214 this.options.onClick.call(this, anchor, e);
221 // hide on body click
222 $(document.body).addEventListener("click", () => {
231 if (lastShownContextMenu && (lastShownContextMenu !== this))
232 lastShownContextMenu.hide();
234 this.options.onShow.call(this);
235 lastShownContextMenu = this;
241 if (lastShownContextMenu && (lastShownContextMenu.menu.style.visibility !== "hidden")) {
243 this.options.onHide.call(this);
248 setItemChecked(item, checked) {
249 this.menu.getElement("a[href$=" + item + "]").firstChild.style.opacity =
254 getItemChecked(item) {
255 return this.menu.getElement("a[href$=" + item + "]").firstChild.style.opacity !== "0";
260 this.menu.getElement("a[href$=" + item + "]").parentNode.addClass("invisible");
266 this.menu.getElement("a[href$=" + item + "]").parentNode.removeClass("invisible");
270 // enable/disable an item
271 setEnabled(item, enabled) {
272 this.menu.querySelector(`:scope a[href$="${item}"]`).parentElement.classList.toggle("disabled", !enabled);
276 // disable the entire menu
278 this.options.disabled = true;
282 // enable the entire menu
284 this.options.disabled = false;
289 execute(action, element) {
290 if (this.options.actions[action])
291 this.options.actions[action](element, this, action);
296 class FilterListContextMenu extends ContextMenu {
297 constructor(options) {
299 this.torrentObserver = new MutationObserver((records, observer) => {
300 this.updateTorrentActions();
304 startTorrentObserver() {
305 this.torrentObserver.observe(torrentsTable.tableBody, { childList: true });
308 stopTorrentObserver() {
309 this.torrentObserver.disconnect();
312 updateTorrentActions() {
313 const torrentsVisible = torrentsTable.tableBody.children.length > 0;
314 this.setEnabled("startTorrents", torrentsVisible)
315 .setEnabled("stopTorrents", torrentsVisible)
316 .setEnabled("deleteTorrents", torrentsVisible);
320 class TorrentsTableContextMenu extends ContextMenu {
322 let all_are_seq_dl = true;
323 let there_are_seq_dl = false;
324 let all_are_f_l_piece_prio = true;
325 let there_are_f_l_piece_prio = false;
326 let all_are_downloaded = true;
327 let all_are_stopped = true;
328 let there_are_stopped = false;
329 let all_are_force_start = true;
330 let there_are_force_start = false;
331 let all_are_super_seeding = true;
332 let all_are_auto_tmm = true;
333 let there_are_auto_tmm = false;
334 let thereAreV1Hashes = false;
335 let thereAreV2Hashes = false;
336 const tagCount = new Map();
337 const categoryCount = new Map();
339 const selectedRows = torrentsTable.selectedRowsIds();
340 selectedRows.forEach((item, index) => {
341 const data = torrentsTable.getRow(item).full_data;
343 if (data["seq_dl"] !== true)
344 all_are_seq_dl = false;
346 there_are_seq_dl = true;
348 if (data["f_l_piece_prio"] !== true)
349 all_are_f_l_piece_prio = false;
351 there_are_f_l_piece_prio = true;
353 if (data["progress"] !== 1.0) // not downloaded
354 all_are_downloaded = false;
355 else if (data["super_seeding"] !== true)
356 all_are_super_seeding = false;
358 if ((data["state"] !== "stoppedUP") && (data["state"] !== "stoppedDL"))
359 all_are_stopped = false;
361 there_are_stopped = true;
363 if (data["force_start"] !== true)
364 all_are_force_start = false;
366 there_are_force_start = true;
368 if (data["auto_tmm"] === true)
369 there_are_auto_tmm = true;
371 all_are_auto_tmm = false;
373 if (data["infohash_v1"] !== "")
374 thereAreV1Hashes = true;
376 if (data["infohash_v2"] !== "")
377 thereAreV2Hashes = true;
379 const torrentTags = data["tags"].split(", ");
380 for (const tag of torrentTags) {
381 const count = tagCount.get(tag);
382 tagCount.set(tag, ((count !== undefined) ? (count + 1) : 1));
385 const torrentCategory = data["category"];
386 const count = categoryCount.get(torrentCategory);
387 categoryCount.set(torrentCategory, ((count !== undefined) ? (count + 1) : 1));
390 // hide renameFiles when more than 1 torrent is selected
391 if (selectedRows.length === 1) {
392 const data = torrentsTable.getRow(selectedRows[0]).full_data;
393 const metadata_downloaded = !((data["state"] === "metaDL") || (data["state"] === "forcedMetaDL") || (data["total_size"] === -1));
395 this.showItem("rename");
396 // hide renameFiles when metadata hasn't been downloaded yet
398 ? this.showItem("renameFiles")
399 : this.hideItem("renameFiles");
402 this.hideItem("renameFiles");
403 this.hideItem("rename");
406 if (all_are_downloaded) {
407 this.hideItem("downloadLimit");
408 this.menu.getElement("a[href$=uploadLimit]").parentNode.addClass("separator");
409 this.hideItem("sequentialDownload");
410 this.hideItem("firstLastPiecePrio");
411 this.showItem("superSeeding");
412 this.setItemChecked("superSeeding", all_are_super_seeding);
415 const show_seq_dl = (all_are_seq_dl || !there_are_seq_dl);
416 const show_f_l_piece_prio = (all_are_f_l_piece_prio || !there_are_f_l_piece_prio);
418 if (!show_seq_dl && show_f_l_piece_prio)
419 this.menu.getElement("a[href$=firstLastPiecePrio]").parentNode.addClass("separator");
421 this.menu.getElement("a[href$=firstLastPiecePrio]").parentNode.removeClass("separator");
424 this.showItem("sequentialDownload");
426 this.hideItem("sequentialDownload");
428 if (show_f_l_piece_prio)
429 this.showItem("firstLastPiecePrio");
431 this.hideItem("firstLastPiecePrio");
433 this.setItemChecked("sequentialDownload", all_are_seq_dl);
434 this.setItemChecked("firstLastPiecePrio", all_are_f_l_piece_prio);
436 this.showItem("downloadLimit");
437 this.menu.getElement("a[href$=uploadLimit]").parentNode.removeClass("separator");
438 this.hideItem("superSeeding");
441 this.showItem("start");
442 this.showItem("stop");
443 this.showItem("forceStart");
445 this.hideItem("stop");
446 else if (all_are_force_start)
447 this.hideItem("forceStart");
448 else if (!there_are_stopped && !there_are_force_start)
449 this.hideItem("start");
451 if (!all_are_auto_tmm && there_are_auto_tmm) {
452 this.hideItem("autoTorrentManagement");
455 this.showItem("autoTorrentManagement");
456 this.setItemChecked("autoTorrentManagement", all_are_auto_tmm);
459 this.setEnabled("copyInfohash1", thereAreV1Hashes);
460 this.setEnabled("copyInfohash2", thereAreV2Hashes);
462 const contextTagList = $("contextTagList");
463 tagList.forEach((tag, tagHash) => {
464 const checkbox = contextTagList.getElement(`a[href="#Tag/${tag.name}"] input[type="checkbox"]`);
465 const count = tagCount.get(tag.name);
466 const hasCount = (count !== undefined);
467 const isLesser = (count < selectedRows.length);
468 checkbox.indeterminate = (hasCount ? isLesser : false);
469 checkbox.checked = (hasCount ? !isLesser : false);
472 const contextCategoryList = document.getElementById("contextCategoryList");
473 category_list.forEach((category, categoryHash) => {
474 const categoryIcon = contextCategoryList.querySelector(`a[href$="#Category/${category.name}"] img`);
475 const count = categoryCount.get(category.name);
476 const isEqual = ((count !== undefined) && (count === selectedRows.length));
477 categoryIcon.classList.toggle("highlightedCategoryIcon", isEqual);
481 updateCategoriesSubMenu(categoryList) {
482 const contextCategoryList = $("contextCategoryList");
483 contextCategoryList.getChildren().each(c => c.destroy());
485 const createMenuItem = (text, imgURL, clickFn) => {
486 const anchor = document.createElement("a");
487 anchor.textContent = text;
488 anchor.addEventListener("click", () => { clickFn(); });
490 const img = document.createElement("img");
495 const item = document.createElement("li");
496 item.appendChild(anchor);
500 contextCategoryList.appendChild(createMenuItem("QBT_TR(New...)QBT_TR[CONTEXT=TransferListWidget]", "images/list-add.svg", torrentNewCategoryFN));
501 contextCategoryList.appendChild(createMenuItem("QBT_TR(Reset)QBT_TR[CONTEXT=TransferListWidget]", "images/edit-clear.svg", () => { torrentSetCategoryFN(0); }));
503 const sortedCategories = [];
504 categoryList.forEach((category, hash) => sortedCategories.push({
505 categoryName: category.name,
508 sortedCategories.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(
509 left.categoryName, right.categoryName));
512 for (const { categoryName, categoryHash } of sortedCategories) {
513 const anchor = document.createElement("a");
514 anchor.href = `#Category/${categoryName}`;
515 anchor.textContent = categoryName;
516 anchor.addEventListener("click", (event) => {
517 event.preventDefault();
518 torrentSetCategoryFN(categoryHash);
521 const img = document.createElement("img");
522 img.src = "images/view-categories.svg";
525 const setCategoryItem = document.createElement("li");
526 setCategoryItem.appendChild(anchor);
528 setCategoryItem.addClass("separator");
532 contextCategoryList.appendChild(setCategoryItem);
536 updateTagsSubMenu(tagList) {
537 const contextTagList = $("contextTagList");
538 while (contextTagList.firstChild !== null)
539 contextTagList.removeChild(contextTagList.firstChild);
541 const createMenuItem = (text, imgURL, clickFn) => {
542 const anchor = document.createElement("a");
543 anchor.textContent = text;
544 anchor.addEventListener("click", () => { clickFn(); });
546 const img = document.createElement("img");
551 const item = document.createElement("li");
552 item.appendChild(anchor);
556 contextTagList.appendChild(createMenuItem("QBT_TR(Add...)QBT_TR[CONTEXT=TransferListWidget]", "images/list-add.svg", torrentAddTagsFN));
557 contextTagList.appendChild(createMenuItem("QBT_TR(Remove All)QBT_TR[CONTEXT=TransferListWidget]", "images/edit-clear.svg", torrentRemoveAllTagsFN));
559 const sortedTags = [];
560 tagList.forEach((tag, hash) => sortedTags.push({
564 sortedTags.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(left.tagName, right.tagName));
566 for (let i = 0; i < sortedTags.length; ++i) {
567 const { tagName, tagHash } = sortedTags[i];
569 const input = document.createElement("input");
570 input.type = "checkbox";
571 input.addEventListener("click", (event) => {
572 input.checked = !input.checked;
575 const anchor = document.createElement("a");
576 anchor.href = `#Tag/${tagName}`;
577 anchor.textContent = tagName;
578 anchor.addEventListener("click", (event) => {
579 event.preventDefault();
580 torrentSetTagsFN(tagHash, !input.checked);
582 anchor.prepend(input);
584 const setTagItem = document.createElement("li");
585 setTagItem.appendChild(anchor);
587 setTagItem.addClass("separator");
589 contextTagList.appendChild(setTagItem);
594 class StatusesFilterContextMenu extends FilterListContextMenu {
596 this.updateTorrentActions();
600 class CategoriesFilterContextMenu extends FilterListContextMenu {
602 const id = Number(this.options.element.id);
603 if ((id !== CATEGORIES_ALL) && (id !== CATEGORIES_UNCATEGORIZED)) {
604 this.showItem("editCategory");
605 this.showItem("deleteCategory");
606 if (useSubcategories)
607 this.showItem("createSubcategory");
609 this.hideItem("createSubcategory");
612 this.hideItem("editCategory");
613 this.hideItem("deleteCategory");
614 this.hideItem("createSubcategory");
617 this.updateTorrentActions();
621 class TagsFilterContextMenu extends FilterListContextMenu {
623 const id = Number(this.options.element.id);
624 if ((id !== TAGS_ALL) && (id !== TAGS_UNTAGGED))
625 this.showItem("deleteTag");
627 this.hideItem("deleteTag");
629 this.updateTorrentActions();
633 class TrackersFilterContextMenu extends FilterListContextMenu {
635 const id = Number(this.options.element.id);
636 if ((id !== TRACKERS_ALL) && (id !== TRACKERS_TRACKERLESS))
637 this.showItem("deleteTracker");
639 this.hideItem("deleteTracker");
641 this.updateTorrentActions();
645 class SearchPluginsTableContextMenu extends ContextMenu {
647 const enabledColumnIndex = (text) => {
648 const columns = $("searchPluginsTableFixedHeaderRow").getChildren("th");
649 for (let i = 0; i < columns.length; ++i) {
650 if (columns[i].textContent === "Enabled")
655 this.showItem("Enabled");
656 this.setItemChecked("Enabled", (this.options.element.getChildren("td")[enabledColumnIndex()].textContent === "Yes"));
658 this.showItem("Uninstall");
662 class RssFeedContextMenu extends ContextMenu {
664 const selectedRows = window.qBittorrent.Rss.rssFeedTable.selectedRowsIds();
665 this.menu.getElement("a[href$=newSubscription]").parentNode.addClass("separator");
666 switch (selectedRows.length) {
668 // remove separator on top of newSubscription entry to avoid double line
669 this.menu.getElement("a[href$=newSubscription]").parentNode.removeClass("separator");
670 // menu when nothing selected
671 this.hideItem("update");
672 this.hideItem("markRead");
673 this.hideItem("rename");
674 this.hideItem("edit");
675 this.hideItem("delete");
676 this.showItem("newSubscription");
677 this.showItem("newFolder");
678 this.showItem("updateAll");
679 this.hideItem("copyFeedURL");
682 if (selectedRows[0] === "0") {
683 // menu when "unread" feed selected
684 this.showItem("update");
685 this.showItem("markRead");
686 this.hideItem("rename");
687 this.hideItem("edit");
688 this.hideItem("delete");
689 this.showItem("newSubscription");
690 this.hideItem("newFolder");
691 this.hideItem("updateAll");
692 this.hideItem("copyFeedURL");
694 else if (window.qBittorrent.Rss.rssFeedTable.getRow(selectedRows[0]).full_data.dataUid === "") {
695 // menu when single folder selected
696 this.showItem("update");
697 this.showItem("markRead");
698 this.showItem("rename");
699 this.hideItem("edit");
700 this.showItem("delete");
701 this.showItem("newSubscription");
702 this.showItem("newFolder");
703 this.hideItem("updateAll");
704 this.hideItem("copyFeedURL");
707 // menu when single feed selected
708 this.showItem("update");
709 this.showItem("markRead");
710 this.showItem("rename");
711 this.showItem("edit");
712 this.showItem("delete");
713 this.showItem("newSubscription");
714 this.hideItem("newFolder");
715 this.hideItem("updateAll");
716 this.showItem("copyFeedURL");
720 // menu when multiple items selected
721 this.showItem("update");
722 this.showItem("markRead");
723 this.hideItem("rename");
724 this.hideItem("edit");
725 this.showItem("delete");
726 this.hideItem("newSubscription");
727 this.hideItem("newFolder");
728 this.hideItem("updateAll");
729 this.showItem("copyFeedURL");
735 class RssArticleContextMenu extends ContextMenu {};
737 class RssDownloaderRuleContextMenu extends ContextMenu {
738 adjustMenuPosition(e) {
739 this.updateMenuItems();
741 // draw the menu off-screen to know the menu dimensions
742 this.menu.style.left = "-999em";
743 this.menu.style.top = "-999em";
745 let xPosMenu = e.pageX + this.options.offsets.x - $("rssdownloaderpage").offsetLeft;
746 let yPosMenu = e.pageY + this.options.offsets.y - $("rssdownloaderpage").offsetTop;
747 if ((xPosMenu + this.menu.offsetWidth) > document.documentElement.clientWidth)
748 xPosMenu -= this.menu.offsetWidth;
749 if ((yPosMenu + this.menu.offsetHeight) > document.documentElement.clientHeight)
750 yPosMenu = document.documentElement.clientHeight - this.menu.offsetHeight;
751 xPosMenu = Math.max(xPosMenu, 0);
752 yPosMenu = Math.max(yPosMenu, 0);
754 this.menu.style.left = `${xPosMenu}px`;
755 this.menu.style.top = `${yPosMenu}px`;
756 this.menu.style.position = "absolute";
757 this.menu.style.zIndex = "2000";
760 const selectedRows = window.qBittorrent.RssDownloader.rssDownloaderRulesTable.selectedRowsIds();
761 this.showItem("addRule");
762 switch (selectedRows.length) {
764 // menu when nothing selected
765 this.hideItem("deleteRule");
766 this.hideItem("renameRule");
767 this.hideItem("clearDownloadedEpisodes");
770 // menu when single item selected
771 this.showItem("deleteRule");
772 this.showItem("renameRule");
773 this.showItem("clearDownloadedEpisodes");
776 // menu when multiple items selected
777 this.showItem("deleteRule");
778 this.hideItem("renameRule");
779 this.showItem("clearDownloadedEpisodes");
787 Object.freeze(window.qBittorrent.ContextMenu);