userscripts/gemini-scrubber.js
2025-12-07 15:06:59 +00:00

214 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name Gemini Minimal Scrubber
// @namespace https://git.js0ny.net
// @version 1.6
// @description Sidebar navigation with text preview. No scroll-spy, just click-to-jump.
// @author js0ny
// @match https://gemini.google.com/*
// @grant GM_addStyle
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
const CONFIG = {
debounceTime: 200,
gap: '8px',
tooltipWidth: '380px',
hitAreaHeight: '32px',
baseColor: 'rgba(255, 255, 255, 0.25)',
hoverColor: '#a8c7fa',
};
GM_addStyle(`
#gemini-scrubber {
position: fixed;
right: 16px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: ${CONFIG.gap};
z-index: 99999;
padding: 10px 8px;
pointer-events: none; /* 容器穿透 */
}
/* 1. 交互层 (热区) */
.scrub-item {
position: relative;
height: ${CONFIG.hitAreaHeight};
width: 32px; /* 稍微加宽热区 */
display: flex;
align-items: center;
justify-content: flex-end;
cursor: pointer;
pointer-events: auto; /* 恢复点击 */
}
/* 2. 视觉层 (胶囊) */
.scrub-visual {
width: 6px;
height: 24px;
background-color: ${CONFIG.baseColor};
border-radius: 99px;
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
opacity: 0.6;
}
/* Hover 效果:变宽、变色、变高 */
.scrub-item:hover .scrub-visual {
background-color: ${CONFIG.hoverColor};
width: 10px;
height: 32px;
opacity: 1;
box-shadow: 0 0 10px ${CONFIG.hoverColor};
}
/* 3. 预览框 (Tooltip) */
.scrub-tooltip {
position: absolute;
right: 36px;
top: 50%;
transform: translateY(-50%) translateX(10px);
width: ${CONFIG.tooltipWidth};
background-color: #1e1e1e;
border: 1px solid #444;
border-radius: 12px;
padding: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: all 0.2s ease-out;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
font-family: "Google Sans", system-ui, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #e3e3e3;
z-index: 100000;
}
.scrub-item:hover .scrub-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(-50%) translateX(0);
}
.tooltip-block { margin-bottom: 12px; }
.tooltip-block:last-child { margin-bottom: 0; }
.tooltip-label {
font-size: 11px;
color: #8e918f;
font-weight: 700;
letter-spacing: 0.5px;
margin-bottom: 4px;
text-transform: uppercase;
}
.tooltip-text {
word-wrap: break-word;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
}
.tooltip-text.user { color: #d2e3fc; }
`);
let container = null;
let debounceTimer = null;
function getCleanText(element) {
if (!element) return "";
const p = element.querySelector("p");
// 优先取 p否则取纯文本替换多余空白
const raw = (p && p.textContent.trim()) ? p.textContent : element.textContent;
return raw.replace(/\s+/g, ' ').trim();
}
function renderScrubber() {
if (!container) {
container = document.createElement('div');
container.id = 'gemini-scrubber';
document.body.appendChild(container);
}
const uNodes = document.querySelectorAll("user-query-content");
const rNodes = document.querySelectorAll("model-response");
// 没内容时清空
if (uNodes.length === 0) {
container.innerHTML = '';
return;
}
// 清理多余节点
while (container.children.length > uNodes.length) {
container.removeChild(container.lastChild);
}
uNodes.forEach((uNode, idx) => {
const rNode = rNodes[idx];
const uText = getCleanText(uNode);
const rText = rNode ? getCleanText(rNode) : "...";
let item = container.children[idx];
const newTooltipContent = `
<div class="tooltip-block">
<div class="tooltip-label">User</div>
<div class="tooltip-text user">${uText}</div>
</div>
${rText ? `
<div class="tooltip-block">
<div class="tooltip-label">Gemini</div>
<div class="tooltip-text model">${rText}</div>
</div>` : ''}
`;
if (item) {
// diff: update only on change
const currentU = item.dataset.uText;
const currentR = item.dataset.rText;
if (currentU !== uText || currentR !== rText) {
const tooltip = item.querySelector('.scrub-tooltip');
if (tooltip) tooltip.innerHTML = newTooltipContent;
item.dataset.uText = uText;
item.dataset.rText = rText;
}
} else {
item = document.createElement('div');
item.className = 'scrub-item';
item.dataset.uText = uText;
item.dataset.rText = rText;
item.innerHTML = `
<div class="scrub-tooltip">${newTooltipContent}</div>
<div class="scrub-visual"></div>
`;
item.addEventListener('click', (e) => {
e.stopPropagation();
uNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
container.appendChild(item);
}
});
}
const observer = new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(renderScrubber, CONFIG.debounceTime);
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(renderScrubber, 1000);
})();