Grok Chat Navigation Improvements

Keyboard navigation and message interaction for Grok chat

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

  1. // ==UserScript==
  2. // @name Grok Chat Navigation Improvements
  3. // @namespace http://tampermonkey.net/
  4. // @license MIT
  5. // @version 1.0
  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 robustly
  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. document.addEventListener('keydown', (event) => {
  158. const boxes = getMessageBoxes();
  159. if (!boxes.length) return;
  160.  
  161. const editing = isEditingMessage();
  162.  
  163. if (editing) {
  164. if ((event.ctrlKey || event.metaKey) && event.key === 'End') {
  165. const textarea = document.activeElement;
  166. if (textarea.tagName.toLowerCase() === "textarea") {
  167. textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
  168. scrollSelectedToBottom15();
  169. event.preventDefault();
  170. }
  171. } else if ((event.ctrlKey || event.metaKey) && event.key === 'Home') {
  172. const textarea = document.activeElement;
  173. if (textarea.tagName.toLowerCase() === "textarea") {
  174. textarea.selectionStart = textarea.selectionEnd = 0;
  175. boxes[selectedIdx].scrollIntoView({ block: 'start', behavior: 'smooth' });
  176. event.preventDefault();
  177. }
  178. } else if (event.key === 'Escape') {
  179. const messageBox = boxes[selectedIdx];
  180. const cancelButton = Array.from(messageBox.querySelectorAll('button')).find(btn =>
  181. btn.textContent.trim().includes('Cancel')
  182. );
  183. if (cancelButton) {
  184. cancelButton.click();
  185. event.preventDefault();
  186. setTimeout(() => {
  187. highlightSelected({ scrollIntoView: false });
  188. }, 100);
  189. }
  190. }
  191. return;
  192. } else {
  193. if (event.key === 'Escape') {
  194. const aside = document.querySelector('aside');
  195. if (aside && aside.offsetParent !== null) {
  196. const closeButton = aside.querySelector('div.flex.justify-end > button');
  197. if (closeButton) {
  198. closeButton.click();
  199. event.preventDefault();
  200. }
  201. } else if (selectedIdx >= 0) {
  202. const selectedBox = boxes[selectedIdx];
  203. let toggleElem = selectedBox.querySelector('.pl-3.pr-5.py-3.flex.gap-2');
  204. if (!toggleElem) {
  205. toggleElem = selectedBox.querySelector('div.message-bubble > div.py-1 > button');
  206. }
  207. if (toggleElem) {
  208. toggleElem.click();
  209. event.preventDefault();
  210. } else {
  211. selectedIdx = -1;
  212. highlightSelected();
  213. const container = getScrollContainer();
  214. if (container) container.focus();
  215. event.preventDefault();
  216. }
  217. }
  218. } else if (!isInTextInputMode()) {
  219. if (event.key === 'Home') {
  220. if (selectedIdx === -1) {
  221. selectedIdx = 0;
  222. highlightSelected({ scrollIntoView: true });
  223. } else {
  224. boxes[selectedIdx].scrollIntoView({ block: 'start', behavior: 'smooth' });
  225. }
  226. event.preventDefault();
  227. } else if (event.key === 'End') {
  228. if (selectedIdx === -1) {
  229. selectedIdx = boxes.length - 1;
  230. highlightSelected();
  231. scrollSelectedToBottom15();
  232. } else {
  233. scrollSelectedToBottom15();
  234. }
  235. event.preventDefault();
  236. } else if (event.key === 'j' || event.key === 'ArrowDown') {
  237. if (selectedIdx === -1) {
  238. const { first } = getVisibleMessageIndices();
  239. selectedIdx = first !== -1 ? first : 0;
  240. } else {
  241. selectedIdx = Math.min(selectedIdx + 1, boxes.length - 1);
  242. }
  243. highlightSelected({ scrollIntoView: true });
  244. event.preventDefault();
  245. } else if (event.key === 'k' || event.key === 'ArrowUp') {
  246. if (selectedIdx === -1) {
  247. const { last } = getVisibleMessageIndices();
  248. selectedIdx = last !== -1 ? last : boxes.length - 1;
  249. } else {
  250. selectedIdx = Math.max(selectedIdx - 1, 0);
  251. }
  252. highlightSelected({ scrollIntoView: true });
  253. event.preventDefault();
  254. } else if (selectedIdx >= 0) {
  255. const box = boxes[selectedIdx];
  256. if (event.key === 'e' && !event.ctrlKey) {
  257. const editButton = box.querySelector('button[aria-label="Edit"]');
  258. if (editButton) {
  259. editButton.click();
  260. event.preventDefault();
  261. }
  262. } else if (event.key === 'c' && !event.ctrlKey) {
  263. const copyButton = box.querySelector('button[aria-label="Copy"]');
  264. if (copyButton) {
  265. copyButton.click();
  266. event.preventDefault();
  267. }
  268. } else if (event.key === 'r' && !event.ctrlKey) {
  269. const regenerateButton = box.querySelector('button[aria-label="Regenerate"]');
  270. if (regenerateButton) {
  271. regenerateButton.click();
  272. event.preventDefault();
  273. }
  274. }
  275. }
  276. }
  277. }
  278. });
  279.  
  280. // ### Mouse Click Event Listener
  281. document.addEventListener('click', (event) => {
  282. if (event.target.closest('aside')) return;
  283. const boxes = getMessageBoxes();
  284. let found = false;
  285. boxes.forEach((box, i) => {
  286. if (box.contains(event.target)) {
  287. selectedIdx = i;
  288. highlightSelected({ scrollIntoView: false });
  289. found = true;
  290. }
  291. });
  292. if (!found) {
  293. selectedIdx = -1;
  294. highlightSelected();
  295. }
  296. }, true);
  297.  
  298. // ### Scroll Event Listener
  299. function handleScroll() {
  300. if (isEditingMessage() || isTogglingCodeEditor) return;
  301. const boxes = getMessageBoxes();
  302. if (!boxes.length) return;
  303. const container = getScrollContainer();
  304. const scrollTop = container.scrollTop;
  305. const viewportHeight = container.clientHeight;
  306. if (selectedIdx >= 0 && selectedIdx < boxes.length) {
  307. const box = boxes[selectedIdx];
  308. const boxTop = box.offsetTop;
  309. const boxHeight = box.offsetHeight;
  310. if (boxTop + boxHeight <= scrollTop || boxTop >= scrollTop + viewportHeight) {
  311. selectedIdx = -1;
  312. highlightSelected();
  313. }
  314. }
  315. }
  316.  
  317. function installScrollListener() {
  318. const container = getScrollContainer();
  319. if (container) {
  320. let scheduled = null;
  321. container.addEventListener('scroll', () => {
  322. if (scheduled) cancelAnimationFrame(scheduled);
  323. scheduled = requestAnimationFrame(handleScroll);
  324. });
  325. }
  326. }
  327.  
  328. // ### Initialization
  329. setTimeout(() => {
  330. installScrollListener();
  331. handleScroll();
  332. }, 250);
  333.  
  334. // ### DOM Mutation Observer
  335. const observer = new MutationObserver(() => {
  336. highlightSelected({ scrollIntoView: false });
  337. });
  338. observer.observe(document.body, { childList: true, subtree: true });
  339.  
  340. // ### Debugging Handle
  341. window.grokVimNav = { getScrollContainer, getMessageBoxes, highlightSelected, handleScroll };
  342. })();

QingJ © 2025

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