OpenRouter Chat Enhancements

Navigation hotkeys, message highlight, floating speaker, scroll protections, perfect collapse/expand handling, and enhanced edit scroll lock.

目前为 2025-04-14 提交的版本。查看 最新版本

// ==UserScript==
// @name         OpenRouter Chat Enhancements
// @namespace    http://tampermonkey.net/
// @license      MIT
// @version      1.2.0
// @description  Navigation hotkeys, message highlight, floating speaker, scroll protections, perfect collapse/expand handling, and enhanced edit scroll lock.
// @author       Rekt
// @match        https://openrouter.ai/chat*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @run-at       document-end
// ==/UserScript==

(function () {
    'use strict';

    /*********************** CONFIG & STYLES **********************/
    GM_addStyle(`
        /* Message highlight */
        .openrouter-nav-highlight {
            outline: 2.5px solid #22caff !important;
            z-index: 10 !important;
            border-radius: 7px;
            box-shadow: 0 0 0 3px #22caff33, 0 0 4px #22caff44;
            transition: outline 0.17s;
        }
        /* Floating Speaker */
        #openrouter-nav-speaker-float {
            position: fixed;
            top: 8px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 999999;
            background: rgba(20,24,42,0.97);
            color: #f1f1f1;
            font-weight: 600;
            font-size: 1.05rem;
            padding: 7px 24px 7px 14px;
            border-radius: 19px;
            opacity: 0;
            visibility: hidden;
            pointer-events: none;
            min-width: 88px;
            display: flex;
            align-items: center;
            gap: 11px;
            box-shadow: 0 6px 17px #0002;
            max-width: 89vw;
            line-height: 1.2;
            transition: opacity 0.21s, top 0.15s;
        }
        #openrouter-nav-speaker-float.openrouter-nav-visible {
            opacity: 1;
            visibility: visible;
        }
        #openrouter-nav-speaker-float img {
            width: 27px;
            height: 27px;
            border-radius: 50%;
            border: 1px solid #fff6;
            object-fit: cover;
            margin-right: 6px;
        }
        #openrouter-nav-speaker-float span {
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            max-width: 320px;
        }
        /* Floating control panel */
        #openrouter-nav-panel {
            position: fixed;
            right: 20px;
            bottom: 25px;
            z-index: 999998;
            background: rgba(30,33,40,0.92);
            border-radius: 14px;
            box-shadow: 0 4px 22px #0003;
            padding: 11px 12px 11px 11px;
            display: flex;
            gap: 7px;
            align-items: center;
            transition: opacity 0.22s;
            user-select: none;
        }
        .openrouter-nav-btn {
            width: 42px;
            height: 42px;
            display: flex;
            align-items: center;
            justify-content: center;
            border: none;
            outline: none;
            background: rgba(245,245,255,0.08);
            border-radius: 6px;
            color: #fff;
            font-size: 18px;
            transition: background .14s;
            cursor: pointer;
        }
        .openrouter-nav-btn svg {
            width: 21px;
            height: 21px;
        }
        .openrouter-nav-btn:hover {
            background: rgba(245,245,255,0.19);
        }
        .openrouter-nav-divider {
            width: 7px;
        }
        @media (max-width: 768px) {
            #openrouter-nav-panel {
                right: 6px;
                bottom: 9px;
                padding: 7.5px 7px 7.5px 6px;
            }
            .openrouter-nav-btn {
                width: 34px;
                height: 34px;
            }
        }
    `);

    // Settings with persistence
    let modifierKey = GM_getValue('or_modifierKey', "Alt"); // "Alt", "Ctrl", "None"
    let panelEnabled = GM_getValue('or_panelEnabled', true);

    // Menu commands
    GM_registerMenuCommand("Set Hotkey Modifier: (Alt/Ctrl/None)", () => {
        const val = prompt('Use which key as the hotkey modifier? (Alt, Ctrl, None)', modifierKey);
        if (!val) return;
        const normalized = val.replace(/^\s+|\s+$/g, '').toLowerCase();
        const ok = { alt: "Alt", ctrl: "Ctrl", none: "None" }[normalized];
        if (ok) {
            modifierKey = ok;
            GM_setValue('or_modifierKey', ok);
            alert("Modifier set to: " + ok);
        } else {
            alert("Invalid. Must be Alt, Ctrl or None.");
        }
    });
    GM_registerMenuCommand("Toggle Navigation Panel", () => {
        panelEnabled = !panelEnabled;
        GM_setValue('or_panelEnabled', panelEnabled);
        if (panelEnabled) showPanel();
        else clearPanel();
        alert("Panel: " + (panelEnabled ? "Enabled" : "Disabled"));
    });

    /********************* SELECTORS/STRUCTURE ******************/
    function findScrollContainer() {
        return document.querySelector('main div.overflow-y-scroll') ||
               document.querySelector('main div[style*="overflow-y: auto;"]') ||
               document.querySelector('main div[style*="overflow-y: scroll;"]') ||
               document.querySelector('main');
    }

    function findMessageContainers() {
        if (!scrollContainer) return [];
        return Array.from(
            scrollContainer.querySelectorAll('div.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0')
        ).filter(d => d.offsetParent !== null && d.querySelector('.flex.gap-2.items-center, .flex.gap-2.flex-row-reverse'));
    }

    function msgContentElem(msgDiv) {
        return msgDiv.querySelector('.overflow-auto') || msgDiv.querySelector('div.flex.max-w-full.flex-col.relative.overflow-auto');
    }

    function msgToggleExpandBtn(msgDiv) {
        return msgDiv.querySelector(
            'div.group.flex.flex-col.gap-2.items-start > div.flex.max-w-full.flex-col.relative.overflow-auto.gap-1.items-start.w-full > div > div > button, ' +
            'div.group.flex.flex-col.gap-2.items-end > div.flex.max-w-full.flex-col.relative.overflow-auto.gap-1.items-end.w-full > div > div > button'
        );
    }

    function msgHeader(msgDiv) {
        return msgDiv.querySelector('.group.flex.flex-col.gap-2.items-start > .flex.gap-2, .group.flex.flex-col.gap-2.items-end > .flex.gap-2') ||
               msgDiv.querySelector('.flex.gap-2.items-center, .flex.gap-2.flex-row-reverse');
    }

    function getSpeakerName(msgDiv) {
        const hdr = msgHeader(msgDiv);
        if (!hdr) return "";
        const a = hdr.querySelector('span a');
        if (a) return a.textContent.replace(/\|.*/,'').replace('(edited)','').trim();
        const span = hdr.querySelector('span');
        if (span) return span.textContent.replace(/\|.*/,'').replace('(edited)','').trim();
        return "";
    }

    function getSpeakerAvatar(msgDiv) {
        const hdr = msgHeader(msgDiv);
        if (!hdr) return "";
        const img = hdr.querySelector("picture img, img");
        if (img) return img.src;
        return "";
    }

    /*********** PANEL AND FLOATING UX COMPONENTS ************/
    let speakerElem = null, speakerImg = null, speakerName = null;
    function ensureSpeakerFloat() {
        if (document.querySelector("#openrouter-nav-speaker-float")) return;
        speakerElem = document.createElement("div");
        speakerElem.id = "openrouter-nav-speaker-float";
        speakerElem.innerHTML = `<img style="display:none"><span id="openrouter-speaker"></span>`;
        document.body.appendChild(speakerElem);
        speakerImg = speakerElem.querySelector('img');
        speakerName = speakerElem.querySelector('span');
        speakerElem.classList.remove('openrouter-nav-visible');
    }

    function showSpeaker(msgDiv) {
        if (!speakerElem) return;
        if (!msgDiv || !document.body.contains(msgDiv)) {
            speakerElem.classList.remove("openrouter-nav-visible");
            return;
        }
        const name = getSpeakerName(msgDiv);
        const imgSrc = getSpeakerAvatar(msgDiv);
        speakerName.textContent = name || "Speaker";
        if (imgSrc) { speakerImg.style.display = ""; speakerImg.src = imgSrc; }
        else { speakerImg.style.display = "none"; speakerImg.removeAttribute('src'); }
        speakerElem.classList.add("openrouter-nav-visible");
    }

    let panelElem = null;
    function showPanel() {
        clearPanel();
        if (!panelEnabled) return;
        panelElem = document.createElement("div");
        panelElem.id = "openrouter-nav-panel";
        panelElem.innerHTML = `
            <button class="openrouter-nav-btn" title="Previous Message (k)">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <polyline points="15 18 9 12 15 6"/>
                </svg>
            </button>
            <button class="openrouter-nav-btn" title="Next Message (j)">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <polyline points="9 18 15 12 9 6"/>
                </svg>
            </button>
            <span class="openrouter-nav-divider"></span>
            <button class="openrouter-nav-btn" title="Top (Home)">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <polyline points="18 15 12 9 6 15"/>
                </svg>
            </button>
            <button class="openrouter-nav-btn" title="Bottom (End)">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <polyline points="6 9 12 15 18 9"/>
                </svg>
            </button>
            <span class="openrouter-nav-divider"></span>
            <button class="openrouter-nav-btn" title="Expand/Collapse (l/h)">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <polyline points="7 13 12 18 17 13"/>
                    <polyline points="7 6 12 11 17 6"/>
                </svg>
            </button>
        `;
        document.body.appendChild(panelElem);
        const btns = panelElem.querySelectorAll('.openrouter-nav-btn');
        btns[0].onclick = () => navToMsg(-1);
        btns[1].onclick = () => navToMsg(1);
        btns[2].onclick = () => scrollMsgTop();
        btns[3].onclick = () => scrollMsgBottom();
        btns[4].onclick = () => toggleMsgExpand();
    }

    function clearPanel() {
        if (panelElem) { panelElem.remove(); panelElem = null; }
    }

    /******************** NAVIGATION LOGIC ********************/
    let scrollContainer = null;
    let allMessages = [], highlighted = null;
    let blockHighlightUntil = 0;
    let lastInteractedMsg = null;
    let latestInputEdit = 0, lastEditingMsg = null, editPasteProhibit = false;
    let collapseRestoreMsg = null;
    const ANTI_HYSTERESIS_MS = 50;
    const EDIT_LOCK_DURATION_MS = 3000;
    const COLLAPSE_SCROLL_LOCK_MS = 500;

    function updateMsgList() {
        let prevId = highlighted?.dataset?.ormsgid;
        allMessages = findMessageContainers();
        allMessages.forEach((m, i) => {
            if (!m.dataset.ormsgid) m.dataset.ormsgid = "msg-" + Date.now() + "-" + Math.random();
        });
        if (prevId) {
            highlighted = allMessages.find(m => m.dataset?.ormsgid === prevId);
        }
        if (!highlighted && allMessages.length > 0) {
            highlighted = allMessages[allMessages.length - 1];
        }
        allMessages.forEach(m => m.classList.toggle('openrouter-nav-highlight', m === highlighted));
        if (highlighted) {
            showSpeaker(highlighted);
        } else {
            showSpeaker(null);
        }
    }

    function highlightMsg(msgDiv, opts = {}) {
        if (!msgDiv || !document.body.contains(msgDiv)) return;
        if (editPasteProhibit && lastEditingMsg && lastEditingMsg !== msgDiv) return;
        if (Date.now() < blockHighlightUntil && !opts.force) return;
        if (highlighted) highlighted.classList.remove('openrouter-nav-highlight');
        highlighted = msgDiv;
        highlighted.classList.add('openrouter-nav-highlight');
        showSpeaker(highlighted);
        lastInteractedMsg = highlighted;
        if (opts.scrollIntoView) {
            highlighted.scrollIntoView({ behavior: "smooth", block: opts.block || "center" });
            if (opts.scrollTop) {
                let ct = msgContentElem(highlighted);
                if (ct) ct.scrollTop = 0;
            }
            if (opts.scrollBottom) {
                let ct = msgContentElem(highlighted);
                if (ct) ct.scrollTop = ct.scrollHeight;
            }
        }
    }

    function navToMsg(dir = 1) {
        if (!allMessages.length) return;
        let idx = highlighted ? allMessages.indexOf(highlighted) : -1;
        let nextIdx = idx + dir;
        if (nextIdx < 0) nextIdx = 0;
        if (nextIdx > allMessages.length - 1) nextIdx = allMessages.length - 1;
        blockHighlightUntil = Date.now() + 350;
        if (allMessages[nextIdx]) highlightMsg(allMessages[nextIdx], { scrollIntoView: true, force: true });
    }

    function scrollMsgTop() {
        if (!highlighted) return;
        let ct = msgContentElem(highlighted);
        if (ct) ct.scrollTop = 0;
        highlighted.scrollIntoView({ behavior: "smooth", block: "start" });
        blockHighlightUntil = Date.now() + 300;
    }

    function scrollMsgBottom() {
        if (!highlighted) return;
        let ct = msgContentElem(highlighted);
        if (ct) ct.scrollTop = ct.scrollHeight;
        highlighted.scrollIntoView({ behavior: "smooth", block: "end" });
        blockHighlightUntil = Date.now() + 300;
    }

    function toggleMsgExpand() {
        if (!highlighted) return;
        const btn = msgToggleExpandBtn(highlighted);
        if (!btn) return;
        handleToggleScroll(highlighted);
        btn.click();
    }

    function handleToggleScroll(msgDiv) {
        collapseRestoreMsg = msgDiv;
        const scrollContainer = findScrollContainer();
        const scrollTopBefore = scrollContainer.scrollTop;
        const msgTopBefore = msgDiv.offsetTop;
        const visualTop = msgTopBefore - scrollTopBefore;
        setTimeout(() => {
            let msg = allMessages.find(m => m.dataset.ormsgid === collapseRestoreMsg.dataset.ormsgid);
            if (msg) {
                const msgTopAfter = msg.offsetTop;
                scrollContainer.scrollTop = msgTopAfter - visualTop;
                highlightMsg(msg, { force: true });
                ensureScrollInBounds(msg);
            }
            collapseRestoreMsg = null;
            blockHighlightUntil = Date.now() + COLLAPSE_SCROLL_LOCK_MS;
        }, 210);
    }

    function refreshActiveMsg() {
        if (!highlighted) return;
        const refreshSvg = highlighted.querySelector('svg path[d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"]');
        if (refreshSvg) {
            refreshSvg.closest('button').click();
        }
    }

    function updateHighlightOnScroll() {
        if (Date.now() < blockHighlightUntil) return;
        if (editPasteProhibit && lastEditingMsg) {
            ensureScrollInBounds(lastEditingMsg);
            return;
        }
        let best = null, maxVH = 0;
        const containerRect = scrollContainer.getBoundingClientRect();
        allMessages.forEach(m => {
            const rect = m.getBoundingClientRect();
            let top = Math.max(rect.top, containerRect.top);
            let bot = Math.min(rect.bottom, containerRect.bottom);
            let visH = Math.max(0, bot - top);
            if (visH > maxVH && visH > 48) {
                maxVH = visH;
                best = m;
            }
        });
        if (best && best !== highlighted) {
            highlightMsg(best);
        }
    }

    /*************** EDITINGS/PASTE SCROLL BOUNDING ***********/
    let scrollLockTimeout = null;
    function enforceScrollBoundOnEdit() {
        const act = document.activeElement;
        if (act && act.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0') && (act.matches('input:not([type="checkbox"]):not([type="radio"]), textarea, [contenteditable="true"]'))) {
            const activeMsg = act.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0');
            if (activeMsg && document.body.contains(activeMsg)) {
                lastEditingMsg = activeMsg;
                latestInputEdit = Date.now();
                editPasteProhibit = true;
                highlightMsg(activeMsg, { force: true });
                ensureScrollInBounds(activeMsg);
                if (scrollLockTimeout) clearTimeout(scrollLockTimeout);
                scrollLockTimeout = setTimeout(() => {
                    if (Date.now() - latestInputEdit >= EDIT_LOCK_DURATION_MS) {
                        editPasteProhibit = false;
                        lastEditingMsg = null;
                        scrollLockTimeout = null;
                    }
                }, EDIT_LOCK_DURATION_MS);
            }
        }
    }

    function ensureScrollInBounds(msgDiv) {
        if (!msgDiv || !scrollContainer) return;
        const msgRect = msgDiv.getBoundingClientRect();
        const scRect = scrollContainer.getBoundingClientRect();
        if (msgRect.top < scRect.top || msgRect.bottom > scRect.bottom) {
            msgDiv.scrollIntoView({ behavior: "auto", block: "center" });
        }
    }

    function disableContainerScroll() {
        if (scrollContainer) scrollContainer.style.overflowY = 'hidden';
    }

    function enableContainerScroll() {
        if (scrollContainer) scrollContainer.style.overflowY = 'auto';
    }

    /*************** LISTENERS: PANEL+CLICK **************/
    function panelAndPageListeners() {
        scrollContainer.addEventListener('click', e => {
            const msg = e.target.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0');
            if (msg && allMessages.includes(msg)) highlightMsg(msg, { force: true });
        });
        scrollContainer.addEventListener('focusin', e => {
            const msg = e.target.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0, [data-ormsgid]');
            if (msg && allMessages.includes(msg)) highlightMsg(msg, { force: true });
        });
        scrollContainer.addEventListener('mousedown', e => {
            const msg = e.target.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0, [data-ormsgid]');
            if (msg && allMessages.includes(msg)) highlightMsg(msg, { force: true });
        });
        const observer = new MutationObserver(() => {
            requestAnimationFrame(() => {
                requestAnimationFrame(updateMsgList);
            });
        });
        observer.observe(scrollContainer, { childList: true, subtree: true });

        const expandCollapseSelector = 'div.group.flex.flex-col.gap-2.items-start > div.flex.max-w-full.flex-col.relative.overflow-auto.gap-1.items-start.w-full > div > div > button, ' +
                                       'div.group.flex.flex-col.gap-2.items-end > div.flex.max-w-full.flex-col.relative.overflow-auto.gap-1.items-end.w-full > div > div > button';
        scrollContainer.addEventListener('mousedown', e => {
            const btn = e.target.closest(expandCollapseSelector);
            if (btn) {
                const msgDiv = btn.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0');
                if (msgDiv && allMessages.includes(msgDiv)) {
                    handleToggleScroll(msgDiv);
                }
            }
        });
    }

    /***************** HOTKEYS (NAV/CTRL) ********************/
    function isModifier(event) {
        if (modifierKey === "None") return !event.ctrlKey && !event.altKey;
        if (modifierKey === "Ctrl") return event.ctrlKey && !event.altKey;
        if (modifierKey === "Alt") return event.altKey && !event.ctrlKey;
        return false;
    }

    function setupHotkeys() {
        document.addEventListener('keydown', function (e) {
            if (
                e.target.matches('input, textarea, [contenteditable]') &&
                !["Home", "End", "PageUp", "PageDown"].includes(e.key)
            ) return;
            if (!isModifier(e)) return;
            let handled = false;
            switch (e.key) {
                case 'j':
                    navToMsg(1);
                    handled = true;
                    break;
                case 'k':
                    navToMsg(-1);
                    handled = true;
                    break;
                case 'l':
                case 'h':
                    toggleMsgExpand();
                    handled = true;
                    break;
                case 'Home':
                    if (!e.target.matches('[contenteditable]')) {
                        scrollMsgTop();
                        handled = true;
                    }
                    break;
                case 'End':
                    if (!e.target.matches('[contenteditable]')) {
                        scrollMsgBottom();
                        handled = true;
                    }
                    break;
                case 'r':
                    refreshActiveMsg();
                    handled = true;
                    break;
            }
            if (handled) e.preventDefault();
        });
    }

    /******************** INIT ENTRYPOINT ********************/
    async function initPowerNav() {
        const waitFor = (f) => new Promise(resolve => {
            function step() {
                const x = f();
                if (x) resolve(x);
                else setTimeout(step, 220);
            }
            step();
        });
        scrollContainer = await waitFor(findScrollContainer);

        ensureSpeakerFloat();
        if (panelEnabled) showPanel();

        updateMsgList();
        panelAndPageListeners();
        setupHotkeys();

        let lastScrollUpd = 0;
        scrollContainer.addEventListener('scroll', () => {
            if (Date.now() - lastScrollUpd > ANTI_HYSTERESIS_MS) {
                updateHighlightOnScroll();
                lastScrollUpd = Date.now();
            }
            if (editPasteProhibit && lastEditingMsg) {
                ensureScrollInBounds(lastEditingMsg);
            }
            const active = document.activeElement;
            if (active && (active.matches('input, textarea, [contenteditable]'))) {
                active.blur();
            }
        }, { passive: true });

        document.addEventListener('input', enforceScrollBoundOnEdit, true);
        document.addEventListener('paste', (e) => {
            enforceScrollBoundOnEdit();
            disableContainerScroll();
            setTimeout(enableContainerScroll, 100);
        }, true);
        document.addEventListener('cut', enforceScrollBoundOnEdit, true);

        document.addEventListener('focusout', () => {
            if (editPasteProhibit && Date.now() - latestInputEdit > EDIT_LOCK_DURATION_MS / 2) {
                editPasteProhibit = false;
                lastEditingMsg = null;
            }
        }, true);

        setInterval(updateMsgList, 880);

        document.addEventListener('visibilitychange', () => {
            if (document.visibilityState === "visible") setTimeout(updateMsgList, 500);
        });

        window.addEventListener('resize', () => { setTimeout(updateHighlightOnScroll, 80); });
    }

    initPowerNav();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址