您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.
// ==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.`); } })();