Skip Navigation

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@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
        });
    }
}
18
18 comments
You've viewed 18 comments.