/* * Bittorrent Client using Qt and libtorrent. * Copyright (C) 2009 Christophe Dumez * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * * In addition, as a special exception, the copyright holders give permission to * link this program with the OpenSSL project's "OpenSSL" library (or with * modified versions of it that use the same license as the "OpenSSL" library), * and distribute the linked executables. You must obey the GNU General Public * License in all respects for all of the code used other than "OpenSSL". If you * modify file(s), you may extend this exception to your version of the file(s), * but you are not obligated to do so. If you do not wish to do so, delete this * exception statement from your version. */ 'use strict' if (window.qBittorrent === undefined) { window.qBittorrent = {} } window.qBittorrent.ContextMenu = (function () { const exports = function () { return { ContextMenu: ContextMenu, TorrentsTableContextMenu: TorrentsTableContextMenu, CategoriesFilterContextMenu: CategoriesFilterContextMenu, TagsFilterContextMenu: TagsFilterContextMenu, SearchPluginsTableContextMenu: SearchPluginsTableContextMenu, RssFeedContextMenu: RssFeedContextMenu, RssArticleContextMenu: RssArticleContextMenu, RssDownloaderRuleContextMenu: RssDownloaderRuleContextMenu, } } let lastShownContextMenu = null const ContextMenu = new Class({ //implements Implements: [Options, Events], //options options: { actions: {}, menu: 'menu_id', stopEvent: true, targets: 'body', offsets: { x: 0, y: 0, }, onShow: $empty, onHide: $empty, onClick: $empty, fadeSpeed: 200, touchTimer: 600, }, //initialization initialize: function (options) { //set options this.setOptions(options) //option diffs menu this.menu = $(this.options.menu) this.targets = $$(this.options.targets) //fx this.fx = new Fx.Tween(this.menu, { property: 'opacity', duration: this.options.fadeSpeed, onComplete: function () { if (this.getStyle('opacity')) { this.setStyle('visibility', 'visible') } else { this.setStyle('visibility', 'hidden') } }.bind(this.menu), }) //hide and begin the listener this.hide().startListener() //hide the menu this.menu.setStyles({ position: 'absolute', top: '-900000px', display: 'block', }) }, adjustMenuPosition: function (e) { this.updateMenuItems() const scrollableMenuMaxHeight = document.documentElement.clientHeight * 0.75 if (this.menu.hasClass('scrollableMenu')) this.menu.setStyle('max-height', scrollableMenuMaxHeight) // draw the menu off-screen to know the menu dimensions this.menu.setStyles({ left: '-999em', top: '-999em', }) // position the menu let xPosMenu = e.page.x + this.options.offsets.x let yPosMenu = e.page.y + this.options.offsets.y if ( xPosMenu + this.menu.offsetWidth > document.documentElement.clientWidth ) xPosMenu -= this.menu.offsetWidth if ( yPosMenu + this.menu.offsetHeight > document.documentElement.clientHeight ) yPosMenu = document.documentElement.clientHeight - this.menu.offsetHeight if (xPosMenu < 0) xPosMenu = 0 if (yPosMenu < 0) yPosMenu = 0 this.menu.setStyles({ left: xPosMenu, top: yPosMenu, position: 'absolute', 'z-index': '2000', }) // position the sub-menu const uls = this.menu.getElementsByTagName('ul') for (let i = 0; i < uls.length; ++i) { const ul = uls[i] if (ul.hasClass('scrollableMenu')) ul.setStyle('max-height', scrollableMenuMaxHeight) const rectParent = ul.parentNode.getBoundingClientRect() const xPosOrigin = rectParent.left const yPosOrigin = rectParent.bottom let xPos = xPosOrigin + rectParent.width - 1 let yPos = yPosOrigin - rectParent.height - 1 if (xPos + ul.offsetWidth > document.documentElement.clientWidth) xPos -= ul.offsetWidth + rectParent.width - 2 if (yPos + ul.offsetHeight > document.documentElement.clientHeight) yPos = document.documentElement.clientHeight - ul.offsetHeight if (xPos < 0) xPos = 0 if (yPos < 0) yPos = 0 ul.setStyles({ 'margin-left': xPos - xPosOrigin, 'margin-top': yPos - yPosOrigin, }) } }, setupEventListeners: function (elem) { elem.addEvent( 'contextmenu', function (e) { this.triggerMenu(e, elem) }.bind(this) ) elem.addEvent( 'click', function (e) { this.hide() }.bind(this) ) elem.addEvent( 'touchstart', function (e) { e.preventDefault() clearTimeout(this.touchstartTimer) this.hide() const touchstartEvent = e this.touchstartTimer = setTimeout( function () { this.triggerMenu(touchstartEvent, elem) }.bind(this), this.options.touchTimer ) }.bind(this) ) elem.addEvent( 'touchend', function (e) { e.preventDefault() clearTimeout(this.touchstartTimer) }.bind(this) ) }, addTarget: function (t) { this.targets[this.targets.length] = t this.setupEventListeners(t) }, triggerMenu: function (e, el) { if (this.options.disabled) return //prevent default, if told to if (this.options.stopEvent) { e.stop() } //record this as the trigger this.options.element = $(el) this.adjustMenuPosition(e) //show the menu this.show() }, //get things started startListener: function () { /* all elements */ this.targets.each( function (el) { this.setupEventListeners(el) }.bind(this), this ) /* menu items */ this.menu.getElements('a').each(function (item) { item.addEvent( 'click', function (e) { e.preventDefault() if (!item.hasClass('disabled')) { this.execute( item.get('href').split('#')[1], $(this.options.element) ) this.fireEvent('click', [item, e]) } }.bind(this) ) }, this) //hide on body click $(document.body).addEvent( 'click', function () { this.hide() }.bind(this) ) }, updateMenuItems: function () {}, //show menu show: function (trigger) { if (lastShownContextMenu && lastShownContextMenu != this) lastShownContextMenu.hide() this.fx.start(1) this.fireEvent('show') this.shown = true lastShownContextMenu = this return this }, //hide the menu hide: function (trigger) { if (this.shown) { this.fx.start(0) //this.menu.fade('out'); this.fireEvent('hide') this.shown = false } return this }, setItemChecked: function (item, checked) { if (this.menu.getElement('a[href$=' + item + ']')) { this.menu.getElement('a[href$=' + item + ']').firstChild.style.opacity = checked ? '1' : '0' } return this }, getItemChecked: function (item) { if (this.menu.getElement('a[href$=' + item + ']')) { return ( '0' != this.menu.getElement('a[href$=' + item + ']').firstChild.style.opacity ) } else { return '0' } }, //hide an item hideItem: function (item) { if (this.menu.getElement('a[href$=' + item + ']')) { this.menu .getElement('a[href$=' + item + ']') .parentNode.addClass('invisible') } return this }, //show an item showItem: function (item) { if (this.menu.getElement('a[href$=' + item + ']')) { this.menu .getElement('a[href$=' + item + ']') .parentNode.removeClass('invisible') } return this }, //disable the entire menu disable: function () { this.options.disabled = true return this }, //enable the entire menu enable: function () { this.options.disabled = false return this }, //execute an action execute: function (action, element) { if (this.options.actions[action]) { this.options.actions[action](element, this, action) } return this }, }) const TorrentsTableContextMenu = new Class({ Extends: ContextMenu, updateMenuItems: function () { let all_are_seq_dl = true let there_are_seq_dl = false let all_are_f_l_piece_prio = true let there_are_f_l_piece_prio = false let all_are_downloaded = true let all_are_paused = true let there_are_paused = false let all_are_force_start = true let there_are_force_start = false let all_are_super_seeding = true let all_are_auto_tmm = true let there_are_auto_tmm = false const tagsSelectionState = Object.clone(tagList) const h = torrentsTable.selectedRowsIds() h.each(function (item, index) { const data = torrentsTable.rows.get(item).full_data if (data['seq_dl'] !== true) all_are_seq_dl = false else there_are_seq_dl = true if (data['f_l_piece_prio'] !== true) all_are_f_l_piece_prio = false else there_are_f_l_piece_prio = true if (data['progress'] != 1.0) // not downloaded all_are_downloaded = false else if (data['super_seeding'] !== true) all_are_super_seeding = false if (data['state'] != 'pausedUP' && data['state'] != 'pausedDL') all_are_paused = false else there_are_paused = true if (data['force_start'] !== true) all_are_force_start = false else there_are_force_start = true if (data['auto_tmm'] === true) there_are_auto_tmm = true else all_are_auto_tmm = false const torrentTags = data['tags'].split(', ') for (const key in tagsSelectionState) { const tag = tagsSelectionState[key] const tagExists = torrentTags.contains(tag.name) if (tag.checked !== undefined && tag.checked != tagExists) tag.indeterminate = true if (tag.checked === undefined) tag.checked = tagExists else tag.checked = tag.checked && tagExists } }) let show_seq_dl = true // hide renameFiles when more than 1 torrent is selected if (h.length == 1) { const data = torrentsTable.rows.get(h[0]).full_data let metadata_downloaded = !( data['state'] == 'metaDL' || data['state'] == 'forcedMetaDL' || data['total_size'] == -1 ) // hide renameFiles when metadata hasn't been downloaded yet metadata_downloaded ? this.showItem('renameFiles') : this.hideItem('renameFiles') } else this.hideItem('renameFiles') if (!all_are_seq_dl && there_are_seq_dl) show_seq_dl = false let show_f_l_piece_prio = true if (!all_are_f_l_piece_prio && there_are_f_l_piece_prio) show_f_l_piece_prio = false if (all_are_downloaded) { this.hideItem('downloadLimit') this.menu .getElement('a[href$=uploadLimit]') .parentNode.addClass('separator') this.hideItem('sequentialDownload') this.hideItem('firstLastPiecePrio') this.showItem('superSeeding') this.setItemChecked('superSeeding', all_are_super_seeding) } else { if (!show_seq_dl && show_f_l_piece_prio) this.menu .getElement('a[href$=firstLastPiecePrio]') .parentNode.addClass('separator') else this.menu .getElement('a[href$=firstLastPiecePrio]') .parentNode.removeClass('separator') if (show_seq_dl) this.showItem('sequentialDownload') else this.hideItem('sequentialDownload') if (show_f_l_piece_prio) this.showItem('firstLastPiecePrio') else this.hideItem('firstLastPiecePrio') this.setItemChecked('sequentialDownload', all_are_seq_dl) this.setItemChecked('firstLastPiecePrio', all_are_f_l_piece_prio) this.showItem('downloadLimit') this.menu .getElement('a[href$=uploadLimit]') .parentNode.removeClass('separator') this.hideItem('superSeeding') } this.showItem('start') this.showItem('pause') this.showItem('forceStart') if (all_are_paused) this.hideItem('pause') else if (all_are_force_start) this.hideItem('forceStart') else if (!there_are_paused && !there_are_force_start) this.hideItem('start') if (!all_are_auto_tmm && there_are_auto_tmm) { this.hideItem('autoTorrentManagement') } else { this.showItem('autoTorrentManagement') this.setItemChecked('autoTorrentManagement', all_are_auto_tmm) } const contextTagList = $('contextTagList') for (const tagHash in tagList) { const checkbox = contextTagList.getElement( 'a[href=#Tag/' + tagHash + '] input[type=checkbox]' ) const checkboxState = tagsSelectionState[tagHash] checkbox.indeterminate = checkboxState.indeterminate checkbox.checked = checkboxState.checked } }, updateCategoriesSubMenu: function (category_list) { const categoryList = $('contextCategoryList') categoryList.empty() categoryList.appendChild( new Element('li', { html: 'New...)QBT_TR[CONTEXT=TransferListWidget] QBT_TR(New...', }) ) categoryList.appendChild( new Element('li', { html: 'Reset)QBT_TR[CONTEXT=TransferListWidget] QBT_TR(Reset', }) ) const sortedCategories = [] Object.each(category_list, function (category) { sortedCategories.push(category.name) }) sortedCategories.sort() let first = true Object.each(sortedCategories, function (categoryName) { const categoryHash = genHash(categoryName) const el = new Element('li', { html: ' ' + window.qBittorrent.Misc.escapeHtml(categoryName) + '', }) if (first) { el.addClass('separator') first = false } categoryList.appendChild(el) }) }, updateTagsSubMenu: function (tagList) { const contextTagList = $('contextTagList') while (contextTagList.firstChild !== null) contextTagList.removeChild(contextTagList.firstChild) contextTagList.appendChild( new Element('li', { html: '' + 'Add...' + ' Add...' + '', }) ) contextTagList.appendChild( new Element('li', { html: '' + 'Remove All' + ' Remove All' + '', }) ) const sortedTags = [] for (const key in tagList) sortedTags.push(tagList[key].name) sortedTags.sort() for (let i = 0; i < sortedTags.length; ++i) { const tagName = sortedTags[i] const tagHash = genHash(tagName) const el = new Element('li', { html: '" + ' ' + window.qBittorrent.Misc.escapeHtml(tagName) + '', }) if (i === 0) el.addClass('separator') contextTagList.appendChild(el) } }, }) const CategoriesFilterContextMenu = new Class({ Extends: ContextMenu, updateMenuItems: function () { const id = this.options.element.id if (id != CATEGORIES_ALL && id != CATEGORIES_UNCATEGORIZED) { this.showItem('editCategory') this.showItem('deleteCategory') if (useSubcategories) { this.showItem('createSubcategory') } else { this.hideItem('createSubcategory') } } else { this.hideItem('editCategory') this.hideItem('deleteCategory') this.hideItem('createSubcategory') } }, }) const TagsFilterContextMenu = new Class({ Extends: ContextMenu, updateMenuItems: function () { const id = this.options.element.id if (id !== TAGS_ALL.toString() && id !== TAGS_UNTAGGED.toString()) this.showItem('deleteTag') else this.hideItem('deleteTag') }, }) const SearchPluginsTableContextMenu = new Class({ Extends: ContextMenu, updateMenuItems: function () { const enabledColumnIndex = function (text) { const columns = $('searchPluginsTableFixedHeaderRow').getChildren('th') for (let i = 0; i < columns.length; ++i) if (columns[i].get('html') === 'Enabled') return i } this.showItem('Enabled') this.setItemChecked( 'Enabled', this.options.element .getChildren('td') [enabledColumnIndex()].get('html') === 'Yes' ) this.showItem('Uninstall') }, }) const RssFeedContextMenu = new Class({ Extends: ContextMenu, updateMenuItems: function () { let selectedRows = window.qBittorrent.Rss.rssFeedTable.selectedRowsIds() this.menu .getElement('a[href$=newSubscription]') .parentNode.addClass('separator') switch (selectedRows.length) { case 0: // remove separator on top of newSubscription entry to avoid double line this.menu .getElement('a[href$=newSubscription]') .parentNode.removeClass('separator') // menu when nothing selected this.hideItem('update') this.hideItem('markRead') this.hideItem('rename') this.hideItem('delete') this.showItem('newSubscription') this.showItem('newFolder') this.showItem('updateAll') this.hideItem('copyFeedURL') break case 1: if (selectedRows[0] === 0) { // menu when "unread" feed selected this.showItem('update') this.showItem('markRead') this.hideItem('rename') this.hideItem('delete') this.showItem('newSubscription') this.hideItem('newFolder') this.hideItem('updateAll') this.hideItem('copyFeedURL') } else if ( window.qBittorrent.Rss.rssFeedTable.rows[selectedRows[0]].full_data .dataUid === '' ) { // menu when single folder selected this.showItem('update') this.showItem('markRead') this.showItem('rename') this.showItem('delete') this.showItem('newSubscription') this.showItem('newFolder') this.hideItem('updateAll') this.hideItem('copyFeedURL') } else { // menu when single feed selected this.showItem('update') this.showItem('markRead') this.showItem('rename') this.showItem('delete') this.showItem('newSubscription') this.hideItem('newFolder') this.hideItem('updateAll') this.showItem('copyFeedURL') } break default: // menu when multiple items selected this.showItem('update') this.showItem('markRead') this.hideItem('rename') this.showItem('delete') this.hideItem('newSubscription') this.hideItem('newFolder') this.hideItem('updateAll') this.showItem('copyFeedURL') break } }, }) const RssArticleContextMenu = new Class({ Extends: ContextMenu, }) const RssDownloaderRuleContextMenu = new Class({ Extends: ContextMenu, adjustMenuPosition: function (e) { this.updateMenuItems() // draw the menu off-screen to know the menu dimensions this.menu.setStyles({ left: '-999em', top: '-999em', }) // position the menu let xPosMenu = e.page.x + this.options.offsets.x - $('rssdownloaderpage').offsetLeft let yPosMenu = e.page.y + this.options.offsets.y - $('rssdownloaderpage').offsetTop if ( xPosMenu + this.menu.offsetWidth > document.documentElement.clientWidth ) xPosMenu -= this.menu.offsetWidth if ( yPosMenu + this.menu.offsetHeight > document.documentElement.clientHeight ) yPosMenu = document.documentElement.clientHeight - this.menu.offsetHeight xPosMenu = Math.max(xPosMenu, 0) yPosMenu = Math.max(yPosMenu, 0) this.menu.setStyles({ left: xPosMenu, top: yPosMenu, position: 'absolute', 'z-index': '2000', }) }, updateMenuItems: function () { let selectedRows = window.qBittorrent.RssDownloader.rssDownloaderRulesTable.selectedRowsIds() this.showItem('addRule') switch (selectedRows.length) { case 0: // menu when nothing selected this.hideItem('deleteRule') this.hideItem('renameRule') this.hideItem('clearDownloadedEpisodes') break case 1: // menu when single item selected this.showItem('deleteRule') this.showItem('renameRule') this.showItem('clearDownloadedEpisodes') break default: // menu when multiple items selected this.showItem('deleteRule') this.hideItem('renameRule') this.showItem('clearDownloadedEpisodes') break } }, }) return exports() })() Object.freeze(window.qBittorrent.ContextMenu)