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 CategoriesFilterContextMenu
: CategoriesFilterContextMenu
,
38 TagsFilterContextMenu
: TagsFilterContextMenu
,
39 SearchPluginsTableContextMenu
: SearchPluginsTableContextMenu
,
40 RssFeedContextMenu
: RssFeedContextMenu
,
41 RssArticleContextMenu
: RssArticleContextMenu
,
42 RssDownloaderRuleContextMenu
: RssDownloaderRuleContextMenu
46 let lastShownContextMenu
= null;
47 const ContextMenu
= new Class({
49 Implements
: [Options
, Events
],
69 initialize: function(options
) {
71 this.setOptions(options
);
74 this.menu
= $(this.options
.menu
);
75 this.targets
= $$(this.options
.targets
);
78 this.fx
= new Fx
.Tween(this.menu
, {
80 duration
: this.options
.fadeSpeed
,
82 this.menu
.style
.visibility
= (getComputedStyle(this.menu
).opacity
> 0) ? "visible" : "hidden";
86 // hide and begin the listener
87 this.hide().startListener();
90 this.menu
.style
.position
= "absolute";
91 this.menu
.style
.top
= "-900000px";
92 this.menu
.style
.display
= "block";
95 adjustMenuPosition: function(e
) {
96 this.updateMenuItems();
98 const scrollableMenuMaxHeight
= document
.documentElement
.clientHeight
* 0.75;
100 if (this.menu
.hasClass("scrollableMenu"))
101 this.menu
.style
.maxHeight
= `${scrollableMenuMaxHeight}px`;
103 // draw the menu off-screen to know the menu dimensions
104 this.menu
.style
.left
= "-999em";
105 this.menu
.style
.top
= "-999em";
108 let xPosMenu
= e
.pageX
+ this.options
.offsets
.x
;
109 let yPosMenu
= e
.pageY
+ this.options
.offsets
.y
;
110 if ((xPosMenu
+ this.menu
.offsetWidth
) > document
.documentElement
.clientWidth
)
111 xPosMenu
-= this.menu
.offsetWidth
;
112 if ((yPosMenu
+ this.menu
.offsetHeight
) > document
.documentElement
.clientHeight
)
113 yPosMenu
= document
.documentElement
.clientHeight
- this.menu
.offsetHeight
;
118 this.menu
.style
.left
= `${xPosMenu}px`;
119 this.menu
.style
.top
= `${yPosMenu}px`;
120 this.menu
.style
.position
= "absolute";
121 this.menu
.style
.zIndex
= "2000";
123 // position the sub-menu
124 const uls
= this.menu
.getElementsByTagName("ul");
125 for (let i
= 0; i
< uls
.length
; ++i
) {
127 if (ul
.hasClass("scrollableMenu"))
128 ul
.style
.maxHeight
= `${scrollableMenuMaxHeight}px`;
129 const rectParent
= ul
.parentNode
.getBoundingClientRect();
130 const xPosOrigin
= rectParent
.left
;
131 const yPosOrigin
= rectParent
.bottom
;
132 let xPos
= xPosOrigin
+ rectParent
.width
- 1;
133 let yPos
= yPosOrigin
- rectParent
.height
- 1;
134 if ((xPos
+ ul
.offsetWidth
) > document
.documentElement
.clientWidth
)
135 xPos
-= (ul
.offsetWidth
+ rectParent
.width
- 2);
136 if ((yPos
+ ul
.offsetHeight
) > document
.documentElement
.clientHeight
)
137 yPos
= document
.documentElement
.clientHeight
- ul
.offsetHeight
;
142 ul
.style
.marginLeft
= `${xPos - xPosOrigin}px`;
143 ul
.style
.marginTop
= `${yPos - yPosOrigin}px`;
147 setupEventListeners: function(elem
) {
148 elem
.addEventListener("contextmenu", (e
) => {
149 this.triggerMenu(e
, elem
);
151 elem
.addEventListener("click", (e
) => {
155 elem
.addEventListener("touchstart", (e
) => {
157 this.touchStartAt
= performance
.now();
158 this.touchStartEvent
= e
;
159 }, { passive
: true });
160 elem
.addEventListener("touchend", (e
) => {
161 const now
= performance
.now();
162 const touchStartAt
= this.touchStartAt
;
163 const touchStartEvent
= this.touchStartEvent
;
165 this.touchStartAt
= null;
166 this.touchStartEvent
= null;
168 const isTargetUnchanged
= (Math
.abs(e
.event
.pageX
- touchStartEvent
.event
.pageX
) <= 10) && (Math
.abs(e
.event
.pageY
- touchStartEvent
.event
.pageY
) <= 10);
169 if (((now
- touchStartAt
) >= this.options
.touchTimer
) && isTargetUnchanged
)
170 this.triggerMenu(touchStartEvent
, elem
);
171 }, { passive
: true });
174 addTarget: function(t
) {
175 // prevent long press from selecting this text
176 t
.style
.userSelect
= "none";
178 this.targets
[this.targets
.length
] = t
;
179 this.setupEventListeners(t
);
182 searchAndAddTargets: function() {
183 document
.querySelectorAll(this.options
.targets
).forEach((target
) => { this.addTarget(target
); });
186 triggerMenu: function(e
, el
) {
187 if (this.options
.disabled
)
190 // prevent default, if told to
191 if (this.options
.stopEvent
) {
195 // record this as the trigger
196 this.options
.element
= $(el
);
197 this.adjustMenuPosition(e
);
202 // get things started
203 startListener: function() {
205 this.targets
.each((el
) => {
206 this.setupEventListeners(el
);
210 this.menu
.getElements("a").each(function(item
) {
211 item
.addEventListener("click", (e
) => {
213 if (!item
.hasClass("disabled")) {
214 this.execute(item
.href
.split("#")[1], $(this.options
.element
));
215 this.fireEvent("click", [item
, e
]);
220 // hide on body click
221 $(document
.body
).addEventListener("click", () => {
226 updateMenuItems: function() {},
229 show: function(trigger
) {
230 if (lastShownContextMenu
&& (lastShownContextMenu
!== this))
231 lastShownContextMenu
.hide();
233 this.fireEvent("show");
234 lastShownContextMenu
= this;
239 hide: function(trigger
) {
240 if (lastShownContextMenu
&& (lastShownContextMenu
.menu
.style
.visibility
!== "hidden")) {
242 // this.menu.fade('out');
243 this.fireEvent("hide");
248 setItemChecked: function(item
, checked
) {
249 this.menu
.getElement("a[href$=" + item
+ "]").firstChild
.style
.opacity
=
254 getItemChecked: function(item
) {
255 return this.menu
.getElement("a[href$=" + item
+ "]").firstChild
.style
.opacity
!== "0";
259 hideItem: function(item
) {
260 this.menu
.getElement("a[href$=" + item
+ "]").parentNode
.addClass("invisible");
265 showItem: function(item
) {
266 this.menu
.getElement("a[href$=" + item
+ "]").parentNode
.removeClass("invisible");
270 // disable the entire menu
271 disable: function() {
272 this.options
.disabled
= true;
276 // enable the entire menu
278 this.options
.disabled
= false;
283 execute: function(action
, element
) {
284 if (this.options
.actions
[action
])
285 this.options
.actions
[action
](element
, this, action
);
290 const TorrentsTableContextMenu
= new Class({
291 Extends
: ContextMenu
,
293 updateMenuItems: function() {
294 let all_are_seq_dl
= true;
295 let there_are_seq_dl
= false;
296 let all_are_f_l_piece_prio
= true;
297 let there_are_f_l_piece_prio
= false;
298 let all_are_downloaded
= true;
299 let all_are_stopped
= true;
300 let there_are_stopped
= false;
301 let all_are_force_start
= true;
302 let there_are_force_start
= false;
303 let all_are_super_seeding
= true;
304 let all_are_auto_tmm
= true;
305 let there_are_auto_tmm
= false;
306 const tagCount
= new Map();
307 const categoryCount
= new Map();
309 const selectedRows
= torrentsTable
.selectedRowsIds();
310 selectedRows
.forEach((item
, index
) => {
311 const data
= torrentsTable
.rows
.get(item
).full_data
;
313 if (data
["seq_dl"] !== true)
314 all_are_seq_dl
= false;
316 there_are_seq_dl
= true;
318 if (data
["f_l_piece_prio"] !== true)
319 all_are_f_l_piece_prio
= false;
321 there_are_f_l_piece_prio
= true;
323 if (data
["progress"] !== 1.0) // not downloaded
324 all_are_downloaded
= false;
325 else if (data
["super_seeding"] !== true)
326 all_are_super_seeding
= false;
328 if ((data
["state"] !== "stoppedUP") && (data
["state"] !== "stoppedDL"))
329 all_are_stopped
= false;
331 there_are_stopped
= true;
333 if (data
["force_start"] !== true)
334 all_are_force_start
= false;
336 there_are_force_start
= true;
338 if (data
["auto_tmm"] === true)
339 there_are_auto_tmm
= true;
341 all_are_auto_tmm
= false;
343 const torrentTags
= data
["tags"].split(", ");
344 for (const tag
of torrentTags
) {
345 const count
= tagCount
.get(tag
);
346 tagCount
.set(tag
, ((count
!== undefined) ? (count
+ 1) : 1));
349 const torrentCategory
= data
["category"];
350 const count
= categoryCount
.get(torrentCategory
);
351 categoryCount
.set(torrentCategory
, ((count
!== undefined) ? (count
+ 1) : 1));
354 // hide renameFiles when more than 1 torrent is selected
355 if (selectedRows
.length
=== 1) {
356 const data
= torrentsTable
.rows
.get(selectedRows
[0]).full_data
;
357 const metadata_downloaded
= !((data
["state"] === "metaDL") || (data
["state"] === "forcedMetaDL") || (data
["total_size"] === -1));
359 // hide renameFiles when metadata hasn't been downloaded yet
361 ? this.showItem("renameFiles")
362 : this.hideItem("renameFiles");
365 this.hideItem("renameFiles");
368 if (all_are_downloaded
) {
369 this.hideItem("downloadLimit");
370 this.menu
.getElement("a[href$=uploadLimit]").parentNode
.addClass("separator");
371 this.hideItem("sequentialDownload");
372 this.hideItem("firstLastPiecePrio");
373 this.showItem("superSeeding");
374 this.setItemChecked("superSeeding", all_are_super_seeding
);
377 const show_seq_dl
= (all_are_seq_dl
|| !there_are_seq_dl
);
378 const show_f_l_piece_prio
= (all_are_f_l_piece_prio
|| !there_are_f_l_piece_prio
);
380 if (!show_seq_dl
&& show_f_l_piece_prio
)
381 this.menu
.getElement("a[href$=firstLastPiecePrio]").parentNode
.addClass("separator");
383 this.menu
.getElement("a[href$=firstLastPiecePrio]").parentNode
.removeClass("separator");
386 this.showItem("sequentialDownload");
388 this.hideItem("sequentialDownload");
390 if (show_f_l_piece_prio
)
391 this.showItem("firstLastPiecePrio");
393 this.hideItem("firstLastPiecePrio");
395 this.setItemChecked("sequentialDownload", all_are_seq_dl
);
396 this.setItemChecked("firstLastPiecePrio", all_are_f_l_piece_prio
);
398 this.showItem("downloadLimit");
399 this.menu
.getElement("a[href$=uploadLimit]").parentNode
.removeClass("separator");
400 this.hideItem("superSeeding");
403 this.showItem("start");
404 this.showItem("stop");
405 this.showItem("forceStart");
407 this.hideItem("stop");
408 else if (all_are_force_start
)
409 this.hideItem("forceStart");
410 else if (!there_are_stopped
&& !there_are_force_start
)
411 this.hideItem("start");
413 if (!all_are_auto_tmm
&& there_are_auto_tmm
) {
414 this.hideItem("autoTorrentManagement");
417 this.showItem("autoTorrentManagement");
418 this.setItemChecked("autoTorrentManagement", all_are_auto_tmm
);
421 const contextTagList
= $("contextTagList");
422 tagList
.forEach((tag
, tagHash
) => {
423 const checkbox
= contextTagList
.getElement(`a[href="#Tag/${tag.name}"] input[type="checkbox"]`);
424 const count
= tagCount
.get(tag
.name
);
425 const hasCount
= (count
!== undefined);
426 const isLesser
= (count
< selectedRows
.length
);
427 checkbox
.indeterminate
= (hasCount
? isLesser
: false);
428 checkbox
.checked
= (hasCount
? !isLesser
: false);
431 const contextCategoryList
= document
.getElementById("contextCategoryList");
432 category_list
.forEach((category
, categoryHash
) => {
433 const categoryIcon
= contextCategoryList
.querySelector(`a[href$="#Category/${category.name}"] img`);
434 const count
= categoryCount
.get(category
.name
);
435 const isEqual
= ((count
!== undefined) && (count
=== selectedRows
.length
));
436 categoryIcon
.classList
.toggle("highlightedCategoryIcon", isEqual
);
440 updateCategoriesSubMenu: function(categoryList
) {
441 const contextCategoryList
= $("contextCategoryList");
442 contextCategoryList
.getChildren().each(c
=> c
.destroy());
444 const createMenuItem
= (text
, imgURL
, clickFn
) => {
445 const anchor
= document
.createElement("a");
446 anchor
.textContent
= text
;
447 anchor
.addEventListener("click", () => { clickFn(); });
449 const img
= document
.createElement("img");
454 const item
= document
.createElement("li");
455 item
.appendChild(anchor
);
459 contextCategoryList
.appendChild(createMenuItem("QBT_TR(New...)QBT_TR[CONTEXT=TransferListWidget]", "images/list-add.svg", torrentNewCategoryFN
));
460 contextCategoryList
.appendChild(createMenuItem("QBT_TR(Reset)QBT_TR[CONTEXT=TransferListWidget]", "images/edit-clear.svg", () => { torrentSetCategoryFN(0); }));
462 const sortedCategories
= [];
463 categoryList
.forEach((category
, hash
) => sortedCategories
.push({
464 categoryName
: category
.name
,
467 sortedCategories
.sort((left
, right
) => window
.qBittorrent
.Misc
.naturalSortCollator
.compare(
468 left
.categoryName
, right
.categoryName
));
471 for (const { categoryName
, categoryHash
} of sortedCategories
) {
472 const anchor
= document
.createElement("a");
473 anchor
.href
= `#Category/${categoryName}`;
474 anchor
.textContent
= categoryName
;
475 anchor
.addEventListener("click", (event
) => {
476 torrentSetCategoryFN(categoryHash
);
479 const img
= document
.createElement("img");
480 img
.src
= "images/view-categories.svg";
483 const setCategoryItem
= document
.createElement("li");
484 setCategoryItem
.appendChild(anchor
);
486 setCategoryItem
.addClass("separator");
490 contextCategoryList
.appendChild(setCategoryItem
);
494 updateTagsSubMenu: function(tagList
) {
495 const contextTagList
= $("contextTagList");
496 while (contextTagList
.firstChild
!== null)
497 contextTagList
.removeChild(contextTagList
.firstChild
);
499 const createMenuItem
= (text
, imgURL
, clickFn
) => {
500 const anchor
= document
.createElement("a");
501 anchor
.textContent
= text
;
502 anchor
.addEventListener("click", () => { clickFn(); });
504 const img
= document
.createElement("img");
509 const item
= document
.createElement("li");
510 item
.appendChild(anchor
);
514 contextTagList
.appendChild(createMenuItem("QBT_TR(Add...)QBT_TR[CONTEXT=TransferListWidget]", "images/list-add.svg", torrentAddTagsFN
));
515 contextTagList
.appendChild(createMenuItem("QBT_TR(Remove All)QBT_TR[CONTEXT=TransferListWidget]", "images/edit-clear.svg", torrentRemoveAllTagsFN
));
517 const sortedTags
= [];
518 tagList
.forEach((tag
, hash
) => sortedTags
.push({
522 sortedTags
.sort((left
, right
) => window
.qBittorrent
.Misc
.naturalSortCollator
.compare(left
.tagName
, right
.tagName
));
524 for (let i
= 0; i
< sortedTags
.length
; ++i
) {
525 const { tagName
, tagHash
} = sortedTags
[i
];
527 const input
= document
.createElement("input");
528 input
.type
= "checkbox";
529 input
.addEventListener("click", (event
) => {
530 input
.checked
= !input
.checked
;
533 const anchor
= document
.createElement("a");
534 anchor
.href
= `#Tag/${tagName}`;
535 anchor
.textContent
= tagName
;
536 anchor
.addEventListener("click", (event
) => {
537 event
.preventDefault();
538 torrentSetTagsFN(tagHash
, !input
.checked
);
540 anchor
.prepend(input
);
542 const setTagItem
= document
.createElement("li");
543 setTagItem
.appendChild(anchor
);
545 setTagItem
.addClass("separator");
547 contextTagList
.appendChild(setTagItem
);
552 const CategoriesFilterContextMenu
= new Class({
553 Extends
: ContextMenu
,
554 updateMenuItems: function() {
555 const id
= Number(this.options
.element
.id
);
556 if ((id
!== CATEGORIES_ALL
) && (id
!== CATEGORIES_UNCATEGORIZED
)) {
557 this.showItem("editCategory");
558 this.showItem("deleteCategory");
559 if (useSubcategories
)
560 this.showItem("createSubcategory");
562 this.hideItem("createSubcategory");
565 this.hideItem("editCategory");
566 this.hideItem("deleteCategory");
567 this.hideItem("createSubcategory");
572 const TagsFilterContextMenu
= new Class({
573 Extends
: ContextMenu
,
574 updateMenuItems: function() {
575 const id
= Number(this.options
.element
.id
);
576 if ((id
!== TAGS_ALL
) && (id
!== TAGS_UNTAGGED
))
577 this.showItem("deleteTag");
579 this.hideItem("deleteTag");
583 const SearchPluginsTableContextMenu
= new Class({
584 Extends
: ContextMenu
,
586 updateMenuItems: function() {
587 const enabledColumnIndex = function(text
) {
588 const columns
= $("searchPluginsTableFixedHeaderRow").getChildren("th");
589 for (let i
= 0; i
< columns
.length
; ++i
) {
590 if (columns
[i
].textContent
=== "Enabled")
595 this.showItem("Enabled");
596 this.setItemChecked("Enabled", (this.options
.element
.getChildren("td")[enabledColumnIndex()].textContent
=== "Yes"));
598 this.showItem("Uninstall");
602 const RssFeedContextMenu
= new Class({
603 Extends
: ContextMenu
,
604 updateMenuItems: function() {
605 const selectedRows
= window
.qBittorrent
.Rss
.rssFeedTable
.selectedRowsIds();
606 this.menu
.getElement("a[href$=newSubscription]").parentNode
.addClass("separator");
607 switch (selectedRows
.length
) {
609 // remove separator on top of newSubscription entry to avoid double line
610 this.menu
.getElement("a[href$=newSubscription]").parentNode
.removeClass("separator");
611 // menu when nothing selected
612 this.hideItem("update");
613 this.hideItem("markRead");
614 this.hideItem("rename");
615 this.hideItem("delete");
616 this.showItem("newSubscription");
617 this.showItem("newFolder");
618 this.showItem("updateAll");
619 this.hideItem("copyFeedURL");
622 if (selectedRows
[0] === 0) {
623 // menu when "unread" feed selected
624 this.showItem("update");
625 this.showItem("markRead");
626 this.hideItem("rename");
627 this.hideItem("delete");
628 this.showItem("newSubscription");
629 this.hideItem("newFolder");
630 this.hideItem("updateAll");
631 this.hideItem("copyFeedURL");
633 else if (window
.qBittorrent
.Rss
.rssFeedTable
.rows
[selectedRows
[0]].full_data
.dataUid
=== "") {
634 // menu when single folder selected
635 this.showItem("update");
636 this.showItem("markRead");
637 this.showItem("rename");
638 this.showItem("delete");
639 this.showItem("newSubscription");
640 this.showItem("newFolder");
641 this.hideItem("updateAll");
642 this.hideItem("copyFeedURL");
645 // menu when single feed selected
646 this.showItem("update");
647 this.showItem("markRead");
648 this.showItem("rename");
649 this.showItem("delete");
650 this.showItem("newSubscription");
651 this.hideItem("newFolder");
652 this.hideItem("updateAll");
653 this.showItem("copyFeedURL");
657 // menu when multiple items selected
658 this.showItem("update");
659 this.showItem("markRead");
660 this.hideItem("rename");
661 this.showItem("delete");
662 this.hideItem("newSubscription");
663 this.hideItem("newFolder");
664 this.hideItem("updateAll");
665 this.showItem("copyFeedURL");
671 const RssArticleContextMenu
= new Class({
675 const RssDownloaderRuleContextMenu
= new Class({
676 Extends
: ContextMenu
,
677 adjustMenuPosition: function(e
) {
678 this.updateMenuItems();
680 // draw the menu off-screen to know the menu dimensions
681 this.menu
.style
.left
= "-999em";
682 this.menu
.style
.top
= "-999em";
684 let xPosMenu
= e
.pageX
+ this.options
.offsets
.x
- $("rssdownloaderpage").offsetLeft
;
685 let yPosMenu
= e
.pageY
+ this.options
.offsets
.y
- $("rssdownloaderpage").offsetTop
;
686 if ((xPosMenu
+ this.menu
.offsetWidth
) > document
.documentElement
.clientWidth
)
687 xPosMenu
-= this.menu
.offsetWidth
;
688 if ((yPosMenu
+ this.menu
.offsetHeight
) > document
.documentElement
.clientHeight
)
689 yPosMenu
= document
.documentElement
.clientHeight
- this.menu
.offsetHeight
;
690 xPosMenu
= Math
.max(xPosMenu
, 0);
691 yPosMenu
= Math
.max(yPosMenu
, 0);
693 this.menu
.style
.left
= `${xPosMenu}px`;
694 this.menu
.style
.top
= `${yPosMenu}px`;
695 this.menu
.style
.position
= "absolute";
696 this.menu
.style
.zIndex
= "2000";
698 updateMenuItems: function() {
699 const selectedRows
= window
.qBittorrent
.RssDownloader
.rssDownloaderRulesTable
.selectedRowsIds();
700 this.showItem("addRule");
701 switch (selectedRows
.length
) {
703 // menu when nothing selected
704 this.hideItem("deleteRule");
705 this.hideItem("renameRule");
706 this.hideItem("clearDownloadedEpisodes");
709 // menu when single item selected
710 this.showItem("deleteRule");
711 this.showItem("renameRule");
712 this.showItem("clearDownloadedEpisodes");
715 // menu when multiple items selected
716 this.showItem("deleteRule");
717 this.hideItem("renameRule");
718 this.showItem("clearDownloadedEpisodes");
726 Object
.freeze(window
.qBittorrent
.ContextMenu
);