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.

Verze ze dne 09. 06. 2025. Zobrazit nejnovější verzi.

// ==UserScript==
// @name         Save a Gemini message to Google Docs
// @namespace    https://x.com/TakashiSasaki/greasyfork/gemini-message-options-shortcut
// @version      0.4.1
// @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.
// @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        GM_registerMenuCommand
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- Injection banner ---
    const SCRIPT_NAME = 'Save a Gemini message to Google Docs';
    const banner = document.createElement('div');
    banner.textContent = SCRIPT_NAME;
    banner.style.cssText = `
        position: fixed;
        bottom: 10px;
        right: 10px;
        background: rgba(0,0,0,0.7);
        color: #fff;
        padding: 4px 8px;
        border-radius: 4px;
        z-index: 999999;
        font-size: 12px;
        font-family: sans-serif;
    `;
    document.body.appendChild(banner);

    // --- 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 SELECTOR_RESPONSE_CONTAINER      = 'response-container';
    const EXPORT_BTN_CLASS                 = 'gm-export-btn';
    const WAIT_BEFORE_CLICK_HIGHLIGHT_MS   = 150;
    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;
    const POLLING_INTERVAL_MS              = 50;
    const MAX_POLLING_TIME_MS              = 3000;

    // --- Highlight styles ---
    const HIGHLIGHT_STYLE = {
        backgroundColor: 'rgba(255,255,0,0.7)',
        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     = {};

    // --- Apply & remove highlight on an element ---
    function applyHighlight(el) {
        if (!el) return;
        removeCurrentHighlight();
        lastOriginalStyles = {};
        DEFAULT_STYLE_KEYS.forEach(key => {
            lastOriginalStyles[key] = el.style[key] || '';
        });
        Object.assign(el.style, HIGHLIGHT_STYLE);
        lastHighlightedElement = el;
    }
    function removeCurrentHighlight() {
        if (lastHighlightedElement) {
            Object.assign(lastHighlightedElement.style, lastOriginalStyles);
        }
        lastHighlightedElement = null;
        lastOriginalStyles     = {};
    }

    // --- Visibility helpers ---
    function isElementBasicallyVisible(el) {
        if (!el) return false;
        const s = window.getComputedStyle(el);
        return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0' && el.offsetParent;
    }
    function isElementInViewport(el) {
        if (!isElementBasicallyVisible(el)) return false;
        const r = el.getBoundingClientRect();
        return r.top < window.innerHeight && r.bottom > 0 && r.left < window.innerWidth && r.right > 0;
    }

    // --- Sleep helper ---
    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    // --- Poll for visible element ---
    async function pollForElement(selector, maxTime, interval) {
        const start = Date.now();
        while (Date.now() - start < maxTime) {
            for (const c of document.querySelectorAll(selector)) {
                if (isElementInViewport(c)) return c;
            }
            await sleep(interval);
        }
        console.warn(`[${SCRIPT_NAME}] pollForElement timed out: ${selector}`);
        return null;
    }

    // --- Find "Export to Docs" menu item ---
    async function findExportToDocsButton(maxTime, interval) {
        const start = Date.now();
        while (Date.now() - start < maxTime) {
            const buttons = document.querySelectorAll(
                'button.mat-ripple.option, button[matripple].option, button.mat-mdc-menu-item'
            );
            for (const btn of buttons) {
                const lab = btn.querySelector('span.item-label, span.mat-mdc-menu-item-text');
                const ico = btn.querySelector('mat-icon[data-mat-icon-name="docs"]');
                if (lab && lab.textContent.trim() === 'Export to Docs' && ico && isElementInViewport(btn)) {
                    return btn;
                }
            }
            await sleep(interval);
        }
        return null;
    }

    // --- Export sequence for a specific container ---
    async function exportFor(container) {
        removeCurrentHighlight();
        const menuBtn = container.querySelector(SELECTOR_MESSAGE_MENU_BUTTON);
        if (!menuBtn || !isElementInViewport(menuBtn)) {
            console.warn(`[${SCRIPT_NAME}] Menu button not found in container`);
            return;
        }
        applyHighlight(menuBtn);
        await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
        menuBtn.click();
        await sleep(WAIT_AFTER_MENU_CLICK_MS);

        let exportMenu = await pollForElement(SELECTOR_EXPORT_BUTTON, MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
        if (!exportMenu) {
            removeCurrentHighlight();
            document.body.dispatchEvent(new KeyboardEvent('keydown', {
                key: 'Escape', code: 'Escape', keyCode: 27, which: 27,
                bubbles: true, cancelable: true
            }));
            await sleep(WAIT_AFTER_ESC_MS);
            const shareBtn = await pollForElement(SELECTOR_SHARE_AND_EXPORT_BUTTON, MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
            if (shareBtn) exportMenu = shareBtn;
        }
        if (exportMenu) {
            applyHighlight(exportMenu);
            await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
            exportMenu.click();
            await sleep(WAIT_AFTER_EXPORT_MENU_CLICK_MS);

            const docsBtn = await findExportToDocsButton(MAX_POLLING_TIME_MS, POLLING_INTERVAL_MS);
            if (docsBtn) {
                applyHighlight(docsBtn);
                await sleep(WAIT_BEFORE_CLICK_HIGHLIGHT_MS);
                docsBtn.click();
                console.log(`[${SCRIPT_NAME}] Export to Docs clicked for container.`);
            } else {
                console.warn(`[${SCRIPT_NAME}] 'Export to Docs' button not found.`);
            }
        } else {
            console.error(`[${SCRIPT_NAME}] Neither export menu found.`);
        }
        removeCurrentHighlight();
    }

    // --- Inject Export button into each response-container ---
    function injectExportButtons() {
        document.querySelectorAll(SELECTOR_RESPONSE_CONTAINER).forEach(container => {
            if (container.dataset.hasExportBtn) return;
            const inner = container.querySelector('div');
            if (!inner) return;
            const btn = document.createElement('button');
            btn.textContent = '📄 Export';
            btn.className = EXPORT_BTN_CLASS;
            btn.style.cssText = `
                margin-left: 8px;
                padding: 2px 6px;
                font-size: 12px;
                cursor: pointer;
            `;
            btn.addEventListener('click', e => {
                e.stopPropagation();
                exportFor(container);
            });
            inner.appendChild(btn);
            container.dataset.hasExportBtn = 'true';
        });
    }

    // --- Outline dynamic content, topmost solid others dashed ---
    function processContainers() {
        injectExportButtons();
        // Clear all outlines on inner divs
        document.querySelectorAll(`${SELECTOR_RESPONSE_CONTAINER} > div`).forEach(div => {
            div.style.outline = '';
        });
        // Find visible containers
        const visibles = Array.from(document.querySelectorAll(SELECTOR_RESPONSE_CONTAINER))
            .filter(c => isElementInViewport(c) && c.querySelector('div'));
        if (visibles.length === 0) return;
        // Determine topmost
        visibles.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
        const top = visibles[0];
        // Apply outlines
        visibles.forEach(c => {
            const inner = c.querySelector('div');
            if (!inner) return;
            if (c === top) {
                inner.style.outline = '3px solid lime';
            } else {
                inner.style.outline = '3px dashed lime';
            }
        });
    }
    window.addEventListener('load',   processContainers);
    window.addEventListener('scroll',  processContainers);
    window.addEventListener('resize',  processContainers);

    // --- Observe for new containers ---
    new MutationObserver(muts => {
        for (const m of muts) {
            for (const node of m.addedNodes) {
                if (node.nodeType === 1 &&
                    (node.matches(SELECTOR_RESPONSE_CONTAINER) || node.querySelector(SELECTOR_RESPONSE_CONTAINER))
                ) {
                    processContainers();
                    return;
                }
            }
        }
    }).observe(document.body, { childList: true, subtree: true });

    // --- Keyboard shortcut listener (fallback) ---
    document.addEventListener('keydown', event => {
        if (
            event.ctrlKey === USE_CTRL_KEY &&
            event.shiftKey === USE_SHIFT_KEY &&
            event.key.toUpperCase() === TRIGGER_KEY_D
        ) {
            event.preventDefault();
            event.stopPropagation();
            const topContainer = Array.from(document.querySelectorAll(SELECTOR_RESPONSE_CONTAINER))
                .find(isElementInViewport);
            if (topContainer) exportFor(topContainer);
        }
    }, true);

    // --- Tampermonkey menu command ---
    if (typeof GM_registerMenuCommand === 'function') {
        GM_registerMenuCommand(`Check ${SCRIPT_NAME}`, () => {
            alert(`${SCRIPT_NAME} is active`);
        });
    }

    // --- Log load ---
    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.`);
    }
})();