Save a Gemini message to Google Docs

Uses Ctrl+Shift+D to export a Gemini message to Google Docs, visually highlighting each interacted UI element. Handles UI variations.

  1. // ==UserScript==
  2. // @name Save a Gemini message to Google Docs
  3. // @namespace https://x.com/TakashiSasaki/greasyfork/gemini-message-options-shortcut
  4. // @version 0.2.3
  5. // @description Uses Ctrl+Shift+D to export a Gemini message to Google Docs, visually highlighting each interacted UI element. Handles UI variations.
  6. // @author Takashi Sasasaki
  7. // @license MIT
  8. // @homepageURL https://x.com/TakashiSasaki
  9. // @match https://gemini.google.com/app/*
  10. // @match https://gemini.google.com/app
  11. // @icon https://www.gstatic.com/lamda/images/gemini_favicon_f069958c85030456e93de685481c559f160ea06b.png
  12. // @grant none
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. 'use strict';
  17.  
  18. // --- Configuration ---
  19. const USE_CTRL_KEY = true;
  20. const USE_SHIFT_KEY = true;
  21. const TRIGGER_KEY_D = 'D';
  22.  
  23. const SELECTOR_MESSAGE_MENU_BUTTON = '[data-test-id="more-menu-button"]';
  24. const SELECTOR_EXPORT_BUTTON = '[data-test-id="export-button"]';
  25. const SELECTOR_SHARE_AND_EXPORT_BUTTON = '[data-test-id="share-and-export-menu-button"]';
  26.  
  27. const WAIT_BEFORE_CLICK_HIGHLIGHT_MS = 150; // Time to see highlight before click
  28. const WAIT_AFTER_MENU_CLICK_MS = 200;
  29. const WAIT_AFTER_EXPORT_MENU_CLICK_MS = 200;
  30. const WAIT_AFTER_SHARE_BUTTON_CLICK_MS = 200;
  31. const WAIT_AFTER_ESC_MS = 150; // Time for UI to react after Esc
  32. const POLLING_INTERVAL_MS = 50;
  33. const MAX_POLLING_TIME_MS = 3000;
  34.  
  35. const SCRIPT_NAME = 'Save a Gemini message to Google Docs';
  36.  
  37. const HIGHLIGHT_STYLE = {
  38. backgroundColor: 'rgba(255, 255, 0, 0.7)', // Yellowish
  39. border: '2px solid orange',
  40. outline: '2px dashed red',
  41. zIndex: '99999',
  42. transition: 'background-color 0.1s, border 0.1s, outline 0.1s'
  43. };
  44. const DEFAULT_STYLE_KEYS = ['backgroundColor', 'border', 'outline', 'zIndex', 'transition'];
  45.  
  46. let lastHighlightedElement = null;
  47. let lastOriginalStyles = {};
  48.  
  49. function applyHighlight(element) {
  50. if (!element) return;
  51. removeCurrentHighlight(); // Remove highlight from any previously highlighted element
  52.  
  53. lastOriginalStyles = {};
  54. DEFAULT_STYLE_KEYS.forEach(key => {
  55. lastOriginalStyles[key] = element.style[key] || '';
  56. });
  57.  
  58. Object.assign(element.style, HIGHLIGHT_STYLE);
  59. lastHighlightedElement = element;
  60. }
  61.  
  62. function removeCurrentHighlight() {
  63. if (lastHighlightedElement && lastOriginalStyles) {
  64. Object.assign(lastHighlightedElement.style, lastOriginalStyles);
  65. // Forcing reflow might be needed if transition is stuck, but usually not.
  66. // lastHighlightedElement.offsetHeight;
  67. }
  68. lastHighlightedElement = null;
  69. lastOriginalStyles = {};
  70. }
  71.  
  72.  
  73. // --- Utility to check basic visibility (not display:none, etc.) ---
  74. function isElementBasicallyVisible(el) {
  75. if (!el) return false;
  76. const style = window.getComputedStyle(el);
  77. if (style.display === 'none') return false;
  78. if (style.visibility === 'hidden') return false;
  79. if (style.opacity === '0') return false;
  80. return el.offsetParent !== null;
  81. }
  82.  
  83. // --- Utility to check if an element is truly in the viewport ---
  84. function isElementInViewport(el) {
  85. if (!isElementBasicallyVisible(el)) {
  86. return false;
  87. }
  88. const rect = el.getBoundingClientRect();
  89. return (
  90. rect.top < window.innerHeight && rect.bottom > 0 &&
  91. rect.left < window.innerWidth && rect.right > 0
  92. );
  93. }
  94.  
  95. // --- Utility to sleep ---
  96. function sleep(ms) {
  97. return new Promise(resolve => setTimeout(resolve, ms));
  98. }
  99.  
  100. // --- Utility to poll for an element (uses isElementInViewport, finds first in DOM that's in viewport) ---
  101. async function pollForElement(selector, maxTime, interval) {
  102. const startTime = Date.now();
  103. while (Date.now() - startTime < maxTime) {
  104. const candidates = document.querySelectorAll(selector);
  105. for (const candidate of candidates) {
  106. if (isElementInViewport(candidate)) {
  107. return candidate;
  108. }
  109. }
  110. await sleep(interval);
  111. }
  112. console.warn(`[${SCRIPT_NAME}] pollForElement timed out for selector: ${selector}`);
  113. return null;
  114. }
  115.  
  116. // --- Utility to find "Export to Docs" button (generalized for different menu types, uses isElementInViewport) ---
  117. async function findExportToDocsButton(maxTime, interval) {
  118. const startTime = Date.now();
  119. while (Date.now() - startTime < maxTime) {
  120. const buttons = document.querySelectorAll(
  121. 'button.mat-ripple.option, button[matripple].option, button.mat-mdc-menu-item'
  122. );
  123. for (const button of buttons) {
  124. const labelElement = button.querySelector('span.item-label, span.mat-mdc-menu-item-text');
  125. const iconElement = button.querySelector('mat-icon[data-mat-icon-name="docs"]');
  126.  
  127. if (labelElement && labelElement.textContent.trim() === 'Export to Docs' && iconElement && isElementInViewport(button)) {
  128. return button;
  129. }
  130. }
  131. await sleep(interval);
  132. }
  133. return null;
  134. }
  135.  
  136. // --- Main function to perform the export sequence ---
  137. async function performExportSequence() {
  138. removeCurrentHighlight(); // Clear any previous highlight before starting
  139.  
  140. try {
  141. // Step 1 (Common): Find, highlight, and click the topmost IN VIEWPORT message menu button
  142. const allMenuButtons = document.querySelectorAll(SELECTOR_MESSAGE_MENU_BUTTON);
  143. let inViewportMenuButtons = [];
  144. for (const button of allMenuButtons) {
  145. if (isElementInViewport(button)) {
  146. inViewportMenuButtons.push(button);
  147. }
  148. }
  149.  
  150. if (inViewportMenuButtons.length === 0) {
  151. console.warn(`[${SCRIPT_NAME}] Step 1: No message menu buttons (${SELECTOR_MESSAGE_MENU_BUTTON}) found in viewport.`);
  152. // alert('画面内にメッセージオプションのメニューボタンが見つかりませんでした。');
  153. return;
  154. }
  155.  
  156. let targetMenuButton;
  157. if (inViewportMenuButtons.length === 1) {
  158. targetMenuButton = inViewportMenuButtons[0];
  159. } else {
  160. inViewportMenuButtons.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
  161. targetMenuButton = inViewportMenuButtons[0];
  162. }
  163.  
  164. console.log(`[${SCRIPT_NAME}] Step 1: Found topmost in-viewport message menu button. Highlighting and clicking:`, targetMenuButton);
  165. applyHighlight(targetMenuButton);
  166. await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
  167. targetMenuButton.click();
  168. // Highlight on targetMenuButton will be cleared if we proceed and highlight another element, or if an error occurs and finally block clears it.
  169.  
  170. console.log(`[${SCRIPT_NAME}] Waiting ${WAIT_AFTER_MENU_CLICK_MS}ms after clicking message menu...`);
  171. await sleep(WAIT_AFTER_MENU_CLICK_MS);
  172.  
  173. // Attempt Primary Sequence: Step 2 (Poll for 'Export to...' button)
  174. console.log(`[${SCRIPT_NAME}] Attempting Primary Sequence: Polling for 'Export to...' button (${SELECTOR_EXPORT_BUTTON})`);
  175. let exportButton = await pollForElement(SELECTOR_EXPORT_BUTTON, MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
  176.  
  177. if (exportButton) { // Primary Path
  178. console.log(`[${SCRIPT_NAME}] Primary Path: Found 'Export to...' button. Highlighting and clicking:`, exportButton);
  179. applyHighlight(exportButton);
  180. await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
  181. exportButton.click();
  182.  
  183. console.log(`[${SCRIPT_NAME}] Waiting ${WAIT_AFTER_EXPORT_MENU_CLICK_MS}ms after clicking 'Export to...'...`);
  184. await sleep(WAIT_AFTER_EXPORT_MENU_CLICK_MS);
  185.  
  186. console.log(`[${SCRIPT_NAME}] Primary Path: Polling for 'Export to Docs' button...`);
  187. const primaryExportToDocsButton = await findExportToDocsButton(MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
  188.  
  189. if (primaryExportToDocsButton) {
  190. console.log(`[${SCRIPT_NAME}] Primary Path: Found 'Export to Docs' button. Highlighting and clicking:`, primaryExportToDocsButton);
  191. applyHighlight(primaryExportToDocsButton);
  192. await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
  193. primaryExportToDocsButton.click();
  194. console.log(`[${SCRIPT_NAME}] 'Export to Docs' button clicked successfully (Primary Path).`);
  195. // alert('Google Docsへのエクスポート処理を開始しました。(Primary Path)');
  196. } else {
  197. console.warn(`[${SCRIPT_NAME}] Primary Path: 'Export to Docs' button not found after clicking 'Export to...'.`);
  198. // alert('「Export to Docs」ボタンが見つかりませんでした。(Primary Path)');
  199. }
  200. } else { // Primary Path failed, try Alternative Path
  201. console.warn(`[${SCRIPT_NAME}] Primary Path: 'Export to...' button (${SELECTOR_EXPORT_BUTTON}) not found. Attempting Alternative Path.`);
  202.  
  203. removeCurrentHighlight(); // Clear highlight from targetMenuButton
  204.  
  205. console.log(`[${SCRIPT_NAME}] Emulating Escape key press to close any potentially open menu from Step 1.`);
  206. document.body.dispatchEvent(new KeyboardEvent('keydown', {
  207. key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true
  208. }));
  209. await sleep(WAIT_AFTER_ESC_MS);
  210.  
  211. console.log(`[${SCRIPT_NAME}] Alternative Path: Polling for 'Share & export' button (${SELECTOR_SHARE_AND_EXPORT_BUTTON})`);
  212. const shareAndExportButton = await pollForElement(SELECTOR_SHARE_AND_EXPORT_BUTTON, MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
  213.  
  214. if (shareAndExportButton) {
  215. console.log(`[${SCRIPT_NAME}] Alternative Path: Found 'Share & export' button. Highlighting and clicking:`, shareAndExportButton);
  216. applyHighlight(shareAndExportButton);
  217. await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
  218. shareAndExportButton.click();
  219.  
  220. console.log(`[${SCRIPT_NAME}] Waiting ${WAIT_AFTER_SHARE_BUTTON_CLICK_MS}ms after clicking 'Share & export'...`);
  221. await sleep(WAIT_AFTER_SHARE_BUTTON_CLICK_MS);
  222.  
  223. console.log(`[${SCRIPT_NAME}] Alternative Path: Polling for 'Export to Docs' button...`);
  224. const altExportToDocsButton = await findExportToDocsButton(MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
  225.  
  226. if (altExportToDocsButton) {
  227. console.log(`[${SCRIPT_NAME}] Alternative Path: Found 'Export to Docs' button. Highlighting and clicking:`, altExportToDocsButton);
  228. applyHighlight(altExportToDocsButton);
  229. await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
  230. altExportToDocsButton.click();
  231. console.log(`[${SCRIPT_NAME}] 'Export to Docs' button clicked successfully (Alternative Path).`);
  232. // alert('Google Docsへのエクスポート処理を開始しました。(Alternative Path)');
  233. } else {
  234. console.warn(`[${SCRIPT_NAME}] Alternative Path: 'Export to Docs' button not found after clicking 'Share & export'.`);
  235. // alert('「Export to Docs」ボタンが見つかりませんでした。(Alternative Path)');
  236. }
  237. } else {
  238. console.error(`[${SCRIPT_NAME}] Alternative Path: 'Share & export' button (${SELECTOR_SHARE_AND_EXPORT_BUTTON}) also not found. Cannot proceed.`);
  239. // alert('「Share & export」ボタンも見つからず、処理を続行できません。');
  240. }
  241. }
  242. } catch (error) {
  243. console.error(`[${SCRIPT_NAME}] An error occurred during the export sequence:`, error);
  244. // alert('エクスポート処理中にエラーが発生しました。コンソールを確認してください。');
  245. } finally {
  246. // Ensure any active highlight is cleared when the sequence completes or errors out.
  247. removeCurrentHighlight();
  248. }
  249. }
  250.  
  251. // --- Keyboard shortcut listener ---
  252. document.addEventListener('keydown', function(event) {
  253. if (event.ctrlKey === USE_CTRL_KEY &&
  254. event.shiftKey === USE_SHIFT_KEY &&
  255. (event.key.toUpperCase() === TRIGGER_KEY_D)
  256. ) {
  257. event.preventDefault();
  258. event.stopPropagation();
  259. console.log(`[${SCRIPT_NAME}] Ctrl+Shift+D pressed.`);
  260. performExportSequence();
  261. }
  262. }, true);
  263.  
  264. // Log script load and version
  265. if (typeof GM_info !== 'undefined' && GM_info.script) {
  266. console.log(`[${SCRIPT_NAME}] v${GM_info.script.version} loaded and active.`);
  267. } else {
  268. console.log(`[${SCRIPT_NAME}] loaded and active. (GM_info not available)`);
  269. }
  270.  
  271. })();