// ==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(); })();