YouMind Chat Navigation

添加 "上一问" / "下一问" 悬浮按钮,方便在长对话中快速定位 (兼容单开/分屏/SPA)

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         YouMind Chat Navigation
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  添加 "上一问" / "下一问" 悬浮按钮,方便在长对话中快速定位 (兼容单开/分屏/SPA)
// @author       YouMind User
// @match        https://youmind.com/*
// @match        https://*.youmind.com/*
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // 配置:选择器
    const SELECTORS = {
        // 用户提问的容器类名
        USER_MSG: '.ym-ask-user-content',
        // 核心滚动容器类名
        SCROLL_CONTAINER_CLASS: 'overflow-y-scroll'
    };

    // 图标 (SVG)
    const ICONS = {
        UP: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>`,
        DOWN: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>`
    };

    const css = `
        /* 导航控制器容器 */
        #ym-chat-nav {
            position: absolute; 
            bottom: 20px;       /* 距离底部仅 20px (因为注入到不含输入框的容器中) */
            right: 20px;        /* 右下角 */
            display: flex;
            flex-direction: column;
            gap: 6px;
            z-index: 50;
            opacity: 0.3;
            transition: opacity 0.3s ease;
            pointer-events: auto;
        }

        #ym-chat-nav:hover {
            opacity: 1;
        }

        .ym-nav-btn {
            width: 32px;
            height: 32px;
            background: #ffffff;
            border: 1px solid #e5e7eb;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: pointer;
            box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
            color: #6b7280;
            transition: all 0.2s ease;
            user-select: none;
        }

        .ym-nav-btn:hover {
            background: #f3f4f6;
            color: #111827;
            transform: translateY(-1px);
        }

        .ym-nav-btn:active {
            transform: translateY(0);
        }
    `;

    // 注入样式
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // 核心函数:寻找并注入
    function checkAndInject() {
        if (document.getElementById('ym-chat-nav')) return; // 已存在

        // 策略:从 .ym-ask-user-content 向上找最稳定的挂载点
        // 1. 找到滚动容器 (.overflow-y-scroll)
        // 2. 注入到 滚动容器的 父级 (这个父级通常是 flex-1, 高度 = 此容器 + 0,不含 input)

        let scrollContainer = document.querySelector('.' + SELECTORS.SCROLL_CONTAINER_CLASS);

        // 如果找不到通用的 scroll class,尝试通过内容反向查找
        if (!scrollContainer) {
            const userMsg = document.querySelector(SELECTORS.USER_MSG);
            if (userMsg) {
                let curr = userMsg.parentElement;
                while (curr && curr !== document.body) {
                    const overflow = window.getComputedStyle(curr).overflowY;
                    if (overflow === 'auto' || overflow === 'scroll') {
                        scrollContainer = curr;
                        break;
                    }
                    curr = curr.parentElement;
                }
            }
        }

        if (!scrollContainer) return; // 还没加载出来

        // 找到了滚动容器,现在找它的父级作为注入点
        const targetContainer = scrollContainer.parentElement;
        if (!targetContainer) return;

        // 确保父级有定位上下文
        const style = window.getComputedStyle(targetContainer);
        if (style.position === 'static') {
            targetContainer.style.position = 'relative';
        }

        const nav = document.createElement('div');
        nav.id = 'ym-chat-nav';

        const prevBtn = document.createElement('div');
        prevBtn.className = 'ym-nav-btn';
        prevBtn.innerHTML = ICONS.UP;
        prevBtn.title = '上一问';
        prevBtn.onclick = (e) => { e.stopPropagation(); scrollToMessage('prev', scrollContainer); };

        const nextBtn = document.createElement('div');
        nextBtn.className = 'ym-nav-btn';
        nextBtn.innerHTML = ICONS.DOWN;
        nextBtn.title = '下一问';
        nextBtn.onclick = (e) => { e.stopPropagation(); scrollToMessage('next', scrollContainer); };

        nav.appendChild(prevBtn);
        nav.appendChild(nextBtn);
        targetContainer.appendChild(nav);
    }

    // 滚动逻辑
    // 滚动逻辑
    function scrollToMessage(direction, scrollContainer) {
        const messages = Array.from(scrollContainer.querySelectorAll(SELECTORS.USER_MSG));
        if (messages.length === 0) return;

        // 使用 scrollTop 计算更精准,不受父级 offset 影响
        const currentScroll = scrollContainer.scrollTop;

        // 我们需要找到每个 message 相对于 content 流顶部的位置
        const containerRect = scrollContainer.getBoundingClientRect();

        let targetEl = null;

        if (direction === 'prev') {
            // 逆序找第一个位置在当前视口之上的
            for (let i = messages.length - 1; i >= 0; i--) {
                const rect = messages[i].getBoundingClientRect();
                // rect.top 是元素顶部距视窗顶部的距离
                // containerRect.top 是容器顶部距视窗顶部的距离
                // diff < -50 说明元素头部已经滚出去了
                if (rect.top - containerRect.top < -50) {
                    targetEl = messages[i];
                    break;
                }
            }
            if (!targetEl && currentScroll > 50) targetEl = messages[0];
        } else {
            // 正序找第一个位置在当前视口之下的
            for (let i = 0; i < messages.length; i++) {
                const rect = messages[i].getBoundingClientRect();
                // diff > 100 说明元素在下面
                if (rect.top - containerRect.top > 100) {
                    targetEl = messages[i];
                    break;
                }
            }

            // 如果没有找到下一个目标 (说明后面没有提问了),且当前已经在看最后一个提问或其回答
            if (!targetEl) {
                // 滚到底部
                scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
                return;
            }
        }

        if (targetEl) {
            targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
        }
    }

    // 强力监听:使用 MutationObserver 应对所有 SPA 页面切换
    const observer = new MutationObserver((mutations) => {
        // 简单粗暴:只要 DOM 变了且按钮不存在,就尝试注入
        if (!document.getElementById('ym-chat-nav')) {
            checkAndInject();
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

    // 初始尝试
    checkAndInject();

    // 键盘快捷键绑定: PageUp / PageDown
    document.addEventListener('keydown', (e) => {
        // 确保不是在输入框中按键 (避免干扰打字)
        const activeTag = document.activeElement ? document.activeElement.tagName.toLowerCase() : '';
        if (activeTag === 'input' || activeTag === 'textarea' || document.activeElement.isContentEditable) {
            return;
        }

        if (e.key === 'PageUp') {
            const btn = document.querySelector('#ym-chat-nav .ym-nav-btn[title="上一问"]');
            if (btn && btn.offsetParent !== null) { // 存在且可见
                e.preventDefault();
                btn.click();
            }
        } else if (e.key === 'PageDown') {
            const btn = document.querySelector('#ym-chat-nav .ym-nav-btn[title="下一问"]');
            if (btn && btn.offsetParent !== null) { // 存在且可见
                e.preventDefault();
                btn.click();
            }
        }
    });

})();