Google Gemini Mod (Toolbar & Download)

Enhances Google Gemini with a toolbar for snippets and canvas content download.

当前为 2025-05-16 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Google Gemini Mod (Toolbar & Download)
// @namespace    http://tampermonkey.net/
// @version      0.0.5
// @description  Enhances Google Gemini with a toolbar for snippets and canvas content download.
// @description[de] Verbessert Google Gemini mit einer Symbolleiste für Snippets und dem Herunterladen von Canvas-Inhalten.
// @author       Adromir
// @match        https://gemini.google.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=gemini.google.com
// @license      MIT
// @licenseURL   https://opensource.org/licenses/MIT
// @homepageURL  https://github.com/adromir/scripts/tree/main/userscripts/gemini-snippets
// @supportURL   https://github.com/adromir/scripts/issues
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM_getClipboard
// ==/UserScript==

(function() {
    'use strict';

    // ===================================================================================
    // I. CONFIGURATION SECTION
    // ===================================================================================

    // --- Customizable Labels for Toolbar Buttons ---
    const PASTE_BUTTON_LABEL = "📋 Paste";
    const DOWNLOAD_BUTTON_LABEL = "💾 Download Canvas as File";

    // --- CSS Selectors for DOM Elements ---
    // Selector to find the h2 title element of an active canvas.
    const GEMINI_CANVAS_TITLE_TEXT_SELECTOR = "#app-root > main > side-navigation-v2 > bard-sidenav-container > bard-sidenav-content > div.content-wrapper > div > div.content-container > chat-window > immersive-panel > code-immersive-panel > toolbar > div > div.left-panel > h2.title-text.gds-title-s.ng-star-inserted"; 
    
    // Selector for the "Copy to Clipboard" button, relative to the toolbar element.
    const GEMINI_COPY_BUTTON_IN_TOOLBAR_SELECTOR = "div.action-buttons > copy-button.ng-star-inserted > button.copy-button";

    // Selectors for the Gemini input field (for snippet insertion)
    const GEMINI_INPUT_FIELD_SELECTORS = [
        '.ql-editor p', 
        '.ql-editor',   
        'div[contenteditable="true"]' 
    ];

    // --- Download Feature Configuration ---
    const DEFAULT_DOWNLOAD_EXTENSION = "txt"; 

    // --- Regular Expressions for Filename Sanitization ---
    // eslint-disable-next-line no-control-regex
    const INVALID_FILENAME_CHARS_REGEX = /[<>:"/\\|?*\x00-\x1F]/g;
    const RESERVED_WINDOWS_NAMES_REGEX = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
    const FILENAME_WITH_EXT_REGEX = /^(.+)\.([a-zA-Z0-9]{1,8})$/; 
    const SUBSTRING_FILENAME_REGEX = /([\w\s.,\-()[\\]{}'!~@#$%^&+=]+?\.([a-zA-Z0-9]{1,8}))(?=\s|$|[,.;:!?])/g;

    // ===================================================================================
    // II. TOOLBAR ELEMENT DEFINITIONS
    // ===================================================================================

    const buttonSnippets = [
        { label: "Greeting", text: "Hello Gemini!" },
        { label: "Explain", text: "Could you please explain ... in more detail?" },
    ];

    const dropdownConfigurations = [
        {
            placeholder: "Actions...",
            options: [
                { label: "Summarize", text: "Please summarize the following text:\n" },
                { label: "Ideas", text: "Give me 5 ideas for ..." },
                { label: "Code (JS)", text: "Give me a JavaScript code example for ..." },
            ]
        },
        {
            placeholder: "Translations",
            options: [
                { label: "DE -> EN", text: "Translate the following into English:\n" },
                { label: "EN -> DE", text: "Translate the following into German:\n" },
                { label: "Correct Text", text: "Please correct the grammar and spelling in the following text:\n" }
            ]
        },
    ];

    // ===================================================================================
    // III. SCRIPT LOGIC
    // ===================================================================================
    
    // --- Embedded CSS for the Toolbar ---
    const embeddedCSS = `
        #gemini-snippet-toolbar-userscript { 
          position: fixed !important; top: 0 !important; left: 50% !important; 
          transform: translateX(-50%) !important; 
          width: auto !important; 
          max-width: 80% !important; 
          padding: 10px 15px !important; 
          z-index: 999999 !important; 
          display: flex !important; flex-wrap: wrap !important;
          gap: 8px !important; align-items: center !important; font-family: 'Roboto', 'Arial', sans-serif !important;
          box-sizing: border-box !important; background-color: rgba(40, 42, 44, 0.95) !important;
          border-radius: 0 0 16px 16px !important; 
          box-shadow: 0 4px 12px rgba(0,0,0,0.25);
        }
        #gemini-snippet-toolbar-userscript button, 
        #gemini-snippet-toolbar-userscript select {
          padding: 4px 10px !important; cursor: pointer !important; background-color: #202122 !important;
          color: #e3e3e3 !important; border-radius: 16px !important; font-size: 13px !important;
          font-family: inherit !important; font-weight: 500 !important; height: 28px !important;
          box-sizing: border-box !important; vertical-align: middle !important;
          transition: background-color 0.2s ease, transform 0.1s ease !important;
          border: none !important; flex-shrink: 0;
        }
        #gemini-snippet-toolbar-userscript select {
          padding-right: 25px !important;
          appearance: none !important;
          background-image: url('data:image/svg+xml;charset=US-ASCII,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="%23e3e3e3" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/></svg>') !important;
          background-repeat: no-repeat !important;
          background-position: right 8px center !important;
          background-size: 12px 12px !important;
        }
        #gemini-snippet-toolbar-userscript option {
          background-color: #2a2a2a !important;
          color: #e3e3e3 !important;
          font-weight: normal !important;
          padding: 5px 10px !important;
        }
        #gemini-snippet-toolbar-userscript button:hover,
        #gemini-snippet-toolbar-userscript select:hover {
          background-color: #4a4e51 !important;
        }
        #gemini-snippet-toolbar-userscript button:active {
          background-color: #5f6368 !important;
          transform: scale(0.98) !important;
        }
        .userscript-toolbar-spacer { 
            margin-left: auto !important;
        }
    `;

    /**
     * Injects the embedded CSS using GM_addStyle.
     */
    function injectCustomCSS() {
        try {
            GM_addStyle(embeddedCSS);
            console.log("Gemini Mod Userscript: Custom CSS injected successfully.");
        } catch (error) {
            console.error("Gemini Mod Userscript: Failed to inject custom CSS:", error);
            const styleId = 'gemini-mod-userscript-styles';
            if (document.getElementById(styleId)) return;
            const style = document.createElement('style');
            style.id = styleId;
            style.textContent = embeddedCSS;
            document.head.appendChild(style);
        }
    }

    /**
     * Displays a message to the user (console and alert).
     * @param {string} message - The message to display.
     * @param {boolean} isError - True if it's an error message.
     */
    function displayUserscriptMessage(message, isError = true) {
        const prefix = "Gemini Mod Userscript: ";
        if (isError) console.error(prefix + message);
        else console.log(prefix + message);
        alert(prefix + message);
    }

    /**
     * Moves the cursor to the end of the provided element's content.
     * @param {Element} element - The contenteditable element or paragraph within it.
     */
    function moveCursorToEnd(element) {
        try {
            const range = document.createRange();
            const sel = window.getSelection();
            range.selectNodeContents(element);
            range.collapse(false);
            sel.removeAllRanges();
            sel.addRange(range);
            element.focus();
        } catch (e) {
            console.error("Gemini Mod Userscript: Error setting cursor position:", e);
        }
    }

    /**
     * Finds the target Gemini input element.
     * @returns {Element | null} The found input element or null.
     */
    function findTargetInputElement() {
        let targetInputElement = null;
        for (const selector of GEMINI_INPUT_FIELD_SELECTORS) {
            const element = document.querySelector(selector);
            if (element) {
                if (element.classList.contains('ql-editor')) {
                    const pInEditor = element.querySelector('p');
                    targetInputElement = pInEditor || element;
                } else {
                    targetInputElement = element;
                }
                break;
            }
        }
        return targetInputElement;
    }

    /**
     * Inserts text into the Gemini input field, always appending.
     * @param {string} textToInsert - The text snippet to insert.
     */
    function insertSnippetText(textToInsert) {
        let targetInputElement = findTargetInputElement();
        if (!targetInputElement) {
            displayUserscriptMessage("Could not find Gemini input field.");
            return;
        }
        let actualInsertionPoint = targetInputElement;
        if (targetInputElement.classList.contains('ql-editor')) {
            let p = targetInputElement.querySelector('p');
            if (!p) {
                p = document.createElement('p');
                targetInputElement.appendChild(p);
            }
            actualInsertionPoint = p;
        }
        actualInsertionPoint.focus();
        setTimeout(() => {
            moveCursorToEnd(actualInsertionPoint);
            let insertedViaExec = false;
            try {
                insertedViaExec = document.execCommand('insertText', false, textToInsert);
            } catch (e) {
                console.warn("Gemini Mod Userscript: execCommand('insertText') threw an error:", e);
            }
            if (!insertedViaExec) {
                if (actualInsertionPoint.innerHTML === '<br>') actualInsertionPoint.innerHTML = '';
                actualInsertionPoint.textContent += textToInsert;
                moveCursorToEnd(actualInsertionPoint);
            }
            const editorToDispatchOn = document.querySelector('.ql-editor') || targetInputElement;
            if (editorToDispatchOn) {
                editorToDispatchOn.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
                editorToDispatchOn.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
            }
            console.log("Gemini Mod Userscript: Snippet inserted.");
        }, 50);
    }

    /**
     * Handles the paste button click. Reads from clipboard and inserts text.
     */
    async function handlePasteButtonClick() {
        try {
            if (!navigator.clipboard || !navigator.clipboard.readText) {
                displayUserscriptMessage("Clipboard access is not available or not permitted.");
                return;
            }
            const text = await navigator.clipboard.readText();
            if (text) insertSnippetText(text);
            else console.log("Gemini Mod Userscript: Clipboard is empty.");
        } catch (err) {
            console.error('Gemini Mod Userscript: Failed to read clipboard contents: ', err);
            displayUserscriptMessage(err.name === 'NotAllowedError' ? 'Permission to read clipboard was denied.' : 'Failed to paste from clipboard. See console.');
        }
    }

    /**
     * Helper function to ensure filename length does not exceed a maximum.
     * @param {string} filename - The filename to check.
     * @param {number} maxLength - The maximum allowed length.
     * @returns {string} The potentially truncated filename.
     */
    function ensureLength(filename, maxLength = 255) {
        if (filename.length <= maxLength) {
            return filename;
        }
        const dotIndex = filename.lastIndexOf('.');
        if (dotIndex === -1 || dotIndex < filename.length - 10 ) { 
            return filename.substring(0, maxLength);
        }
        const base = filename.substring(0, dotIndex);
        const ext = filename.substring(dotIndex);
        const maxBaseLength = maxLength - ext.length;
        if (maxBaseLength <= 0) {
            return filename.substring(0, maxLength);
        }
        return base.substring(0, maxBaseLength) + ext;
    }

    /**
     * Sanitizes a base filename part (no extension).
     * @param {string} baseName - The base name to sanitize.
     * @returns {string} The sanitized base name.
     */
    function sanitizeBasename(baseName) {
        if (typeof baseName !== 'string' || baseName.trim() === "") return "downloaded_document";
        let sanitized = baseName.trim()
            .replace(INVALID_FILENAME_CHARS_REGEX, '_')
            .replace(/\s+/g, '_')
            .replace(/__+/g, '_')
            .replace(/^[_.-]+|[_.-]+$/g, '');
        if (!sanitized || RESERVED_WINDOWS_NAMES_REGEX.test(sanitized)) {
            sanitized = `_${sanitized || "file"}_`;
            sanitized = sanitized.replace(INVALID_FILENAME_CHARS_REGEX, '_').replace(/\s+/g, '_').replace(/__+/g, '_').replace(/^[_.-]+|[_.-]+$/g, '');
        }
        return sanitized || "downloaded_document";
    }

    /**
     * Determines the filename for download based on the canvas title,
     * prioritizing a `basename.ext` structure if found.
     * @param {string} title - The original string (e.g., canvas title).
     * @param {string} defaultExtension - The default extension if no structure is found.
     * @returns {string} A processed filename.
     */
    function determineFilename(title, defaultExtension = "txt") {
        const logPrefix = "Gemini Mod Userscript: determineFilename - ";
        if (!title || typeof title !== 'string' || title.trim() === "") {
            console.log(`${logPrefix}Input title invalid or empty, defaulting to "downloaded_document.${defaultExtension}".`);
            return ensureLength(`downloaded_document.${defaultExtension}`);
        }
        let trimmedTitle = title.trim();
        let baseNamePart = "";
        let extensionPart = "";
        const fullTitleMatch = trimmedTitle.match(FILENAME_WITH_EXT_REGEX);
        if (fullTitleMatch) {
            const potentialBase = fullTitleMatch[1];
            const potentialExt = fullTitleMatch[2].toLowerCase();
            if (!INVALID_FILENAME_CHARS_REGEX.test(potentialBase.replace(/\s/g, '_'))) {
                baseNamePart = potentialBase;
                extensionPart = potentialExt;
                console.log(`${logPrefix}Entire title "${trimmedTitle}" matches basename.ext. Base: "${baseNamePart}", Ext: "${extensionPart}"`);
            }
        }
        if (!extensionPart) { 
            let lastMatch = null;
            let currentMatch;
            SUBSTRING_FILENAME_REGEX.lastIndex = 0; 
            while ((currentMatch = SUBSTRING_FILENAME_REGEX.exec(trimmedTitle)) !== null) {
                lastMatch = currentMatch;
            }
            if (lastMatch) {
                const substringExtMatch = lastMatch[1].match(FILENAME_WITH_EXT_REGEX);
                if (substringExtMatch) {
                    baseNamePart = substringExtMatch[1];
                    extensionPart = substringExtMatch[2].toLowerCase();
                    console.log(`${logPrefix}Found substring "${lastMatch[1]}" matching basename.ext. Base: "${baseNamePart}", Ext: "${extensionPart}"`);
                }
            }
        }
        if (extensionPart) { 
            const sanitizedBase = sanitizeBasename(baseNamePart);
            return ensureLength(`${sanitizedBase}.${extensionPart}`);
        } else {
            console.log(`${logPrefix}No basename.ext pattern found. Sanitizing full title "${trimmedTitle}" with default extension "${defaultExtension}".`);
            const sanitizedTitleBase = sanitizeBasename(trimmedTitle);
            return ensureLength(`${sanitizedTitleBase}.${defaultExtension}`);
        }
    }

    /**
     * Creates and triggers a download for the given text content.
     * @param {string} filename - The desired filename.
     * @param {string} content - The text content to download.
     */
    function triggerDownload(filename, content) {
        try {
            const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
            console.log(`Gemini Mod Userscript: Download triggered for "${filename}".`);
        } catch (error) {
            console.error(`Gemini Mod Userscript: Failed to trigger download for "${filename}":`, error);
            displayUserscriptMessage(`Failed to download: ${error.message}`);
        }
    }

    /**
     * Handles the click of the global canvas download button.
     * Finds the active canvas title, then its toolbar and copy button,
     * then reads from clipboard and initiates download.
     */
    async function handleGlobalCanvasDownload() {
        const titleTextElement = document.querySelector(GEMINI_CANVAS_TITLE_TEXT_SELECTOR);
        if (!titleTextElement) {
            console.warn("Gemini Mod Userscript: No active canvas title found. Selector:", GEMINI_CANVAS_TITLE_TEXT_SELECTOR);
            displayUserscriptMessage("No active canvas found to download.");
            return;
        }
        console.log("Gemini Mod Userscript: Found canvas title element:", titleTextElement);

        const toolbarElement = titleTextElement.closest('toolbar'); 
        if (!toolbarElement) {
            console.warn("Gemini Mod Userscript: Could not find parent toolbar for the title element. Searched for 'toolbar' tag from title.");
            displayUserscriptMessage("Could not locate the toolbar for the active canvas.");
            return;
        }
        console.log("Gemini Mod Userscript: Found toolbar element relative to title:", toolbarElement);

        const copyButton = toolbarElement.querySelector(GEMINI_COPY_BUTTON_IN_TOOLBAR_SELECTOR);
        if (!copyButton) {
            console.warn("Gemini Mod Userscript: 'Copy to Clipboard' button not found within the identified toolbar. Selector used on toolbar:", GEMINI_COPY_BUTTON_IN_TOOLBAR_SELECTOR);
            displayUserscriptMessage("Could not find the 'Copy to Clipboard' button in the active canvas's toolbar.");
            return;
        }
        console.log("Gemini Mod Userscript: Found 'Copy to Clipboard' button:", copyButton);
        copyButton.click();
        console.log("Gemini Mod Userscript: Programmatically clicked 'Copy to Clipboard' button.");

        setTimeout(async () => {
            try {
                if (!navigator.clipboard || !navigator.clipboard.readText) {
                    displayUserscriptMessage("Clipboard access not available.");
                    return;
                }
                const clipboardContent = await navigator.clipboard.readText();
                console.log("Gemini Mod Userscript: Successfully read from clipboard.");
                if (!clipboardContent || clipboardContent.trim() === "") {
                    displayUserscriptMessage("Clipboard empty after copy. Nothing to download.");
                    return;
                }
                
                const canvasTitle = (titleTextElement.textContent || "Untitled Canvas").trim();
                const filename = determineFilename(canvasTitle); 
                triggerDownload(filename, clipboardContent);
                console.log("Gemini Mod Userscript: Global download initiated for canvas title:", canvasTitle, "using clipboard content. Filename:", filename);
            } catch (err) {
                console.error('Gemini Mod Userscript: Error reading from clipboard:', err);
                displayUserscriptMessage(err.name === 'NotAllowedError' ? 'Clipboard permission denied.' : 'Failed to read clipboard.');
            }
        }, 300);
    }

    /**
     * Creates the snippet toolbar and adds it to the page.
     */
    function createToolbar() {
        const toolbarId = 'gemini-snippet-toolbar-userscript';
        if (document.getElementById(toolbarId)) {
            console.log("Gemini Mod Userscript: Toolbar already exists.");
            return;
        }
        console.log("Gemini Mod Userscript: Initializing toolbar...");
        const toolbar = document.createElement('div');
        toolbar.id = toolbarId;
        buttonSnippets.forEach(snippet => {
            const button = document.createElement('button');
            button.textContent = snippet.label;
            button.title = snippet.text;
            button.addEventListener('click', () => insertSnippetText(snippet.text));
            toolbar.appendChild(button);
        });
        dropdownConfigurations.forEach(config => {
            if (config.options && config.options.length > 0) {
                const select = document.createElement('select');
                select.title = config.placeholder || "Select snippet";
                const defaultOption = document.createElement('option');
                defaultOption.textContent = config.placeholder || "Select...";
                defaultOption.value = "";
                defaultOption.disabled = true;
                defaultOption.selected = true;
                select.appendChild(defaultOption);
                config.options.forEach(snippet => {
                    const option = document.createElement('option');
                    option.textContent = snippet.label;
                    option.value = snippet.text;
                    select.appendChild(option);
                });
                select.addEventListener('change', (event) => {
                    const selectedText = event.target.value;
                    if (selectedText) {
                        insertSnippetText(selectedText);
                        event.target.selectedIndex = 0;
                    }
                });
                toolbar.appendChild(select);
            }
        });
        const spacer = document.createElement('div');
        spacer.className = 'userscript-toolbar-spacer';
        toolbar.appendChild(spacer);
        const pasteButton = document.createElement('button');
        pasteButton.textContent = PASTE_BUTTON_LABEL;
        pasteButton.title = "Paste from Clipboard";
        pasteButton.addEventListener('click', handlePasteButtonClick);
        toolbar.appendChild(pasteButton);
        const globalDownloadButton = document.createElement('button');
        globalDownloadButton.textContent = DOWNLOAD_BUTTON_LABEL;
        globalDownloadButton.title = "Download active canvas content (uses canvas's copy button)";
        globalDownloadButton.addEventListener('click', handleGlobalCanvasDownload);
        toolbar.appendChild(globalDownloadButton);
        document.body.insertBefore(toolbar, document.body.firstChild);
        console.log("Gemini Mod Userscript: Toolbar inserted.");
    }

    /**
     * Handles dark mode. For a userscript, this is mostly about adapting to the site's
     * existing dark mode, if necessary for the toolbar.
     */
    function handleDarkModeForUserscript() {
        console.log("Gemini Mod Userscript: Dark mode handling is passive (toolbar is dark by default).");
    }

    // --- Initialization Logic ---
    function init() {
        console.log("Gemini Mod Userscript: Initializing...");
        injectCustomCSS();
        const M_INITIALIZATION_DELAY = 1500;
        setTimeout(() => {
            try {
                createToolbar();
                handleDarkModeForUserscript();
                 console.log("Gemini Mod Userscript: Fully initialized.");
            } catch(e) {
                console.error("Gemini Mod Userscript: Error during delayed initialization:", e);
                displayUserscriptMessage("Error initializing toolbar. See console.");
            }
        }, M_INITIALIZATION_DELAY);
    }

    if (document.readyState === 'loading') {
        window.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();