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 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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

})();