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