Image occlusion zoom in

Hello everyone

Today I purchased ankimobile, the reason is that I’m taking a one-week holiday and I have a lot of reviews to do, which would be easier to be done on an Ipad screen than on a little phone screen.

I found out that anki image occlusion feature has (in my humble opinion) a major flaw: it’s not possible to zoom in images. With things like mind maps, it makes it impossible to make cards out of them.

I gave Claude the original code, which fixed the problem both on PC and Android. It’s not perfect, and has some flaws, but overall works.

The major issue is that it does not work at all on ankimobile. It does not even show the card. If anyone has any idea, or would like to review the code and help with it, I’d appreciate it. I hope that in the future the ‘zoom in’ function will be implemented natively

Here’s the code, both for front and back of the card:

{{#Header}}<div>{{Header}}</div>{{/Header}}
<div style="display: none">{{cloze:Occlusion}}</div>
<div id="err"></div>
<div id="image-occlusion-container">
{{Image}}
<canvas id="image-occlusion-canvas"></canvas>
</div>
<script>
function initializeImageOcclusion() {
    try {
        anki.imageOcclusion.setup();

        const container = document.getElementById('image-occlusion-container');
        const canvas = document.getElementById('image-occlusion-canvas');
        let img = null;

        let scale = 1;
        let originX = 0;
        let originY = 0;
        let isDragging = false;
        let startX, startY;
        let masksVisible = true;
        let lastPinchDistance = 0;
        let lastTouchX, lastTouchY;

        const MIN_SCALE = 0.1;
        const MAX_SCALE = 10;

        function findImage() {
            return container.querySelector('img') || document.querySelector('#image-occlusion-container img');
        }

        function waitForImage(callback, maxAttempts = 10, interval = 100) {
            let attempts = 0;
            const checkImage = () => {
                img = findImage();
                if (img) {
                    callback();
                } else if (attempts < maxAttempts) {
                    attempts++;
                    setTimeout(checkImage, interval);
                } else {
                    throw new Error("Image not found after maximum attempts");
                }
            };
            checkImage();
        }

        function saveZoomState() {
            const state = { scale, originX, originY };
            localStorage.setItem('zoomState', JSON.stringify(state));
        }

        function loadZoomState() {
            const savedState = localStorage.getItem('zoomState');
            if (savedState) {
                const state = JSON.parse(savedState);
                scale = state.scale;
                originX = state.originX;
                originY = state.originY;
                setTransform(0);
            }
        }

        function setTransform(duration = 0) {
            if (!img) return;
            const transform = `translate(${originX}px, ${originY}px) scale(${scale})`;
            [img, canvas].forEach(el => {
                el.style.transform = transform;
                el.style.transition = `transform ${duration}ms ease-out`;
            });
            saveZoomState();
        }

        function limitZoom(value) {
            return Math.min(Math.max(value, MIN_SCALE), MAX_SCALE);
        }

        function handleZoom(delta, centerX, centerY) {
            const newScale = limitZoom(scale + delta);

            const rect = container.getBoundingClientRect();
            const mouseX = centerX - rect.left;
            const mouseY = centerY - rect.top;

            originX = originX - (mouseX / scale - mouseX / newScale);
            originY = originY - (mouseY / scale - mouseY / newScale);

            scale = newScale;
            setTransform(100);
        }

        function handleWheel(event) {
            if (event.shiftKey) {
                event.preventDefault();
                const delta = event.deltaY > 0 ? -0.1 : 0.1;
                handleZoom(delta, event.clientX, event.clientY);
            }
        }

        function handleMouseDown(event) {
            isDragging = true;
            startX = event.clientX - originX;
            startY = event.clientY - originY;
            container.style.cursor = 'grabbing';
        }

        function handleMouseMove(event) {
            if (isDragging) {
                originX = event.clientX - startX;
                originY = event.clientY - startY;
                setTransform();
            }
        }

        function handleMouseUp() {
            isDragging = false;
            container.style.cursor = 'grab';
        }

        function handleTouchStart(event) {
            if (event.touches.length === 2) {
                const touch1 = event.touches[0];
                const touch2 = event.touches[1];
                lastPinchDistance = Math.hypot(touch1.clientX - touch2.clientX, touch1.clientY - touch2.clientY);
            } else if (event.touches.length === 1) {
                isDragging = true;
                const touch = event.touches[0];
                startX = touch.clientX - originX;
                startY = touch.clientY - originY;
                lastTouchX = touch.clientX;
                lastTouchY = touch.clientY;
            }
        }

        function handleTouchMove(event) {
            event.preventDefault();
            if (event.touches.length === 2) {
                const touch1 = event.touches[0];
                const touch2 = event.touches[1];
                const pinchDistance = Math.hypot(touch1.clientX - touch2.clientX, touch1.clientY - touch2.clientY);
                const delta = (pinchDistance - lastPinchDistance) * 0.01;
                lastPinchDistance = pinchDistance;

                const centerX = (touch1.clientX + touch2.clientX) / 2;
                const centerY = (touch1.clientY + touch2.clientY) / 2;
                
                handleZoom(delta, centerX, centerY);
            } else if (event.touches.length === 1 && isDragging) {
                const touch = event.touches[0];
                const deltaX = touch.clientX - lastTouchX;
                const deltaY = touch.clientY - lastTouchY;
                
                originX += deltaX;
                originY += deltaY;
                
                lastTouchX = touch.clientX;
                lastTouchY = touch.clientY;
                
                setTransform();
            }
        }

        function handleTouchEnd(event) {
            if (event.touches.length < 2) {
                lastPinchDistance = 0;
            }
            if (event.touches.length === 0) {
                isDragging = false;
            }
        }

        function handleKeyDown(event) {
            if (event.key === 'Escape') {
                scale = 1;
                originX = 0;
                originY = 0;
                setTransform(300);
            }
        }

        let rafId = null;
        function optimizedHandleMouseMove(event) {
            if (isDragging) {
                if (rafId) cancelAnimationFrame(rafId);
                rafId = requestAnimationFrame(() => handleMouseMove(event));
            }
        }

        function toggleMasks() {
            masksVisible = !masksVisible;
            canvas.style.display = masksVisible ? 'block' : 'none';
        }

        function setupEventListeners() {
            container.addEventListener('wheel', handleWheel, { passive: false });
            container.addEventListener('mousedown', handleMouseDown);
            container.addEventListener('mousemove', optimizedHandleMouseMove);
            container.addEventListener('mouseup', handleMouseUp);
            container.addEventListener('mouseleave', handleMouseUp);
            container.addEventListener('touchstart', handleTouchStart);
            container.addEventListener('touchmove', handleTouchMove, { passive: false });
            container.addEventListener('touchend', handleTouchEnd);
            document.addEventListener('keydown', handleKeyDown);

            container.setAttribute('tabindex', '0');
            container.setAttribute('aria-label', 'Immagine zoomabile e spostabile');

            container.style.cursor = 'grab';

            const toggleButton = document.getElementById('toggle');
            if (toggleButton) {
                toggleButton.addEventListener('click', toggleMasks);
            }
        }

        function initialize() {
            loadZoomState();
            setupEventListeners();
        }

        waitForImage(initialize);

    } catch (exc) {
        document.getElementById("err").innerHTML = `Error loading image occlusion. Is your Anki version up to date?<br><br>${exc}`;
        console.error("Image Occlusion Error:", exc);
    }
}

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initializeImageOcclusion);
} else {
    initializeImageOcclusion();
}
</script>

<div><button id="toggle">Toggle Masks</button></div>
{{#Back Extra}}<div>{{Back Extra}}</div>{{/Back Extra}}{{#Header}}<div>{{Header}}</div>{{/Header}}
<div style="display: none">{{cloze:Occlusion}}</div>
<div id="err"></div>
<div id="image-occlusion-container">
{{Image}}
<canvas id="image-occlusion-canvas"></canvas>
</div>
<script>
function initializeImageOcclusion() {
    try {
        anki.imageOcclusion.setup();

        const container = document.getElementById('image-occlusion-container');
        const canvas = document.getElementById('image-occlusion-canvas');
        let img = null;

        let scale = 1;
        let originX = 0;
        let originY = 0;
        let isDragging = false;
        let startX, startY;
        let masksVisible = true;
        let lastPinchDistance = 0;
        let lastTouchX, lastTouchY;

        const MIN_SCALE = 0.1;
        const MAX_SCALE = 10;

        function findImage() {
            return container.querySelector('img') || document.querySelector('#image-occlusion-container img');
        }

        function waitForImage(callback, maxAttempts = 10, interval = 100) {
            let attempts = 0;
            const checkImage = () => {
                img = findImage();
                if (img) {
                    callback();
                } else if (attempts < maxAttempts) {
                    attempts++;
                    setTimeout(checkImage, interval);
                } else {
                    throw new Error("Image not found after maximum attempts");
                }
            };
            checkImage();
        }

        function saveZoomState() {
            const state = { scale, originX, originY };
            localStorage.setItem('zoomState', JSON.stringify(state));
        }

        function loadZoomState() {
            const savedState = localStorage.getItem('zoomState');
            if (savedState) {
                const state = JSON.parse(savedState);
                scale = state.scale;
                originX = state.originX;
                originY = state.originY;
                setTransform(0);
            }
        }

        function setTransform(duration = 0) {
            if (!img) return;
            const transform = `translate(${originX}px, ${originY}px) scale(${scale})`;
            [img, canvas].forEach(el => {
                el.style.transform = transform;
                el.style.transition = `transform ${duration}ms ease-out`;
            });
            saveZoomState();
        }

        function limitZoom(value) {
            return Math.min(Math.max(value, MIN_SCALE), MAX_SCALE);
        }

        function handleZoom(delta, centerX, centerY) {
            const newScale = limitZoom(scale + delta);

            const rect = container.getBoundingClientRect();
            const mouseX = centerX - rect.left;
            const mouseY = centerY - rect.top;

            originX = originX - (mouseX / scale - mouseX / newScale);
            originY = originY - (mouseY / scale - mouseY / newScale);

            scale = newScale;
            setTransform(100);
        }

        function handleWheel(event) {
            if (event.shiftKey) {
                event.preventDefault();
                const delta = event.deltaY > 0 ? -0.1 : 0.1;
                handleZoom(delta, event.clientX, event.clientY);
            }
        }

        function handleMouseDown(event) {
            isDragging = true;
            startX = event.clientX - originX;
            startY = event.clientY - originY;
            container.style.cursor = 'grabbing';
        }

        function handleMouseMove(event) {
            if (isDragging) {
                originX = event.clientX - startX;
                originY = event.clientY - startY;
                setTransform();
            }
        }

        function handleMouseUp() {
            isDragging = false;
            container.style.cursor = 'grab';
        }

        function handleTouchStart(event) {
            if (event.touches.length === 2) {
                const touch1 = event.touches[0];
                const touch2 = event.touches[1];
                lastPinchDistance = Math.hypot(touch1.clientX - touch2.clientX, touch1.clientY - touch2.clientY);
            } else if (event.touches.length === 1) {
                isDragging = true;
                const touch = event.touches[0];
                startX = touch.clientX - originX;
                startY = touch.clientY - originY;
                lastTouchX = touch.clientX;
                lastTouchY = touch.clientY;
            }
        }

        function handleTouchMove(event) {
            event.preventDefault();
            if (event.touches.length === 2) {
                const touch1 = event.touches[0];
                const touch2 = event.touches[1];
                const pinchDistance = Math.hypot(touch1.clientX - touch2.clientX, touch1.clientY - touch2.clientY);
                const delta = (pinchDistance - lastPinchDistance) * 0.01;
                lastPinchDistance = pinchDistance;

                const centerX = (touch1.clientX + touch2.clientX) / 2;
                const centerY = (touch1.clientY + touch2.clientY) / 2;
                
                handleZoom(delta, centerX, centerY);
            } else if (event.touches.length === 1 && isDragging) {
                const touch = event.touches[0];
                const deltaX = touch.clientX - lastTouchX;
                const deltaY = touch.clientY - lastTouchY;
                
                originX += deltaX;
                originY += deltaY;
                
                lastTouchX = touch.clientX;
                lastTouchY = touch.clientY;
                
                setTransform();
            }
        }

        function handleTouchEnd(event) {
            if (event.touches.length < 2) {
                lastPinchDistance = 0;
            }
            if (event.touches.length === 0) {
                isDragging = false;
            }
        }

        function handleKeyDown(event) {
            if (event.key === 'Escape') {
                scale = 1;
                originX = 0;
                originY = 0;
                setTransform(300);
            }
        }

        let rafId = null;
        function optimizedHandleMouseMove(event) {
            if (isDragging) {
                if (rafId) cancelAnimationFrame(rafId);
                rafId = requestAnimationFrame(() => handleMouseMove(event));
            }
        }

        function toggleMasks() {
            masksVisible = !masksVisible;
            canvas.style.display = masksVisible ? 'block' : 'none';
        }

        function setupEventListeners() {
            container.addEventListener('wheel', handleWheel, { passive: false });
            container.addEventListener('mousedown', handleMouseDown);
            container.addEventListener('mousemove', optimizedHandleMouseMove);
            container.addEventListener('mouseup', handleMouseUp);
            container.addEventListener('mouseleave', handleMouseUp);
            container.addEventListener('touchstart', handleTouchStart);
            container.addEventListener('touchmove', handleTouchMove, { passive: false });
            container.addEventListener('touchend', handleTouchEnd);
            document.addEventListener('keydown', handleKeyDown);

            container.setAttribute('tabindex', '0');
            container.setAttribute('aria-label', 'Immagine zoomabile e spostabile');

            container.style.cursor = 'grab';

            const toggleButton = document.getElementById('toggle');
            if (toggleButton) {
                toggleButton.addEventListener('click', toggleMasks);
            }
        }

        function initialize() {
            loadZoomState();
            setupEventListeners();
        }

        waitForImage(initialize);

    } catch (exc) {
        document.getElementById("err").innerHTML = `Error loading image occlusion. Is your Anki version up to date?<br><br>${exc}`;
        console.error("Image Occlusion Error:", exc);
    }
}

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initializeImageOcclusion);
} else {
    initializeImageOcclusion();
}
</script>

<div><button id="toggle">Toggle Masks</button></div>
{{#Back Extra}}<div>{{Back Extra}}</div>{{/Back Extra}}
1 Like

We have an issue tracking support for this. Someone has contributed a workaround which you could try for now: Image occlusion doesn't scale when page zoomed in · Issue #2588 · ankitects/anki · GitHub

1 Like

Hello Damien, thanks for reaching out!

After you pointed out the possible solution, since I don’t know anything about coding, I tried again giving the solution to claude and implementing it to both work on PC and iPad. It seems to function correctly on both platforms, and I also tried to ask it to optimize the image loading. So far, so good!

I want to leave here the code for anyone who could run into the same issue in the future:

`{{#Header}}<div>{{Header}}</div>{{/Header}}
<div style="display: none">{{cloze:Occlusion}}</div>
<div id="err"></div>
<div id="image-occlusion-container">
    {{Image}}
    <canvas id="image-occlusion-canvas"></canvas>
</div>
<script>
(function() {
    const container = document.getElementById('image-occlusion-container');
    const img = container.querySelector('img');
    const canvas = document.getElementById('image-occlusion-canvas');

    let scale = 1;
    let originX = 0;
    let originY = 0;
    let isDragging = false;
    let startX, startY;
    let lastTouchDistance = 0;
    let lastPinchCenter = { x: 0, y: 0 };
    let rafId = null;

    // Preload the image
    const preloadImage = new Image();
    preloadImage.src = img.src;
    preloadImage.onload = () => {
        img.src = preloadImage.src;
        initializeImageOcclusion();
    };

    function initializeImageOcclusion() {
        const setTransform = (function() {
            const transform = {scale: 1, translateX: 0, translateY: 0};
            return function(duration = 0) {
                transform.scale = scale;
                transform.translateX = originX;
                transform.translateY = originY;
                const transformString = `translate3d(${transform.translateX}px, ${transform.translateY}px, 0) scale(${transform.scale})`;
                [img, canvas].forEach(el => {
                    el.style.transform = transformString;
                    el.style.transition = `transform ${duration}ms`;
                });
                saveState();
            };
        })();

        function saveState() {
            const state = JSON.stringify({scale, originX, originY});
            localStorage.setItem('anki-image-occlusion-state', state);
            if (typeof webkit !== 'undefined' && webkit.messageHandlers && webkit.messageHandlers.ankiPersistentData) {
                webkit.messageHandlers.ankiPersistentData.postMessage(state);
            }
        }

        function loadState() {
            let state;
            if (typeof webkit !== 'undefined' && webkit.messageHandlers && webkit.messageHandlers.ankiPersistentData) {
                state = webkit.messageHandlers.ankiPersistentData.postMessage('getState');
            } else {
                state = localStorage.getItem('anki-image-occlusion-state');
            }
            if (state) {
                ({scale, originX, originY} = JSON.parse(state));
                setTransform();
            }
        }

        const handleWheel = (function() {
            let lastWheelTime = 0;
            const wheelDelay = 16;
            return function(event) {
                if (event.ctrlKey) {
                    event.preventDefault();
                    const now = Date.now();
                    if (now - lastWheelTime > wheelDelay) {
                        const delta = event.deltaY * -0.001;
                        const containerRect = container.getBoundingClientRect();
                        const point = {
                            x: event.clientX - containerRect.left,
                            y: event.clientY - containerRect.top
                        };
                        
                        const relativeX = (point.x - originX) / scale;
                        const relativeY = (point.y - originY) / scale;
                        
                        scale = Math.max(0.1, Math.min(10, scale * (1 + delta)));
                        
                        originX = point.x - relativeX * scale;
                        originY = point.y - relativeY * scale;
                        
                        setTransform(0);
                        lastWheelTime = now;
                    }
                }
            };
        })();

        function handleTouchStart(event) {
            if (event.touches.length === 2) {
                const [touch1, touch2] = event.touches;
                lastTouchDistance = Math.hypot(touch1.clientX - touch2.clientX, touch1.clientY - touch2.clientY);
                lastPinchCenter = {
                    x: (touch1.clientX + touch2.clientX) / 2,
                    y: (touch1.clientY + touch2.clientY) / 2
                };
            } else if (event.touches.length === 1) {
                isDragging = true;
                [startX, startY] = [event.touches[0].clientX - originX, event.touches[0].clientY - originY];
            }
        }

        function handleTouchMove(event) {
            event.preventDefault();
            if (event.touches.length === 2) {
                const [touch1, touch2] = event.touches;
                const distance = Math.hypot(touch1.clientX - touch2.clientX, touch1.clientY - touch2.clientY);
                const delta = distance / lastTouchDistance;
                lastTouchDistance = distance;

                const pinchCenter = {
                    x: (touch1.clientX + touch2.clientX) / 2,
                    y: (touch1.clientY + touch2.clientY) / 2
                };

                const containerRect = container.getBoundingClientRect();
                const point = {
                    x: pinchCenter.x - containerRect.left,
                    y: pinchCenter.y - containerRect.top
                };

                const relativeX = (point.x - originX) / scale;
                const relativeY = (point.y - originY) / scale;

                scale = Math.max(0.1, Math.min(10, scale * delta));

                originX = point.x - relativeX * scale;
                originY = point.y - relativeY * scale;

                setTransform(0);
                lastPinchCenter = pinchCenter;
            } else if (event.touches.length === 1 && isDragging) {
                [originX, originY] = [event.touches[0].clientX - startX, event.touches[0].clientY - startY];
                setTransform(0);
            }
        }

        function handleMouseDown(event) {
            isDragging = true;
            [startX, startY] = [event.clientX - originX, event.clientY - originY];
            container.style.cursor = 'grabbing';
        }

        function handleMouseMove(event) {
            if (isDragging) {
                cancelAnimationFrame(rafId);
                rafId = requestAnimationFrame(() => {
                    [originX, originY] = [event.clientX - startX, event.clientY - startY];
                    setTransform(0);
                });
            }
        }

        function handleMouseUp() {
            isDragging = false;
            container.style.cursor = 'grab';
        }

        function handleKeyDown(event) {
            if (event.key === 'Escape') {
                [scale, originX, originY] = [1, 0, 0];
                setTransform(300);
            }
        }

        container.addEventListener('wheel', handleWheel, { passive: false });
        container.addEventListener('mousedown', handleMouseDown);
        container.addEventListener('mousemove', handleMouseMove);
        container.addEventListener('mouseup', handleMouseUp);
        container.addEventListener('mouseleave', handleMouseUp);
        container.addEventListener('touchstart', handleTouchStart);
        container.addEventListener('touchmove', handleTouchMove, { passive: false });
        container.addEventListener('touchend', handleMouseUp);
        document.addEventListener('keydown', handleKeyDown);

        container.style.cursor = 'grab';
        loadState();

        try {
            anki.setupImageCloze();
        } catch (exc) {
            document.getElementById("err").innerHTML = `Error loading image occlusion: ${exc}`;
        }
    }
})();
</script>`
2 Likes