您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Keyboard navigation and message interaction for Grok chat
当前为
- // ==UserScript==
- // @name Grok Chat Navigation Improvements
- // @namespace http://tampermonkey.net/
- // @license MIT
- // @version 1.2
- // @description Keyboard navigation and message interaction for Grok chat
- // @author cdr-x
- // @match https://grok.com/*
- // @grant none
- // ==/UserScript==
- (function () {
- 'use strict';
- let selectedIdx = -1;
- let isTogglingCodeEditor = false;
- // Inject CSS for selection styling and z-index handling with rainbow effect
- const style = document.createElement('style');
- style.textContent = `
- div.relative.group.flex.flex-col {
- transition: outline 0.2s ease, transform 0.2s ease;
- transform: scale(1);
- z-index: 1;
- }
- .grok-vim-selected {
- outline: 2px solid;
- animation: rainbowHighlight 2s linear infinite;
- z-index: 10 !important;
- transform: scale(1.01);
- position: relative;
- }
- @keyframes rainbowHighlight {
- 0% { outline-color: #569cd6; }
- 12.5% { outline-color: #da70d6; }
- 25% { outline-color: #d4d4d4; }
- 37.5% { outline-color: #ce9178; }
- 50% { outline-color: #179fff; }
- 62.5% { outline-color: #3dc9b0; }
- 75% { outline-color: #ffd700; }
- 87.5% { outline-color: #608b4e; }
- 100% { outline-color: #1e90ff; }
- }
- `;
- document.head.appendChild(style);
- // ### Helper Functions
- // Get the scrollable container
- function getScrollContainer() {
- const candidates = Array.from(document.querySelectorAll("div.overflow-y-auto"));
- if (!candidates.length) return null;
- let container = candidates.find(div =>
- div.className.includes("flex-col") && div.className.includes("items-center") && div.className.includes("px-5")
- );
- return container || candidates[0];
- }
- // Get the input area at the bottom
- function getInputArea() {
- return document.querySelector("div.absolute.bottom-0");
- }
- // Retrieve all message boxes with more specific selector
- function getMessageBoxes() {
- const container = getScrollContainer();
- if (!container) return [];
- return Array.from(container.querySelectorAll("div.relative.group.flex.flex-col")).filter(box =>
- box.querySelector(".message-bubble")
- );
- }
- // Highlight the selected message with improved scrolling logic
- function highlightSelected({ scrollIntoView = false } = {}) {
- const boxes = getMessageBoxes();
- const container = getScrollContainer();
- boxes.forEach(box => {
- box.classList.remove('grok-vim-selected');
- box.style.zIndex = '1';
- box.style.outline = '';
- });
- if (selectedIdx >= 0 && selectedIdx < boxes.length) {
- const box = boxes[selectedIdx];
- box.classList.add('grok-vim-selected');
- box.style.zIndex = '10';
- if (scrollIntoView && container) {
- const boxRect = box.getBoundingClientRect();
- const containerRect = container.getBoundingClientRect();
- if (boxRect.top < containerRect.top || boxRect.bottom > containerRect.bottom) {
- box.scrollIntoView({ block: "center", behavior: "smooth" });
- }
- }
- }
- }
- // Scroll selected message bottom to 15% above the effective visible area
- function scrollSelectedToBottom15() {
- const container = getScrollContainer();
- const boxes = getMessageBoxes();
- if (!container || selectedIdx < 0 || selectedIdx >= boxes.length) return;
- const box = boxes[selectedIdx];
- const inputBar = getInputArea();
- let offset = 20; // default padding
- if (inputBar) {
- offset = inputBar.getBoundingClientRect().height + 20;
- }
- const boxBottom = box.offsetTop + box.offsetHeight;
- const desiredScrollTop = boxBottom - (container.clientHeight - offset);
- const maxScroll = container.scrollHeight - container.clientHeight;
- const scrollTop = Math.max(0, Math.min(desiredScrollTop, maxScroll));
- container.scrollTo({ top: scrollTop, behavior: "smooth" });
- }
- // Check if currently editing the selected message
- function isEditingMessage() {
- const ae = document.activeElement;
- if (!ae || ae.tagName.toLowerCase() !== "textarea") return false;
- const box = ae.closest("div.relative.group.flex.flex-col");
- if (!box) return false;
- const boxes = getMessageBoxes();
- const idx = boxes.indexOf(box);
- return (idx >= 0 && idx === selectedIdx);
- }
- // Check if in text input mode (bottom input area)
- function isInTextInputMode() {
- const ae = document.activeElement;
- return ae && ae.tagName.toLowerCase() === "textarea" && !isEditingMessage();
- }
- // Get the first and last visible message indices based on scroll position
- function getVisibleMessageIndices() {
- const container = getScrollContainer();
- if (!container) return { first: -1, last: -1 };
- const boxes = getMessageBoxes();
- const viewportTop = container.scrollTop;
- const viewportBottom = viewportTop + container.clientHeight;
- let first = -1;
- let last = -1;
- for (let i = 0; i < boxes.length; i++) {
- const box = boxes[i];
- const boxTop = box.offsetTop;
- const boxBottom = boxTop + box.offsetHeight;
- if (boxBottom > viewportTop && boxTop < viewportBottom) {
- if (first === -1) first = i;
- last = i;
- } else if (boxTop >= viewportBottom) {
- break;
- }
- }
- return { first, last };
- }
- // ### Keyboard Event Listener
- let lastEndKeyTime = 0;
- let lastHomeKeyTime = 0;
- document.addEventListener('keydown', (event) => {
- const boxes = getMessageBoxes();
- if (!boxes.length) return;
- // Ctrl+I focuses the main chat input box
- if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'i') {
- const inputBox = document.querySelector('div.absolute.bottom-0 textarea');
- if (inputBox) {
- inputBox.focus();
- event.preventDefault();
- return;
- }
- }
- const editing = isEditingMessage();
- if (editing) {
- if ((event.ctrlKey || event.metaKey) && event.key === 'End') {
- const textarea = document.activeElement;
- if (textarea.tagName.toLowerCase() === "textarea") {
- textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
- scrollSelectedToBottom15();
- event.preventDefault();
- }
- } else if ((event.ctrlKey || event.metaKey) && event.key === 'Home') {
- const textarea = document.activeElement;
- if (textarea.tagName.toLowerCase() === "textarea") {
- textarea.selectionStart = textarea.selectionEnd = 0;
- boxes[selectedIdx].scrollIntoView({ block: 'start', behavior: 'smooth' });
- event.preventDefault();
- }
- } else if (event.key === 'Escape') {
- // Robustly find the cancel button after the textarea
- const textarea = document.activeElement;
- if (textarea && textarea.tagName.toLowerCase() === 'textarea') {
- // Find the closest message box
- const messageBox = textarea.closest('div.relative.group.flex.flex-col');
- // Find the button group after the textarea
- let cancelButton = null;
- if (messageBox) {
- // Find the parent of textarea, then the next sibling div with buttons
- let parent = textarea.parentElement;
- while (parent && parent !== messageBox && !cancelButton) {
- let sibling = parent.nextElementSibling;
- while (sibling && !cancelButton) {
- // Look for a div with flex-row and buttons
- if (sibling.matches && sibling.matches('div.flex.flex-row')) {
- const btns = Array.from(sibling.querySelectorAll('button'));
- if (btns.length > 0) {
- cancelButton = btns[0]; // First button is always cancel
- break;
- }
- }
- sibling = sibling.nextElementSibling;
- }
- parent = parent.parentElement;
- }
- }
- if (!cancelButton && messageBox) {
- // Fallback: look for any visible button with 2+ buttons after textarea
- const btns = Array.from(messageBox.querySelectorAll('textarea ~ div button'));
- if (btns.length > 0) cancelButton = btns[0];
- }
- if (cancelButton) {
- cancelButton.click();
- event.preventDefault();
- setTimeout(() => {
- highlightSelected({ scrollIntoView: false });
- }, 100);
- }
- }
- }
- return;
- } else {
- if (event.key === 'Escape') {
- const ae = document.activeElement;
- // If in chat input box (bottom input area), defocus and select previously selected message
- if (ae && ae.tagName.toLowerCase() === 'textarea' && isInTextInputMode()) {
- ae.blur();
- if (window._grokPrevSelectedIdx !== undefined && window._grokPrevSelectedIdx >= 0 && window._grokPrevSelectedIdx < boxes.length) {
- selectedIdx = window._grokPrevSelectedIdx;
- highlightSelected({ scrollIntoView: true });
- }
- event.preventDefault();
- return;
- }
- const aside = document.querySelector('aside');
- if (aside && aside.offsetParent !== null) {
- const closeButton = aside.querySelector('div.flex.justify-end > button');
- if (closeButton) {
- closeButton.click();
- event.preventDefault();
- }
- } else if (selectedIdx >= 0) {
- const selectedBox = boxes[selectedIdx];
- let toggleElem = selectedBox.querySelector('.pl-3.pr-5.py-3.flex.gap-2');
- if (!toggleElem) {
- toggleElem = selectedBox.querySelector('div.message-bubble > div.py-1 > button');
- }
- if (toggleElem) {
- toggleElem.click();
- event.preventDefault();
- } else {
- selectedIdx = -1;
- highlightSelected();
- const container = getScrollContainer();
- if (container) container.focus();
- event.preventDefault();
- }
- }
- } else if (!isInTextInputMode()) {
- if (event.key === 'Home') {
- const now = Date.now();
- if (selectedIdx === -1) {
- selectedIdx = 0;
- highlightSelected({ scrollIntoView: true });
- } else {
- // Double-tap Home: if within 800 ms, go to first message
- if (now - lastHomeKeyTime < 800) {
- selectedIdx = 0;
- highlightSelected({ scrollIntoView: true });
- boxes[0].scrollIntoView({ block: 'start', behavior: 'smooth' });
- } else {
- boxes[selectedIdx].scrollIntoView({ block: 'start', behavior: 'smooth' });
- }
- lastHomeKeyTime = now;
- }
- event.preventDefault();
- } else if (event.key === 'End') {
- const now = Date.now();
- if (selectedIdx === -1) {
- selectedIdx = boxes.length - 1;
- highlightSelected();
- scrollSelectedToBottom15();
- } else {
- // Double-tap End: if within 800 ms, go to last message
- if (now - lastEndKeyTime < 800) {
- selectedIdx = boxes.length - 1;
- highlightSelected({ scrollIntoView: true });
- scrollSelectedToBottom15();
- } else {
- scrollSelectedToBottom15();
- }
- lastEndKeyTime = now;
- }
- event.preventDefault();
- } else if (event.key === 'j' || event.key === 'ArrowDown') {
- if (selectedIdx === -1) {
- const { first } = getVisibleMessageIndices();
- selectedIdx = first !== -1 ? first : 0;
- } else {
- selectedIdx = Math.min(selectedIdx + 1, boxes.length - 1);
- }
- highlightSelected({ scrollIntoView: true });
- event.preventDefault();
- } else if (event.key === 'k' || event.key === 'ArrowUp') {
- if (selectedIdx === -1) {
- const { last } = getVisibleMessageIndices();
- selectedIdx = last !== -1 ? last : boxes.length - 1;
- } else {
- selectedIdx = Math.max(selectedIdx - 1, 0);
- }
- highlightSelected({ scrollIntoView: true });
- event.preventDefault();
- } else if (selectedIdx >= 0) {
- const box = boxes[selectedIdx];
- // Robustly find the edit/copy/regenerate buttons regardless of language
- const visibleButtons = Array.from(box.querySelectorAll('button')).filter(btn => btn.offsetParent !== null);
- if (event.key === 'e' && !event.ctrlKey) {
- // Select the first visible button (Edit)
- if (visibleButtons.length > 0) {
- visibleButtons[0].click();
- event.preventDefault();
- }
- } else if (event.key === 'c' && !event.ctrlKey) {
- // Try to find the copy button by aria-label or icon
- let copyBtn = visibleButtons.find(btn => {
- const label = btn.getAttribute('aria-label') || '';
- return /copy|コピー|копировать|copier|kopieren|copia/i.test(label);
- });
- if (!copyBtn) {
- // Fallback: if regenerate button exists, try next button
- let regenIdx = visibleButtons.findIndex(btn => {
- const label = btn.getAttribute('aria-label') || '';
- return /regenerate|再生成|再生成|regenerar|erneuern|regenerieren/i.test(label);
- });
- if (regenIdx !== -1 && visibleButtons[regenIdx + 1]) {
- copyBtn = visibleButtons[regenIdx + 1];
- }
- }
- if (copyBtn) {
- copyBtn.click();
- event.preventDefault();
- }
- } else if (event.key === 'r' && !event.ctrlKey) {
- // Try to find the regenerate button by aria-label or icon
- let regenBtn = visibleButtons.find(btn => {
- const label = btn.getAttribute('aria-label') || '';
- return /regenerate|再生成|再生成|regenerar|erneuern|regenerieren/i.test(label);
- });
- if (!regenBtn) {
- // Fallback: look for a button with a refresh/rotate icon
- regenBtn = visibleButtons.find(btn => {
- const svg = btn.querySelector('svg');
- if (!svg) return false;
- return /rotate|refresh|regenerate|arrow/i.test(svg.outerHTML);
- });
- }
- if (regenBtn) {
- regenBtn.click();
- event.preventDefault();
- }
- }
- }
- }
- }
- // Track previous selectedIdx for chat input Escape
- if (!isInTextInputMode()) {
- window._grokPrevSelectedIdx = selectedIdx;
- }
- });
- // ### Mouse Click Event Listener
- document.addEventListener('click', (event) => {
- if (event.target.closest('aside')) return;
- const boxes = getMessageBoxes();
- let found = false;
- boxes.forEach((box, i) => {
- if (box.contains(event.target)) {
- selectedIdx = i;
- highlightSelected({ scrollIntoView: false });
- found = true;
- }
- });
- if (!found) {
- selectedIdx = -1;
- highlightSelected();
- }
- }, true);
- // ### Scroll Event Listener
- function handleScroll() {
- if (isEditingMessage() || isTogglingCodeEditor) return;
- const boxes = getMessageBoxes();
- if (!boxes.length) return;
- const container = getScrollContainer();
- const scrollTop = container.scrollTop;
- const viewportHeight = container.clientHeight;
- if (selectedIdx >= 0 && selectedIdx < boxes.length) {
- const box = boxes[selectedIdx];
- const boxTop = box.offsetTop;
- const boxHeight = box.offsetHeight;
- if (boxTop + boxHeight <= scrollTop || boxTop >= scrollTop + viewportHeight) {
- selectedIdx = -1;
- highlightSelected();
- }
- }
- }
- function installScrollListener() {
- const container = getScrollContainer();
- if (container) {
- let scheduled = null;
- container.addEventListener('scroll', () => {
- if (scheduled) cancelAnimationFrame(scheduled);
- scheduled = requestAnimationFrame(handleScroll);
- });
- }
- }
- // ### Initialization
- setTimeout(() => {
- installScrollListener();
- handleScroll();
- }, 250);
- // ### DOM Mutation Observer
- const observer = new MutationObserver(() => {
- highlightSelected({ scrollIntoView: false });
- });
- observer.observe(document.body, { childList: true, subtree: true });
- // ### Debugging Handle
- window.grokVimNav = { getScrollContainer, getMessageBoxes, highlightSelected, handleScroll };
- })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址