Save a Gemini message to Google Docs

Uses Ctrl+Shift+D to export a Gemini message to Google Docs, injecting per-message export buttons, highlighting UI elements, and outlining dynamic content with the topmost solid and others dashed. Includes injection banner and menu command for debugging.

  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.4.1
  5. // @description Uses Ctrl+Shift+D to export a Gemini message to Google Docs, injecting per-message export buttons, highlighting UI elements, and outlining dynamic content with the topmost solid and others dashed. Includes injection banner and menu command for debugging.
  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 GM_registerMenuCommand
  13. // @run-at document-idle
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. // --- Injection banner ---
  20. const SCRIPT_NAME = 'Save a Gemini message to Google Docs';
  21. const banner = document.createElement('div');
  22. banner.textContent = SCRIPT_NAME;
  23. banner.style.cssText = `
  24. position: fixed;
  25. bottom: 10px;
  26. right: 10px;
  27. background: rgba(0,0,0,0.7);
  28. color: #fff;
  29. padding: 4px 8px;
  30. border-radius: 4px;
  31. z-index: 999999;
  32. font-size: 12px;
  33. font-family: sans-serif;
  34. `;
  35. document.body.appendChild(banner);
  36.  
  37. // --- Configuration ---
  38. const USE_CTRL_KEY = true;
  39. const USE_SHIFT_KEY = true;
  40. const TRIGGER_KEY_D = 'D';
  41. const SELECTOR_MESSAGE_MENU_BUTTON = '[data-test-id="more-menu-button"]';
  42. const SELECTOR_EXPORT_BUTTON = '[data-test-id="export-button"]';
  43. const SELECTOR_SHARE_AND_EXPORT_BUTTON = '[data-test-id="share-and-export-menu-button"]';
  44. const SELECTOR_RESPONSE_CONTAINER = 'response-container';
  45. const EXPORT_BTN_CLASS = 'gm-export-btn';
  46. const WAIT_BEFORE_CLICK_HIGHLIGHT_MS = 150;
  47. const WAIT_AFTER_MENU_CLICK_MS = 200;
  48. const WAIT_AFTER_EXPORT_MENU_CLICK_MS = 200;
  49. const WAIT_AFTER_SHARE_BUTTON_CLICK_MS = 200;
  50. const WAIT_AFTER_ESC_MS = 150;
  51. const POLLING_INTERVAL_MS = 50;
  52. const MAX_POLLING_TIME_MS = 3000;
  53.  
  54. // --- Highlight styles ---
  55. const HIGHLIGHT_STYLE = {
  56. backgroundColor: 'rgba(255,255,0,0.7)',
  57. border: '2px solid orange',
  58. outline: '2px dashed red',
  59. zIndex: '99999',
  60. transition: 'background-color 0.1s, border 0.1s, outline 0.1s'
  61. };
  62. const DEFAULT_STYLE_KEYS = ['backgroundColor','border','outline','zIndex','transition'];
  63.  
  64. let lastHighlightedElement = null;
  65. let lastOriginalStyles = {};
  66.  
  67. // --- Apply & remove highlight on an element ---
  68. function applyHighlight(el) {
  69. if (!el) return;
  70. removeCurrentHighlight();
  71. lastOriginalStyles = {};
  72. DEFAULT_STYLE_KEYS.forEach(key => {
  73. lastOriginalStyles[key] = el.style[key] || '';
  74. });
  75. Object.assign(el.style, HIGHLIGHT_STYLE);
  76. lastHighlightedElement = el;
  77. }
  78. function removeCurrentHighlight() {
  79. if (lastHighlightedElement) {
  80. Object.assign(lastHighlightedElement.style, lastOriginalStyles);
  81. }
  82. lastHighlightedElement = null;
  83. lastOriginalStyles = {};
  84. }
  85.  
  86. // --- Visibility helpers ---
  87. function isElementBasicallyVisible(el) {
  88. if (!el) return false;
  89. const s = window.getComputedStyle(el);
  90. return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0' && el.offsetParent;
  91. }
  92. function isElementInViewport(el) {
  93. if (!isElementBasicallyVisible(el)) return false;
  94. const r = el.getBoundingClientRect();
  95. return r.top < window.innerHeight && r.bottom > 0 && r.left < window.innerWidth && r.right > 0;
  96. }
  97.  
  98. // --- Sleep helper ---
  99. function sleep(ms) {
  100. return new Promise(resolve => setTimeout(resolve, ms));
  101. }
  102.  
  103. // --- Poll for visible element ---
  104. async function pollForElement(selector, maxTime, interval) {
  105. const start = Date.now();
  106. while (Date.now() - start < maxTime) {
  107. for (const c of document.querySelectorAll(selector)) {
  108. if (isElementInViewport(c)) return c;
  109. }
  110. await sleep(interval);
  111. }
  112. console.warn(`[${SCRIPT_NAME}] pollForElement timed out: ${selector}`);
  113. return null;
  114. }
  115.  
  116. // --- Find "Export to Docs" menu item ---
  117. async function findExportToDocsButton(maxTime, interval) {
  118. const start = Date.now();
  119. while (Date.now() - start < maxTime) {
  120. const buttons = document.querySelectorAll(
  121. 'button.mat-ripple.option, button[matripple].option, button.mat-mdc-menu-item'
  122. );
  123. for (const btn of buttons) {
  124. const lab = btn.querySelector('span.item-label, span.mat-mdc-menu-item-text');
  125. const ico = btn.querySelector('mat-icon[data-mat-icon-name="docs"]');
  126. if (lab && lab.textContent.trim() === 'Export to Docs' && ico && isElementInViewport(btn)) {
  127. return btn;
  128. }
  129. }
  130. await sleep(interval);
  131. }
  132. return null;
  133. }
  134.  
  135. // --- Export sequence for a specific container ---
  136. async function exportFor(container) {
  137. removeCurrentHighlight();
  138. const menuBtn = container.querySelector(SELECTOR_MESSAGE_MENU_BUTTON);
  139. if (!menuBtn || !isElementInViewport(menuBtn)) {
  140. console.warn(`[${SCRIPT_NAME}] Menu button not found in container`);
  141. return;
  142. }
  143. applyHighlight(menuBtn);
  144. await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
  145. menuBtn.click();
  146. await sleep(WAIT_AFTER_MENU_CLICK_MS);
  147.  
  148. let exportMenu = await pollForElement(SELECTOR_EXPORT_BUTTON, MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
  149. if (!exportMenu) {
  150. removeCurrentHighlight();
  151. document.body.dispatchEvent(new KeyboardEvent('keydown', {
  152. key: 'Escape', code: 'Escape', keyCode: 27, which: 27,
  153. bubbles: true, cancelable: true
  154. }));
  155. await sleep(WAIT_AFTER_ESC_MS);
  156. const shareBtn = await pollForElement(SELECTOR_SHARE_AND_EXPORT_BUTTON, MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
  157. if (shareBtn) exportMenu = shareBtn;
  158. }
  159. if (exportMenu) {
  160. applyHighlight(exportMenu);
  161. await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
  162. exportMenu.click();
  163. await sleep(WAIT_AFTER_EXPORT_MENU_CLICK_MS);
  164.  
  165. const docsBtn = await findExportToDocsButton(MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
  166. if (docsBtn) {
  167. applyHighlight(docsBtn);
  168. await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
  169. docsBtn.click();
  170. console.log(`[${SCRIPT_NAME}] Export to Docs clicked for container.`);
  171. } else {
  172. console.warn(`[${SCRIPT_NAME}] 'Export to Docs' button not found.`);
  173. }
  174. } else {
  175. console.error(`[${SCRIPT_NAME}] Neither export menu found.`);
  176. }
  177. removeCurrentHighlight();
  178. }
  179.  
  180. // --- Inject Export button into each response-container ---
  181. function injectExportButtons() {
  182. document.querySelectorAll(SELECTOR_RESPONSE_CONTAINER).forEach(container => {
  183. if (container.dataset.hasExportBtn) return;
  184. const inner = container.querySelector('div');
  185. if (!inner) return;
  186. const btn = document.createElement('button');
  187. btn.textContent = '📄 Export';
  188. btn.className = EXPORT_BTN_CLASS;
  189. btn.style.cssText = `
  190. margin-left: 8px;
  191. padding: 2px 6px;
  192. font-size: 12px;
  193. cursor: pointer;
  194. `;
  195. btn.addEventListener('click', e => {
  196. e.stopPropagation();
  197. exportFor(container);
  198. });
  199. inner.appendChild(btn);
  200. container.dataset.hasExportBtn = 'true';
  201. });
  202. }
  203.  
  204. // --- Outline dynamic content, topmost solid others dashed ---
  205. function processContainers() {
  206. injectExportButtons();
  207. // Clear all outlines on inner divs
  208. document.querySelectorAll(`${SELECTOR_RESPONSE_CONTAINER} > div`).forEach(div => {
  209. div.style.outline = '';
  210. });
  211. // Find visible containers
  212. const visibles = Array.from(document.querySelectorAll(SELECTOR_RESPONSE_CONTAINER))
  213. .filter(c => isElementInViewport(c) && c.querySelector('div'));
  214. if (visibles.length === 0) return;
  215. // Determine topmost
  216. visibles.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
  217. const top = visibles[0];
  218. // Apply outlines
  219. visibles.forEach(c => {
  220. const inner = c.querySelector('div');
  221. if (!inner) return;
  222. if (c === top) {
  223. inner.style.outline = '3px solid lime';
  224. } else {
  225. inner.style.outline = '3px dashed lime';
  226. }
  227. });
  228. }
  229. window.addEventListener('load', processContainers);
  230. window.addEventListener('scroll', processContainers);
  231. window.addEventListener('resize', processContainers);
  232.  
  233. // --- Observe for new containers ---
  234. new MutationObserver(muts => {
  235. for (const m of muts) {
  236. for (const node of m.addedNodes) {
  237. if (node.nodeType === 1 &&
  238. (node.matches(SELECTOR_RESPONSE_CONTAINER) || node.querySelector(SELECTOR_RESPONSE_CONTAINER))
  239. ) {
  240. processContainers();
  241. return;
  242. }
  243. }
  244. }
  245. }).observe(document.body, { childList: true, subtree: true });
  246.  
  247. // --- Keyboard shortcut listener (fallback) ---
  248. document.addEventListener('keydown', event => {
  249. if (
  250. event.ctrlKey === USE_CTRL_KEY &&
  251. event.shiftKey === USE_SHIFT_KEY &&
  252. event.key.toUpperCase() === TRIGGER_KEY_D
  253. ) {
  254. event.preventDefault();
  255. event.stopPropagation();
  256. const topContainer = Array.from(document.querySelectorAll(SELECTOR_RESPONSE_CONTAINER))
  257. .find(isElementInViewport);
  258. if (topContainer) exportFor(topContainer);
  259. }
  260. }, true);
  261.  
  262. // --- Tampermonkey menu command ---
  263. if (typeof GM_registerMenuCommand === 'function') {
  264. GM_registerMenuCommand(`Check ${SCRIPT_NAME}`, () => {
  265. alert(`${SCRIPT_NAME} is active`);
  266. });
  267. }
  268.  
  269. // --- Log load ---
  270. if (typeof GM_info !== 'undefined' && GM_info.script) {
  271. console.log(`[${SCRIPT_NAME}] v${GM_info.script.version} loaded and active.`);
  272. } else {
  273. console.log(`[${SCRIPT_NAME}] loaded and active.`);
  274. }
  275. })();