// ==UserScript==
// @name Reddit Scroller
// @namespace https://github.com/YokoAC/reddit-scroller
// @version 0.1.5
// @description Auto-scrolls Reddit feeds and threads, driven by the numpad, with an on-screen HUD. An optional Windows companion keeps the keys working while another application, such as a full-screen game, has focus.
// @author YokoAC
// @license MIT
// @homepageURL https://github.com/YokoAC/reddit-scroller
// @supportURL https://github.com/YokoAC/reddit-scroller/issues
// @updateURL https://raw.githubusercontent.com/YokoAC/reddit-scroller/main/userscript/dist/reddit-scroller.user.js
// @downloadURL https://raw.githubusercontent.com/YokoAC/reddit-scroller/main/userscript/dist/reddit-scroller.user.js
// @match https://www.reddit.com/*
// @compatible firefox
// @compatible chrome
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @connect 127.0.0.1
// @run-at document-idle
// @noframes
// ==/UserScript==
(() => {
// src/commands.js
function detectMode(pathname) {
return pathname.includes("/comments/") ? "thread" : "feed";
}
var ACTIONS = {
toggle: { feed: "toggleScroll", thread: "toggleScroll" },
faster: { feed: "speedUp", thread: "speedUp" },
slower: { feed: "speedDown", thread: "speedDown" },
open: { feed: "openSelected", thread: "noop" },
back: { feed: "noop", thread: "goBack" },
next: { feed: "selectNext", thread: "pageDown" },
prev: { feed: "selectPrev", thread: "pageUp" },
reverse: { feed: "flipDirection", thread: "flipDirection" },
help: { feed: "toggleHelp", thread: "toggleHelp" },
standby: { feed: "toggleStandby", thread: "toggleStandby" },
image_prev: { feed: "imagePrev", thread: "imagePrev" },
image_next: { feed: "imageNext", thread: "imageNext" }
};
function resolveAction(command, mode) {
const byMode = ACTIONS[command];
if (!byMode) return "noop";
return byMode[mode] || "noop";
}
var KEY_CODES = {
Numpad0: "toggle",
NumpadEnter: "open",
NumpadDecimal: "back",
NumpadAdd: "faster",
NumpadSubtract: "slower",
Numpad8: "prev",
Numpad2: "next",
Numpad5: "reverse",
NumpadMultiply: "help",
Numpad1: "standby",
Numpad4: "image_prev",
Numpad6: "image_next"
};
var DEFAULT_BINDINGS = {
toggle: "numpad0",
open: "numpad_enter",
back: "numpad_dot",
faster: "numpad_plus",
slower: "numpad_minus",
prev: "numpad8",
next: "numpad2",
reverse: "numpad5",
help: "numpad_star",
standby: "numpad1",
image_prev: "numpad4",
image_next: "numpad6"
};
// src/bindings.js
var PRESETS = {
// The defaults, kept in commands.js; here only turned command -> key.
numpad: Object.fromEntries(
Object.entries(KEY_CODES).map(([code, command]) => [command, code])
),
// Only keys that sit in the same place on QWERTY and QWERTZ.
laptop: {
toggle: "Space",
open: "Enter",
back: "Backspace",
faster: "KeyF",
slower: "KeyS",
prev: "ArrowUp",
next: "ArrowDown",
reverse: "KeyR",
help: "KeyH",
standby: "KeyO",
image_prev: "ArrowLeft",
image_next: "ArrowRight"
}
};
var COMMANDS = Object.keys(PRESETS.numpad);
var NAMED = {
NumpadDecimal: "Num .",
NumpadAdd: "Num +",
NumpadSubtract: "Num \u2212",
NumpadMultiply: "Num *",
NumpadDivide: "Num /",
NumpadEnter: "Num Enter",
Space: "Space",
Enter: "Enter",
Backspace: "Backspace",
Tab: "Tab",
Delete: "Delete",
ArrowUp: "\u2191",
ArrowDown: "\u2193",
ArrowLeft: "\u2190",
ArrowRight: "\u2192"
};
function labelFor(code, key) {
if (NAMED[code]) return NAMED[code];
const numpad = code.match(/^Numpad(\d)$/);
if (numpad) return `Num ${numpad[1]}`;
if (typeof key === "string" && key.length === 1) return key.toUpperCase();
const plain = code.match(/^(?:Key|Digit)(.)$/);
return plain ? plain[1] : code;
}
function fromPreset(name) {
return Object.fromEntries(
Object.entries(PRESETS[name]).map(([command, code]) => [
command,
{ code, label: labelFor(code) }
])
);
}
function parseStored(raw) {
let stored = raw;
if (typeof raw === "string") {
try {
stored = JSON.parse(raw);
} catch {
stored = null;
}
}
if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
stored = {};
}
const defaults = fromPreset("numpad");
const result = {};
const taken = /* @__PURE__ */ new Set();
for (const command of COMMANDS) {
const entry = stored[command];
let binding = defaults[command];
if (entry === null) {
binding = null;
} else if (entry && typeof entry.code === "string" && typeof entry.label === "string") {
binding = { code: entry.code, label: entry.label };
}
if (binding && taken.has(binding.code)) binding = null;
if (binding) taken.add(binding.code);
result[command] = binding;
}
return result;
}
function toCodes(bindings) {
return Object.fromEntries(
COMMANDS.map((command) => [command, bindings[command]?.code ?? null])
);
}
function fromCodes(codes) {
const source = codes && typeof codes === "object" ? codes : {};
return Object.fromEntries(
COMMANDS.map((command) => {
const code = source[command];
return [
command,
typeof code === "string" ? { code, label: labelFor(code) } : null
];
})
);
}
function assign(bindings, command, code, key) {
const next = {};
for (const [other, binding] of Object.entries(bindings)) {
next[other] = binding?.code === code ? null : binding;
}
next[command] = { code, label: labelFor(code, key) };
return next;
}
function commandFor(bindings, code) {
for (const [command, binding] of Object.entries(bindings)) {
if (binding?.code === code) return command;
}
return null;
}
function labelsOf(bindings) {
return Object.fromEntries(
Object.entries(bindings).filter(([, binding]) => binding).map(([command, binding]) => [command, binding.label])
);
}
// src/gallery.js
var BUTTONS = {
prev: '[slot="prevButton"] button',
next: '[slot="nextButton"] button'
};
function deepQuery(root, selector) {
const direct = root.querySelector(selector);
if (direct) return direct;
for (const host of [root, ...root.querySelectorAll("*")]) {
if (host.shadowRoot) {
const found = deepQuery(host.shadowRoot, selector);
if (found) return found;
}
}
return null;
}
function stepGallery(post, direction) {
if (!post) return false;
const button = deepQuery(post, BUTTONS[direction]);
if (!button || button.getAttribute("aria-disabled") === "true") return false;
button.click();
return true;
}
// src/selection.js
var HIGHLIGHT_CLASS = "rs-selected";
var POST_SELECTOR = "shreddit-post";
function rankPosts(rects, focusY, viewportHeight) {
let best = -1;
let bestDistance = Infinity;
rects.forEach((rect, index) => {
if (rect.bottom <= 0 || rect.top >= viewportHeight) return;
const distance = Math.abs(rect.top - focusY);
if (distance < bestDistance) {
bestDistance = distance;
best = index;
}
});
return best;
}
function readPosts(root) {
return Array.from(root.querySelectorAll(POST_SELECTOR)).filter((element) => element.getAttribute("permalink")).map((element) => ({
element,
permalink: element.getAttribute("permalink"),
title: element.getAttribute("post-title") || "",
subreddit: element.getAttribute("subreddit-prefixed-name") || "",
score: Number(element.getAttribute("score") || 0)
}));
}
var Selection = class {
constructor({ root, getViewportHeight, focusLine }) {
this._root = root;
this._getViewportHeight = getViewportHeight;
this._focusLine = focusLine;
this._posts = [];
this._index = -1;
this._pinned = null;
}
get count() {
return this._posts.length;
}
get focusLine() {
return this._focusLine;
}
setFocusLine(fraction) {
this._focusLine = fraction;
}
get selectedElement() {
const post = this._posts[this._index];
return post ? post.element : null;
}
get selected() {
const post = this._posts[this._index];
if (!post) return null;
const { permalink, title, subreddit, score } = post;
return { permalink, title, subreddit, score };
}
refresh() {
this._posts = readPosts(this._root);
if (this._posts.length === 0) {
this._index = -1;
return;
}
const viewportHeight = this._getViewportHeight();
const rects = this._posts.map(
(post) => post.element.getBoundingClientRect()
);
if (this._pinned !== null) {
const pinnedIndex = this._posts.findIndex(
(post) => post.permalink === this._pinned
);
const rect = rects[pinnedIndex];
const stillVisible = rect && rect.bottom > 0 && rect.top < viewportHeight;
if (stillVisible) {
this._index = pinnedIndex;
return;
}
this._pinned = null;
}
this._index = rankPosts(
rects,
viewportHeight * this._focusLine,
viewportHeight
);
}
move(delta) {
if (this._posts.length === 0) return null;
const from = this._index === -1 ? delta > 0 ? -1 : 0 : this._index;
this._index = Math.min(this._posts.length - 1, Math.max(0, from + delta));
this._pinned = this._posts[this._index].permalink;
return this.selectedElement;
}
/** Strip the outline without forgetting which post is current. */
clearHighlight() {
this._root.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((element) => {
element.classList.remove(HIGHLIGHT_CLASS);
});
}
applyHighlight() {
const wanted = this.selectedElement;
this._root.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((element) => {
if (element !== wanted) element.classList.remove(HIGHLIGHT_CLASS);
});
if (wanted) wanted.classList.add(HIGHLIGHT_CLASS);
}
};
// src/hud.js
var HUD_ID = "rs-hud";
var BAR_CELLS = 12;
var STYLE_ID = "rs-style";
var EDIT_GRACE_MS = 500;
var CSS = `
#${HUD_ID} {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 2147483647;
width: 320px;
padding: 14px 16px;
border-radius: 10px;
background: rgba(16, 16, 20, 0.92);
color: #f2f2f2;
font: 500 17px/1.35 "Segoe UI", system-ui, sans-serif;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
pointer-events: none;
user-select: none;
}
#${HUD_ID} .rs-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
#${HUD_ID} .rs-status { font-weight: 700; letter-spacing: 0.04em; }
#${HUD_ID} .rs-running { color: #56d364; }
#${HUD_ID} .rs-paused { color: #e3b341; }
#${HUD_ID} .rs-online { color: #56d364; font-size: 14px; }
/* Amber, not red: the script is working, it is simply doing it alone. */
#${HUD_ID} .rs-offline { color: #e3b341; font-size: 14px; }
#${HUD_ID} .rs-dormant { color: #8b949e; }
/* Collapsed keeps the first row -- status and daemon -- and drops the
rest. A dormant script that draws nothing looks like a broken one. */
#${HUD_ID}.rs-collapsed { width: auto; opacity: 0.8; }
#${HUD_ID}.rs-collapsed > *:not(:first-child) { display: none; }
/* The one clickable thing on a panel that otherwise lets every click through. */
#${HUD_ID} .rs-daemon { margin-left: auto; }
#${HUD_ID} .rs-gear {
pointer-events: auto;
cursor: pointer;
padding: 0 2px;
border: 0;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 1;
opacity: 0.6;
}
#${HUD_ID} .rs-gear:hover, #${HUD_ID} .rs-gear:focus-visible { opacity: 1; }
#${HUD_ID} .rs-speed { pointer-events: auto; cursor: text; }
#${HUD_ID} .rs-speed:hover { text-decoration: underline dotted; }
#${HUD_ID} .rs-speed-input {
pointer-events: auto;
width: 96px;
padding: 0 4px;
border: 1px solid #58a6ff;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
color: inherit;
font: inherit;
}
#${HUD_ID} .rs-rule {
height: 1px;
margin: 9px 0;
background: rgba(255, 255, 255, 0.16);
}
#${HUD_ID} .rs-bar {
font-family: "Cascadia Mono", Consolas, monospace;
letter-spacing: 1px;
color: #58a6ff;
}
#${HUD_ID} .rs-mode { font-size: 14px; opacity: 0.65; letter-spacing: 0.08em; }
#${HUD_ID} .rs-sub { font-size: 14px; opacity: 0.75; }
#${HUD_ID} .rs-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
#${HUD_ID} .rs-flash { font-size: 14px; color: #58a6ff; min-height: 19px; }
#${HUD_ID} .rs-help {
margin-top: 9px;
padding-top: 9px;
border-top: 1px solid rgba(255, 255, 255, 0.16);
font-size: 14px;
}
#${HUD_ID} .rs-help-row {
display: flex;
gap: 10px;
padding: 1px 0;
opacity: 0.85;
}
#${HUD_ID} .rs-help-key {
flex: 0 0 76px;
font-family: "Cascadia Mono", Consolas, monospace;
color: #58a6ff;
}
.${HIGHLIGHT_CLASS} {
outline: 3px solid #58a6ff !important;
outline-offset: 2px;
border-radius: 8px;
}
`;
var KEY_LABELS = {
numpad0: "Num 0",
numpad1: "Num 1",
numpad2: "Num 2",
numpad3: "Num 3",
numpad4: "Num 4",
numpad5: "Num 5",
numpad6: "Num 6",
numpad7: "Num 7",
numpad8: "Num 8",
numpad9: "Num 9",
numpad_dot: "Num .",
numpad_plus: "Num +",
numpad_minus: "Num \u2212",
numpad_star: "Num *",
numpad_slash: "Num /",
numpad_enter: "Num Enter"
};
var HELP_ORDER = [
["toggle", "pause / resume"],
["faster", "speed up (hold to ramp)"],
["slower", "slow down (hold to ramp)"],
["reverse", "flip scroll direction"],
["next", "next post / page down"],
["prev", "previous post / page up"],
["image_prev", "previous image in a gallery"],
["image_next", "next image in a gallery"],
["open", "open selected post"],
["back", "back to the feed"],
["help", "show or hide the key list"],
["standby", "switch the script off / on"]
];
function helpRows(bindings) {
const source = bindings && Object.keys(bindings).length ? bindings : DEFAULT_BINDINGS;
const rows = [];
for (const [command, action] of HELP_ORDER) {
const name = source[command];
if (!name) continue;
rows.push({ command, action, key: KEY_LABELS[name] || name });
}
return rows;
}
function formatHud(state) {
const span = Math.max(1, state.speedMax - state.speedMin);
const filled = Math.round(
(state.speed - state.speedMin) / span * BAR_CELLS
);
const clamped = Math.min(BAR_CELLS, Math.max(0, filled));
let subreddit = "";
let title = "";
if (state.mode === "feed") {
if (state.selected) {
subreddit = state.selected.subreddit;
title = `\u201C${state.selected.title}\u201D`;
} else {
title = state.postCount === 0 ? "no posts detected" : "no post in focus";
}
}
const status = state.standby ? { text: "OFF", cls: "rs-dormant" } : state.running ? { text: "SCROLLING", cls: "rs-running" } : { text: "PAUSED", cls: "rs-paused" };
return {
status: status.text,
statusClass: status.cls,
collapsed: Boolean(state.standby),
speed: `${state.direction === -1 ? "\u25B2" : "\u25BC"} ${Math.round(state.speed)} px/s`,
bar: "\u2593".repeat(clamped) + "\u2591".repeat(BAR_CELLS - clamped),
mode: state.mode.toUpperCase(),
subreddit,
title,
// "browser only", not "no daemon": name the mode that runs. Every key works,
// just not while another window has focus; amber says degraded, not broken.
daemon: state.daemonConnected ? "daemon" : "browser only",
daemonClass: state.daemonConnected ? "rs-online" : "rs-offline",
flash: state.lastCommand ? state.lastCommand.toUpperCase() : ""
};
}
var Hud = class {
constructor(doc, { onSettings, onSpeed } = {}) {
this._doc = doc;
this._onSettings = onSettings;
this._onSpeed = onSpeed;
this._root = null;
this._nodes = null;
this._last = null;
this._input = null;
this._editEndedAt = Number.NEGATIVE_INFINITY;
}
/** Whether a speed is being typed, or was until a moment ago. */
get editing() {
return this._input !== null || Date.now() - this._editEndedAt < EDIT_GRACE_MS;
}
mount() {
const existing = this._doc.getElementById(HUD_ID);
if (existing) {
this._root = existing;
this._nodes = this._collect(existing);
this._wire(existing);
return;
}
if (!this._doc.getElementById(STYLE_ID)) {
const style = this._doc.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
this._doc.head.appendChild(style);
}
const root = this._doc.createElement("div");
root.id = HUD_ID;
root.innerHTML = `
`;
this._doc.body.appendChild(root);
this._root = root;
this._nodes = this._collect(root);
this._wire(root);
}
_wire(root) {
root.querySelector(".rs-gear")?.addEventListener("click", () => this._onSettings?.());
root.querySelector(".rs-speed").addEventListener("click", () => this._editSpeed());
}
/** Swap the speed text for a number field until Enter, Escape or blur. */
_editSpeed() {
if (this._input || !this._last) return;
const text = this._nodes.speed;
const input = this._doc.createElement("input");
input.type = "number";
input.className = "rs-speed-input";
input.min = "1";
input.max = String(this._last.speedMax);
input.value = String(Math.round(this._last.speed));
input.setAttribute("aria-label", "Scroll speed in pixels per second");
const finish = (commit) => {
if (this._input !== input) return;
this._input = null;
this._editEndedAt = Date.now();
const value = Math.round(Number(input.value));
input.remove();
text.hidden = false;
if (commit && input.value.trim() !== "" && value >= 1) {
this._onSpeed?.(value);
}
};
input.addEventListener("keydown", (event) => {
event.stopPropagation();
if (event.key === "Enter") finish(true);
else if (event.key === "Escape") finish(false);
});
input.addEventListener("blur", () => finish(true));
text.hidden = true;
text.after(input);
this._input = input;
input.focus();
input.select();
}
_collect(root) {
return {
status: root.querySelector(".rs-status"),
daemon: root.querySelector(".rs-daemon"),
speed: root.querySelector(".rs-speed"),
bar: root.querySelector(".rs-bar"),
mode: root.querySelector(".rs-mode"),
sub: root.querySelector(".rs-sub"),
title: root.querySelector(".rs-title"),
flash: root.querySelector(".rs-flash"),
help: root.querySelector(".rs-help")
};
}
render(state) {
if (!this._nodes) return;
this._last = state;
const view = formatHud(state);
const n = this._nodes;
this._root.classList.toggle("rs-collapsed", view.collapsed);
n.status.textContent = view.status;
n.status.className = `rs-status ${view.statusClass}`;
n.daemon.textContent = `\u25CF ${view.daemon}`;
n.daemon.className = `rs-daemon ${view.daemonClass}`;
n.speed.textContent = view.speed;
n.bar.textContent = view.bar;
n.mode.textContent = view.mode;
n.sub.textContent = view.subreddit;
n.title.textContent = view.title;
n.flash.textContent = view.flash;
this._renderHelp(state);
}
_renderHelp(state) {
const node = this._nodes.help;
if (!node) return;
node.hidden = !state.helpVisible;
if (!state.helpVisible) return;
const rows = helpRows(state.bindings);
const signature = JSON.stringify(rows);
if (node.dataset.signature === signature) return;
node.dataset.signature = signature;
node.textContent = "";
for (const row of rows) {
const line = this._doc.createElement("div");
line.className = "rs-help-row";
const key = this._doc.createElement("span");
key.className = "rs-help-key";
key.textContent = row.key;
const action = this._doc.createElement("span");
action.textContent = row.action;
line.append(key, action);
node.appendChild(line);
}
}
unmount() {
if (this._root?.parentNode) {
this._root.parentNode.removeChild(this._root);
}
this._root = null;
this._nodes = null;
}
};
// src/panel.js
var PANEL_ID = "rs-bindings";
var STYLE_ID2 = "rs-bindings-style";
var CSS2 = `
#${PANEL_ID} {
position: fixed;
right: 16px;
z-index: 2147483647;
/* border-box, so the padding counts towards the height limit set in _place. */
box-sizing: border-box;
width: 460px;
max-width: calc(100vw - 32px);
overflow: auto;
padding: 18px 20px;
border-radius: 10px;
background: rgb(16, 16, 20);
color: #f2f2f2;
font: 500 15px/1.4 "Segoe UI", system-ui, sans-serif;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6);
}
#${PANEL_ID} .rs-bindings-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
#${PANEL_ID} h2 { margin: 0; font-size: 17px; color: #f2f2f2; }
#${PANEL_ID} .rs-bindings-note { margin: 0 0 10px; font-size: 13px; color: #e3b341; }
#${PANEL_ID} .rs-bindings-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 3px 0;
}
#${PANEL_ID} button {
font: inherit;
color: #f2f2f2;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 3px 10px;
cursor: pointer;
}
#${PANEL_ID} button:hover, #${PANEL_ID} button:focus-visible { border-color: #58a6ff; }
#${PANEL_ID} button[data-command] {
min-width: 120px;
font-family: "Cascadia Mono", Consolas, monospace;
color: #58a6ff;
}
/* Amber, as on the HUD: nothing is broken, the action just has no key. */
#${PANEL_ID} button[data-unbound] {
color: #e3b341;
border-color: rgba(227, 179, 65, 0.5);
}
#${PANEL_ID} .rs-bindings-foot {
display: flex;
gap: 8px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.16);
}
/* A bare icon, like the gear that opens the panel. */
#${PANEL_ID} button[data-close] {
padding: 0 2px;
border: 0;
background: none;
font-size: 22px;
line-height: 1;
opacity: 0.6;
}
#${PANEL_ID} button[data-close]:hover,
#${PANEL_ID} button[data-close]:focus-visible { opacity: 1; }
`;
var MODIFIER = /^(Shift|Control|Alt|Meta|OS)/;
var BindingsPanel = class {
/**
* @param rows [command, description] pairs, in display order.
* The panel holds no bindings itself: it reads them through `getBindings`
* and reports changes through `onAssign` and `onPreset`.
*/
constructor(doc, { rows, getBindings, getNote, onAssign, onPreset, anchor }) {
this._doc = doc;
this._rows = rows;
this._getBindings = getBindings;
this._getNote = getNote;
this._onAssign = onAssign;
this._onPreset = onPreset;
this._anchor = anchor;
this._root = null;
this._capturing = null;
}
get open() {
return this._root !== null;
}
/** The command waiting for a key, or null. */
get capturing() {
return this._capturing;
}
toggle() {
if (this.open) this.hide();
else this.show();
}
show() {
if (this.open) return;
if (!this._doc.getElementById(STYLE_ID2)) {
const style = this._doc.createElement("style");
style.id = STYLE_ID2;
style.textContent = CSS2;
this._doc.head.appendChild(style);
}
const root = this._doc.createElement("div");
root.id = PANEL_ID;
root.setAttribute("role", "dialog");
root.setAttribute("aria-label", "Key bindings");
root.addEventListener("click", (event) => this._onClick(event));
this._place(root);
this._doc.body.appendChild(root);
this._root = root;
this.render();
}
// Just above the HUD it was opened from, rather than over the post being
// read. If it is taller than the room there, it scrolls inside itself.
_place(root) {
const anchor = this._anchor?.();
const view = this._doc.defaultView;
const gap = anchor ? Math.round(view.innerHeight - anchor.getBoundingClientRect().top) + 8 : 16;
root.style.bottom = `${gap}px`;
root.style.maxHeight = `calc(100vh - ${gap + 16}px)`;
}
hide() {
this._root?.remove();
this._root = null;
this._capturing = null;
}
/**
* Offer a keydown to the panel. Returns whether it took it: every key while
* open, so that rebinding never also runs the command being rebound.
*/
handleKey(event) {
if (!this.open) return false;
event.preventDefault();
event.stopPropagation();
if (event.code === "Escape") {
if (this._capturing) this._capturing = null;
else this.hide();
} else if (this._capturing && !MODIFIER.test(event.code)) {
this._onAssign(this._capturing, event.code, event.key);
this._capturing = null;
}
this.render();
return true;
}
render() {
if (!this.open) return;
const bindings = this._getBindings();
const root = this._root;
root.textContent = "";
const head = this._doc.createElement("div");
head.className = "rs-bindings-head";
const title = this._doc.createElement("h2");
title.textContent = "Key bindings";
const close = this._button("\xD7", "close", "");
close.setAttribute("aria-label", "Close");
head.append(title, close);
const note = this._doc.createElement("p");
note.className = "rs-bindings-note";
const text = this._getNote?.() ?? null;
note.textContent = text ?? "";
note.hidden = text === null;
root.append(head, note);
for (const [command, description] of this._rows) {
const row = this._doc.createElement("div");
row.className = "rs-bindings-row";
const label = this._doc.createElement("span");
label.textContent = description;
const button = this._button(
this._capturing === command ? "press a key\u2026" : bindings[command]?.label ?? "unbound",
"command",
command
);
if (!bindings[command] && this._capturing !== command) {
button.dataset.unbound = "";
}
row.append(label, button);
root.append(row);
}
const foot = this._doc.createElement("div");
foot.className = "rs-bindings-foot";
foot.append(
this._button("Numpad (default)", "preset", "numpad"),
this._button("Laptop", "preset", "laptop")
);
root.append(foot);
}
_button(text, dataKey, dataValue) {
const button = this._doc.createElement("button");
button.type = "button";
button.textContent = text;
button.dataset[dataKey] = dataValue;
return button;
}
_onClick(event) {
const button = event.target.closest?.("button");
if (!button) return;
const { command, preset } = button.dataset;
if (command) {
this._capturing = command;
} else if (preset) {
this._capturing = null;
this._onPreset(preset);
} else {
this.hide();
return;
}
this.render();
}
};
// src/scroll.js
var MAX_FRAME_SECONDS = 0.1;
var EXACT_MIN = 1;
function clampSpeed(speed, min, max) {
if (Number.isNaN(speed)) return min;
return Math.min(max, Math.max(min, speed));
}
var ScrollEngine = class {
constructor({
scrollBy,
requestFrame,
cancelFrame,
speed,
min,
max,
step,
// Whether `speed` is a deliberate choice (e.g. persisted from a previous
// session) rather than the built-in default. See seedDefaultSpeed().
seeded = false,
// Whether `speed` was typed by hand, and so may sit below `min`.
exact = false
}) {
this._scrollBy = scrollBy;
this._requestFrame = requestFrame;
this._cancelFrame = cancelFrame;
this._min = min;
this._max = max;
this._step = step;
this._speed = clampSpeed(speed, exact ? EXACT_MIN : min, max);
this._seeded = seeded;
this._exact = exact;
this._direction = 1;
this._running = false;
this._frame = null;
this._lastTimestamp = null;
this._remainder = 0;
}
get running() {
return this._running;
}
get speed() {
return this._speed;
}
/** Whether the speed was typed by hand rather than reached with the keys. */
get exact() {
return this._exact;
}
/** +1 scrolls down the page, -1 scrolls back up. Speed stays positive. */
get direction() {
return this._direction;
}
flipDirection() {
this._direction = -this._direction;
this._remainder = 0;
return this._direction;
}
get step() {
return this._step;
}
setSpeed(pxPerSecond) {
this._exact = false;
this._speed = clampSpeed(pxPerSecond, this._min, this._max);
this._remainder = 0;
return this._speed;
}
/**
* A speed chosen by hand, from EXACT_MIN to the maximum. The minimum bounds
* only the keys, and later daemon limits will not raise it.
*/
setExactSpeed(pxPerSecond) {
this._seeded = true;
this._exact = true;
this._speed = clampSpeed(pxPerSecond, EXACT_MIN, this._max);
this._remainder = 0;
return this._speed;
}
/**
* One press of a speed key: move to the next multiple of |delta| in its
* direction. Not `speed + delta` -- with a minimum that is not a multiple
* of the step, that walks off the steps for good (5, 20, 35, ...).
*/
adjustSpeed(delta) {
this._seeded = true;
const step = Math.abs(delta);
const next = delta > 0 ? Math.floor(this._speed / step) * step + step : Math.ceil(this._speed / step) * step - step;
return this.setSpeed(next);
}
/**
* Adopt a daemon-configured default speed, unless already seeded (persisted
* speed, an earlier call, or adjustSpeed()). Safe on every reconnect: it
* cannot undo a live +/- adjustment.
*/
seedDefaultSpeed(pxPerSecond) {
if (this._seeded) return this._speed;
this._seeded = true;
return this.setSpeed(pxPerSecond);
}
/** Adopt limits reported by the daemon, re-clamping the current speed. */
setLimits(min, max) {
this._min = min;
this._max = max;
return this._exact ? this.setExactSpeed(this._speed) : this.setSpeed(this._speed);
}
start() {
if (this._running) return true;
this._running = true;
this._lastTimestamp = null;
this._remainder = 0;
this._frame = this._requestFrame((t) => this.tick(t));
return true;
}
stop() {
if (!this._running) return false;
this._running = false;
if (this._frame !== null) this._cancelFrame(this._frame);
this._frame = null;
this._lastTimestamp = null;
return false;
}
toggle() {
return this._running ? this.stop() : this.start();
}
tick(timestampMs) {
if (!this._running) return;
if (this._lastTimestamp !== null) {
const dt = Math.min(
MAX_FRAME_SECONDS,
(timestampMs - this._lastTimestamp) / 1e3
);
this._remainder += this._speed * this._direction * dt;
const whole = Math.trunc(this._remainder);
if (whole !== 0) {
this._remainder -= whole;
this._scrollBy(whole);
}
}
this._lastTimestamp = timestampMs;
this._frame = this._requestFrame((t) => this.tick(t));
}
};
// src/transport.js
var MAX_BACKOFF_MS = 5e3;
var DEFAULT_PORT = 8765;
function normalisePort(raw) {
const port = typeof raw === "string" ? Number(raw.trim()) : Number(raw);
if (!Number.isInteger(port) || port < 1 || port > 65535) return DEFAULT_PORT;
return port;
}
function nextBackoff(current) {
if (!current) return 1e3;
return Math.min(current * 2, MAX_BACKOFF_MS);
}
function gmRequest({ method, url, body }) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
data: body,
headers: body ? { "Content-Type": "application/json" } : void 0,
timeout: 4e4,
onload: (r) => resolve({ status: r.status, text: r.responseText }),
onerror: () => reject(new Error(`request to ${url} failed`)),
ontimeout: () => reject(new Error(`request to ${url} timed out`))
});
});
}
var Transport = class {
constructor({ port, request, sleep, onCommands, onConnectionChange }) {
this._base = `http://127.0.0.1:${port}`;
this._request = request;
this._sleep = sleep;
this._onCommands = onCommands;
this._onConnectionChange = onConnectionChange;
this._cursor = 0;
this._backoff = 0;
this._running = false;
this._connected = null;
this._settings = null;
}
get connected() {
return this._connected;
}
get settings() {
return this._settings;
}
stop() {
this._running = false;
}
async start() {
this._running = true;
while (this._running) {
try {
if (this._settings === null) {
const health = await this._json("GET", "/health");
this._settings = health.settings || {};
if (typeof health.cursor === "number") this._cursor = health.cursor;
}
const body = await this._json("GET", `/events?cursor=${this._cursor}`);
this._setConnected(true);
this._backoff = 0;
if (typeof body.cursor === "number") this._cursor = body.cursor;
const commands = (body.events || []).map((event) => event.command);
if (commands.length) this._onCommands(commands);
} catch {
this._setConnected(false);
if (!this._running) break;
this._backoff = nextBackoff(this._backoff);
await this._sleep(this._backoff);
}
}
}
async postState(state) {
try {
await this._request({
method: "POST",
url: `${this._base}/state`,
body: JSON.stringify(state)
});
} catch {
}
}
/**
* Send the page's key bindings for the daemon to listen for. Never throws:
* `ok` is false for a daemon that is down, predates the endpoint, or
* refuses, and the caller then leaves the daemon to its own keys.
*/
async postBindings(codes) {
const failed = { ok: false, unsupported: [] };
try {
const response = await this._request({
method: "POST",
url: `${this._base}/bindings`,
body: JSON.stringify({ bindings: codes })
});
if (response.status !== 200) return failed;
const body = JSON.parse(response.text);
if (body.ok !== true) return failed;
return {
ok: true,
unsupported: Array.isArray(body.unsupported) ? body.unsupported : []
};
} catch {
return failed;
}
}
async _json(method, path) {
const response = await this._request({
method,
url: `${this._base}${path}`
});
if (response.status !== 200) {
throw new Error(`${path} returned ${response.status}`);
}
return JSON.parse(response.text);
}
_setConnected(value) {
if (this._connected === value) return;
this._connected = value;
this._onConnectionChange(value);
}
};
// src/main.js
var PORT_KEY = "rs-port";
var STANDBY_KEY = "rs-standby";
var BINDINGS_KEY = "rs-bindings";
var SPEED_KEY = "rs-speed";
var STATE_KEY = "rs-scroll-state";
var FLASH_MS = 900;
var HELP_AUTOSHOW_MS = 6e3;
var DEFAULTS = {
speed_min: 5,
speed_max: 600,
speed_step: 15,
default_speed: 90,
focus_line: 0.25
};
function asSpeed(value) {
const speed = typeof value === "number" ? value : value?.speed;
if (typeof speed !== "number" || !Number.isFinite(speed) || speed <= 0) {
return null;
}
return { speed, exact: value?.exact === true };
}
function loadPersisted() {
try {
const own = asSpeed(JSON.parse(sessionStorage.getItem(STATE_KEY)));
if (own) return own;
} catch {
}
try {
return asSpeed(GM_getValue(SPEED_KEY));
} catch {
return null;
}
}
function persist(state) {
try {
sessionStorage.setItem(STATE_KEY, JSON.stringify(state));
} catch {
}
}
function persistChosen(state) {
try {
GM_setValue(SPEED_KEY, state);
} catch {
}
}
function loadPort() {
try {
const stored = GM_getValue(PORT_KEY);
if (stored === void 0 || stored === null || stored === "") {
GM_setValue(PORT_KEY, DEFAULT_PORT);
return DEFAULT_PORT;
}
return normalisePort(stored);
} catch {
return DEFAULT_PORT;
}
}
function loadStandby() {
try {
const stored = GM_getValue(STANDBY_KEY);
if (stored === void 0 || stored === null || stored === "") {
GM_setValue(STANDBY_KEY, false);
return false;
}
return stored === true || stored === "true";
} catch {
return false;
}
}
function saveStandby(value) {
try {
GM_setValue(STANDBY_KEY, value);
} catch {
}
}
function loadBindings() {
try {
const stored = GM_getValue(BINDINGS_KEY);
const customised = stored !== void 0 && stored !== null && stored !== "";
return { bindings: parseStored(stored), customised };
} catch {
return { bindings: fromPreset("numpad"), customised: false };
}
}
function saveBindings(bindings) {
try {
GM_setValue(BINDINGS_KEY, bindings);
} catch {
}
}
function boot() {
const settings = { ...DEFAULTS };
const persisted = loadPersisted();
const engine = new ScrollEngine({
scrollBy: (dy) => window.scrollBy(0, dy),
requestFrame: (cb) => window.requestAnimationFrame(cb),
cancelFrame: (id) => window.cancelAnimationFrame(id),
speed: persisted?.speed ?? settings.default_speed,
min: settings.speed_min,
max: settings.speed_max,
step: settings.speed_step,
// A persisted speed is a deliberate prior choice; the daemon's
// default_speed must not override it once it arrives.
seeded: typeof persisted?.speed === "number",
exact: persisted?.exact === true
});
const selection = new Selection({
root: document,
getViewportHeight: () => window.innerHeight,
focusLine: settings.focus_line
});
const hud = new Hud(document, {
onSettings: () => toggleBindings(),
onSpeed: (value) => {
engine.setExactSpeed(value);
rememberSpeed();
paint();
}
});
hud.mount();
let mode = detectMode(window.location.pathname);
let standby = loadStandby();
let { bindings, customised } = loadBindings();
let keySync = null;
let pageOnly = [];
let daemonConnected = false;
let helpVisible = false;
let helpTimer = null;
let helpShownOnce = false;
let lastCommand = null;
let flashTimer = null;
function snapshot() {
return {
running: engine.running,
speed: engine.speed,
direction: engine.direction,
speedMin: settings.speed_min,
speedMax: settings.speed_max,
mode,
selected: selection.selected,
postCount: selection.count,
daemonConnected,
lastCommand,
// The keys in effect. Only a daemon that keeps its own, unknown to us,
// is described by what it reported instead.
bindings: keySync === "legacy" ? settings.bindings : labelsOf(bindings),
helpVisible,
standby
};
}
function paint() {
hud.render(snapshot());
}
function flash(command) {
lastCommand = command;
paint();
if (flashTimer) clearTimeout(flashTimer);
flashTimer = setTimeout(() => {
lastCommand = null;
paint();
}, FLASH_MS);
}
function leavePaused() {
engine.stop();
saveSpeed();
}
function showHelp(visible) {
helpVisible = visible;
if (helpTimer) {
clearTimeout(helpTimer);
helpTimer = null;
}
paint();
}
function saveSpeed() {
persist({ speed: engine.speed, exact: engine.exact });
}
function rememberSpeed() {
saveSpeed();
persistChosen({ speed: engine.speed, exact: engine.exact });
}
function scrollToSelected() {
const element = selection.selectedElement;
if (!element) return;
const rect = element.getBoundingClientRect();
const target = window.innerHeight * settings.focus_line;
window.scrollBy(0, rect.top - target);
}
function galleryPost() {
return mode === "feed" ? selection.selectedElement : document.querySelector("shreddit-post");
}
const ACTIONS2 = {
toggleScroll() {
engine.toggle();
saveSpeed();
},
speedUp() {
engine.adjustSpeed(settings.speed_step);
rememberSpeed();
},
speedDown() {
engine.adjustSpeed(-settings.speed_step);
rememberSpeed();
},
openSelected() {
const post = selection.selected;
if (!post) return;
leavePaused();
window.location.href = post.permalink;
},
goBack() {
leavePaused();
window.history.back();
},
selectNext() {
selection.move(1);
scrollToSelected();
selection.applyHighlight();
},
selectPrev() {
selection.move(-1);
scrollToSelected();
selection.applyHighlight();
},
flipDirection() {
engine.flipDirection();
},
toggleHelp() {
showHelp(!helpVisible);
},
toggleStandby() {
standby = !standby;
saveStandby(standby);
if (!standby) return;
engine.stop();
saveSpeed();
selection.clearHighlight();
},
pageDown() {
window.scrollBy(0, window.innerHeight * 0.8);
},
pageUp() {
window.scrollBy(0, -window.innerHeight * 0.8);
},
imagePrev() {
stepGallery(galleryPost(), "prev");
},
imageNext() {
stepGallery(galleryPost(), "next");
},
noop() {
}
};
const ignoredOnStandby = (command) => standby && command !== "standby" && command !== "help";
function handleCommand(command) {
if (ignoredOnStandby(command)) return;
flash(command);
(ACTIONS2[resolveAction(command, mode)] || ACTIONS2.noop)();
refresh();
}
let refreshQueued = false;
function refresh() {
if (refreshQueued) return;
refreshQueued = true;
window.requestAnimationFrame(() => {
refreshQueued = false;
mode = detectMode(window.location.pathname);
if (!standby && mode === "feed") {
selection.refresh();
selection.applyHighlight();
}
paint();
});
}
const transport = new Transport({
port: loadPort(),
request: gmRequest,
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
onCommands: (commands) => {
if (document.hidden) return;
if (hud.editing) return;
if (document.hasFocus() && isTyping(activeElement())) return;
commands.forEach(handleCommand);
},
onConnectionChange: (ok) => {
daemonConnected = ok;
if (!ok) keySync = null;
if (ok && transport.settings) {
Object.assign(settings, transport.settings);
engine.setLimits(settings.speed_min, settings.speed_max);
engine.seedDefaultSpeed(settings.default_speed);
selection.setFocusLine(settings.focus_line);
if (!helpShownOnce && !standby) {
helpShownOnce = true;
helpVisible = true;
helpTimer = setTimeout(() => {
helpVisible = false;
helpTimer = null;
paint();
}, HELP_AUTOSHOW_MS);
}
}
if (ok) syncKeys();
refresh();
panel.render();
}
});
function setBindings(next) {
bindings = next;
customised = true;
saveBindings(bindings);
paint();
syncKeys();
}
async function syncKeys() {
if (!daemonConnected) return;
if (customised) {
const result = await transport.postBindings(toCodes(bindings));
if (!daemonConnected) return;
keySync = result.ok ? "panel" : "legacy";
pageOnly = result.unsupported;
} else if (settings.binding_codes) {
bindings = fromCodes(settings.binding_codes);
keySync = "config";
pageOnly = [];
} else {
keySync = "legacy";
pageOnly = [];
}
refresh();
panel.render();
}
function bindingsNote() {
if (keySync === "legacy") {
return "The daemon is using the keys in its config.json. These apply when it is not running.";
}
if (keySync === "config") {
return "These are the keys from the daemon's config.json. Change one here and this panel takes over.";
}
if (keySync === "panel" && pageOnly.length) {
const names = HELP_ORDER.filter(([command]) => pageOnly.includes(command)).map(([, description]) => description).join(", ");
return `The daemon cannot listen for the key set for: ${names}. It works while the browser has focus.`;
}
return null;
}
const panel = new BindingsPanel(document, {
rows: HELP_ORDER,
getBindings: () => bindings,
getNote: bindingsNote,
onAssign: (command, code, key) => setBindings(assign(bindings, command, code, key)),
onPreset: (name) => setBindings(fromPreset(name)),
anchor: () => document.getElementById(HUD_ID)
});
function toggleBindings() {
if (!panel.open) showHelp(false);
panel.toggle();
}
try {
GM_registerMenuCommand("Key bindings", toggleBindings);
} catch {
}
function isTyping(target) {
if (!target) return false;
if (target.isContentEditable) return true;
return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
}
function activeElement() {
let element = document.activeElement;
while (element?.shadowRoot?.activeElement) {
element = element.shadowRoot.activeElement;
}
return element;
}
window.addEventListener("scroll", refresh, { passive: true });
window.addEventListener(
"keydown",
(event) => {
if (panel.handleKey(event)) return;
if (isTyping(event.composedPath?.()[0] ?? event.target)) return;
if (event.ctrlKey || event.altKey || event.metaKey) return;
const command = commandFor(bindings, event.code);
if (!command || ignoredOnStandby(command)) return;
const synced = keySync === "panel" || keySync === "config";
if (daemonConnected && !synced) return;
event.preventDefault();
event.stopPropagation();
if (daemonConnected && !pageOnly.includes(command)) return;
handleCommand(command);
},
true
);
window.addEventListener("popstate", refresh);
window.addEventListener("pagehide", saveSpeed);
setInterval(() => transport.postState(snapshot()), 1e3);
refresh();
transport.start();
}
boot();
})();