WebUI: Improve filter lists
[qBittorrent.git] / src / webui / www / private / scripts / contextmenu.js
blobaa7f8d0c12436ef0ddb8f86a5275ffd4e22ec65e
1 /*
2  * Bittorrent Client using Qt and libtorrent.
3  * Copyright (C) 2009  Christophe Dumez <chris@qbittorrent.org>
4  *
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.
9  *
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.
14  *
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.
18  *
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.
27  */
29 "use strict";
31 window.qBittorrent ??= {};
32 window.qBittorrent.ContextMenu ??= (() => {
33     const exports = () => {
34         return {
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
45         };
46     };
48     let lastShownContextMenu = null;
49     const ContextMenu = new Class({
50         // implements
51         Implements: [Options, Events],
53         // options
54         options: {
55             actions: {},
56             menu: "menu_id",
57             stopEvent: true,
58             targets: "body",
59             offsets: {
60                 x: 0,
61                 y: 0
62             },
63             onShow: () => {},
64             onHide: () => {},
65             onClick: () => {},
66             fadeSpeed: 200,
67             touchTimer: 600
68         },
70         // initialization
71         initialize: function(options) {
72             // set options
73             this.setOptions(options);
75             // option diffs menu
76             this.menu = $(this.options.menu);
77             this.targets = $$(this.options.targets);
79             // fx
80             this.fx = new Fx.Tween(this.menu, {
81                 property: "opacity",
82                 duration: this.options.fadeSpeed,
83                 onComplete: () => {
84                     this.menu.style.visibility = (getComputedStyle(this.menu).opacity > 0) ? "visible" : "hidden";
85                 }
86             });
88             // hide and begin the listener
89             this.hide().startListener();
91             // hide the menu
92             this.menu.style.position = "absolute";
93             this.menu.style.top = "-900000px";
94             this.menu.style.display = "block";
95         },
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";
109             // position the menu
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;
116             if (xPosMenu < 0)
117                 xPosMenu = 0;
118             if (yPosMenu < 0)
119                 yPosMenu = 0;
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) {
128                 const ul = uls[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;
140                 if (xPos < 0)
141                     xPos = 0;
142                 if (yPos < 0)
143                     yPos = 0;
144                 ul.style.marginLeft = `${xPos - xPosOrigin}px`;
145                 ul.style.marginTop = `${yPos - yPosOrigin}px`;
146             }
147         },
149         setupEventListeners: function(elem) {
150             elem.addEventListener("contextmenu", (e) => {
151                 this.triggerMenu(e, elem);
152             });
153             elem.addEventListener("click", (e) => {
154                 this.hide();
155             });
157             elem.addEventListener("touchstart", (e) => {
158                 this.hide();
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 });
174         },
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);
182         },
184         searchAndAddTargets: function() {
185             document.querySelectorAll(this.options.targets).forEach((target) => { this.addTarget(target); });
186         },
188         triggerMenu: function(e, el) {
189             if (this.options.disabled)
190                 return;
192             // prevent default, if told to
193             if (this.options.stopEvent) {
194                 e.preventDefault();
195                 e.stopPropagation();
196             }
197             // record this as the trigger
198             this.options.element = $(el);
199             this.adjustMenuPosition(e);
200             // show the menu
201             this.show();
202         },
204         // get things started
205         startListener: function() {
206             /* all elements */
207             this.targets.each((el) => {
208                 this.setupEventListeners(el);
209             }, this);
211             /* menu items */
212             this.menu.addEventListener("click", (e) => {
213                 const menuItem = e.target.closest("li");
214                 if (!menuItem)
215                     return;
217                 e.preventDefault();
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]);
222                 }
223                 else {
224                     e.stopPropagation();
225                 }
226             });
228             // hide on body click
229             $(document.body).addEventListener("click", () => {
230                 this.hide();
231             });
232         },
234         updateMenuItems: function() {},
236         // show menu
237         show: function(trigger) {
238             if (lastShownContextMenu && (lastShownContextMenu !== this))
239                 lastShownContextMenu.hide();
240             this.fx.start(1);
241             this.fireEvent("show");
242             lastShownContextMenu = this;
243             return this;
244         },
246         // hide the menu
247         hide: function(trigger) {
248             if (lastShownContextMenu && (lastShownContextMenu.menu.style.visibility !== "hidden")) {
249                 this.fx.start(0);
250                 // this.menu.fade('out');
251                 this.fireEvent("hide");
252             }
253             return this;
254         },
256         setItemChecked: function(item, checked) {
257             this.menu.getElement("a[href$=" + item + "]").firstChild.style.opacity =
258                 checked ? "1" : "0";
259             return this;
260         },
262         getItemChecked: function(item) {
263             return this.menu.getElement("a[href$=" + item + "]").firstChild.style.opacity !== "0";
264         },
266         // hide an item
267         hideItem: function(item) {
268             this.menu.getElement("a[href$=" + item + "]").parentNode.addClass("invisible");
269             return this;
270         },
272         // show an item
273         showItem: function(item) {
274             this.menu.getElement("a[href$=" + item + "]").parentNode.removeClass("invisible");
275             return this;
276         },
278         // enable/disable an item
279         setEnabled: function(item, enabled) {
280             this.menu.querySelector(`:scope a[href$="${item}"]`).parentElement.classList.toggle("disabled", !enabled);
281             return this;
282         },
284         // disable the entire menu
285         disable: function() {
286             this.options.disabled = true;
287             return this;
288         },
290         // enable the entire menu
291         enable: function() {
292             this.options.disabled = false;
293             return this;
294         },
296         // execute an action
297         execute: function(action, element) {
298             if (this.options.actions[action])
299                 this.options.actions[action](element, this, action);
300             return this;
301         }
302     });
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();
310             });
311         },
313         startTorrentObserver: function() {
314             this.torrentObserver.observe(torrentsTable.tableBody, { childList: true });
315         },
317         stopTorrentObserver: function() {
318             this.torrentObserver.disconnect();
319         },
321         updateTorrentActions: function() {
322             const torrentsVisible = torrentsTable.tableBody.children.length > 0;
323             this.setEnabled("startTorrents", torrentsVisible)
324                 .setEnabled("stopTorrents", torrentsVisible)
325                 .setEnabled("deleteTorrents", torrentsVisible);
326         }
327     });
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;
356                 else
357                     there_are_seq_dl = true;
359                 if (data["f_l_piece_prio"] !== true)
360                     all_are_f_l_piece_prio = false;
361                 else
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;
371                 else
372                     there_are_stopped = true;
374                 if (data["force_start"] !== true)
375                     all_are_force_start = false;
376                 else
377                     there_are_force_start = true;
379                 if (data["auto_tmm"] === true)
380                     there_are_auto_tmm = true;
381                 else
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));
394                 }
396                 const torrentCategory = data["category"];
397                 const count = categoryCount.get(torrentCategory);
398                 categoryCount.set(torrentCategory, ((count !== undefined) ? (count + 1) : 1));
399             });
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
407                 metadata_downloaded
408                     ? this.showItem("renameFiles")
409                     : this.hideItem("renameFiles");
410             }
411             else {
412                 this.hideItem("renameFiles");
413             }
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);
422             }
423             else {
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");
429                 else
430                     this.menu.getElement("a[href$=firstLastPiecePrio]").parentNode.removeClass("separator");
432                 if (show_seq_dl)
433                     this.showItem("sequentialDownload");
434                 else
435                     this.hideItem("sequentialDownload");
437                 if (show_f_l_piece_prio)
438                     this.showItem("firstLastPiecePrio");
439                 else
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");
448             }
450             this.showItem("start");
451             this.showItem("stop");
452             this.showItem("forceStart");
453             if (all_are_stopped)
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");
462             }
463             else {
464                 this.showItem("autoTorrentManagement");
465                 this.setItemChecked("autoTorrentManagement", all_are_auto_tmm);
466             }
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);
479             });
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);
487             });
488         },
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");
500                 img.src = imgURL;
501                 img.alt = text;
502                 anchor.prepend(img);
504                 const item = document.createElement("li");
505                 item.appendChild(anchor);
507                 return item;
508             };
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,
515                 categoryHash: hash
516             }));
517             sortedCategories.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(
518                 left.categoryName, right.categoryName));
520             let first = true;
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);
528                 });
530                 const img = document.createElement("img");
531                 img.src = "images/view-categories.svg";
532                 anchor.prepend(img);
534                 const setCategoryItem = document.createElement("li");
535                 setCategoryItem.appendChild(anchor);
536                 if (first) {
537                     setCategoryItem.addClass("separator");
538                     first = false;
539                 }
541                 contextCategoryList.appendChild(setCategoryItem);
542             }
543         },
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");
556                 img.src = imgURL;
557                 img.alt = text;
558                 anchor.prepend(img);
560                 const item = document.createElement("li");
561                 item.appendChild(anchor);
563                 return item;
564             };
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({
570                 tagName: tag.name,
571                 tagHash: hash
572             }));
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;
582                 });
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);
590                 });
591                 anchor.prepend(input);
593                 const setTagItem = document.createElement("li");
594                 setTagItem.appendChild(anchor);
595                 if (i === 0)
596                     setTagItem.addClass("separator");
598                 contextTagList.appendChild(setTagItem);
599             }
600         }
601     });
603     const StatusesFilterContextMenu = new Class({
604         Extends: FilterListContextMenu,
605         updateMenuItems: function() {
606             this.updateTorrentActions();
607         }
608     });
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");
619                 else
620                     this.hideItem("createSubcategory");
621             }
622             else {
623                 this.hideItem("editCategory");
624                 this.hideItem("deleteCategory");
625                 this.hideItem("createSubcategory");
626             }
628             this.updateTorrentActions();
629         }
630     });
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");
638             else
639                 this.hideItem("deleteTag");
641             this.updateTorrentActions();
642         }
643     });
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");
651             else
652                 this.hideItem("deleteTracker");
654             this.updateTorrentActions();
655         }
656     });
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")
666                         return i;
667                 }
668             };
670             this.showItem("Enabled");
671             this.setItemChecked("Enabled", (this.options.element.getChildren("td")[enabledColumnIndex()].textContent === "Yes"));
673             this.showItem("Uninstall");
674         }
675     });
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) {
683                 case 0:
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");
696                     break;
697                 case 1:
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");
709                     }
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");
721                     }
722                     else {
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");
733                     }
734                     break;
735                 default:
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");
746                     break;
747             }
748         }
749     });
751     const RssArticleContextMenu = new Class({
752         Extends: ContextMenu
753     });
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";
763             // position the menu
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";
777         },
778         updateMenuItems: function() {
779             const selectedRows = window.qBittorrent.RssDownloader.rssDownloaderRulesTable.selectedRowsIds();
780             this.showItem("addRule");
781             switch (selectedRows.length) {
782                 case 0:
783                     // menu when nothing selected
784                     this.hideItem("deleteRule");
785                     this.hideItem("renameRule");
786                     this.hideItem("clearDownloadedEpisodes");
787                     break;
788                 case 1:
789                     // menu when single item selected
790                     this.showItem("deleteRule");
791                     this.showItem("renameRule");
792                     this.showItem("clearDownloadedEpisodes");
793                     break;
794                 default:
795                     // menu when multiple items selected
796                     this.showItem("deleteRule");
797                     this.hideItem("renameRule");
798                     this.showItem("clearDownloadedEpisodes");
799                     break;
800             }
801         }
802     });
804     return exports();
805 })();
806 Object.freeze(window.qBittorrent.ContextMenu);