[Resource] How to zoom images when reviewing?

I’ve written this from seeing the common trouble of seeing big images such as slideshows as seen from this thread. For the time being, I’m satisfied with being able to click/tap and pan around. I won’t be adding further functionality like panning by dragging the mouse. I’m licensing this as 0BSD so feel free to adopt this into your cool card template or addon, i don’t need attribution.

Changelog

  • 2025-02-14 #1: Fixed non-centered magnified image. whoops.

Demonstration: Basic front side card template

<script>
  'use strict';
  var isAnkiweb;
  var qaElm;
  var cssEval;
  var magnifyElm;

  function getCSSProperty(elm, prop, default_ = undefined) {
    return getComputedStyle(elm).getPropertyValue(prop) || default_;
  }
  function computeCSSLength (v) {
    // See also: https://drafts.csswg.org/cssom/#resolved-values
    cssEval.style.height = v;
    return getComputedStyle(cssEval).height;
  }
  function isSVG(img) {
    const src = img.src.toLowerCase()
    return src.endsWith('.svg') || src.startsWith('data:image/svg+xml') || src.match(/\.svg[?#]/);
  }
  function isMagnifyAble(img, heightThreshold, widthThreshold) {
    return img.hasAttribute('data-magnify') || img.naturalHeight >= heightThreshold || img.naturalWidth >= widthThreshold;
  }
  
  var styleImgSize = (width, height) =>
    `width: calc(${width}px * var(--magnify-zoom)); height: calc(${height}px * var(--magnify-zoom));`;
  
  function magnifyImg(ev) {
    ev.stopPropagation();
    const magnifyImg = magnifyElm.querySelector('img');
    const originImg = ev.currentTarget;
    let styleAttr;
    if (isSVG(originImg)) {
      styleAttr = `${styleAttr} ${styleImgSize(originImg.width, originImg.height)}`
    } else {
      styleAttr = `${styleAttr} ${styleImgSize(originImg.naturalWidth, originImg.naturalHeight)}`;
    }
    const zoomAttr = originImg.getAttribute('data-magnify-zoom');
    if (zoomAttr !== null) {
      styleAttr = `${styleAttr} --magnify-zoom: ${zoomAttr};`;
    }
    magnifyImg.setAttribute('style', styleAttr);
    magnifyImg.src = originImg.src;
    const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
    if (scrollbarWidth > 0) {
      magnifyElm.setAttribute('style', `--scrollbar-width: ${scrollbarWidth}px;`);
    }
    magnifyElm.hidden = false;
  }
  function magnifyClose(ev) {
    magnifyElm.hidden = true;
  }
  
  function setup() {
    const magnifyElm_ = document.querySelector('#magnify:not(template *)');
    if (magnifyElm_ !== null) {
      magnifyElm_.hidden = true;
      return;
    }
    isAnkiweb = location.hostname.includes('ankiuser.net') || location.hostname.includes('ankiweb.net');
    qaElm = document.querySelector('#qa');
    if (isAnkiweb) {
      document.documentElement.classList.add('ankiweb');
    }
    qaElm.querySelectorAll('.to-body-end')
      .forEach((tp) => qaElm.appendChild(tp.content.cloneNode(true)));
    
    cssEval = qaElm.querySelector('#css-eval:not(template *)');
    magnifyElm = qaElm.querySelector('#magnify:not(template *)');
    magnifyElm.querySelector('button').addEventListener('click', magnifyClose);
  }
  function main() {
    setup();
    qaElm.querySelectorAll('img:not(template *, #magnify *)').forEach((img) => {
      const heightThreshold = parseInt(computeCSSLength(
        getCSSProperty(img, '--magnify-height-threshold', '100vh')));
      const widthThreshold = parseInt(computeCSSLength(
        getCSSProperty(img, '--magnify-width-threshold', '100vw')));
      if (img.complete) {
        if (isSVG(img) || isMagnifyAble(img, heightThreshold, widthThreshold)) {
          img.classList.add('can-magnify');
          img.addEventListener('click', magnifyImg);
        }
      } else {
        img.addEventListener('load', () => {
          if (isSVG(img) || isMagnifyAble(img, heightThreshold, widthThreshold)) {
            img.classList.add('can-magnify');
            img.addEventListener('click', magnifyImg);
          }
        });
      }
    });
  }

  if (document.querySelector('.card') !== null) {
    console.log('[Init] .card is ready when loading <script>');
    if (document.readyState === 'loading') {
      console.log('[Init] document is still loading, deferring to "DOMContentLoaded" event');
      document.addEventListener('DOMContentLoaded', main);
    } else {
      console.log('[Init] document is ready')
      main();
    }
  } else {
    console.log('[Init] deferring to next macrotask');
    setTimeout(main);
  }
</script>
<style>
  .card {
    --border-radius-large: 0.7em;
  }

  .can-magnify:hover {
    cursor: zoom-in;
    box-shadow: 0 0 1em var(--shadow);
    transition: box-shadow 0.25s ease-out;
  }
  @media (max-width: 768px) {
    .can-magnify {
      box-shadow: 0 0 0.5em var(--shadow);
    }
  }

  #magnify[hidden] {
    display: none !important;
  }
  #magnify {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    max-width: none;
    max-height: none;
    background-color: hsla(0, 0%, 0%, 0.5);
    background-clip: padding-box;
    overflow: auto;
    z-index: 10;
  }

  /* AnkiWeb hacks */
  .ankiweb #magnify {
    z-index: 2010;
  }
  .ankiweb #ansarea {
    z-index: 2000;
  }

  #magnify > img {
    display: block;
    max-width: none;
    max-height: none;
    margin: auto;
  }

  #magnify > button {
    --border-strong: hsl(0, 90%, 75%);
    --border-subtle: hsl(0, 90%, 80%);
    --button-bg: hsl(0, 90%, 95%);
    --button-gradient-start: hsl(0, 100%, 97%);
    --button-gradient-end: hsl(0, 90%, 95%);
    position: fixed;
    top: 0;
    right: 0;
    margin: 0;
    margin-top: 2em;
    margin-right: calc(2em + var(--scrollbar-width, 0px));
    padding: 1em;
    background: var(--button-bg);
    border-radius: var(--border-radius-large);
    border: 1px solid var(--border-subtle);
    box-shadow: 0px 2px 1px -1px rgba(20,20,20,.12),0px 1px 1px 0px rgba(20,20,20,.06),0px 1px 3px 0px rgba(20,20,20,.04);
    opacity: 0.2;
    transition: opacity 0.15s ease-out;
    font-size: 0.8em;
  }
  #magnify > button:hover {
    cursor: pointer;
    opacity: 1;
    background: linear-gradient(180deg, var(--button-gradient-start) 0%, var(--button-gradient-end) 100%);
    border: 1px solid var(--border-strong);
    box-shadow: 0px 3px 1px -2px rgba(20,20,20,.2),0px 2px 2px 0px rgba(20,20,20,.14),0px 1px 5px 0px rgba(20,20,20,.12);
  }
  .nightMode #magnify > button {
    --border-strong: hsl(0, 70%, 45%);
    --border-subtle: hsl(0, 70%, 40%);
    --button-bg: hsl(0, 75%, 22%);
    --button-gradient-start: hsl(0, 70%, 27%);
    --button-gradient-end: hsl(0, 75%, 22%);
  }
  @media (max-width: 768px) {
    #magnify > button {
      font-size: 1em;
    }
  }
</style>
<template class="to-body-end">
  <div id="magnify" hidden>
    <button>Exit<br>Zoom</button>
    <img src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw== ">
  </div>
  <div id="css-eval" hidden></div>
</template>

{{Front}}

Demonstration: Basic back side card template

{{FrontSide}}

<hr id=answer>

{{Back}}

Demonstration: CSS for card template

.card {
    font-family: arial;
    font-size: 20px;
    text-align: center;
    color: black;
    background-color: white;
    /* Images larger than 50% of your screen height can be magnified */
    --magnify-height-threshold: 50vh;
    /* Images larger than 50% of your screen width can be magnified */
    --magnify-width-threshold: 50vw;
    /* Default zoom factor, must be in floating point.
     * You can set manually for each image, e.g. <img data-magnify-zoom="2.0" src=...>
     * to zoom it to 2x the size.
     */
    --magnify-zoom: 1.5;
}

Demonstration: Sample Images

These looks pretty much the same even in AnkiWeb and AnkiDroid. AnkiWeb is a bit hacky though.



2 Likes

A few days ago I did something similar, though I do not zoom in as much as you do.

My version works on Anki for Desktop and on AnkiDroid (others not tested) and was basically a way for me to increase the image if I ever wanted to (I have css that restricts the image size on default).

For those interested:

Front

{{Front}}

<!--------------------------------------------------------------------->
<!-- IMAGE VIEWER                                                    -->
<!--------------------------------------------------------------------->

<!--
	Used to display images in fullscreen. The image here is a dummy which
	will be replaced accordingly once the user clicks on the image.
-->

<div id="img_viewer">
	<img class="img_increased_size">
</div>

<script>
	/**************************************************************/
	/** [JS] DISPLAY BIG PICTURES ON CLICK                       **/
	/**************************************************************/
	
	var image_nodes = document.getElementsByTagName('img');
	
	for (var i = 0; i < image_nodes.length; i++) {
		image_nodes[i].addEventListener('click', function() {
			toggle_fullscreen(this);
		});
	}
	
	function toggle_fullscreen(element){
		img_viewer = document.getElementById("img_viewer");
		
		if (window.getComputedStyle(img_viewer).display === "flex") {
			img_viewer.style.display="none";
		} else {
			document.getElementById("img_viewer").querySelector("img").setAttribute("src",element.src);
			img_viewer.style.display="flex";
		}
	}
</script>

Back

{{FrontSide}}

<hr id=answer>

{{Back}}

CSS

.card {
    font-family: arial;
    font-size: 20px;
    text-align: center;
    color: black;
    background-color: white;
}



/*
 *  Fullscreen Image Viewer
 */

#img_viewer {
	display: none;
	position: fixed;
	z-index: 1000;
	left: 0;
	top: 0;
	width: 100%;
	height: 100%;
	overflow: auto;
	background-color: #ececec;
}

.img_increased_size {
	margin: auto;
	display: block;
	width: 100%;
	height: 100%;
	object-fit: contain;
	max-height: 100%;
}



/*
 *  Images
 */

img {
	display: block;
	max-height: 50vh; /* max height of images is 50% of the viewport height (if set to 50vh) */
	margin: 1rem auto;
	border-radius: 0.25rem;
	border: 1px solid rgba(0,0,0,0.1);
}

Pictures

This is what it looks like:
Normal view:

On zoom:

(img source: Liste der Schraubenkopfantriebe – Wikipedia)

Edit: Fixed the code to display the image centered and to scale it up properly to be as big as possible without clipping / scrolling.

2 Likes

the goal I set myself was that I want 1.0x (or any desired ratio) with display pixel-to-image pixel ratio. it would be as if i’m opening the image in my image viewer with 100% zoom/actual size. when i use the much more common slideshow sizes (like cisco’s material here) with --magnify-zoom: 1.5 it doesn’t look as extreme (this one would be 150% zoom in an image viewer). And I can even give different values on different devices. it also opens up to me being able to use stranger image sizes. image display in Anki during review really frustrated me, so it’s nice to be able to have a more resilient solution that works with any image.


this is a bit of a side project to the real card template i wanted to make.