Keyboard navigation shortcuts for lemmy

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
  • navigating to next/previous page while in “expand mode” will auto-expand the first post of the new page
<pre style="background-color:#ffffff;">
<span style="color:#323232;">// ==UserScript==
</span><span style="color:#323232;">// @name             lemmy navigation
</span><span style="color:#323232;">// @description      Lemmy hotkeys for navigating.
</span><span style="color:#323232;">// @match            https://sh.itjust.works/*
</span><span style="color:#323232;">// @match            https://burggit.moe/*
</span><span style="color:#323232;">// @match            https://vlemmy.net/*
</span><span style="color:#323232;">// @match            https://lemmy.world/*
</span><span style="color:#323232;">// @match            https://lemm.ee/*
</span><span style="color:#323232;">// @version          1.2
</span><span style="color:#323232;">// @run-at           document-start
</span><span style="color:#323232;">// ==/UserScript==
</span><span style="color:#323232;">
</span><span style="color:#323232;">// Set selected entry colors
</span><span style="color:#323232;">const backgroundColor = 'darkslategray';
</span><span style="color:#323232;">const textColor = 'white';
</span><span style="color:#323232;">
</span><span style="color:#323232;">// Set navigation keys with keycodes here: https://www.toptal.com/developers/keycode
</span><span style="color:#323232;">const nextKey = 'KeyJ';
</span><span style="color:#323232;">const prevKey = 'KeyK';
</span><span style="color:#323232;">const expandKey = 'KeyX';
</span><span style="color:#323232;">const openCommentsKey = 'KeyC';
</span><span style="color:#323232;">const openLinkKey = 'Enter';
</span><span style="color:#323232;">const nextPageKey = 'KeyN';
</span><span style="color:#323232;">const prevPageKey = 'KeyP';
</span><span style="color:#323232;">
</span><span style="color:#323232;">
</span><span style="color:#323232;">const css = [
</span><span style="color:#323232;">".selected {",
</span><span style="color:#323232;">"  background-color: " + backgroundColor + " !important;",
</span><span style="color:#323232;">"  color: " + textColor + ";",
</span><span style="color:#323232;">"}"
</span><span style="color:#323232;">].join("n");
</span><span style="color:#323232;">
</span><span style="color:#323232;">if (typeof GM_addStyle !== "undefined") {
</span><span style="color:#323232;">    GM_addStyle(css);
</span><span style="color:#323232;">} else if (typeof PRO_addStyle !== "undefined") {
</span><span style="color:#323232;">    PRO_addStyle(css);
</span><span style="color:#323232;">} else if (typeof addStyle !== "undefined") {
</span><span style="color:#323232;">    addStyle(css);
</span><span style="color:#323232;">} else {
</span><span style="color:#323232;">    let node = document.createElement("style");
</span><span style="color:#323232;">    node.type = "text/css";
</span><span style="color:#323232;">    node.appendChild(document.createTextNode(css));
</span><span style="color:#323232;">    let heads = document.getElementsByTagName("head");
</span><span style="color:#323232;">    if (heads.length > 0) {
</span><span style="color:#323232;">        heads[0].appendChild(node);
</span><span style="color:#323232;">    } else {
</span><span style="color:#323232;">        // no head yet, stick it whereever
</span><span style="color:#323232;">        document.documentElement.appendChild(node);
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">}
</span><span style="color:#323232;">const selectedClass = "selected";
</span><span style="color:#323232;">
</span><span style="color:#323232;">let currentEntry;
</span><span style="color:#323232;">let entries = [];
</span><span style="color:#323232;">let previousUrl = "";
</span><span style="color:#323232;">let expand = false;
</span><span style="color:#323232;">
</span><span style="color:#323232;">const targetNode = document.documentElement;
</span><span style="color:#323232;">const config = { childList: true, subtree: true };
</span><span style="color:#323232;">
</span><span style="color:#323232;">const observer = new MutationObserver(() => {
</span><span style="color:#323232;">    entries = document.querySelectorAll(".post-listing, .comment-node");
</span><span style="color:#323232;">
</span><span style="color:#323232;">    if (entries.length > 0) {
</span><span style="color:#323232;">        if (location.href !== previousUrl) {
</span><span style="color:#323232;">            previousUrl = location.href;
</span><span style="color:#323232;">            currentEntry = null;
</span><span style="color:#323232;">        }
</span><span style="color:#323232;">        init();
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">});
</span><span style="color:#323232;">
</span><span style="color:#323232;">observer.observe(targetNode, config);
</span><span style="color:#323232;">
</span><span style="color:#323232;">function init() {
</span><span style="color:#323232;">    // If jumping to comments
</span><span style="color:#323232;">    if (window.location.search.includes("scrollToComments=true") &&
</span><span style="color:#323232;">        entries.length > 1 &&
</span><span style="color:#323232;">        (!currentEntry || Array.from(entries).indexOf(currentEntry) < 0)
</span><span style="color:#323232;">    ) {
</span><span style="color:#323232;">        selectEntry(entries[1], true);
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">    // If jumping to comment from anchor link
</span><span style="color:#323232;">    else if (window.location.pathname.includes("/comment/") &&
</span><span style="color:#323232;">            (!currentEntry || Array.from(entries).indexOf(currentEntry) < 0)
</span><span style="color:#323232;">    ) {
</span><span style="color:#323232;">        const commentId = window.location.pathname.replace("/comment/", "");
</span><span style="color:#323232;">        const anchoredEntry = document.getElementById("comment-" + commentId);
</span><span style="color:#323232;">
</span><span style="color:#323232;">        if (anchoredEntry) {
</span><span style="color:#323232;">            selectEntry(anchoredEntry, true);
</span><span style="color:#323232;">        }
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">    // If no entries yet selected, default to first
</span><span style="color:#323232;">    else if (!currentEntry || Array.from(entries).indexOf(currentEntry) < 0) {
</span><span style="color:#323232;">        selectEntry(entries[0]);
</span><span style="color:#323232;">        if (expand) expandEntry();
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">
</span><span style="color:#323232;">    Array.from(entries).forEach(entry => {
</span><span style="color:#323232;">        entry.removeEventListener("click", clickEntry, true);
</span><span style="color:#323232;">        entry.addEventListener('click', clickEntry, true);
</span><span style="color:#323232;">    });
</span><span style="color:#323232;">
</span><span style="color:#323232;">    document.removeEventListener("keydown", handleKeyPress, true);
</span><span style="color:#323232;">    document.addEventListener("keydown", handleKeyPress, true);
</span><span style="color:#323232;">}
</span><span style="color:#323232;">
</span><span style="color:#323232;">function handleKeyPress(event) {
</span><span style="color:#323232;">    if (["TEXTAREA", "INPUT"].indexOf(event.target.tagName) > -1) {
</span><span style="color:#323232;">        return;
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">
</span><span style="color:#323232;">    // Ignore when modifier keys held
</span><span style="color:#323232;">    if (event.altKey || event.ctrlKey || event.metaKey) {
</span><span style="color:#323232;">        return;
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">
</span><span style="color:#323232;">    switch (event.code) {
</span><span style="color:#323232;">        case nextKey:
</span><span style="color:#323232;">        case prevKey:
</span><span style="color:#323232;">            let selectedEntry;
</span><span style="color:#323232;">
</span><span style="color:#323232;">            // Next button
</span><span style="color:#323232;">            if (event.code === nextKey) {
</span><span style="color:#323232;">                // if shift key also pressed
</span><span style="color:#323232;">                if (event.shiftKey) {
</span><span style="color:#323232;">                    selectedEntry = getNextEntrySameLevel(currentEntry);
</span><span style="color:#323232;">                } else {
</span><span style="color:#323232;">                    selectedEntry = getNextEntry(currentEntry);
</span><span style="color:#323232;">                }
</span><span style="color:#323232;">            }
</span><span style="color:#323232;">
</span><span style="color:#323232;">            // Previous button
</span><span style="color:#323232;">            if (event.code === prevKey) {
</span><span style="color:#323232;">                // if shift key also pressed
</span><span style="color:#323232;">                if (event.shiftKey) {
</span><span style="color:#323232;">                    selectedEntry = getPrevEntrySameLevel(currentEntry);
</span><span style="color:#323232;">                } else {
</span><span style="color:#323232;">                    selectedEntry = getPrevEntry(currentEntry);
</span><span style="color:#323232;">                }
</span><span style="color:#323232;">            }
</span><span style="color:#323232;">
</span><span style="color:#323232;">            if (selectedEntry) {
</span><span style="color:#323232;">                if (expand) collapseEntry();
</span><span style="color:#323232;">                selectEntry(selectedEntry, true);
</span><span style="color:#323232;">                if (expand) expandEntry();
</span><span style="color:#323232;">            }
</span><span style="color:#323232;">            break;
</span><span style="color:#323232;">        case expandKey:
</span><span style="color:#323232;">            toggleExpand();
</span><span style="color:#323232;">            expand = isExpanded() ? true : false;
</span><span style="color:#323232;">            break;
</span><span style="color:#323232;">        case openCommentsKey:
</span><span style="color:#323232;">            if (event.shiftKey) {
</span><span style="color:#323232;">                window.open(
</span><span style="color:#323232;">                    currentEntry.querySelector("a.btn[title$='Comments']").href,
</span><span style="color:#323232;">                );
</span><span style="color:#323232;">            } else {
</span><span style="color:#323232;">                currentEntry.querySelector("a.btn[title$='Comments']").click();
</span><span style="color:#323232;">            }
</span><span style="color:#323232;">            break;
</span><span style="color:#323232;">        case openLinkKey:
</span><span style="color:#323232;">            const linkElement = currentEntry.querySelector(".col.flex-grow-0.px-0>div>a") || currentEntry.querySelector(".col.flex-grow-1>p>a");
</span><span style="color:#323232;">            if (linkElement) {
</span><span style="color:#323232;">                if (event.shiftKey) {
</span><span style="color:#323232;">                    window.open(linkElement.href);
</span><span style="color:#323232;">                } else {
</span><span style="color:#323232;">                    linkElement.click();
</span><span style="color:#323232;">                }
</span><span style="color:#323232;">            }
</span><span style="color:#323232;">            break;
</span><span style="color:#323232;">        case nextPageKey:
</span><span style="color:#323232;">        case prevPageKey:
</span><span style="color:#323232;">            const pageButtons = Array.from(document.querySelectorAll(".paginator>button"));
</span><span style="color:#323232;">
</span><span style="color:#323232;">            if (pageButtons) {
</span><span style="color:#323232;">                const buttonText = event.code === nextPageKey ? "Next" : "Prev";
</span><span style="color:#323232;">                pageButtons.find(btn => btn.innerHTML === buttonText).click();
</span><span style="color:#323232;">            }
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">}
</span><span style="color:#323232;">
</span><span style="color:#323232;">function getNextEntry(e) {
</span><span style="color:#323232;">    const currentEntryIndex = Array.from(entries).indexOf(e);
</span><span style="color:#323232;">    
</span><span style="color:#323232;">    if (currentEntryIndex + 1 >= entries.length) {
</span><span style="color:#323232;">        return e;
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">    
</span><span style="color:#323232;">    return entries[currentEntryIndex + 1];
</span><span style="color:#323232;">}
</span><span style="color:#323232;">
</span><span style="color:#323232;">function getPrevEntry(e) {
</span><span style="color:#323232;">    const currentEntryIndex = Array.from(entries).indexOf(e);
</span><span style="color:#323232;">    
</span><span style="color:#323232;">    if (currentEntryIndex - 1 < 0) {
</span><span style="color:#323232;">        return e;
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">    
</span><span style="color:#323232;">    return entries[currentEntryIndex - 1];
</span><span style="color:#323232;">}
</span><span style="color:#323232;">
</span><span style="color:#323232;">function getNextEntrySameLevel(e) {
</span><span style="color:#323232;">    const nextSibling = e.parentElement.nextElementSibling;
</span><span style="color:#323232;">
</span><span style="color:#323232;">    if (!nextSibling || nextSibling.getElementsByTagName("article").length < 1) {
</span><span style="color:#323232;">        return getNextEntry(e);
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">    
</span><span style="color:#323232;">    return nextSibling.getElementsByTagName("article")[0];
</span><span style="color:#323232;">}
</span><span style="color:#323232;">
</span><span style="color:#323232;">function getPrevEntrySameLevel(e) {
</span><span style="color:#323232;">    const prevSibling = e.parentElement.previousElementSibling;
</span><span style="color:#323232;">
</span><span style="color:#323232;">    if (!prevSibling || prevSibling.getElementsByTagName("article").length < 1) {
</span><span style="color:#323232;">        return getPrevEntry(e);
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">    
</span><span style="color:#323232;">    return prevSibling.getElementsByTagName("article")[0];
</span><span style="color:#323232;">}
</span><span style="color:#323232;">
</span><span style="color:#323232;">function clickEntry(event) {
</span><span style="color:#323232;">    const e = event.currentTarget;
</span><span style="color:#323232;">    const target = event.target;
</span><span style="color:#323232;">
</span><span style="color:#323232;">    // Deselect if already selected, also ignore if clicking on any link/button
</span><span style="color:#323232;">    if (e === currentEntry && e.classList.contains(selectedClass) &&
</span><span style="color:#323232;">        !(
</span><span style="color:#323232;">            target.tagName.toLowerCase() === "button" || target.tagName.toLowerCase() === "a" ||
</span><span style="color:#323232;">            target.parentElement.tagName.toLowerCase() === "button" ||
</span><span style="color:#323232;">            target.parentElement.tagName.toLowerCase() === "a" ||
</span><span style="color:#323232;">            target.parentElement.parentElement.tagName.toLowerCase() === "button" ||
</span><span style="color:#323232;">            target.parentElement.parentElement.tagName.toLowerCase() === "a"
</span><span style="color:#323232;">        )
</span><span style="color:#323232;">    ) {
</span><span style="color:#323232;">        e.classList.remove(selectedClass);
</span><span style="color:#323232;">    } else {
</span><span style="color:#323232;">        selectEntry(e);
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">}
</span><span style="color:#323232;">
</span><span style="color:#323232;">function selectEntry(e, scrollIntoView=false) {
</span><span style="color:#323232;">    if (currentEntry) {
</span><span style="color:#323232;">        currentEntry.classList.remove(selectedClass);
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">    currentEntry = e;
</span><span style="color:#323232;">    currentEntry.classList.add(selectedClass);
</span><span style="color:#323232;">
</span><span style="color:#323232;">    if (scrollIntoView) {
</span><span style="color:#323232;">        scrollIntoViewWithOffset(e, 15)
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">}
</span><span style="color:#323232;">
</span><span style="color:#323232;">function isExpanded() {
</span><span style="color:#323232;">    if (
</span><span style="color:#323232;">        currentEntry.querySelector("a.d-inline-block:not(.thumbnail)") ||
</span><span style="color:#323232;">        currentEntry.querySelector("#postContent") ||
</span><span style="color:#323232;">        currentEntry.querySelector(".card-body")
</span><span style="color:#323232;">    ) {
</span><span style="color:#323232;">        return true;
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">
</span><span style="color:#323232;">    return false;
</span><span style="color:#323232;">}
</span><span style="color:#323232;">
</span><span style="color:#323232;">function toggleExpand() {
</span><span style="color:#323232;">    const expandButton = currentEntry.querySelector("button[aria-label='Expand here']");
</span><span style="color:#323232;">    const textExpandButton = currentEntry.querySelector(".post-title>button");
</span><span style="color:#323232;">
</span><span style="color:#323232;">    if (expandButton) {
</span><span style="color:#323232;">        expandButton.click();
</span><span style="color:#323232;">
</span><span style="color:#323232;">        // Scroll into view if picture/text preview cut off
</span><span style="color:#323232;">        const imgContainer = currentEntry.querySelector("a.d-inline-block");
</span><span style="color:#323232;">        if (imgContainer) {
</span><span style="color:#323232;">            // Check container positions once image is loaded
</span><span style="color:#323232;">            imgContainer.querySelector("img").addEventListener("load", function() {
</span><span style="color:#323232;">                scrollIntoViewWithOffset(currentEntry, 0);
</span><span style="color:#323232;">            }, true);
</span><span style="color:#323232;">        }
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">
</span><span style="color:#323232;">    if (textExpandButton) {
</span><span style="color:#323232;">        textExpandButton.click();
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">
</span><span style="color:#323232;">    scrollIntoViewWithOffset(currentEntry, 0);
</span><span style="color:#323232;">}
</span><span style="color:#323232;">
</span><span style="color:#323232;">function expandEntry() {
</span><span style="color:#323232;">    if (!isExpanded()) toggleExpand();
</span><span style="color:#323232;">}
</span><span style="color:#323232;">
</span><span style="color:#323232;">function collapseEntry() {
</span><span style="color:#323232;">    if (isExpanded()) toggleExpand();
</span><span style="color:#323232;">}
</span><span style="color:#323232;">
</span><span style="color:#323232;">function scrollIntoViewWithOffset(e, offset) {
</span><span style="color:#323232;">    if (e.getBoundingClientRect().top < 0 ||
</span><span style="color:#323232;">        e.getBoundingClientRect().bottom > window.innerHeight
</span><span style="color:#323232;">    ) {
</span><span style="color:#323232;">        const y = e.getBoundingClientRect().top + window.pageYOffset - offset;
</span><span style="color:#323232;">        window.scrollTo({
</span><span style="color:#323232;">            top: y
</span><span style="color:#323232;">        });
</span><span style="color:#323232;">    }
</span><span style="color:#323232;">}
</span>
rbits,

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!

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!

shadshack,

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…): pastebin.com/BTYyU17L

god,
@god@sh.itjust.works avatar

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

3v1n0,

Love it, I’d like to get also c to open comments or l / Return to open the selected one (maybe in a new tab).

boobslider100,

Updated 👍. I just did the c and enter for now.

  • All
  • Subscribed
  • Moderated
  • Favorites
  • plugins@sh.itjust.works
  • ngwrru68w68
  • cubers
  • tacticalgear
  • everett
  • Durango
  • rosin
  • InstantRegret
  • DreamBathrooms
  • thenastyranch
  • magazineikmin
  • Youngstown
  • mdbf
  • slotface
  • kavyap
  • megavids
  • tester
  • GTA5RPClips
  • modclub
  • cisconetworking
  • osvaldo12
  • khanakhh
  • ethstaker
  • Leos
  • anitta
  • normalnudes
  • provamag3
  • JUstTest
  • lostlight
  • All magazines