Google Sheets Image Zoom

Auto-preview Google Sheets image tooltips with zoom-to-fit and crosshairs. ESC or click to close. Clean native cursor only. Only triggers on Google Drive file links. Optimized for instant popup without spam logging.

// ==UserScript==
// @name         Google Sheets Image Zoom
// @namespace    https://github.com/1LineAtaTime/TamperMonkey-Scripts
// @version      3.9
// @description  Auto-preview Google Sheets image tooltips with zoom-to-fit and crosshairs. ESC or click to close. Clean native cursor only. Only triggers on Google Drive file links. Optimized for instant popup without spam logging.
// @author       1LineAtaTime
// @match        https://docs.google.com/spreadsheets/*
// @grant        none
// @license      GPL-3.0
// ==/UserScript==

(function () {
	'use strict';

	const DEBUG = false;
	const FILL_PERCENTAGE = 1.0;
	const SHOW_CROSSHAIRS = true;
	const DRIVE_FILE_REGEX = /^https?:\/\/drive\.google\.com\/(?:file\/d\/([^/]+)\/view|uc\?id=([^&]+))/i;

	const BUBBLE_SELECTOR = '.waffle-multilink-tooltip';
	const IMAGE_ID = 'gs_bubble_preview_img';
	const VLINE_ID = 'gs_crosshair_vline';
	const HLINE_ID = 'gs_crosshair_hline';
	const ZINDEX = 999999;

	let currentHref = '';               // last processed URL while bubble is visible
	let bubbleObserver = null;
	let bodyObserver = null;

	function log(...args) { if (DEBUG) console.log('[GS-Preview]', ...args); }

	function isVisible(el) {
		if (!el) return false;
		// The bubble becomes effectively hidden when display: none is applied
		const style = getComputedStyle(el);
		return style.display !== 'none';
	}

	// When resetCache = true, we allow the next visible bubble to trigger again (on hide).
	function hideOverlay(resetCache = false) {
		[IMAGE_ID, VLINE_ID, HLINE_ID].forEach(id => {
			const el = document.getElementById(id);
			if (el) el.remove();
		});
		document.removeEventListener('mousemove', globalMouseMoveHandler);
		if (resetCache) currentHref = '';
	}

	function buildOverlay(imgSrc) {
		// Do NOT reset currentHref here; we only reset it when the bubble actually hides.
		hideOverlay(false);

		const img = document.createElement('img');
		img.id = IMAGE_ID;
		img.src = imgSrc;
		img.style.cssText = `
			position: fixed;
			inset: 0;
			margin: auto;
			width: auto;
			height: auto;
			object-fit: contain;
			z-index: ${ZINDEX};
			background: transparent;
			cursor: default;
			box-shadow: 0 0 25px rgba(0,0,0,0.6);
			transition: opacity 0.15s ease;
			opacity: 0;
		`;

		img.addEventListener('click', () => hideOverlay(false));

		img.onload = () => {
			const vw = window.innerWidth;
			const vh = window.innerHeight;
			const iw = img.naturalWidth;
			const ih = img.naturalHeight;

			const aspectRatio = iw / ih;
			const maxW = vw * FILL_PERCENTAGE;
			const maxH = vh * FILL_PERCENTAGE;

			let finalW, finalH;
			if (maxW / maxH < aspectRatio) {
				finalW = maxW;
				finalH = finalW / aspectRatio;
			} else {
				finalH = maxH;
				finalW = finalH * aspectRatio;
			}

			img.style.width = `${finalW}px`;
			img.style.height = `${finalH}px`;
			img.style.opacity = '1';

			if (SHOW_CROSSHAIRS) setupCrosshairs();
		};

		document.body.appendChild(img);
		log('Overlay built for', imgSrc);
	}

	function setupCrosshairs() {
		let vLine = document.getElementById(VLINE_ID);
		let hLine = document.getElementById(HLINE_ID);

		if (!vLine) {
			vLine = document.createElement('div');
			vLine.id = VLINE_ID;
			vLine.style.cssText = `
				position: fixed;
				width: 1px;
				background: red;
				left: 0;
				top: 0;
				z-index: ${ZINDEX + 1};
				pointer-events: none;
			`;
			document.body.appendChild(vLine);
		}

		if (!hLine) {
			hLine = document.createElement('div');
			hLine.id = HLINE_ID;
			hLine.style.cssText = `
				position: fixed;
				height: 1px;
				background: red;
				left: 0;
				top: 0;
				z-index: ${ZINDEX + 1};
				pointer-events: none;
			`;
			document.body.appendChild(hLine);
		}

		document.addEventListener('mousemove', globalMouseMoveHandler);
	}

	function globalMouseMoveHandler(e) {
		const img = document.getElementById(IMAGE_ID);
		const vLine = document.getElementById(VLINE_ID);
		const hLine = document.getElementById(HLINE_ID);
		if (!img || !vLine || !hLine) return;

		const rect = img.getBoundingClientRect();
		const x = e.clientX;
		const y = e.clientY;

		if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
			vLine.style.display = 'none';
			hLine.style.display = 'none';
			return;
		}

		vLine.style.display = 'block';
		hLine.style.display = 'block';

		vLine.style.left = `${x}px`;
		vLine.style.top = `${rect.top}px`;
		vLine.style.height = `${rect.height}px`;

		hLine.style.left = `${rect.left}px`;
		hLine.style.top = `${y}px`;
		hLine.style.width = `${rect.width}px`;
	}

	function normToUc(url) {
		// Normalize a drive URL into an https uc?id= form for consistent previews
		const match = url.match(DRIVE_FILE_REGEX);
		if (!match) return null;
		const id = match[1] || match[2];
		return `https://drive.google.com/uc?id=${id}`;
	}

	function extractDriveUrl(bubble) {
		// Prefer explicit data-url within the bubble
		const dataEl = bubble.querySelector('[data-url*="drive.google.com"]');
		if (dataEl) {
			const raw = dataEl.getAttribute('data-url');
			const finalUrl = normToUc(raw || '');
			if (finalUrl) {
				log('extractDriveUrl: used data-url element', dataEl);
				return finalUrl;
			}
		}

		// Fallback to first anchor
		const aTag = bubble.querySelector('a[href*="drive.google.com"]');
		if (aTag) {
			const finalUrl = normToUc(aTag.href || '');
			if (finalUrl) {
				log('extractDriveUrl: used <a> href from', aTag);
				return finalUrl;
			}
		}

		log('extractDriveUrl: no valid URL found yet');
		return null;
	}

	function startBubbleObserver(bubble) {
		if (bubbleObserver) bubbleObserver.disconnect();

		bubbleObserver = new MutationObserver(() => {
			// If bubble becomes hidden, reset state so the next show can trigger again
			if (!isVisible(bubble)) {
				log('Bubble hidden — clearing overlay and URL cache');
				hideOverlay(true);      // true => reset currentHref
				startBodyObserver();
				return;
			}

			// Bubble is visible — consider updating preview if URL changes
			const url = extractDriveUrl(bubble);
			if (!url) return;

			if (url === currentHref) {
				// Same URL: do nothing
				return;
			}

			// New URL while bubble still visible (e.g., fast mouse move across cells)
			currentHref = url;
			buildOverlay(url);
		});

		// Watch for style (display), and children/attributes that may mutate the link
		bubbleObserver.observe(bubble, {
			childList: true,
			subtree: true,
			attributes: true,
			attributeFilter: ['style']
		});
	}

	function startBodyObserver() {
		if (bodyObserver) bodyObserver.disconnect();

		bodyObserver = new MutationObserver(() => {
			const bubble = document.querySelector(BUBBLE_SELECTOR);
			if (!bubble) return;

			// Only act when the waffle bubble is actually visible
			if (!isVisible(bubble)) return;

			log('Bubble found — starting fast image extraction');
			startBubbleObserver(bubble);

			// Initial fast extract/build (only if new URL and bubble visible)
			const url = extractDriveUrl(bubble);
			if (url && url !== currentHref) {
				currentHref = url;
				buildOverlay(url);
			}

			// Stop scanning the whole body; the bubble observer will handle changes
			bodyObserver.disconnect();
		});

		bodyObserver.observe(document.body, { childList: true, subtree: true });
	}

	document.addEventListener('keydown', e => { if (e.key === 'Escape') hideOverlay(false); });

	window.addEventListener('load', () => {
		log('Starting bubble watcher...');
		startBodyObserver();
	});
})();