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.

// ==UserScript==
// @name         Google Sheets Image Zoom
// @namespace    https://github.com/1LineAtaTime/TamperMonkey-Scripts
// @version      3.1
// @description  Auto-preview Google Sheets image tooltips with zoom-to-fit and crosshairs. ESC or click to close. Clean native cursor only.
// @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 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 VLINE_ID = 'gs_crosshair_vline';
	const HLINE_ID = 'gs_crosshair_hline';
	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() {
		[IMAGE_ID, VLINE_ID, HLINE_ID].forEach(id => {
			const el = document.getElementById(id);
			if (el) el.remove();
		});
		document.removeEventListener('mousemove', globalMouseMoveHandler);
		log('hide image & crosshairs');
	}

	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: default;
			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';

			if (SHOW_CROSSHAIRS) setupCrosshairs();
		};

		document.body.appendChild(img);
	}

	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;
				height: 0;
				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;
				width: 0;
				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 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();
			}
		});

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