Google Sheets Image Zoom

Auto-preview Google Sheets image tooltips with zoom-to-fit, regardless of pixelation. Escape or click to close. Adjustable zoom %.

// ==UserScript==
// @name         Google Sheets Image Zoom
// @namespace    https://github.com/1LineAtaTime/TamperMonkey-Scripts
// @version      2.0
// @description  Auto-preview Google Sheets image tooltips with zoom-to-fit, regardless of pixelation. Escape or click to close. Adjustable zoom %.
// @author       1LineAtaTime
// @match        https://docs.google.com/spreadsheets/*
// @grant        none
// @license      GPL-3.0
// ==/UserScript==

(function () {
	'use strict';

	const DEBUG = false; //Adds messages to the console log for debugging purposes.
	const FILL_PERCENTAGE = 1.0; // Set to 1.0 for 100%, 0.95 for 95%, etc.

	const BUBBLE_WRAPPER_SELECTOR = '.waffle-multilink-tooltip';
	const IMAGE_SELECTOR = '.link-bubble-drive-thumbnail-image';
	const LINK_SELECTOR = '#docs-linkbubble-link-text';
	const IMAGE_ID = 'gs_bubble_preview_img';
	const ZINDEX = 999999;

	let currentHref = '';
	let observer = null;
	let mainObserver = null;
	let observerInterval;

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

	function hideOverlay() {
		const img = document.getElementById(IMAGE_ID);
		if (img) {
			img.remove();
			log('hide image');
		}
	}

	function buildOverlay(imgSrc) {
		log('Request to show image:', imgSrc);
		hideOverlay();

		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};
			pointer-events: auto;
			background: transparent;
			cursor: zoom-out;
			box-shadow: 0 0 25px rgba(0,0,0,0.6);
			transition: opacity 0.2s ease;
			opacity: 0;
		`;

		img.addEventListener('click', hideOverlay);

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

			log('Original image size:', iw + 'x' + ih);
			log('Viewport:', vw + 'x' + vh);

			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;
			}

			log('Scaled image size:', Math.round(finalW) + 'x' + Math.round(finalH));
			img.style.width = `${finalW}px`;
			img.style.height = `${finalH}px`;
			img.style.opacity = '1';
		};

		document.body.appendChild(img);
	}

	function getBubbleLinkHref() {
		const linkEl = document.querySelector(LINK_SELECTOR);
		return linkEl ? linkEl.href : null;
	}

	function getImageSrcFromBubble() {
		const imgEl = document.querySelector(IMAGE_SELECTOR);
		return imgEl ? imgEl.src : null;
	}

	function isBubbleVisible() {
		const wrapper = document.querySelector(BUBBLE_WRAPPER_SELECTOR);
		return wrapper && wrapper.offsetParent !== null && wrapper.style.display !== 'none';
	}

	function observeBubbleWrapperWhenAvailable() {
		if (mainObserver) mainObserver.disconnect();

		mainObserver = new MutationObserver(() => {
			const wrapper = document.querySelector(BUBBLE_WRAPPER_SELECTOR);
			if (wrapper) {
				log('Bubble wrapper found — starting inner observer');
				startBubbleObserver(wrapper);
				mainObserver.disconnect(); // Only attach once
			}
		});

		mainObserver.observe(document.body, { childList: true, subtree: true });
		log('Waiting for bubble wrapper to appear...');
	}

	function startBubbleObserver(wrapper) {
		if (observer) observer.disconnect();

		observer = new MutationObserver(() => {
			if (!isBubbleVisible()) {
				hideOverlay();
				currentHref = '';
				return;
			}

			const newHref = getBubbleLinkHref();
			if (!newHref || newHref === currentHref) return;

			const imgSrc = getImageSrcFromBubble();
			if (imgSrc) {
				currentHref = newHref;
				buildOverlay(imgSrc);
			}
		});

		observer.observe(wrapper, { attributes: true, childList: true, subtree: true });
		log('Started observing for Sheets bubbles');
	}

	function ensureObserverAlive() {
		if (!observer || observer.takeRecords().length === 0) {
			log('Observer heartbeat: refreshing...');
			observeBubbleWrapperWhenAvailable();
		}
	}

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

	window.addEventListener('load', () => {
		log('Page loaded – watching for bubble wrapper dynamically');
		observeBubbleWrapperWhenAvailable();
		observerInterval = setInterval(ensureObserverAlive, 3000);
	});
})();