添加 "上一问" / "下一问" 悬浮按钮,方便在长对话中快速定位 (兼容单开/分屏/SPA)
// ==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();
}
}
});
})();