Skip to content

Instantly share code, notes, and snippets.

@jonchang
Last active March 4, 2018 06:14
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 jonchang/3368437 to your computer and use it in GitHub Desktop.
Save jonchang/3368437 to your computer and use it in GitHub Desktop.
ETI Keyboard Shortcuts
// ==UserScript==
// @name ETI Keyboard Shortcuts
// @namespace shoecream@endoftheinter.net
// @description Adds keyboard shortcuts to streamline your shitposting
// @match *://boards.endoftheinter.net/*
// @match *://endoftheinter.net/*
// @grant GM.openInTab
// @version 0.17
// ==/UserScript==
/*
* Acunote Shortcuts.
* Javascript keyboard shortcuts mini-framework.
*
* Copyright (c) 2007-2011 Pluron, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
shortcutListener = {
listen: true,
shortcut: null,
combination: '',
lastKeypress: 0,
clearTimeout: 2000,
// Keys we don't listen
keys: {
KEY_BACKSPACE: 8,
KEY_TAB: 9,
KEY_ENTER: 13,
KEY_SHIFT: 16,
KEY_CTRL: 17,
KEY_ALT: 18,
KEY_SPACE: 32,
KEY_LEFT: 37,
KEY_UP: 38,
KEY_RIGHT: 39,
KEY_DOWN: 40,
KEY_DELETE: 46,
KEY_HOME: 36,
KEY_END: 35,
KEY_PAGEUP: 33,
KEY_PAGEDOWN: 34
},
// adapted map from keycode.js
map : {
186: 59, // ;: in IE
187: 61, // =+ in IE
188: 44, // ,<
189: 95, // -_ in IE
190: 62, // .>
191: 47, // /?
192: 126, // `~
219: 91, // {[
220: 92, // \|
221: 93 // }]
},
// adapted map from keycode.js
// revesed key-value, since we need to shift char, and they need to un-shift
shifted : {
59 : 58, // ; -> :
61 : 43, // + -> =
44 : 60, // , -> <
45 : 95, // - -> _
46 : 62, // . -> >
47 : 63, // / -> ?
192 : 96, // ~ -> `
92 : 124, // \ -> |
222 : 39, // 222 -> '
222 : 34, // 222 -> "
49 : 33, // 1 -> !
50 : 64, // 2 -> @
51 : 35, // 3 -> #
52 : 36, // 4 -> $
53 : 37, // 5 -> %
54 : 94, // 6 -> ^
55 : 38, // 7 -> &
56 : 42, // 8 -> *
57 : 40, // 9 -> (
58 : 41, // 0 -> )
91 : 123, // [ -> {
93 : 125 // ] -> }
},
UA : {
gecko : navigator.userAgent.indexOf('Gecko') != -1, // chrome fits here
ie : navigator.userAgent.indexOf('MSIE') != -1,
opera : window.opera,
webkit : (/Safari|Chrome/.test(navigator.userAgent)),
konq : (/Konqueror/.test(navigator.userAgent))
},
init: function() {
if (!SHORTCUTS) return false;
this.createStatusArea();
// opera and webkit needs special keymap for certain symbols
// it produced from shifted map
// adapted code from keymap.js
if (this.UA.opera) {
this.map = {}, reverse = {}
for (var key in this.shifted) {
reverse[this.shifted[key]] = key;
}
var unshift = [33, 64, 35, 36, 37, 94, 38, 42, 40, 41, 58, 43, 60, 95, 62, 63, 124, 34];
for (var i = 0; i < unshift.length; ++i) {
this.map[unshift[i]] = reverse[unshift[i]];
}
}
if (this.UA.konq) {
this.map[0] = 45;
this.map[127] = 46;
this.map[45] = 95;
}
this.setObserver();
},
isInputTarget: function(e) {
var target = e.target || e.srcElement,
targetNodeName = target.nodeName.toLowerCase();
if (targetNodeName == 'textarea' || targetNodeName == 'select' ||
(targetNodeName == 'input' && target.type &&
(target.type.toLowerCase() == 'text' || target.type.toLowerCase() == 'password'))) {
return true;
}
return false;
},
stopEvent: function(event) {
if (event.preventDefault) {
event.preventDefault();
event.stopPropagation();
} else {
event.returnValue = false;
event.cancelBubble = true;
}
return false;
},
// shortcut notification/status area
createStatusArea: function() {
this.statusNode = document.createElement('div');
this.statusNode.setAttribute('id', 'shortcut_status');
this.statusNode.style.display = 'none';
document.body.appendChild(this.statusNode);
},
showStatus: function() {
this.statusNode.style.display = '';
},
hideStatus: function() {
this.statusNode.style.display = 'none';
},
showCombination: function() {
this.statusNode.innerHTML = this.combination;
this.showStatus();
},
// use keypress for Gecko and Opera and keydown for Safari, Chrome and K
eventType : function() {
return (this.UA.ie || this.UA.webkit || this.UA.konq) ? 'keydown' : 'keypress';
},
setObserver: function() {
var listener = function(e) {shortcutListener.keyCollector(e)};
if (document.addEventListener) {
document.addEventListener(this.eventType(), listener, false);
} else if (document.attachEvent) {
document.attachEvent('on' + this.eventType(), listener);
}
},
// Key press collector. Collects all keypresses into combination
// and checks it we have action for it
keyCollector: function(e) {
// do not listen if listener was explicitly turned off
if (!shortcutListener.listen) return true;
// do not listen for functional keys
var isFunctional = (function(e) {
// IE doesn't stop default F-keys actions, so it doesn't need this
if (!this.UA.ie) {
if (!e.key) {
// when only keyCode filled and keyCode is in a range - key is functional
return (!e.which && !e.charCode && e.keyCode >= 112 && e.keyCode <= 123)
} else {
// in modern browsers KeyboardEvent.key is filled with FNN string
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
return (/^F\d+$/.test(e.key))
}
}
}).bind(this)
if (isFunctional(e)) return true;
// get pressed key code
var code = e.which ? e.which : e.keyCode;
code = this.map[code] || code;
// shifting char manually for desired browsers
if (e.shiftKey && (this.UA.ie || this.UA.opera || this.UA.webkit)) {
code = this.shifted[code] || code;
}
// do not listen for Ctrl, Alt, Tab, Space and others
for (var key in this.keys) {
if (this.UA.gecko) {
if ((e.keyCode && e.keyCode == this.keys[key])) return true;
} else {
if (code == this.keys[key]) return true;
}
}
var letter = null;
// adds ability to add shortcut to Esc key
if (code != 27) {
letter = String.fromCharCode(code).toLowerCase();
} else {
letter = 'Esc'
}
// do not listen in input/select/textarea fields
// unless we pressed Esc or the modifier
if (this.isInputTarget(e) && !(letter == 'Esc' || e.altKey || e.ctrlKey || e.metaKey)) return true;
if (e.shiftKey) {
letter = letter.toUpperCase()
}
if (e.altKey || e.ctrlKey || e.metaKey) {
letter = '-' + letter;
}
if (e.altKey) {
letter = 'A' + letter;
}
if (e.ctrlKey) {
letter = 'C' + letter;
}
if (e.metaKey) {
letter = 'M' + letter;
}
if (shortcutListener.process(letter, e)) {
shortcutListener.stopEvent(e);
}
},
// process keys
process: function(letter, e) {
// if no combination then start from the begining
if (!shortcutListener.shortcut) { shortcutListener.shortcut = SHORTCUTS; }
// if unknown letter then say goodbye
if (!shortcutListener.shortcut[letter]) return false;
if (typeof(shortcutListener.shortcut[letter]) == "function") {
shortcutListener.shortcut[letter](e, letter);
shortcutListener.clearCombination();
} else {
shortcutListener.shortcut = shortcutListener.shortcut[letter];
// append combination
shortcutListener.combination = shortcutListener.combination + letter;
if (shortcutListener.combination.length > 0) {
shortcutListener.showCombination();
// save last keypress timestamp (for autoclear)
var d = new Date;
shortcutListener.lastKeypress = d.getTime();
// autoclear combination in 2 seconds
setTimeout(shortcutListener.clearCombinationOnTimeout, shortcutListener.clearTimeout);
};
}
return true;
},
// clear combination
clearCombination: function() {
shortcutListener.shortcut = null;
shortcutListener.combination = '';
this.hideStatus();
},
clearCombinationOnTimeout: function() {
var d = new Date;
// check if last keypress was earlier than (now - clearTimeout)
// 100ms here is used just to be sure that this will work in superfast browsers :)
if ((d.getTime() - shortcutListener.lastKeypress) >= (shortcutListener.clearTimeout - 100)) {
shortcutListener.clearCombination();
}
}
}
// scoped querySelector, querySelectorAll
function $ (q, scope) {
if (!scope) scope = document;
return scope.querySelector(q);
}
function $$ (q, scope) {
if (!scope) scope = document;
return scope.querySelectorAll(q);
}
// convert to Array method
function $A (c) {
if (c instanceof Array) {
return c.slice();
} else {
var a=[];
for (var b = 0; b < c.length; b++) {
a.push(c[b]);
}
return a;
}
}
// ghetto element creation
function $E (tag, attrs, inner) {
let dom = document.createElement(tag);
if (attrs) {
let props = Object.getOwnPropertyNames(attrs);
[].forEach.call(props, function(key) dom.setAttribute(key, attrs[key]));
}
if (inner) dom.innerHTML = inner;
return dom;
}
// various utility methods and information about the current page
let DOM = (function () {
return {
// wrapper around `getBoundingClientRect` that gives absolute positions
get_bbox: el => {
let bb = el.getBoundingClientRect();
let sy = window.scrollY;
let sx = window.scrollX;
return {
top: bb.top + sy,
bottom: bb.bottom + sy,
left: bb.left + sx,
right: bb.right + sx,
height: bb.height,
width: bb.width
}
},
get_valid_rows: function () {
switch (DOM.page_type()) {
case "topic_list":
return [].filter.call($$(".grid tr"), row => !$("th", row));
case "message_list":
return $A($$(".message-container"));
default:
return [];
}
},
// check for Anonymous -- probably fragile (oh well)
get_username: function () {
if ($("h2 a[href*='/topics/Anonymous']")) return "Human";
let profile = $(".userbar a[href*='profile.php']");
let userpieces = profile.textContent.split(/\s+/);
let username = userpieces.slice(0, -1).join(" ");
return username;
},
page_type: () => {
if ($$('.grid tr').length) return "topic_list";
if ($$('.message-container').length) return "message_list";
return "";
}
}
})();
// Scroll(elem, [elem2]) =>
// Scrolls to a specific element `elem`. If `elem2` is provided it
// tries to scroll to that instead, unless `elem` would go out of view.
let Scroll = (function () {
let timers = [];
let current_elem = null;
let time = .3; // 300 ms
let divisions = time * 50;
let de = document.documentElement;
// returns a signed number that represents the direction and magnitude
// of scrolling necessary to put `obj` completely into view.
let findDiff = obj => {
let elem = DOM.get_bbox(obj);
let top = elem.top - de.scrollTop;
let bot = elem.bottom - (de.scrollTop + de.clientHeight);
return (top ^ bot) < 0 ? 0 : top < 0 ? top : bot;
}
let ease = p => -1 * (p * p) + 2 * p;
return (elem, elem2) => {
// don't do anything if we're already scrolling to `elem`
// otherwise, clear all the timers to prevent jittery scrolls.
if (elem === current_elem) {
return;
} else {
timers.forEach(clearTimeout);
timers = [];
current_elem = elem;
}
let diff = findDiff(elem);
if (elem2) {
// check to see that we're within bounds
let test = findDiff(elem2);
if (Math.abs(test) <= de.clientHeight) diff = test;
}
let scrollerFac = distance => () => window.scrollBy(0, distance);
let prev = 0;
for (let i = 0; i < time; i += time / divisions) {
let scrollby = (Math.round(ease(i / time) * diff));
timers.push(setTimeout(scrollerFac(scrollby - prev), i * 1000));
prev = scrollby;
}
};
})();
function insert_at_caret(text, target) {
let s = target.selectionStart;
let e = target.selectionEnd;
let o = target.value;
target.value = o.substring(0, s) + text + o.substring(s);
target.setSelectionRange(s + text.length, e + text.length);
}
function insert_like(target) {
let me = DOM.get_username();
let prof = $("a[href*='profile.php']", target);
let them = prof && prof.textContent || "Human";
let textarea = $(".quickpost-body textarea");
let img = '<img src="http://i4.endoftheinter.net/i/n/f818de60196ad15c888b7f2140a77744/like.png" />';
insert_at_caret(img + me + " likes " + them + "'s post.", textarea);
let submit = $("input[name='post']", textarea.parentNode);
//submit.click();
}
function onlike(target) {
let observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.target.classList.contains("quickpost-expanded")) {
insert_like(target);
observer.disconnect();
}
});
});
let config = {attributes: true, attributeFilter: ["class"]}
observer.observe($("body"), config);
$("a[href*='quote']", target).click();
}
// things involving the movement caret
let Caret = function () {
let rows = DOM.get_valid_rows();
if (rows == false) return;
// create the caret's DOM object
this.caret_dom = $E('div', {style: "left:0px;top:1px;position:absolute;width:8px"});
this.caret_dom.style.backgroundColor = getComputedStyle($(".menubar")).getPropertyValue("background-color");
document.body.appendChild(this.caret_dom);
this.set_initial_position();
}
Caret.prototype.set_initial_position = function () {
switch (DOM.page_type()) {
case "topic_list":
[].some.call(DOM.get_valid_rows(), (row, idx) => {
if (!$('b', row)) {
this.set_caret(idx);
return true;
}
}, this);
break;
case "message_list":
if (location.hash && $(location.hash)) {
DOM.get_valid_rows().forEach((row, idx) => {
if (row.id == location.hash.substr(1)) {
this.set_caret(idx);
}
});
} else {
this.set_caret(0);
}
break;
}
}
Caret.prototype.set_caret = function (idx) {
idx = +idx;
let rows = DOM.get_valid_rows();
if (rows == false) return;
if (idx < 0 || isNaN(idx)) idx = 0;
if (idx > rows.length - 1) idx = rows.length - 1;
this.caret_row = rows[idx];
let pos = DOM.get_bbox(this.caret_row);
this.caret_dom.style.left = pos.left - 8 + "px";
this.caret_dom.style.top = pos.top + "px";
this.caret_dom.style.height = pos.height + "px";
this.idx = idx;
if (idx == 0) {
Scroll(this.caret_row, $(".menubar")); // scroll to top
} else if (idx == rows.length - 1) {
Scroll(this.caret_row, $A($$("small")).pop()); // scroll to bottom
} else {
Scroll(this.caret_row);
}
};
Caret.prototype.make_mover = function (distance) {
distance = +distance;
return () => this.set_caret(this.idx + distance);
}
// opens the topic under the caret with the function `fn`
Caret.prototype.open_with = function (fn) {
return () => {
// find a span that links to showmessages.php since moneybags uses a span
let bookmark = $A($$('td a[href*="showmessages"]', this.caret_row)).pop();
fn((bookmark ? bookmark : $("a", this.caret_row)).href);
}
}
Caret.prototype.quote_post = function() {
return () => { // bind to this scope
let quoter = $('a[href*="postmsg.php"]', this.caret_row);
if (quoter) { quoter.click(); }
}
}
Caret.prototype.like_post = function() {
return () => onlike(this.caret_row)
}
Caret.prototype.maybe_next_page = function() {
return () => {
let rows = DOM.get_valid_rows();
if (this.idx == rows.length - 1) {
let next_page = $("#nextpage");
if (window.getComputedStyle(next_page).display !== "none") {
window.location.assign(next_page.href);
}
} else {
Caret.make_mover(10)();
}
}
}
let make_focus_div = title => $E('div', {"class": "focus-div", hidden: true, style: "position:fixed;background-color:black;border-radius:1em;opacity:.8;top:15%;left:15%;width:70%;color:white;padding:1em;text-align:center;z-index:9;font-size:120%;"}, '<h2 style="font-size:200%;color:white">' + title + '</h2>');
let help = (function () {
let help_div;
function create_help () {
let div = make_focus_div('Keyboard shortcuts');
function add(key, text) {
let line = $E('div');
line.appendChild($E("span", {style: "width:40%;font-family:monospace;text-align:right;display:inline-block;margin:1px 1%"}, key));
line.appendChild($E("span", {style: "display:inline-block;text-align:left;width:50%;margin:1px 1%"}, "&mdash;" + text));
div.appendChild(line);
}
function header(name) {
let line = $E('h3', false, name);
div.appendChild(line);
}
add('?', 'show or hide this help text');
add('Esc', 'close open dialogs (like this one)');
add('j', 'move cursor down');
add('k', 'move cursor up');
add('n', 'move cursor down x10, or next page if at end of message list');
add('p', 'move cursor up x10');
add('o', 'open topic (at topic bookmark, if present)');
add('shift-o', 'open topic in new tab (at topic bookmark)');
add('c', 'create topic');
add('s', 'search topics');
add('r', 'reply to / quote post (message list only)');
add('l', 'like post (message list only)');
add('g then h', 'go to the home page');
add('g then i', 'go to private messages');
add('g then b', 'go to LUE');
add('g then p', 'go to subscribed messages');
add('g then 1, 2, ..., 8', 'go to 1st, 2nd, ..., 8th tag bookmark');
document.body.appendChild(div);
return(div);
}
return function () {
if (!help_div) help_div = create_help();
help_div.hidden = !help_div.hidden;
}
})();
let search = (function () {
let search_div;
function create_search () {
let search = $('div.userbar > a[href*="search.php"]');
if (!search) return;
let search_target = search.href.split('=').pop();
let div = make_focus_div('Search ' + $('h1').textContent);
let frm = $E('form', {method: "get", action: "/topics/" + search_target});
frm.appendChild($E("input", {type: "text", autofocus: true, name: "q", style: "font-size:24px", id: "huge-search-box-input"}));
frm.appendChild($E("input", {type: "submit", value: "Submit", hidden: true}));
div.appendChild(frm);
document.body.appendChild(div);
return div;
}
return function () {
if (!search_div) search_div = create_search();
search_div.hidden = false;
$('input', search_div).select();
}
})();
let g_shortcuts = (function () {
let locs = {
'h': '/main.php',
'i': '/inbox.php',
'b': '/topics/LUE',
'p': '/topics/Posted+Starred'
};
function jump(ll) {
return function () {
location.assign(ll)
}
}
function bookmark_jump(num) {
// the bookmarks need to be read dynamically because they can change
// between page load and when the keyboard shortcut is called.
return function () {
num = +num - 1; // bookmark 1 is index 0
let bookmarks = $$('#bookmarks > span > a');
if (num in bookmarks) {
location.assign(bookmarks[num].href);
}
}
}
for (let ii in locs) {
if (locs.hasOwnProperty(ii)) locs[ii] = jump(locs[ii]);
}
for (let ii = 1; ii <= 8; ii++) {
locs[ii] = bookmark_jump(ii);
}
return locs;
})();
var SHORTCUTS;
function init() {
Caret = new Caret();
SHORTCUTS = {
'?': help,
'j': Caret.make_mover(1),
'k': Caret.make_mover(-1),
'n': Caret.maybe_next_page(),
'p': Caret.make_mover(-10),
'o': Caret.open_with(url => location.assign(url)), // can't pass native functions as function parameters
'O': Caret.open_with(GM.openInTab),
'r': Caret.quote_post(),
'l': Caret.like_post(),
's': search,
'g': g_shortcuts,
'c': function () { location.assign($("a[href*='postmsg.php']")) },
'Esc': function () { [].forEach.call($$(".focus-div"), function (el) el.hidden = true)}
}
shortcutListener.init();
}
// start up the keyboard shortcut helper
// if it's the home page wait for TOTM to load, otherwise load immediately
if ($("#totm")) {
let observer = new MutationObserver(mutations => {
mutations.forEach(function (mutation) {
// melonwolf is an <img> element, not <table>
if (mutation.target.id == "totm" && $("table", mutation.target)) {
init();
observer.disconnect();
}
});
});
let config = {childList: true};
observer.observe($("#totm"), config);
} else {
init()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment