Rewrote something I made for kbin to work with lemmy. Mimics some of RES’ keyboard navigation functionality.
Edit: updated so that expanded images scroll into view.
Edit 2: 2023/07/04
- added ability to open links/comments (hold shift to open in new tab, might have to disable popup blocker)
- traversing through entries while expand was toggled on will collapse previous entry and expand current entry preview
- handle expanding of text posts
Edit 3: 2023/07/04
- add ability to change to next/previous page
Edit 4: 2023/07/06
- updated scroll into view logic
- prevent shortcut actions when modifier keys are held (ctrl+c won’t load comment page anymore)
- updated open link button to also consider images with external links
- updated user script metadata section for compatibility per @God@sh.itjust.works
- navigating to next/previous page while in “expand mode” will auto-expand the first post of the new page
// ==UserScript==
// @name lemmy navigation
// @description Lemmy hotkeys for navigating.
// @match https://sh.itjust.works/*
// @match https://burggit.moe/*
// @match https://vlemmy.net/*
// @match https://lemmy.world/*
// @match https://lemm.ee/*
// @version 1.2
// @run-at document-start
// ==/UserScript==
// Set selected entry colors
const backgroundColor = 'darkslategray';
const textColor = 'white';
// Set navigation keys with keycodes here: https://www.toptal.com/developers/keycode
const nextKey = 'KeyJ';
const prevKey = 'KeyK';
const expandKey = 'KeyX';
const openCommentsKey = 'KeyC';
const openLinkKey = 'Enter';
const nextPageKey = 'KeyN';
const prevPageKey = 'KeyP';
const css = [
".selected {",
" background-color: " + backgroundColor + " !important;",
" color: " + textColor + ";",
"}"
].join("\n");
if (typeof GM_addStyle !== "undefined") {
GM_addStyle(css);
} else if (typeof PRO_addStyle !== "undefined") {
PRO_addStyle(css);
} else if (typeof addStyle !== "undefined") {
addStyle(css);
} else {
let node = document.createElement("style");
node.type = "text/css";
node.appendChild(document.createTextNode(css));
let heads = document.getElementsByTagName("head");
if (heads.length > 0) {
heads[0].appendChild(node);
} else {
// no head yet, stick it whereever
document.documentElement.appendChild(node);
}
}
const selectedClass = "selected";
let currentEntry;
let entries = [];
let previousUrl = "";
let expand = false;
const targetNode = document.documentElement;
const config = { childList: true, subtree: true };
const observer = new MutationObserver(() => {
entries = document.querySelectorAll(".post-listing, .comment-node");
if (entries.length > 0) {
if (location.href !== previousUrl) {
previousUrl = location.href;
currentEntry = null;
}
init();
}
});
observer.observe(targetNode, config);
function init() {
// If jumping to comments
if (window.location.search.includes("scrollToComments=true") &&
entries.length > 1 &&
(!currentEntry || Array.from(entries).indexOf(currentEntry) < 0)
) {
selectEntry(entries[1], true);
}
// If jumping to comment from anchor link
else if (window.location.pathname.includes("/comment/") &&
(!currentEntry || Array.from(entries).indexOf(currentEntry) < 0)
) {
const commentId = window.location.pathname.replace("/comment/", "");
const anchoredEntry = document.getElementById("comment-" + commentId);
if (anchoredEntry) {
selectEntry(anchoredEntry, true);
}
}
// If no entries yet selected, default to first
else if (!currentEntry || Array.from(entries).indexOf(currentEntry) < 0) {
selectEntry(entries[0]);
if (expand) expandEntry();
}
Array.from(entries).forEach(entry => {
entry.removeEventListener("click", clickEntry, true);
entry.addEventListener('click', clickEntry, true);
});
document.removeEventListener("keydown", handleKeyPress, true);
document.addEventListener("keydown", handleKeyPress, true);
}
function handleKeyPress(event) {
if (["TEXTAREA", "INPUT"].indexOf(event.target.tagName) > -1) {
return;
}
// Ignore when modifier keys held
if (event.altKey || event.ctrlKey || event.metaKey) {
return;
}
switch (event.code) {
case nextKey:
case prevKey:
let selectedEntry;
// Next button
if (event.code === nextKey) {
// if shift key also pressed
if (event.shiftKey) {
selectedEntry = getNextEntrySameLevel(currentEntry);
} else {
selectedEntry = getNextEntry(currentEntry);
}
}
// Previous button
if (event.code === prevKey) {
// if shift key also pressed
if (event.shiftKey) {
selectedEntry = getPrevEntrySameLevel(currentEntry);
} else {
selectedEntry = getPrevEntry(currentEntry);
}
}
if (selectedEntry) {
if (expand) collapseEntry();
selectEntry(selectedEntry, true);
if (expand) expandEntry();
}
break;
case expandKey:
toggleExpand();
expand = isExpanded() ? true : false;
break;
case openCommentsKey:
if (event.shiftKey) {
window.open(
currentEntry.querySelector("a.btn[title$='Comments']").href,
);
} else {
currentEntry.querySelector("a.btn[title$='Comments']").click();
}
break;
case openLinkKey:
const linkElement = currentEntry.querySelector(".col.flex-grow-0.px-0>div>a") || currentEntry.querySelector(".col.flex-grow-1>p>a");
if (linkElement) {
if (event.shiftKey) {
window.open(linkElement.href);
} else {
linkElement.click();
}
}
break;
case nextPageKey:
case prevPageKey:
const pageButtons = Array.from(document.querySelectorAll(".paginator>button"));
if (pageButtons) {
const buttonText = event.code === nextPageKey ? "Next" : "Prev";
pageButtons.find(btn => btn.innerHTML === buttonText).click();
}
}
}
function getNextEntry(e) {
const currentEntryIndex = Array.from(entries).indexOf(e);
if (currentEntryIndex + 1 >= entries.length) {
return e;
}
return entries[currentEntryIndex + 1];
}
function getPrevEntry(e) {
const currentEntryIndex = Array.from(entries).indexOf(e);
if (currentEntryIndex - 1 < 0) {
return e;
}
return entries[currentEntryIndex - 1];
}
function getNextEntrySameLevel(e) {
const nextSibling = e.parentElement.nextElementSibling;
if (!nextSibling || nextSibling.getElementsByTagName("article").length < 1) {
return getNextEntry(e);
}
return nextSibling.getElementsByTagName("article")[0];
}
function getPrevEntrySameLevel(e) {
const prevSibling = e.parentElement.previousElementSibling;
if (!prevSibling || prevSibling.getElementsByTagName("article").length < 1) {
return getPrevEntry(e);
}
return prevSibling.getElementsByTagName("article")[0];
}
function clickEntry(event) {
const e = event.currentTarget;
const target = event.target;
// Deselect if already selected, also ignore if clicking on any link/button
if (e === currentEntry && e.classList.contains(selectedClass) &&
!(
target.tagName.toLowerCase() === "button" || target.tagName.toLowerCase() === "a" ||
target.parentElement.tagName.toLowerCase() === "button" ||
target.parentElement.tagName.toLowerCase() === "a" ||
target.parentElement.parentElement.tagName.toLowerCase() === "button" ||
target.parentElement.parentElement.tagName.toLowerCase() === "a"
)
) {
e.classList.remove(selectedClass);
} else {
selectEntry(e);
}
}
function selectEntry(e, scrollIntoView=false) {
if (currentEntry) {
currentEntry.classList.remove(selectedClass);
}
currentEntry = e;
currentEntry.classList.add(selectedClass);
if (scrollIntoView) {
scrollIntoViewWithOffset(e, 15)
}
}
function isExpanded() {
if (
currentEntry.querySelector("a.d-inline-block:not(.thumbnail)") ||
currentEntry.querySelector("#postContent") ||
currentEntry.querySelector(".card-body")
) {
return true;
}
return false;
}
function toggleExpand() {
const expandButton = currentEntry.querySelector("button[aria-label='Expand here']");
const textExpandButton = currentEntry.querySelector(".post-title>button");
if (expandButton) {
expandButton.click();
// Scroll into view if picture/text preview cut off
const imgContainer = currentEntry.querySelector("a.d-inline-block");
if (imgContainer) {
// Check container positions once image is loaded
imgContainer.querySelector("img").addEventListener("load", function() {
scrollIntoViewWithOffset(currentEntry, 0);
}, true);
}
}
if (textExpandButton) {
textExpandButton.click();
}
scrollIntoViewWithOffset(currentEntry, 0);
}
function expandEntry() {
if (!isExpanded()) toggleExpand();
}
function collapseEntry() {
if (isExpanded()) toggleExpand();
}
function scrollIntoViewWithOffset(e, offset) {
if (e.getBoundingClientRect().top < 0 ||
e.getBoundingClientRect().bottom > window.innerHeight
) {
const y = e.getBoundingClientRect().top + window.pageYOffset - offset;
window.scrollTo({
top: y
});
}
}
Would you be able to add upvote and downvote buttons? Also could you make collapse work on comments? Thanks for the script! It’s great!
Is this GPL? I took the liberty to fork it, to change into arrow navigation and change the styling. I also added upvote/downvote keys, before seeing shadshack did the same 🤣. Hope it’s ok!
This is great! I’ve been working with the code and added keys for upvote/downvote as well (it’s basically the same as the Expand code, but targeting the Upvote/Downvote buttons. I also have it set so that if you vote, it automatically scrolls to the next post and maintains “expand” status.
Now I can scroll lemmy and upvote/downvote to mark posts as read with just a/z, exactly how I used to use RES keyboard shortcuts for Reddit.
Here’s the code I’m using (pastebin because posting it in the comment keeps timing out…): https://pastebin.com/BTYyU17L
I always find myself tapping J and K on lemmy and expecting it to work so thank you for making my muscle memory not go to waste! :D
Love it, I’d like to get also
c
to open comments orl
/Return
to open the selected one (maybe in a new tab).Updated 👍. I just did the c and enter for now.