- // ==UserScript==
- // @name Save a Gemini message to Google Docs
- // @namespace https://x.com/TakashiSasaki/greasyfork/gemini-message-options-shortcut
- // @version 0.2.3
- // @description Uses Ctrl+Shift+D to export a Gemini message to Google Docs, visually highlighting each interacted UI element. Handles UI variations.
- // @author Takashi Sasasaki
- // @license MIT
- // @homepageURL https://x.com/TakashiSasaki
- // @match https://gemini.google.com/app/*
- // @match https://gemini.google.com/app
- // @icon https://www.gstatic.com/lamda/images/gemini_favicon_f069958c85030456e93de685481c559f160ea06b.png
- // @grant none
- // ==/UserScript==
-
- (function() {
- 'use strict';
-
- // --- Configuration ---
- const USE_CTRL_KEY = true;
- const USE_SHIFT_KEY = true;
- const TRIGGER_KEY_D = 'D';
-
- const SELECTOR_MESSAGE_MENU_BUTTON = '[data-test-id="more-menu-button"]';
- const SELECTOR_EXPORT_BUTTON = '[data-test-id="export-button"]';
- const SELECTOR_SHARE_AND_EXPORT_BUTTON = '[data-test-id="share-and-export-menu-button"]';
-
- const WAIT_BEFORE_CLICK_HIGHLIGHT_MS = 150; // Time to see highlight before click
- const WAIT_AFTER_MENU_CLICK_MS = 200;
- const WAIT_AFTER_EXPORT_MENU_CLICK_MS = 200;
- const WAIT_AFTER_SHARE_BUTTON_CLICK_MS = 200;
- const WAIT_AFTER_ESC_MS = 150; // Time for UI to react after Esc
- const POLLING_INTERVAL_MS = 50;
- const MAX_POLLING_TIME_MS = 3000;
-
- const SCRIPT_NAME = 'Save a Gemini message to Google Docs';
-
- const HIGHLIGHT_STYLE = {
- backgroundColor: 'rgba(255, 255, 0, 0.7)', // Yellowish
- border: '2px solid orange',
- outline: '2px dashed red',
- zIndex: '99999',
- transition: 'background-color 0.1s, border 0.1s, outline 0.1s'
- };
- const DEFAULT_STYLE_KEYS = ['backgroundColor', 'border', 'outline', 'zIndex', 'transition'];
-
- let lastHighlightedElement = null;
- let lastOriginalStyles = {};
-
- function applyHighlight(element) {
- if (!element) return;
- removeCurrentHighlight(); // Remove highlight from any previously highlighted element
-
- lastOriginalStyles = {};
- DEFAULT_STYLE_KEYS.forEach(key => {
- lastOriginalStyles[key] = element.style[key] || '';
- });
-
- Object.assign(element.style, HIGHLIGHT_STYLE);
- lastHighlightedElement = element;
- }
-
- function removeCurrentHighlight() {
- if (lastHighlightedElement && lastOriginalStyles) {
- Object.assign(lastHighlightedElement.style, lastOriginalStyles);
- // Forcing reflow might be needed if transition is stuck, but usually not.
- // lastHighlightedElement.offsetHeight;
- }
- lastHighlightedElement = null;
- lastOriginalStyles = {};
- }
-
-
- // --- Utility to check basic visibility (not display:none, etc.) ---
- function isElementBasicallyVisible(el) {
- if (!el) return false;
- const style = window.getComputedStyle(el);
- if (style.display === 'none') return false;
- if (style.visibility === 'hidden') return false;
- if (style.opacity === '0') return false;
- return el.offsetParent !== null;
- }
-
- // --- Utility to check if an element is truly in the viewport ---
- function isElementInViewport(el) {
- if (!isElementBasicallyVisible(el)) {
- return false;
- }
- const rect = el.getBoundingClientRect();
- return (
- rect.top < window.innerHeight && rect.bottom > 0 &&
- rect.left < window.innerWidth && rect.right > 0
- );
- }
-
- // --- Utility to sleep ---
- function sleep(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- // --- Utility to poll for an element (uses isElementInViewport, finds first in DOM that's in viewport) ---
- async function pollForElement(selector, maxTime, interval) {
- const startTime = Date.now();
- while (Date.now() - startTime < maxTime) {
- const candidates = document.querySelectorAll(selector);
- for (const candidate of candidates) {
- if (isElementInViewport(candidate)) {
- return candidate;
- }
- }
- await sleep(interval);
- }
- console.warn(`[${SCRIPT_NAME}] pollForElement timed out for selector: ${selector}`);
- return null;
- }
-
- // --- Utility to find "Export to Docs" button (generalized for different menu types, uses isElementInViewport) ---
- async function findExportToDocsButton(maxTime, interval) {
- const startTime = Date.now();
- while (Date.now() - startTime < maxTime) {
- const buttons = document.querySelectorAll(
- 'button.mat-ripple.option, button[matripple].option, button.mat-mdc-menu-item'
- );
- for (const button of buttons) {
- const labelElement = button.querySelector('span.item-label, span.mat-mdc-menu-item-text');
- const iconElement = button.querySelector('mat-icon[data-mat-icon-name="docs"]');
-
- if (labelElement && labelElement.textContent.trim() === 'Export to Docs' && iconElement && isElementInViewport(button)) {
- return button;
- }
- }
- await sleep(interval);
- }
- return null;
- }
-
- // --- Main function to perform the export sequence ---
- async function performExportSequence() {
- removeCurrentHighlight(); // Clear any previous highlight before starting
-
- try {
- // Step 1 (Common): Find, highlight, and click the topmost IN VIEWPORT message menu button
- const allMenuButtons = document.querySelectorAll(SELECTOR_MESSAGE_MENU_BUTTON);
- let inViewportMenuButtons = [];
- for (const button of allMenuButtons) {
- if (isElementInViewport(button)) {
- inViewportMenuButtons.push(button);
- }
- }
-
- if (inViewportMenuButtons.length === 0) {
- console.warn(`[${SCRIPT_NAME}] Step 1: No message menu buttons (${SELECTOR_MESSAGE_MENU_BUTTON}) found in viewport.`);
- // alert('画面内にメッセージオプションのメニューボタンが見つかりませんでした。');
- return;
- }
-
- let targetMenuButton;
- if (inViewportMenuButtons.length === 1) {
- targetMenuButton = inViewportMenuButtons[0];
- } else {
- inViewportMenuButtons.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
- targetMenuButton = inViewportMenuButtons[0];
- }
-
- console.log(`[${SCRIPT_NAME}] Step 1: Found topmost in-viewport message menu button. Highlighting and clicking:`, targetMenuButton);
- applyHighlight(targetMenuButton);
- await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
- targetMenuButton.click();
- // Highlight on targetMenuButton will be cleared if we proceed and highlight another element, or if an error occurs and finally block clears it.
-
- console.log(`[${SCRIPT_NAME}] Waiting ${WAIT_AFTER_MENU_CLICK_MS}ms after clicking message menu...`);
- await sleep(WAIT_AFTER_MENU_CLICK_MS);
-
- // Attempt Primary Sequence: Step 2 (Poll for 'Export to...' button)
- console.log(`[${SCRIPT_NAME}] Attempting Primary Sequence: Polling for 'Export to...' button (${SELECTOR_EXPORT_BUTTON})`);
- let exportButton = await pollForElement(SELECTOR_EXPORT_BUTTON, MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
-
- if (exportButton) { // Primary Path
- console.log(`[${SCRIPT_NAME}] Primary Path: Found 'Export to...' button. Highlighting and clicking:`, exportButton);
- applyHighlight(exportButton);
- await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
- exportButton.click();
-
- console.log(`[${SCRIPT_NAME}] Waiting ${WAIT_AFTER_EXPORT_MENU_CLICK_MS}ms after clicking 'Export to...'...`);
- await sleep(WAIT_AFTER_EXPORT_MENU_CLICK_MS);
-
- console.log(`[${SCRIPT_NAME}] Primary Path: Polling for 'Export to Docs' button...`);
- const primaryExportToDocsButton = await findExportToDocsButton(MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
-
- if (primaryExportToDocsButton) {
- console.log(`[${SCRIPT_NAME}] Primary Path: Found 'Export to Docs' button. Highlighting and clicking:`, primaryExportToDocsButton);
- applyHighlight(primaryExportToDocsButton);
- await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
- primaryExportToDocsButton.click();
- console.log(`[${SCRIPT_NAME}] 'Export to Docs' button clicked successfully (Primary Path).`);
- // alert('Google Docsへのエクスポート処理を開始しました。(Primary Path)');
- } else {
- console.warn(`[${SCRIPT_NAME}] Primary Path: 'Export to Docs' button not found after clicking 'Export to...'.`);
- // alert('「Export to Docs」ボタンが見つかりませんでした。(Primary Path)');
- }
- } else { // Primary Path failed, try Alternative Path
- console.warn(`[${SCRIPT_NAME}] Primary Path: 'Export to...' button (${SELECTOR_EXPORT_BUTTON}) not found. Attempting Alternative Path.`);
-
- removeCurrentHighlight(); // Clear highlight from targetMenuButton
-
- console.log(`[${SCRIPT_NAME}] Emulating Escape key press to close any potentially open menu from Step 1.`);
- document.body.dispatchEvent(new KeyboardEvent('keydown', {
- key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true
- }));
- await sleep(WAIT_AFTER_ESC_MS);
-
- console.log(`[${SCRIPT_NAME}] Alternative Path: Polling for 'Share & export' button (${SELECTOR_SHARE_AND_EXPORT_BUTTON})`);
- const shareAndExportButton = await pollForElement(SELECTOR_SHARE_AND_EXPORT_BUTTON, MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
-
- if (shareAndExportButton) {
- console.log(`[${SCRIPT_NAME}] Alternative Path: Found 'Share & export' button. Highlighting and clicking:`, shareAndExportButton);
- applyHighlight(shareAndExportButton);
- await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
- shareAndExportButton.click();
-
- console.log(`[${SCRIPT_NAME}] Waiting ${WAIT_AFTER_SHARE_BUTTON_CLICK_MS}ms after clicking 'Share & export'...`);
- await sleep(WAIT_AFTER_SHARE_BUTTON_CLICK_MS);
-
- console.log(`[${SCRIPT_NAME}] Alternative Path: Polling for 'Export to Docs' button...`);
- const altExportToDocsButton = await findExportToDocsButton(MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
-
- if (altExportToDocsButton) {
- console.log(`[${SCRIPT_NAME}] Alternative Path: Found 'Export to Docs' button. Highlighting and clicking:`, altExportToDocsButton);
- applyHighlight(altExportToDocsButton);
- await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
- altExportToDocsButton.click();
- console.log(`[${SCRIPT_NAME}] 'Export to Docs' button clicked successfully (Alternative Path).`);
- // alert('Google Docsへのエクスポート処理を開始しました。(Alternative Path)');
- } else {
- console.warn(`[${SCRIPT_NAME}] Alternative Path: 'Export to Docs' button not found after clicking 'Share & export'.`);
- // alert('「Export to Docs」ボタンが見つかりませんでした。(Alternative Path)');
- }
- } else {
- console.error(`[${SCRIPT_NAME}] Alternative Path: 'Share & export' button (${SELECTOR_SHARE_AND_EXPORT_BUTTON}) also not found. Cannot proceed.`);
- // alert('「Share & export」ボタンも見つからず、処理を続行できません。');
- }
- }
- } catch (error) {
- console.error(`[${SCRIPT_NAME}] An error occurred during the export sequence:`, error);
- // alert('エクスポート処理中にエラーが発生しました。コンソールを確認してください。');
- } finally {
- // Ensure any active highlight is cleared when the sequence completes or errors out.
- removeCurrentHighlight();
- }
- }
-
- // --- Keyboard shortcut listener ---
- document.addEventListener('keydown', function(event) {
- if (event.ctrlKey === USE_CTRL_KEY &&
- event.shiftKey === USE_SHIFT_KEY &&
- (event.key.toUpperCase() === TRIGGER_KEY_D)
- ) {
- event.preventDefault();
- event.stopPropagation();
- console.log(`[${SCRIPT_NAME}] Ctrl+Shift+D pressed.`);
- performExportSequence();
- }
- }, true);
-
- // Log script load and version
- if (typeof GM_info !== 'undefined' && GM_info.script) {
- console.log(`[${SCRIPT_NAME}] v${GM_info.script.version} loaded and active.`);
- } else {
- console.log(`[${SCRIPT_NAME}] loaded and active. (GM_info not available)`);
- }
-
- })();