您需要先安装一个扩展,例如 篡改猴、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.`);
- }
- })();