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.

目前為 2025-05-09 提交的版本,檢視 最新版本

// ==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)`);
    }

})();