Grok Chat Navigation Improvements

Keyboard navigation and message interaction for Grok chat

当前为 2025-04-29 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Grok Chat Navigation Improvements
  3. // @namespace http://tampermonkey.net/
  4. // @license MIT
  5. // @version 1.2
  6. // @description Keyboard navigation and message interaction for Grok chat
  7. // @author cdr-x
  8. // @match https://grok.com/*
  9. // @grant none
  10. // ==/UserScript==
  11.  
  12. (function () {
  13. 'use strict';
  14.  
  15. let selectedIdx = -1;
  16. let isTogglingCodeEditor = false;
  17.  
  18. // Inject CSS for selection styling and z-index handling with rainbow effect
  19. const style = document.createElement('style');
  20. style.textContent = `
  21. div.relative.group.flex.flex-col {
  22. transition: outline 0.2s ease, transform 0.2s ease;
  23. transform: scale(1);
  24. z-index: 1;
  25. }
  26. .grok-vim-selected {
  27. outline: 2px solid;
  28. animation: rainbowHighlight 2s linear infinite;
  29. z-index: 10 !important;
  30. transform: scale(1.01);
  31. position: relative;
  32. }
  33. @keyframes rainbowHighlight {
  34. 0% { outline-color: #569cd6; }
  35. 12.5% { outline-color: #da70d6; }
  36. 25% { outline-color: #d4d4d4; }
  37. 37.5% { outline-color: #ce9178; }
  38. 50% { outline-color: #179fff; }
  39. 62.5% { outline-color: #3dc9b0; }
  40. 75% { outline-color: #ffd700; }
  41. 87.5% { outline-color: #608b4e; }
  42. 100% { outline-color: #1e90ff; }
  43. }
  44. `;
  45. document.head.appendChild(style);
  46.  
  47. // ### Helper Functions
  48.  
  49. // Get the scrollable container
  50. function getScrollContainer() {
  51. const candidates = Array.from(document.querySelectorAll("div.overflow-y-auto"));
  52. if (!candidates.length) return null;
  53. let container = candidates.find(div =>
  54. div.className.includes("flex-col") && div.className.includes("items-center") && div.className.includes("px-5")
  55. );
  56. return container || candidates[0];
  57. }
  58.  
  59. // Get the input area at the bottom
  60. function getInputArea() {
  61. return document.querySelector("div.absolute.bottom-0");
  62. }
  63.  
  64. // Retrieve all message boxes with more specific selector
  65. function getMessageBoxes() {
  66. const container = getScrollContainer();
  67. if (!container) return [];
  68. return Array.from(container.querySelectorAll("div.relative.group.flex.flex-col")).filter(box =>
  69. box.querySelector(".message-bubble")
  70. );
  71. }
  72.  
  73. // Highlight the selected message with improved scrolling logic
  74. function highlightSelected({ scrollIntoView = false } = {}) {
  75. const boxes = getMessageBoxes();
  76. const container = getScrollContainer();
  77.  
  78. boxes.forEach(box => {
  79. box.classList.remove('grok-vim-selected');
  80. box.style.zIndex = '1';
  81. box.style.outline = '';
  82. });
  83.  
  84. if (selectedIdx >= 0 && selectedIdx < boxes.length) {
  85. const box = boxes[selectedIdx];
  86. box.classList.add('grok-vim-selected');
  87. box.style.zIndex = '10';
  88. if (scrollIntoView && container) {
  89. const boxRect = box.getBoundingClientRect();
  90. const containerRect = container.getBoundingClientRect();
  91. if (boxRect.top < containerRect.top || boxRect.bottom > containerRect.bottom) {
  92. box.scrollIntoView({ block: "center", behavior: "smooth" });
  93. }
  94. }
  95. }
  96. }
  97.  
  98. // Scroll selected message bottom to 15% above the effective visible area
  99. function scrollSelectedToBottom15() {
  100. const container = getScrollContainer();
  101. const boxes = getMessageBoxes();
  102. if (!container || selectedIdx < 0 || selectedIdx >= boxes.length) return;
  103. const box = boxes[selectedIdx];
  104. const inputBar = getInputArea();
  105. let offset = 20; // default padding
  106. if (inputBar) {
  107. offset = inputBar.getBoundingClientRect().height + 20;
  108. }
  109. const boxBottom = box.offsetTop + box.offsetHeight;
  110. const desiredScrollTop = boxBottom - (container.clientHeight - offset);
  111. const maxScroll = container.scrollHeight - container.clientHeight;
  112. const scrollTop = Math.max(0, Math.min(desiredScrollTop, maxScroll));
  113. container.scrollTo({ top: scrollTop, behavior: "smooth" });
  114. }
  115.  
  116. // Check if currently editing the selected message
  117. function isEditingMessage() {
  118. const ae = document.activeElement;
  119. if (!ae || ae.tagName.toLowerCase() !== "textarea") return false;
  120. const box = ae.closest("div.relative.group.flex.flex-col");
  121. if (!box) return false;
  122. const boxes = getMessageBoxes();
  123. const idx = boxes.indexOf(box);
  124. return (idx >= 0 && idx === selectedIdx);
  125. }
  126.  
  127. // Check if in text input mode (bottom input area)
  128. function isInTextInputMode() {
  129. const ae = document.activeElement;
  130. return ae && ae.tagName.toLowerCase() === "textarea" && !isEditingMessage();
  131. }
  132.  
  133. // Get the first and last visible message indices based on scroll position
  134. function getVisibleMessageIndices() {
  135. const container = getScrollContainer();
  136. if (!container) return { first: -1, last: -1 };
  137. const boxes = getMessageBoxes();
  138. const viewportTop = container.scrollTop;
  139. const viewportBottom = viewportTop + container.clientHeight;
  140. let first = -1;
  141. let last = -1;
  142. for (let i = 0; i < boxes.length; i++) {
  143. const box = boxes[i];
  144. const boxTop = box.offsetTop;
  145. const boxBottom = boxTop + box.offsetHeight;
  146. if (boxBottom > viewportTop && boxTop < viewportBottom) {
  147. if (first === -1) first = i;
  148. last = i;
  149. } else if (boxTop >= viewportBottom) {
  150. break;
  151. }
  152. }
  153. return { first, last };
  154. }
  155.  
  156. // ### Keyboard Event Listener
  157. let lastEndKeyTime = 0;
  158. let lastHomeKeyTime = 0;
  159. document.addEventListener('keydown', (event) => {
  160. const boxes = getMessageBoxes();
  161. if (!boxes.length) return;
  162.  
  163. // Ctrl+I focuses the main chat input box
  164. if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'i') {
  165. const inputBox = document.querySelector('div.absolute.bottom-0 textarea');
  166. if (inputBox) {
  167. inputBox.focus();
  168. event.preventDefault();
  169. return;
  170. }
  171. }
  172.  
  173. const editing = isEditingMessage();
  174.  
  175. if (editing) {
  176. if ((event.ctrlKey || event.metaKey) && event.key === 'End') {
  177. const textarea = document.activeElement;
  178. if (textarea.tagName.toLowerCase() === "textarea") {
  179. textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
  180. scrollSelectedToBottom15();
  181. event.preventDefault();
  182. }
  183. } else if ((event.ctrlKey || event.metaKey) && event.key === 'Home') {
  184. const textarea = document.activeElement;
  185. if (textarea.tagName.toLowerCase() === "textarea") {
  186. textarea.selectionStart = textarea.selectionEnd = 0;
  187. boxes[selectedIdx].scrollIntoView({ block: 'start', behavior: 'smooth' });
  188. event.preventDefault();
  189. }
  190. } else if (event.key === 'Escape') {
  191. // Robustly find the cancel button after the textarea
  192. const textarea = document.activeElement;
  193. if (textarea && textarea.tagName.toLowerCase() === 'textarea') {
  194. // Find the closest message box
  195. const messageBox = textarea.closest('div.relative.group.flex.flex-col');
  196. // Find the button group after the textarea
  197. let cancelButton = null;
  198. if (messageBox) {
  199. // Find the parent of textarea, then the next sibling div with buttons
  200. let parent = textarea.parentElement;
  201. while (parent && parent !== messageBox && !cancelButton) {
  202. let sibling = parent.nextElementSibling;
  203. while (sibling && !cancelButton) {
  204. // Look for a div with flex-row and buttons
  205. if (sibling.matches && sibling.matches('div.flex.flex-row')) {
  206. const btns = Array.from(sibling.querySelectorAll('button'));
  207. if (btns.length > 0) {
  208. cancelButton = btns[0]; // First button is always cancel
  209. break;
  210. }
  211. }
  212. sibling = sibling.nextElementSibling;
  213. }
  214. parent = parent.parentElement;
  215. }
  216. }
  217. if (!cancelButton && messageBox) {
  218. // Fallback: look for any visible button with 2+ buttons after textarea
  219. const btns = Array.from(messageBox.querySelectorAll('textarea ~ div button'));
  220. if (btns.length > 0) cancelButton = btns[0];
  221. }
  222. if (cancelButton) {
  223. cancelButton.click();
  224. event.preventDefault();
  225. setTimeout(() => {
  226. highlightSelected({ scrollIntoView: false });
  227. }, 100);
  228. }
  229. }
  230. }
  231. return;
  232. } else {
  233. if (event.key === 'Escape') {
  234. const ae = document.activeElement;
  235. // If in chat input box (bottom input area), defocus and select previously selected message
  236. if (ae && ae.tagName.toLowerCase() === 'textarea' && isInTextInputMode()) {
  237. ae.blur();
  238. if (window._grokPrevSelectedIdx !== undefined && window._grokPrevSelectedIdx >= 0 && window._grokPrevSelectedIdx < boxes.length) {
  239. selectedIdx = window._grokPrevSelectedIdx;
  240. highlightSelected({ scrollIntoView: true });
  241. }
  242. event.preventDefault();
  243. return;
  244. }
  245. const aside = document.querySelector('aside');
  246. if (aside && aside.offsetParent !== null) {
  247. const closeButton = aside.querySelector('div.flex.justify-end > button');
  248. if (closeButton) {
  249. closeButton.click();
  250. event.preventDefault();
  251. }
  252. } else if (selectedIdx >= 0) {
  253. const selectedBox = boxes[selectedIdx];
  254. let toggleElem = selectedBox.querySelector('.pl-3.pr-5.py-3.flex.gap-2');
  255. if (!toggleElem) {
  256. toggleElem = selectedBox.querySelector('div.message-bubble > div.py-1 > button');
  257. }
  258. if (toggleElem) {
  259. toggleElem.click();
  260. event.preventDefault();
  261. } else {
  262. selectedIdx = -1;
  263. highlightSelected();
  264. const container = getScrollContainer();
  265. if (container) container.focus();
  266. event.preventDefault();
  267. }
  268. }
  269. } else if (!isInTextInputMode()) {
  270. if (event.key === 'Home') {
  271. const now = Date.now();
  272. if (selectedIdx === -1) {
  273. selectedIdx = 0;
  274. highlightSelected({ scrollIntoView: true });
  275. } else {
  276. // Double-tap Home: if within 800 ms, go to first message
  277. if (now - lastHomeKeyTime < 800) {
  278. selectedIdx = 0;
  279. highlightSelected({ scrollIntoView: true });
  280. boxes[0].scrollIntoView({ block: 'start', behavior: 'smooth' });
  281. } else {
  282. boxes[selectedIdx].scrollIntoView({ block: 'start', behavior: 'smooth' });
  283. }
  284. lastHomeKeyTime = now;
  285. }
  286. event.preventDefault();
  287. } else if (event.key === 'End') {
  288. const now = Date.now();
  289. if (selectedIdx === -1) {
  290. selectedIdx = boxes.length - 1;
  291. highlightSelected();
  292. scrollSelectedToBottom15();
  293. } else {
  294. // Double-tap End: if within 800 ms, go to last message
  295. if (now - lastEndKeyTime < 800) {
  296. selectedIdx = boxes.length - 1;
  297. highlightSelected({ scrollIntoView: true });
  298. scrollSelectedToBottom15();
  299. } else {
  300. scrollSelectedToBottom15();
  301. }
  302. lastEndKeyTime = now;
  303. }
  304. event.preventDefault();
  305. } else if (event.key === 'j' || event.key === 'ArrowDown') {
  306. if (selectedIdx === -1) {
  307. const { first } = getVisibleMessageIndices();
  308. selectedIdx = first !== -1 ? first : 0;
  309. } else {
  310. selectedIdx = Math.min(selectedIdx + 1, boxes.length - 1);
  311. }
  312. highlightSelected({ scrollIntoView: true });
  313. event.preventDefault();
  314. } else if (event.key === 'k' || event.key === 'ArrowUp') {
  315. if (selectedIdx === -1) {
  316. const { last } = getVisibleMessageIndices();
  317. selectedIdx = last !== -1 ? last : boxes.length - 1;
  318. } else {
  319. selectedIdx = Math.max(selectedIdx - 1, 0);
  320. }
  321. highlightSelected({ scrollIntoView: true });
  322. event.preventDefault();
  323. } else if (selectedIdx >= 0) {
  324. const box = boxes[selectedIdx];
  325. // Robustly find the edit/copy/regenerate buttons regardless of language
  326. const visibleButtons = Array.from(box.querySelectorAll('button')).filter(btn => btn.offsetParent !== null);
  327. if (event.key === 'e' && !event.ctrlKey) {
  328. // Select the first visible button (Edit)
  329. if (visibleButtons.length > 0) {
  330. visibleButtons[0].click();
  331. event.preventDefault();
  332. }
  333. } else if (event.key === 'c' && !event.ctrlKey) {
  334. // Try to find the copy button by aria-label or icon
  335. let copyBtn = visibleButtons.find(btn => {
  336. const label = btn.getAttribute('aria-label') || '';
  337. return /copy|コピー|копировать|copier|kopieren|copia/i.test(label);
  338. });
  339. if (!copyBtn) {
  340. // Fallback: if regenerate button exists, try next button
  341. let regenIdx = visibleButtons.findIndex(btn => {
  342. const label = btn.getAttribute('aria-label') || '';
  343. return /regenerate|再生成|再生成|regenerar|erneuern|regenerieren/i.test(label);
  344. });
  345. if (regenIdx !== -1 && visibleButtons[regenIdx + 1]) {
  346. copyBtn = visibleButtons[regenIdx + 1];
  347. }
  348. }
  349. if (copyBtn) {
  350. copyBtn.click();
  351. event.preventDefault();
  352. }
  353. } else if (event.key === 'r' && !event.ctrlKey) {
  354. // Try to find the regenerate button by aria-label or icon
  355. let regenBtn = visibleButtons.find(btn => {
  356. const label = btn.getAttribute('aria-label') || '';
  357. return /regenerate|再生成|再生成|regenerar|erneuern|regenerieren/i.test(label);
  358. });
  359. if (!regenBtn) {
  360. // Fallback: look for a button with a refresh/rotate icon
  361. regenBtn = visibleButtons.find(btn => {
  362. const svg = btn.querySelector('svg');
  363. if (!svg) return false;
  364. return /rotate|refresh|regenerate|arrow/i.test(svg.outerHTML);
  365. });
  366. }
  367. if (regenBtn) {
  368. regenBtn.click();
  369. event.preventDefault();
  370. }
  371. }
  372. }
  373. }
  374. }
  375. // Track previous selectedIdx for chat input Escape
  376. if (!isInTextInputMode()) {
  377. window._grokPrevSelectedIdx = selectedIdx;
  378. }
  379. });
  380.  
  381. // ### Mouse Click Event Listener
  382. document.addEventListener('click', (event) => {
  383. if (event.target.closest('aside')) return;
  384. const boxes = getMessageBoxes();
  385. let found = false;
  386. boxes.forEach((box, i) => {
  387. if (box.contains(event.target)) {
  388. selectedIdx = i;
  389. highlightSelected({ scrollIntoView: false });
  390. found = true;
  391. }
  392. });
  393. if (!found) {
  394. selectedIdx = -1;
  395. highlightSelected();
  396. }
  397. }, true);
  398.  
  399. // ### Scroll Event Listener
  400. function handleScroll() {
  401. if (isEditingMessage() || isTogglingCodeEditor) return;
  402. const boxes = getMessageBoxes();
  403. if (!boxes.length) return;
  404. const container = getScrollContainer();
  405. const scrollTop = container.scrollTop;
  406. const viewportHeight = container.clientHeight;
  407. if (selectedIdx >= 0 && selectedIdx < boxes.length) {
  408. const box = boxes[selectedIdx];
  409. const boxTop = box.offsetTop;
  410. const boxHeight = box.offsetHeight;
  411. if (boxTop + boxHeight <= scrollTop || boxTop >= scrollTop + viewportHeight) {
  412. selectedIdx = -1;
  413. highlightSelected();
  414. }
  415. }
  416. }
  417.  
  418. function installScrollListener() {
  419. const container = getScrollContainer();
  420. if (container) {
  421. let scheduled = null;
  422. container.addEventListener('scroll', () => {
  423. if (scheduled) cancelAnimationFrame(scheduled);
  424. scheduled = requestAnimationFrame(handleScroll);
  425. });
  426. }
  427. }
  428.  
  429. // ### Initialization
  430. setTimeout(() => {
  431. installScrollListener();
  432. handleScroll();
  433. }, 250);
  434.  
  435. // ### DOM Mutation Observer
  436. const observer = new MutationObserver(() => {
  437. highlightSelected({ scrollIntoView: false });
  438. });
  439. observer.observe(document.body, { childList: true, subtree: true });
  440.  
  441. // ### Debugging Handle
  442. window.grokVimNav = { getScrollContainer, getMessageBoxes, highlightSelected, handleScroll };
  443. })();

QingJ © 2025

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