diff --git a/userscript/README.md b/userscript/README.md new file mode 100644 index 00000000..f1c35f4e --- /dev/null +++ b/userscript/README.md @@ -0,0 +1,5 @@ +# User Scripts + +The scripts may be installed in one's browser (using Greasemonkey or Tampermonkey) to provide additional features using OBS via the web. + +- [Staging Move Drag-n-Drop](https://github.com/openSUSE/openSUSE-release-tools/raw/master/userscript/staging-move-drag-n-drop.user.js) diff --git a/userscript/staging-move-drag-n-drop.user.js b/userscript/staging-move-drag-n-drop.user.js new file mode 100644 index 00000000..87295e9d --- /dev/null +++ b/userscript/staging-move-drag-n-drop.user.js @@ -0,0 +1,620 @@ +// ==UserScript== +// @name OSRT Staging Move Drag-n-Drop +// @namespace openSUSE/openSUSE-release-tools +// @version 0.1.0 +// @description Provide staging request moving interface on staging dashboard. +// @author Jimmy Berry +// @match */project/staging_projects/* +// @require https://code.jquery.com/jquery-3.3.1.min.js +// @require https://raw.githubusercontent.com/p34eu/selectables/master/selectables.js +// @grant none +// ==/UserScript== + +// Uses a combination of two sources: +// - https://www.sitepoint.com/accessible-drag-drop/ (modified slightly) +// - https://github.com/p34eu/selectables (used directly with abuse of modifier key option) + +(function() +{ + // Add explanation of trigger shortcut to legend box. + var explanation = document.createElement('div'); + explanation.id = 'osrt-explanation'; + explanation.innerText = 'press ctrl+m to move requests'; + document.querySelector('#legends').appendChild(explanation); + + window.onkeyup = function(e) { + if (e.keyCode == 77 && e.ctrlKey) { + initMoveInterface(); + } + } + + // Include CSS immediately for explanation. + $('head').append(``); + +})(); + +var initMoveInterface = function(){ + //exclude older browsers by the features we need them to support + //and legacy opera explicitly so we don't waste time on a dead browser + if + ( + !document.querySelectorAll + || + !('draggable' in document.createElement('span')) + || + window.opera + ) + { return; } + + // Update explanation text and add new legend entries. + function addLegend(type) + { + var listItem = document.createElement('li'); + var span = document.createElement('span'); + span.classList.add(type.toLowerCase()); + listItem.appendChild(span); + listItem.appendChild(document.createTextNode(type)); + document.querySelector('ul.color-legend').appendChild(listItem); + } + + addLegend('Moved'); + addLegend('Selected'); + + document.querySelector('#osrt-explanation').innerText = 'move mode activated: drag box around requests or ctrl/shift+click requests to select and drag a request to another staging.'; + + // @resource will not work since served without proper MIME type. + $.get('https://raw.githubusercontent.com/p34eu/selectables/master/selectables.css', function(data, status) { + $('head').append(''); + }); + + // Mark the drag targets and draggable items. + // Preferable to use the tr element as target, but mouse events not handled properly. + // Avoid making expand/collapse links selectable and avoid forcing them to be expanded. + // The pointer-events changes only seem to work properly from script. + $('table.staging-dashboard td').attr('data-draggable', 'target'); + $('table.staging-dashboard ul.packages-list li.request:not(:has(a.staging_expand, a.staging_collapse))').attr('data-draggable', 'item').css('pointer-events', 'all'); + + // Disable mouse events on the request links as that makes it nearly impossible to drag them. + $('table.staging-dashboard ul.packages-list li.request:not(:has(a.staging_expand, a.staging_collapse)) a').css('pointer-events', 'none'); + + // Configure selectables to play nice with drag-n-drop code. + new Selectables({ + elements: 'ul.packages-list li[data-draggable="item"]', + zone: 'body', + start: function (e) { + e.osrtContinue = (e.target.getAttribute('data-draggable') != 'item' && + e.target.tagName != 'A' && + e.target.tagName != 'LABEL' && + e.target.id != 'osrt-summary'); + }, + // Abuse key option by setting the value in start callback whic is run + // first and the value determines if drag selection is started. + key: 'osrtContinue', + onSelect: function (e) { + addSelection(e); + }, + onDeselect: function (e) { + removeSelection(e); + } + }); + + function getStaging(item) + { + var parent; + if (item.tagName == 'TD') { + parent = item.parentElement; + } else { + parent = item.parentElement.parentElement.parentElement; + } + if (item.parentElement.classList.contains('staging_collapsible')) { + // An additional layer since in hidden container. + parent = parent.parentElement; + } + return parent.querySelector('div.letter a').innerText; + } + + function updateSummary() + { + var summaryElement = document.querySelector('div#osrt-summary'); + if (!summaryElement) { + summaryElement = document.createElement('div'); + summaryElement.id = 'osrt-summary'; + document.body.appendChild(summaryElement); + } + + var elements = document.querySelectorAll('.osrt-moved'); + var summary = {}; + var staging; + for (var i = 0; i < elements.length; i++) { + staging = getStaging(elements[i]); + if (!(staging in summary)) { + summary[staging] = []; + } + summary[staging].push(elements[i].children[0].innerText.trim()); + } + + var summaryText = ''; + var pathParts = window.location.pathname.split('/'); + var project = pathParts[pathParts.length - 1]; + for (var key in summary) { + staging = key; + if (!isNaN(key)) { + staging = 'adi:' + key; + } + summaryText += 'osc staging -p ' + project + ' select --move ' + staging + ' ' + summary[key].join(' ') + "\n"; + } + + summaryElement.innerText = summaryText; + } + + //get the collection of draggable targets and add their draggable attribute + for(var + targets = document.querySelectorAll('[data-draggable="target"]'), + len = targets.length, + i = 0; i < len; i ++) + { + targets[i].setAttribute('aria-dropeffect', 'none'); + } + + //get the collection of draggable items and add their draggable attributes + for(var + items = document.querySelectorAll('[data-draggable="item"]'), + len = items.length, + i = 0; i < len; i ++) + { + items[i].setAttribute('draggable', 'true'); + items[i].setAttribute('aria-grabbed', 'false'); + items[i].setAttribute('tabindex', '0'); + + // OSRT modification: keep track of original staging. + items[i].setAttribute('data-staging-origin', getStaging(items[i])); + } + + //dictionary for storing the selections data + //comprising an array of the currently selected items + //a reference to the selected items' owning container + //and a refernce to the current drop target container + var selections = + { + items : [], + owner : null, + droptarget : null + }; + + //function for selecting an item + function addSelection(item) + { + //if the owner reference is still null, set it to this item's parent + //so that further selection is only allowed within the same container + if(!selections.owner) + { + selections.owner = item.parentNode; + } + + //or if that's already happened then compare it with this item's parent + //and if they're not the same container, return to prevent selection + else if(selections.owner != item.parentNode) + { + return; + } + + //set this item's grabbed state + item.setAttribute('aria-grabbed', 'true'); + + //add it to the items array + selections.items.push(item); + } + + //function for unselecting an item + function removeSelection(item) + { + //reset this item's grabbed state + item.setAttribute('aria-grabbed', 'false'); + + //then find and remove this item from the existing items array + for(var len = selections.items.length, i = 0; i < len; i ++) + { + if(selections.items[i] == item) + { + selections.items.splice(i, 1); + break; + } + } + } + + //function for resetting all selections + function clearSelections() + { + //if we have any selected items + if(selections.items.length) + { + //reset the owner reference + selections.owner = null; + + //reset the grabbed state on every selected item + for(var len = selections.items.length, i = 0; i < len; i ++) + { + selections.items[i].setAttribute('aria-grabbed', 'false'); + } + + //then reset the items array + selections.items = []; + } + } + + //shorctut function for testing whether a selection modifier is pressed + function hasModifier(e) + { + return (e.ctrlKey || e.metaKey || e.shiftKey); + } + + //function for applying dropeffect to the target containers + function addDropeffects() + { + //apply aria-dropeffect and tabindex to all targets apart from the owner + for(var len = targets.length, i = 0; i < len; i ++) + { + if + ( + targets[i] != selections.owner + && + targets[i].getAttribute('aria-dropeffect') == 'none' + ) + { + targets[i].setAttribute('aria-dropeffect', 'move'); + targets[i].setAttribute('tabindex', '0'); + } + } + + //remove aria-grabbed and tabindex from all items inside those containers + for(var len = items.length, i = 0; i < len; i ++) + { + if + ( + items[i].parentNode != selections.owner + && + items[i].getAttribute('aria-grabbed') + ) + { + items[i].removeAttribute('aria-grabbed'); + items[i].removeAttribute('tabindex'); + } + } + } + + //function for removing dropeffect from the target containers + function clearDropeffects() + { + //if we have any selected items + if(selections.items.length) + { + //reset aria-dropeffect and remove tabindex from all targets + for(var len = targets.length, i = 0; i < len; i ++) + { + if(targets[i].getAttribute('aria-dropeffect') != 'none') + { + targets[i].setAttribute('aria-dropeffect', 'none'); + targets[i].removeAttribute('tabindex'); + } + } + + //restore aria-grabbed and tabindex to all selectable items + //without changing the grabbed value of any existing selected items + for(var len = items.length, i = 0; i < len; i ++) + { + if(!items[i].getAttribute('aria-grabbed')) + { + items[i].setAttribute('aria-grabbed', 'false'); + items[i].setAttribute('tabindex', '0'); + } + else if(items[i].getAttribute('aria-grabbed') == 'true') + { + items[i].setAttribute('tabindex', '0'); + } + } + } + } + + //shortcut function for identifying an event element's target container + function getContainer(element) + { + do + { + if(element.nodeType == 1 && element.getAttribute('aria-dropeffect')) + { + return element; + } + } + while(element = element.parentNode); + + return null; + } + + //mousedown event to implement single selection + document.addEventListener('mousedown', function(e) + { + //if the element is a draggable item + if(e.target.getAttribute('draggable')) + { + //clear dropeffect from the target containers + clearDropeffects(); + + //if the multiple selection modifier is not pressed + //and the item's grabbed state is currently false + if + ( + !hasModifier(e) + && + e.target.getAttribute('aria-grabbed') == 'false' + ) + { + //clear all existing selections + clearSelections(); + + //then add this new selection + addSelection(e.target); + } + } + + //else [if the element is anything else] + //and the selection modifier is not pressed + else if(!hasModifier(e)) + { + //clear dropeffect from the target containers + clearDropeffects(); + + //clear all existing selections + clearSelections(); + } + + //else [if the element is anything else and the modifier is pressed] + else + { + //clear dropeffect from the target containers + clearDropeffects(); + } + + }, false); + + //mouseup event to implement multiple selection + document.addEventListener('mouseup', function(e) + { + //if the element is a draggable item + //and the multipler selection modifier is pressed + if(e.target.getAttribute('draggable') && hasModifier(e)) + { + //if the item's grabbed state is currently true + if(e.target.getAttribute('aria-grabbed') == 'true') + { + //unselect this item + removeSelection(e.target); + + //if that was the only selected item + //then reset the owner container reference + if(!selections.items.length) + { + selections.owner = null; + } + } + + //else [if the item's grabbed state is false] + else + { + //add this additional selection + addSelection(e.target); + } + } + + }, false); + + //dragstart event to initiate mouse dragging + document.addEventListener('dragstart', function(e) + { + //if the element's parent is not the owner, then block this event + if(selections.owner != e.target.parentNode) + { + e.preventDefault(); + return; + } + + //[else] if the multiple selection modifier is pressed + //and the item's grabbed state is currently false + if + ( + hasModifier(e) + && + e.target.getAttribute('aria-grabbed') == 'false' + ) + { + //add this additional selection + addSelection(e.target); + } + + //we don't need the transfer data, but we have to define something + //otherwise the drop action won't work at all in firefox + //most browsers support the proper mime-type syntax, eg. "text/plain" + //but we have to use this incorrect syntax for the benefit of IE10+ + e.dataTransfer.setData('text', ''); + + //apply dropeffect to the target containers + addDropeffects(); + + }, false); + + //related variable is needed to maintain a reference to the + //dragleave's relatedTarget, since it doesn't have e.relatedTarget + var related = null; + + //dragenter event to set that variable + document.addEventListener('dragenter', function(e) + { + related = e.target; + + }, false); + + //dragleave event to maintain target highlighting using that variable + document.addEventListener('dragleave', function(e) + { + //get a drop target reference from the relatedTarget + var droptarget = getContainer(related); + + //if the target is the owner then it's not a valid drop target + if(droptarget == selections.owner) + { + droptarget = null; + } + + //if the drop target is different from the last stored reference + //(or we have one of those references but not the other one) + if(droptarget != selections.droptarget) + { + //if we have a saved reference, clear its existing dragover class + if(selections.droptarget) + { + selections.droptarget.className = + selections.droptarget.className.replace(/ dragover/g, ''); + } + + //apply the dragover class to the new drop target reference + if(droptarget) + { + droptarget.className += ' dragover'; + } + + //then save that reference for next time + selections.droptarget = droptarget; + } + + }, false); + + //dragover event to allow the drag by preventing its default + document.addEventListener('dragover', function(e) + { + //if we have any selected items, allow them to be dragged + if(selections.items.length) + { + e.preventDefault(); + } + + }, false); + + //dragend event to implement items being validly dropped into targets, + //or invalidly dropped elsewhere, and to clean-up the interface either way + document.addEventListener('dragend', function(e) + { + //if we have a valid drop target reference + //(which implies that we have some selected items) + if(selections.droptarget) + { + // OSRT modification: only move if location is changing. + if (getStaging(selections.droptarget) == getStaging(selections.items[0])) { + e.preventDefault(); + return; + } + + // OSRT modification: place requests back in package list. + var target = selections.droptarget.parentElement.querySelector('ul.packages-list'); + + //append the selected items to the end of the target container + for(var len = selections.items.length, i = 0; i < len; i ++) + { + // OSRT modification: place in package list and determine if moved from origin. + // selections.droptarget.appendChild(selections.items[i]); + target.appendChild(selections.items[i]); + if (getStaging(selections.items[i]) != selections.items[i].getAttribute('data-staging-origin')) + { + selections.items[i].classList.add('osrt-moved'); + } + else { + selections.items[i].classList.remove('osrt-moved'); + } + } + + // OSRT modification: after drag update overall summary of moves. + updateSummary(); + + //prevent default to allow the action + e.preventDefault(); + } + + //if we have any selected items + if(selections.items.length) + { + //clear dropeffect from the target containers + clearDropeffects(); + + //if we have a valid drop target reference + if(selections.droptarget) + { + //reset the selections array + clearSelections(); + + //reset the target's dragover class + selections.droptarget.className = + selections.droptarget.className.replace(/ dragover/g, ''); + + //reset the target reference + selections.droptarget = null; + } + } + + }, false); +};