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
});
}
}
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
I was playing around with your first version. Thank you. Would you be opposed to this going into my addon I’m making? Would attribute you ofcourse.
LemmyTools
Oh cool, I don’t mind.
I can’t thank you enough for this boobslider100 and afoutopatisa!
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!
Oh yea go for it. I should’ve put it on github in the first place but I’m lazy.
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!
Ooooh very nice! downloaded and installed
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.
had to swap the metadata section for the following for it to not throw errors on Tampermonkey on Firefox:
// ==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.1 // @run-at document-start // ==/UserScript==
Snuck it into the post. Thanks.
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!
Could you pls make it work with https://mlmym.org/lemmy.world/? it would be a DREAM
I’ll try taking a look this weekend but no promises.