Skip to content

Instantly share code, notes, and snippets.

@zakius
Last active June 25, 2021 07:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save zakius/134d1e17b4c1c291ab2d2f80c1f12940 to your computer and use it in GitHub Desktop.
Save zakius/134d1e17b4c1c291ab2d2f80c1f12940 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name Mouse Gestures
// @author xiaoxiaoflood
// @include main
// @startup UC.MGest.exec(win);
// @shutdown UC.MGest.destroy();
// @onlyonce
// ==/UserScript==
// initially forked from https://web.archive.org/web/20131025160814/http://www.cnblogs.com/ziyunfei/archive/2011/12/15/2289504.html
XPCOMUtils.defineLazyGetter(this, 'SelectionUtils', function() {
let { SelectionUtils } = Cu.import('resource://gre/modules/SelectionUtils.jsm');
return SelectionUtils;
});
UC.MGest = {
// 0, 1, 2: mouse buttons
// -, +: mousewheel direction
// UDLR: up, down, left, right / gesture direction
// FB: forward, back / for mouses with extra buttons
// note: extra buttons must be the last part of the gesture because they are only triggered on 'click' (button release), there's no 'mousedown' for them
GESTURES: {
'2L': {
name: 'Open image URL',
cmd: function () {
BrowserBack(event && event.type == "MozSwipeGesture" ? event : undefined);
}
},
'2R': {
name: 'Open image URL',
cmd: function () {
BrowserForward(event && event.type == "MozSwipeGesture" ? event : undefined);
}
},
'20': {
name: 'Go to top of page (strict)',
cmd: function (win) {
win.gBrowser.selectedBrowser.messageManager.sendAsyncMessage('chromeToContent', { action: 'scroll', direction: 'up' });
}
},
'02': {
name: 'Go to bottom of page (strict)',
cmd: function (win) {
win.gBrowser.selectedBrowser.messageManager.sendAsyncMessage('chromeToContent', { action: 'scroll', direction: 'down' });
}
},
'2D': {
name: 'Close current tab',
cmd: function (win) {
win.gBrowser.removeCurrentTab();
}
},
},
webExts: new Map([
['TST', { id: 'treestyletab@piro.sakura.ne.jp' }],
['TTG', { id: '{dcdaadfa-21f1-4853-9b34-aad681fff6f3}' }]//Tiled Tab Groups
]),
exec: function (win) {
const { customElements, document, gBrowser } = win;
['contextmenu', 'mousedown', 'mouseup'].forEach(type => {
document.addEventListener(type, this, true);
});
win._HandleAppCommandEvent = win.HandleAppCommandEvent;
win.removeEventListener('AppCommand', win.HandleAppCommandEvent, true);
win.orig_selected = Object.getOwnPropertyDescriptor(customElements.get('tabbrowser-tab').prototype, '_selected').set;
Object.defineProperty(customElements.get('tabbrowser-tab').prototype, '_selected', {
set: function (val) {
if (val && !this.everSelected)
this.everSelected = true;
return win.orig_selected.call(this, val);
}
});
win.HandleAppCommandEvent = (evt) => {
if (UC.MGest.directionChain) {
let executed;
switch (evt.command) {
case 'Forward':
executed = this.checkAndRunGest('F', win);
break;
case 'Back':
executed = this.checkAndRunGest('B', win);
}
if (executed)
return;
}
return win._HandleAppCommandEvent.call(win, evt);
};
win.addEventListener('AppCommand', win.HandleAppCommandEvent, true);
},
init: function () {
this.webExts.forEach(obj => {
if (UC.webExts.get(obj.id)?.messageManager)
this.addListener(obj.id);
});
Services.obs.addObserver(this, 'UCJS:WebExtLoaded');
Services.mm.loadFrameScript(this.frameScript, true);
Services.mm.addMessageListener('contentToChrome', this.chromeListener);
},
addListener: function (id) {
switch (id) {
case this.webExts.get('TST').id:
this.webExts.get('TST').browser = UC.webExts.get(id);
this.webExts.get('TST').browser.messageManager.loadFrameScript('data:application/javascript;charset=UTF-8,' + encodeURIComponent('(' + (function () {
let { browser, Tab } = content.wrappedJSObject;
let contentListener = async function (msg) {
let w = await browser.windows.getLastFocused();
let tab;
switch (msg.data) {
case 'next-visible-tab':
tab = Tab.getActiveTab(w.id).$TST.nearestVisibleFollowingTab || Tab.getFirstVisibleTab(w.id);
browser.tabs.update(tab.id, { active: true });
break;
case 'previous-visible-tab':
tab = Tab.getActiveTab(w.id).$TST.nearestVisiblePrecedingTab || Tab.getLastVisibleTab(w.id);
browser.tabs.update(tab.id, { active: true });
break;
case 'destroy':
removeMessageListener('UCJS:MGest', contentListener);
delete contentListener;
}
}
addMessageListener('UCJS:MGest', contentListener);
}).toString() + ')();'), false);
break;
case this.webExts.get('TTG').id:
this.webExts.get('TTG').browser = UC.webExts.get(id);
this.webExts.get('TTG').browser.messageManager.loadFrameScript('data:application/javascript;charset=UTF-8,' + encodeURIComponent('(' + (function () {
let { cycleGroup } = content.wrappedJSObject;
let contentListener = async function (msg) {
switch (msg.data) {
case 'next-group':
cycleGroup(1);
break;
case 'previous-group':
cycleGroup(-1);
break;
case 'destroy':
removeMessageListener('UCJS:MGest', contentListener);
delete contentListener;
}
}
addMessageListener('UCJS:MGest', contentListener);
}).toString() + ')();'), false);
}
},
observe: function (subject, topic, data) {
for (let obj of this.webExts.values()) {
if (obj.id == data) {
this.addListener(data);
break;
}
}
},
chromeListener: function (message) {
const { document, gBrowser } = message.target.ownerGlobal;
const { action, cmd, url } = message.data;
switch (cmd) {
case 'scroll-up':
document.commandDispatcher.getControllerForCommand('cmd_moveTop').doCommand('cmd_moveTop');
break;
case 'scroll-down':
document.commandDispatcher.getControllerForCommand('cmd_moveBottom').doCommand('cmd_moveBottom');
break;
case 'newTab':
gBrowser.addTab(url, {
owner: gBrowser.selectedTab,
relatedToCurrent: true,
triggeringPrincipal: gBrowser.selectedBrowser.contentPrincipal
});
}
},
frameScript: 'data:application/javascript;charset=UTF-8,' +
encodeURIComponent('(' + (function () {
// https://searchfox.org/mozilla-central/rev/6309f663e7396e957138704f7ae7254c92f52f43/browser/actors/ContextMenuChild.jsm#1141-1202
// https://searchfox.org/mozilla-central/rev/6309f663e7396e957138704f7ae7254c92f52f43/browser/actors/ContextMenuChild.jsm#307-320
// https://searchfox.org/mozilla-central/rev/6309f663e7396e957138704f7ae7254c92f52f43/browser/actors/ContextMenuChild.jsm#973-1114
function _isXULTextLinkLabel (aNode) {
return (
aNode.namespaceURI == 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul' &&
aNode.tagName == 'label' &&
aNode.classList.contains('text-link') &&
aNode.href
);
}
function _isMediaURLReusable (aURL) {
if (aURL.startsWith('blob:')) {
return URL.isValidURL(aURL);
}
return true;
}
function _makeURLAbsolute (aBase, aUrl) {
return Services.io.newURI(aUrl, null, Services.io.newURI(aBase)).spec;
}
function _getComputedURL (aElem, aProp) {
let urls = aElem.ownerGlobal.getComputedStyle(aElem).getCSSImageURLs(aProp);
if (!urls.length) {
return null;
}
if (urls.length != 1) {
throw new Error('found multiple URLs');
}
return urls[0];
}
let clickedElement;
let data = {};
let mouseDown = function (e) {
data = {};
clickedElement = e.composedTarget;
if (clickedElement.nodeType != clickedElement.ELEMENT_NODE)
return;
const XLINK_NS = 'http://www.w3.org/1999/xlink';
let elem = clickedElement;
let hasBGImage;
let hasMultipleBGImages;
let win = elem.ownerDocument.defaultView;
while (elem) {
if (elem.nodeType == elem.ELEMENT_NODE) {
if (
(_isXULTextLinkLabel(elem) ||
(elem instanceof content.HTMLAnchorElement &&
elem.href) ||
(elem instanceof content.SVGAElement &&
(elem.href || elem.hasAttributeNS(XLINK_NS, 'href'))) ||
(elem instanceof content.HTMLAreaElement && elem.href) ||
elem instanceof content.HTMLLinkElement ||
elem.getAttributeNS(XLINK_NS, 'type') == 'simple')
) {
// Target is a link or a descendant of a link.
let href = elem.href;
if (href) {
// Handle SVG links:
if (typeof href == 'object' && href.animVal) {
data.linkURL = _makeURLAbsolute(elem.baseURI, elem.animVal);
}
data.linkURL = href;
} else {
href =
elem.getAttribute('href') ||
elem.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
data.linkURL = _makeURLAbsolute(elem.baseURI, href);
}
}
// Background image? Don't bother if we've already found a
// background image further down the hierarchy. Otherwise,
// we look for the computed background-image style.
if (!hasBGImage && !hasMultipleBGImages) {
let bgImgUrl = null;
try {
bgImgUrl = _getComputedURL(elem, 'background-image');
hasMultipleBGImages = false;
} catch (e) {
hasMultipleBGImages = true;
}
if (bgImgUrl &&
elem != win.document.body &&
elem != win.document.documentElement &&
win.getComputedStyle(elem).height == win.getComputedStyle(elem.parentElement).height &&
win.getComputedStyle(elem).width == win.getComputedStyle(elem.parentElement).width) {
hasBGImage = true;
data.bgImageURL = _makeURLAbsolute(elem.baseURI, bgImgUrl);
}
}
}
elem = elem.parentNode;
}
if (
clickedElement instanceof Ci.nsIImageLoadingContent &&
(clickedElement.currentRequestFinalURI || clickedElement.currentURI)
) {
// The actual URL the image was loaded from (after redirects) is the
// currentRequestFinalURI. We should use that as the URL for purposes of
// deciding on the filename, if it is present. It might not be present
// if images are blocked.
//
// It is important to check both the final and the current URI, as they
// could be different blob URIs, see bug 1625786.
data.imageURL = (() => {
let finalURI = clickedElement.currentRequestFinalURI?.spec;
if (finalURI && _isMediaURLReusable(finalURI)) {
return finalURI;
}
let currentURI = clickedElement.currentURI?.spec;
if (currentURI && _isMediaURLReusable(currentURI)) {
return currentURI;
}
return '';
})();
} else if (clickedElement instanceof content.HTMLVideoElement) {
const mediaURL = clickedElement.currentSrc || clickedElement.src;
if (_isMediaURLReusable(mediaURL)) {
data.videoURL = mediaURL;
}
} else if (clickedElement instanceof content.HTMLAudioElement) {
const mediaURL = clickedElement.currentSrc || clickedElement.src;
if (_isMediaURLReusable(mediaURL)) {
data.audioURL = mediaURL;
}
} else if (clickedElement instanceof content.HTMLHtmlElement) {
const bodyElt = clickedElement.ownerDocument.body;
if (bodyElt) {
let computedURL;
try {
computedURL = _getComputedURL(bodyElt, 'background-image');
hasMultipleBGImages = false;
} catch (e) {
hasMultipleBGImages = true;
}
if (computedURL) {
hasBGImage = true;
data.bgImageURL = _makeURLAbsolute(
bodyElt.baseURI,
computedURL
);
}
}
}
}
addEventListener('mousedown', mouseDown, true);
function parseTemplate(url, templateURL, encode) {
if (encode)
url = encodeURIComponent(url);
return templateURL?.replace(/%s/, url) || url;
}
let evalCache = {};
contentListener = async function (msg) {
let { action, code, direction, encode, fallback, name, type, templateURL, url } = msg.data;
let useFallback = false;
if (type == 'image')
url = data.bgImageURL || data.imageURL;
else if (type)
url = data[type];
else
url = data.videoURL || data.audioURL || data.bgImageURL || data.imageURL || data.linkURL;
switch (action) {
case 'copyURL':
if (url)
Cc['@mozilla.org/widget/clipboardhelper;1'].getService(Ci.nsIClipboardHelper).copyString(url);
else
useFallback = true;
break;
case 'copyImage':
function request (url) {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'arraybuffer';
xhr.onload = function () {
if (this.status >= 200 && this.status < 300)
resolve(xhr);
else
reject();
};
xhr.onerror = reject;
xhr.send();
});
}
let response = await request(data.bgImageURL || data.imageURL);
let mimeType = response?.getResponseHeader('Content-Type');
let imageData;
if (mimeType?.startsWith('image')) {
imageData = response.response;
} else {
let canvas = content.document.createElement('canvas');
canvas.width = clickedElement.naturalWidth;
canvas.height = clickedElement.naturalHeight;
canvas.getContext('2d').drawImage(clickedElement, 0, 0);
mimeType = 'image/png';
let blob = await new Promise((resolve) => canvas.toBlob(resolve, mimeType));
imageData = await blob.arrayBuffer();
}
let imgTools = Cc['@mozilla.org/image/tools;1'].getService(Ci.imgITools);
let img = imgTools.decodeImageFromArrayBuffer(imageData, mimeType);
let transferable = Cc['@mozilla.org/widget/transferable;1'].createInstance(Ci.nsITransferable);
transferable.init(null);
let kNativeImageMime = 'application/x-moz-nativeimage';
transferable.addDataFlavor(kNativeImageMime);
transferable.setTransferData(kNativeImageMime, img);
Services.clipboard.setData(transferable, null, Services.clipboard.kGlobalClipboard);
break;
case 'copySelection':
let focusedWindow = {};
let focusedElement = Services.focus.getFocusedElementForWindow(content, true, focusedWindow);
focusedWindow = focusedWindow.value;
let selectionStr = focusedWindow.getSelection().toString();
// https://searchfox.org/mozilla-central/rev/cc9d803f98625175ed20111d9736e77f3d430cd5/toolkit/modules/SelectionUtils.jsm#70-82
// try getting a selected text in text input.
if (!selectionStr && focusedElement) {
// Don't get the selection for password fields. See bug 565717.
if (
ChromeUtils.getClassName(focusedElement) === 'HTMLTextAreaElement' ||
(ChromeUtils.getClassName(focusedElement) === 'HTMLInputElement' &&
focusedElement.mozIsTextField(true))
) {
selectionStr = focusedElement.editor.selection.toString();
}
}
if (selectionStr)
Cc['@mozilla.org/widget/clipboardhelper;1'].getService(Ci.nsIClipboardHelper).copyString(selectionStr);
else
useFallback = true;
break;
case 'newTab':
if (url)
sendAsyncMessage('contentToChrome', { cmd: action, url: parseTemplate(url, templateURL, encode) });
else
useFallback = true;
break;
case 'scroll':
clickedElement.tabIndex = -1;
clickedElement.focus();
sendAsyncMessage('contentToChrome', { cmd: 'scroll-' + direction });
break;
case 'eval':
if (evalCache[name])
evalCache[name]();
else
eval('(evalCache["' + name + '"] = ' + code + ').call()');
break;
case 'destroy':
removeEventListener('mousedown', mouseDown, true);
removeMessageListener('chromeToContent', contentListener);
delete mouseDown;
delete contentListener;
}
if (fallback && useFallback) {
msg.data.action = fallback;
delete msg.data.fallback;
contentListener(msg);
}
}
addMessageListener('chromeToContent', contentListener);
}).toString() + ')();'),
directionChain: '',
firstButton: undefined,
stoppedOutside: async function (win) {
return new Promise(resolve => {
let start = undefined;
const step = function (timestamp) {
if (start === undefined && timestamp)
start = timestamp;
if (timestamp - start < 20 && !this.cancel) // Stop the animation after 20ms
win.requestAnimationFrame(step);
else
resolve();
}
win.requestAnimationFrame(step);
});
},
stopGesture: async function (gst, win) {
if (this.GESTURES[gst]) {
let topWin = win.windowRoot.ownerGlobal;
this.prevent = true;
this.hideAutoScroll(topWin.gBrowser);
if (/[UDLR]/.test(gst))
await this.stoppedOutside(win);
win.document.documentElement.removeEventListener('mouseleave', this, false);
if (!this.cancel)
this.GESTURES[gst].cmd(topWin);
}
},
handleEvent: function (event) {
const { button, composedTarget, screenX, screenY } = event;
const win = event.view.windowRoot.ownerGlobal;
const { document: doc } = win;
let delX,
delY,
absDX,
absDY,
direction;
switch (event.type) {
case 'mousedown':
if (event.ctrlKey || composedTarget.localName == 'resizer')
return;
if (this.directionChain) {
delX = screenX - this.lastX;
delY = screenY - this.lastY;
if (Math.abs(delX) > 30 || Math.abs(delY) > 30) {
return;
} else {
this.stopGesture(this.directionChain + button, win);
event.preventDefault();
event.stopPropagation();
}
} else {
this.lastX = screenX;
this.lastY = screenY;
this.firstButton = button;
this.directionChain += button;
this.cancel = false;
this.prevent = false;
doc.addEventListener('mousemove', this, false);
doc.addEventListener('wheel', this, { capture: true, passive: false });
doc.addEventListener('dragend', this, { capture: true, once: true });
doc.documentElement.addEventListener('mouseleave', this, false);
}
break;
case 'mousemove':
delX = screenX - this.lastX;
delY = screenY - this.lastY;
absDX = Math.abs(delX);
absDY = Math.abs(delY);
direction;
if (absDX < 10 && absDY < 10)
return;
if (absDX > absDY)
direction = delX < 0 ? 'L' : 'R';
else
direction = delY < 0 ? 'U' : 'D';
if (direction != this.directionChain[this.directionChain.length - 1])
this.directionChain += direction;
this.lastX = screenX;
this.lastY = screenY;
break;
case 'dragend':
case 'mouseup':
if (this.directionChain) {
this.stopGesture(this.directionChain, win);
if (this.firstButton == button) {
this.directionChain = '';
doc.removeEventListener('mousemove', this, false);
['dragend', 'wheel'].forEach(type => doc.removeEventListener(type, this, true));
}
if (this.prevent && button != 2) {
if (composedTarget.isRemoteBrowser) {
doc.documentElement.focus();
win.gBrowser.selectedBrowser.focus();
event.preventDefault();
event.stopPropagation();
} else {
win.addEventListener('click', this, { capture: true, once: true});
}
}
}
break;
case 'click':
event.preventDefault();
event.stopPropagation();
break;
case 'contextmenu':
if (this.prevent) {
event.preventDefault();
event.stopPropagation();
}
break;
case 'wheel':
if (this.directionChain) {
event.preventDefault();
event.stopPropagation();
const { gBrowser } = win;
this.stopGesture(this.directionChain + (event.deltaY > 0 ? '+' : '-'), win);
}
break;
case 'mouseleave':
this.cancel = true;
}
},
checkAndRunGest: function (command, win) {
if (this.GESTURES[this.directionChain + command]) {
this.prevent = true;
this.hideAutoScroll(win.gBrowser);
this.GESTURES[this.directionChain + command].cmd(win.windowRoot.ownerGlobal);
return true;
} else {
return false;
}
},
hideAutoScroll: function (gBrowser) {
if (this.directionChain[0] == '1' && gBrowser.getBrowserForTab(gBrowser._selectedTab)._autoScrollPopup?.state == 'open')
gBrowser.getBrowserForTab(gBrowser._selectedTab)._autoScrollPopup.hidePopup();
},
destroy: function () {
Services.mm.broadcastAsyncMessage('chromeToContent', { action: 'destroy' });
Services.mm.removeDelayedFrameScript(this.frameScript);
Services.mm.removeMessageListener('contentToChrome', this.chromeListener);
_uc.windows((doc, win) => {
const { messageManager } = win;
['contextmenu', 'dragend', 'mousedown', 'mouseup', 'wheel'].forEach(type => {
doc.removeEventListener(type, this, true);
});
doc.removeEventListener('mousemove', this, false);
doc.documentElement.removeEventListener('mouseleave', this, false);
Object.defineProperty(customElements.get('tabbrowser-tab').prototype, '_selected', {
set: win.orig_selected,
configurable: true
});
win.removeEventListener('AppCommand', win.HandleAppCommandEvent, true);
win.HandleAppCommandEvent = win._HandleAppCommandEvent;
delete win._HandleAppCommandEvent;
win.addEventListener('AppCommand', win.HandleAppCommandEvent, true);
delete win.orig_selected;
});
Services.obs.removeObserver(this, 'UCJS:WebExtLoaded');
this.webExts.forEach(obj => {
obj.browser?.messageManager.sendAsyncMessage('UCJS:MGest', 'destroy');
});
delete UC.MGest;
},
};
UC.MGest.init();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment