Danbooru Smart Canvas

Creates an adaptive canvas to enable zoom & pan for images on Danbooru (when viewing original) using scroll & drag, and click to set zoom to 1x/reset zoom

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Danbooru Smart Canvas
// @namespace    https://sleazyfork.org/
// @version      1.2
// @description  Creates an adaptive canvas to enable zoom & pan for images on Danbooru (when viewing original) using scroll & drag, and click to set zoom to 1x/reset zoom
// @author       Broodyr
// @license      MIT
// @match        https://danbooru.donmai.us/posts/*
// @run-at       document-end
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// ==/UserScript==

(function() {
    'use strict';


    // Constants - feel free to tweak these as desired
    const MAX_ZOOM_SCALE = 3; // 3 = 300% max zoom
    const ZOOM_FACTOR = 1.1; // 1.1 = 10% per scroll
    const TRANSITION = 'transform 0.1s ease-out'; // CSS transition for image panning/zooming
    const CANVAS_BG_COLOR = 'rgba(0, 0, 0, 0.05)'; // Background color for the canvas ('rgba(0, 0, 0, 0)' for none)
    const ZOOM_DISPLAY_FONT_SIZE = 15; // Font size (px) for the top-right zoom display
    const AUTOSCROLL_CANVAS_HEIGHT = 'calc(100vh - 32px)'; // CSS calculation for the canvas height when Autoscrolling is enabled
    const NOSCROLL_CANVAS_HEIGHT = 'calc(100vh - 32px - 107px)'; // CSS calculation for the canvas height when Autoscrolling is disabled (107px = Danbooru header height)
    const RESET_CLICK_THRESHOLD = 5; // Max mouse movement (px) for a click to count as a reset
    const RESET_CLICK_DURATION = 200; // Max click duration (ms) for a click to count as a reset

    // Script menu commands
    let isPanningEnabled = true;
    let panningToggleCommandId = null;
    let isAutoscrollEnabled = true;
    let autoscrollToggleCommandId = null;
    if (typeof GM_getValue === 'function' && typeof GM_setValue === 'function') {
        isPanningEnabled = GM_getValue('panningEnabledUserSetting', true);
        isAutoscrollEnabled = GM_getValue('autoscrollEnabledUserSetting', true);
    }

    // Global references
    let imgObserver = null;
    let wrapper = null;
    let wrapperResizeObserver = null;
    let imageContainer = null;
    let mediaElement = null;
    let canvasController = null;
    let zoomDisplay = null;
    const originalNoteDimensions = new WeakMap();

function getMediaDimensions(element) {
        if (!element) return { width: 0, height: 0 };
        const tagName = element.tagName;

        if (tagName === 'IMG') {
            return { width: element.naturalWidth || 0, height: element.naturalHeight || 0 };
        } else if (tagName === 'VIDEO') {
            return { width: element.videoWidth || 0, height: element.videoHeight || 0 };
        } else if (tagName === 'CANVAS') {
            return { width: element.width || 0, height: element.height || 0 };
        } else if (tagName === 'DIV' && element.id === 'image') {
            const ugoiraCanvas = element.querySelector('canvas');
            if (ugoiraCanvas) {
                return { width: ugoiraCanvas.width || 0, height: ugoiraCanvas.height || 0 };
            }
            return { width: element.offsetWidth || 0, height: element.offsetHeight || 0 }; // Generic fallback
        } else if (tagName === 'RUFFLE-PLAYER') {
            const ruffleInnerCanvas = element.querySelector('div#container > canvas');
            if (ruffleInnerCanvas) {
                return { width: ruffleInnerCanvas.width || 0, height: ruffleInnerCanvas.height || 0 };
            }
            return { width: element.offsetWidth || 0, height: element.offsetHeight || 0 };
        }
        return { width: element.offsetWidth || 0, height: element.offsetHeight || 0 };
    }

    function onMediaReady(element, callback) {
        if (!element) return;
        const tagName = element.tagName;

        if (tagName === 'IMG') {
            const resizeNotice = document.getElementById('image-resize-notice');
            if (resizeNotice && getComputedStyle(resizeNotice).display !== 'none') {
                console.log('Smart Canvas: Image resize notice active, not initializing for this image.');
                return; // Don't proceed if it's a sample image
            }
            if (element.complete) {
                callback();
            } else {
                element.addEventListener('load', callback, { once: true });
            }
        } else if (tagName === 'VIDEO') {
            if (element.readyState >= 1) { // HAVE_METADATA
                callback();
            } else {
                element.addEventListener('loadedmetadata', callback, { once: true });
            }
        } else if (tagName === 'CANVAS') {
            if (element.width > 0 && element.height > 0) {
                callback();
            } else {
                let attempts = 0;
                const interval = setInterval(() => {
                    attempts++;
                    if (element.width > 0 && element.height > 0) {
                        clearInterval(interval);
                        callback();
                    } else if (attempts > 20) {
                        clearInterval(interval);
                        callback();
                    }
                }, 100);
            }
        } else if (tagName === 'DIV' && element.id === 'image') {
            let attempts = 0;
            const interval = setInterval(() => {
                attempts++;
                const ugoiraCanvasDims = getMediaDimensions(element);
                if (ugoiraCanvasDims.width > 0 && ugoiraCanvasDims.height > 0) {
                    clearInterval(interval);
                    callback();
                } else if (attempts > 20) {
                    clearInterval(interval);
                    callback();
                }
            }, 100);
        } else if (tagName === 'RUFFLE-PLAYER') {
            let attempts = 0;
            const interval = setInterval(() => {
                attempts++;
                const ruffleDims = getMediaDimensions(element);
                if (ruffleDims.width > 0 && ruffleDims.height > 0) {
                    clearInterval(interval);
                    callback();
                } else if (attempts > 20) {
                    clearInterval(interval);
                    callback();
                }
            }, 100);
        } else {
            setTimeout(callback, 50);
        }
    }
    
    function registerOrUpdateMenuCommand(currentCommandId, isEnabled, name, callback) {
        let optionText = { enabled: `Disable ${name}`, disabled: `Enable ${name}` };

        if (typeof GM_registerMenuCommand !== 'function') {
            return currentCommandId;
        }

        const commandText = isEnabled ? optionText.enabled : optionText.disabled;
        if (currentCommandId !== null && typeof GM_unregisterMenuCommand === 'function') {
            try {
                GM_unregisterMenuCommand(currentCommandId);
            } catch (e) {
                console.warn(`Smart Canvas: Could not unregister old menu command:`, e);
            }
        }
        return GM_registerMenuCommand(commandText, callback);
    }

    function togglePanning() {
        isPanningEnabled = !isPanningEnabled;
        if (typeof GM_setValue === 'function') {
            GM_setValue('panningEnabledUserSetting', isPanningEnabled);
        }
        panningToggleCommandId = registerOrUpdateMenuCommand(panningToggleCommandId, isPanningEnabled, 'Panning', togglePanning);

        if (canvasController) {
            canvasController.updateCursorState();
        }
    }

    function toggleAutoscroll() {
        isAutoscrollEnabled = !isAutoscrollEnabled;
        if (typeof GM_setValue === 'function') {
            GM_setValue('autoscrollEnabledUserSetting', isAutoscrollEnabled);
        }
        autoscrollToggleCommandId = registerOrUpdateMenuCommand(autoscrollToggleCommandId, isAutoscrollEnabled, 'Autoscrolling', toggleAutoscroll);

        if (isAutoscrollEnabled) scrollToCanvas();
    }

    function scrollToCanvas() {
        if (!wrapper) return;

        const targetY = wrapper.getBoundingClientRect().top + window.scrollY - 16;

        window.scrollTo({ top: targetY, behavior: 'smooth' });

        // Backup scroll if smooth scroll didn't work
        setTimeout(() => {
            if (Math.abs(window.scrollY - targetY) > 2) {
                window.scrollTo({ top: targetY, behavior: 'instant' });
            }

            if (wrapperResizeObserver) {
                wrapperResizeObserver.unobserve(wrapper);
                wrapperResizeObserver.disconnect();
                wrapperResizeObserver = null;
            }
        }, 500);
    }

    // Adjusts layout and initial scale on window resize
    function handleResize() {
        if (!wrapper || !imageContainer || !mediaElement || !canvasController) {
            return;
        }

        mediaElement.style.transition = 'none'; // Disable transition during resize

        const availableHeightCss = isAutoscrollEnabled ? AUTOSCROLL_CANVAS_HEIGHT : NOSCROLL_CANVAS_HEIGHT;
        imageContainer.style.maxHeight = availableHeightCss;
        wrapper.style.height = availableHeightCss;

        let prevScale = canvasController.getScale();
        let prevX = canvasController.getPosX();
        let prevY = canvasController.getPosY();
        canvasController.destroy();

        const mediaDims = getMediaDimensions(mediaElement);
        if (mediaDims.width === 0 || mediaDims.height === 0) {
            console.warn("Smart Canvas: Media dimensions are zero in handleResize. Cannot calculate scale.");
            return; // Avoid division by zero
        }
        const calcScale = Math.min(
            wrapper.clientWidth / mediaDims.width,
            wrapper.clientHeight / mediaDims.height
        );
        const newMinScale = Math.min(calcScale, 1);

        canvasController = CanvasController(newMinScale, prevScale, prevX, prevY);
    }

    function CanvasController(minScale, prevScale = null, prevX = null, prevY = null) {
        let scale = minScale;
        let posX = 0; let posY = 0;

        // If the canvas just resized, keep the previous scale & position if possible
        if (prevScale !== null && prevScale >= minScale) {
            scale = prevScale;
            posX = prevX;
            posY = prevY;
        }

        let lastX = 0; let lastY = 0;
        let isDragging = false;
        let clickStartTime = 0;
        let clickStartX = 0; let clickStartY = 0;
        let clickStartTarget = null;

        // Danbooru wraps videos in div#image.video-component with custom controls instead of native ones
        const innerVideo = mediaElement.querySelector('video');
        const videoControlsEl = mediaElement.querySelector('.video-controls');
        const ugoiraControlsEl = mediaElement.querySelector('.ugoira-controls');
        const isVideoComponent = !!innerVideo && mediaElement.classList.contains('video-component');
        const isUgoiraContainer = mediaElement.classList.contains('ugoira-container');

        // Drag-end click suppressor. Registered on document/capture so it fires during
        // phase 1, before any AT_TARGET handler on the media (browser-default play/pause
        // on <video>, Alpine handlers on the canvas frame renderer for ugoiras served
        // through the video-component player, etc).
        function swallowDragClickOnce(clickEvent) {
            if (mediaElement.contains(clickEvent.target)) {
                clickEvent.stopImmediatePropagation();
                clickEvent.preventDefault();
            }
        }

        mediaElement.style.transition = TRANSITION;

        // Keep image within the canvas bounds
        function constrainPosition() {
            const containerWidth = wrapper.clientWidth;
            const containerHeight = wrapper.clientHeight;
            const mediaWidth = mediaElement.offsetWidth * scale;
            const mediaHeight = mediaElement.offsetHeight * scale;

            if (mediaWidth <= containerWidth) {
                posX = Math.max(0, Math.min(posX, containerWidth - mediaWidth));
            } else {
                posX = Math.min(0, Math.max(posX, containerWidth - mediaWidth));
            }

            if (mediaHeight <= containerHeight) {
                posY = Math.max(0, Math.min(posY, containerHeight - mediaHeight));
            } else {
                posY = Math.min(0, Math.max(posY, containerHeight - mediaHeight));
            }
        }

        function updateZoomDisplay() {
            if (zoomDisplay) {
                zoomDisplay.textContent = `x${scale.toFixed(2)}`;
                if (Math.abs(scale - 1.0) <= 0.001) {
                    zoomDisplay.style.backgroundColor = 'rgba(0, 155, 230, 0.4)';
                    zoomDisplay.style.fontWeight = 'bold';
                    imageContainer.style.boxShadow = 'unset';
                } else if (Math.abs(scale - minScale) <= 0.001) {
                    zoomDisplay.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
                    zoomDisplay.style.fontWeight = 'bold';
                    imageContainer.style.boxShadow = '0 0 8px 2px rgba(0, 0, 0, 0.4)';
                } else {
                    zoomDisplay.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
                    zoomDisplay.style.fontWeight = 'normal';
                    imageContainer.style.boxShadow = 'unset';
                }
            }
        }

        // Modify note properties to match canvas scale and position
        function updateNotePositionsInternal() {
            const noteBoxes = document.querySelectorAll('.note-box');
            const mediaDims = getMediaDimensions(mediaElement);

            if (!noteBoxes.length || mediaDims.width === 0 || mediaDims.height === 0) {
                return;
            }

            noteBoxes.forEach(note => {
                let dimensions = originalNoteDimensions.get(note);

                if (!dimensions) {
                    const initialPercentWidth = parseFloat(note.style.width);
                    const initialPercentHeight = parseFloat(note.style.height);
                    const initialPercentTop = parseFloat(note.style.top);
                    const initialPercentLeft = parseFloat(note.style.left);

                    if (isNaN(initialPercentWidth) || isNaN(initialPercentHeight) || isNaN(initialPercentTop) || isNaN(initialPercentLeft)) {
                        return; // Skip this note if initial properties are somehow not valid percentages
                    }

                    dimensions = {
                        width: initialPercentWidth,
                        height: initialPercentHeight,
                        top: initialPercentTop,
                        left: initialPercentLeft
                    };
                    originalNoteDimensions.set(note, dimensions);
                }

                const newWidthPx = (dimensions.width / 100) * mediaDims.width * scale;
                const newHeightPx = (dimensions.height / 100) * mediaDims.height * scale;
                const newTopPx = (dimensions.top / 100) * mediaDims.height * scale + posY;
                const newLeftPx = (dimensions.left / 100) * mediaDims.width * scale + posX;

                note.style.width = newWidthPx + 'px';
                note.style.height = newHeightPx + 'px';
                note.style.top = newTopPx + 'px';
                note.style.left = newLeftPx + 'px';
            });
        }

        function updateCursorStateInternal() {
            if (!wrapper) return;

            if (!isPanningEnabled) {
                wrapper.style.cursor = 'default';
            } else if (isDragging) {
                wrapper.style.cursor = 'grabbing';
            } else if (scale > minScale) {
                wrapper.style.cursor = 'grab';
            } else {
                wrapper.style.cursor = 'default';
            }
        }

        function updateVideoControlsTransform() {
            const videoControls = mediaElement.querySelector('.video-controls');
            if (!videoControls) return;

            // Pre-stretch the controls' CSS width so the flex layout gives the extra space
            // to the scrub bar (flex-grow) instead of x-stretching the icons. Then uniform
            // counter-scale around the bottom-left brings everything back to native pixel
            // size while staying anchored to the bottom edge of the video.
            // left:0 overrides the parent's justify-center, which would otherwise center
            // the oversized controls box (and drift it left as zoom grows).
            videoControls.style.left = '0';
            videoControls.style.width = (mediaElement.offsetWidth * scale) + 'px';
            videoControls.style.transform = `scale(${1 / scale})`;
            videoControls.style.transformOrigin = '0 100%';
        }

        function updateUgoiraControlsTransform() {
            const ugoiraControls = mediaElement.querySelector('.ugoira-controls');
            if (!ugoiraControls) return;

            const inverseScale = 1 / scale;
            const controlHeight = ugoiraControls.offsetHeight;
            
            // Counteract parent translation and scale, then move to the bottom of the wrapper
            const newX = -posX;
            const newY = (wrapper.clientHeight - posY) - (controlHeight * scale);

            ugoiraControls.style.transform = `scale(${inverseScale}) translate(${newX}px, ${newY}px)`;
            ugoiraControls.style.transformOrigin = '0 0';
        }

        function applyTransform() {
            constrainPosition();
            mediaElement.style.transform = `translate(${posX}px, ${posY}px) scale(${scale})`;
            mediaElement.style.transformOrigin = '0 0';
            updateZoomDisplay();
            updateNotePositionsInternal();
            updateCursorStateInternal();
            if (mediaElement.classList.contains('ugoira-container')) {
                updateUgoiraControlsTransform();
            } else if (isVideoComponent) {
                updateVideoControlsTransform();
            }
        }

        function resetZoom() {
            scale = minScale;
            const containerWidth = wrapper.clientWidth;
            const containerHeight = wrapper.clientHeight;
            const mediaWidth = mediaElement.offsetWidth * scale;
            const mediaHeight = mediaElement.offsetHeight * scale;
            posX = (containerWidth - mediaWidth) / 2;
            posY = (containerHeight - mediaHeight) / 2;
            applyTransform();
        }

        // Event Handlers
        const handleWheel = (e) => {
            // Holding `shift` while scrolling on the canvas overrides zoom, scrolling the page instead
            if (e.shiftKey) return;
            else e.preventDefault();
            
            const rect = wrapper.getBoundingClientRect();
            const mouseX = e.clientX - rect.left;
            const mouseY = e.clientY - rect.top;
            const imgX = (mouseX - posX) / scale;
            const imgY = (mouseY - posY) / scale;
            const delta = -e.deltaY;
            let newScale = delta > 0 ? scale * ZOOM_FACTOR : scale / ZOOM_FACTOR;
            newScale = Math.max(minScale, Math.min(newScale, MAX_ZOOM_SCALE));

            if (newScale !== scale) {
                if (Math.abs(newScale - minScale) < 0.001) {
                    resetZoom();
                } else {
                    scale = newScale;
                    posX = mouseX - imgX * scale;
                    posY = mouseY - imgY * scale;
                    applyTransform();
                }
            }
        };

        const handleMouseDown = (e) => {
            if (e.button === 0) {
                clickStartTime = Date.now();
                clickStartX = e.clientX;
                clickStartY = e.clientY;
                clickStartTarget = e.target;

                // Let media-native controls (video scrub/volume, ugoira playback) handle their own drags
                if (videoControlsEl && videoControlsEl.contains(e.target)) return;
                if (ugoiraControlsEl && ugoiraControlsEl.contains(e.target)) return;

                if (isPanningEnabled && scale > minScale) {
                    isDragging = true;
                    lastX = e.clientX; lastY = e.clientY;
                    mediaElement.style.transition = 'none'; // Disable transition during drag for performance
                    e.preventDefault();
                    updateCursorStateInternal();
                }
            }
        };

        const handleMouseUp = (e) => {
            if (e.button === 0) {
                const clickEndTime = Date.now();
                const clickFinalX = e.clientX;
                const clickFinalY = e.clientY;

                const wasDragging = isDragging;
                if (isDragging) isDragging = false;

                mediaElement.style.transition = TRANSITION; // Re-enable transition

                const wasGestureClick = (
                    clickEndTime - clickStartTime < RESET_CLICK_DURATION &&
                    Math.abs(clickFinalX - clickStartX) < RESET_CLICK_THRESHOLD &&
                    Math.abs(clickFinalY - clickStartY) < RESET_CLICK_THRESHOLD
                );
                const clickedInsideVideoComponent = isVideoComponent && mediaElement.contains(clickStartTarget);
                const isClick = wasGestureClick && !clickedInsideVideoComponent;

                if (isClick) {
                    // If current scale is not ~1.00, set to 1.0, else reset image
                    if (Math.abs(scale - 1.0) > 0.001) {
                        const rect = wrapper.getBoundingClientRect();
                        const mouseX = e.clientX - rect.left;
                        const mouseY = e.clientY - rect.top;
                        const imgX = (mouseX - posX) / scale;
                        const imgY = (mouseY - posY) / scale;

                        scale = 1.0;

                        // Center if image fits in frame, else base position on mouse
                        const mediaDims = getMediaDimensions(mediaElement);
                        if (Math.abs(minScale - 1.0) <= 0.001 && mediaDims.width > 0 && mediaDims.height > 0) {
                            posX = (wrapper.clientWidth - mediaDims.width) / 2;
                            posY = (wrapper.clientHeight - mediaDims.height) / 2;
                        } else {
                            posX = mouseX - imgX * scale;
                            posY = mouseY - imgY * scale;
                        }
                        
                        applyTransform();
                    } else { // If scale is already 1.0, reset to minScale
                        resetZoom();
                    }
                } else if (wasDragging) {
                    applyTransform();
                    // Only swallow the trailing click on an actual drag, not a click-while-zoomed
                    if (!wasGestureClick && (isVideoComponent || isUgoiraContainer) && mediaElement.contains(clickStartTarget)) {
                        document.addEventListener('click', swallowDragClickOnce, { capture: true, once: true });
                    }
                }
            }
        };

        const handleMouseMove = (e) => {
            if (isDragging && scale > minScale) {
                const deltaX = e.clientX - lastX; const deltaY = e.clientY - lastY;
                posX += deltaX; posY += deltaY;
                lastX = e.clientX; lastY = e.clientY;
                applyTransform();
            }
        };

        wrapper.addEventListener('wheel', handleWheel);
        wrapper.addEventListener('mousedown', handleMouseDown);
        document.addEventListener('mousemove', handleMouseMove);
        document.addEventListener('mouseup', handleMouseUp);

        function destroy() {
            wrapper.removeEventListener('wheel', handleWheel);
            wrapper.removeEventListener('mousedown', handleMouseDown);
            document.removeEventListener('mousemove', handleMouseMove);
            document.removeEventListener('mouseup', handleMouseUp);
        }

        applyTransform();

        return {
            resetZoom,
            applyTransform,
            destroy,
            updateCursorState: updateCursorStateInternal,
            getScale: function() { return scale; },
            getPosX: function() { return posX; },
            getPosY: function() { return posY; }
        };
    }

    // Sets up the image, wrapper, and controller
    function initializeCanvas() {
        imageContainer = document.querySelector('section.image-container');
        if (!imageContainer) return;
        
        mediaElement = imageContainer.querySelector('img#image') || // Images
                       imageContainer.querySelector('video#image') || // Videos
                       imageContainer.querySelector('div#image') || // Ugoira
                       imageContainer.querySelector('ruffle-player > div#container > canvas') || // Flash
                       imageContainer.querySelector('ruffle-player'); // Flash fallback

        if (!mediaElement) return; // Exit if no suitable media element is found

        if (mediaElement.tagName === 'IMG') {
            const resizeNotice = document.getElementById('image-resize-notice');
            if (resizeNotice && getComputedStyle(resizeNotice).display !== 'none') return;
        }

        const availableHeightCss = isAutoscrollEnabled ? AUTOSCROLL_CANVAS_HEIGHT : NOSCROLL_CANVAS_HEIGHT;

        imageContainer.style.maxHeight = availableHeightCss;
        imageContainer.style.width = '100%';
        imageContainer.style.overflow = 'hidden';
        imageContainer.style.margin = '16px 0';
        imageContainer.style.transition = 'box-shadow 0.3s ease-in-out';

        // Ensure the media element is a direct child of the imageContainer for styling,
        // or becomes a child of the wrapper.
        // The wrapper will be inserted into imageContainer, and mediaElement into wrapper.

        wrapper = document.createElement('div');
        wrapper.className = 'zoom-wrapper';
        wrapper.style.position = 'relative';
        wrapper.style.display = 'block';
        wrapper.style.overflow = 'hidden';
        wrapper.style.width = '100%';
        wrapper.style.height = availableHeightCss;
        wrapper.style.backgroundColor = CANVAS_BG_COLOR;

        if (mediaElement.parentNode !== imageContainer && mediaElement.parentNode !== wrapper) {
            // If mediaElement is deeply nested (e.g. ruffle), this might need adjustment
            // For now, assume it's either in imageContainer or needs to be moved
            imageContainer.appendChild(wrapper); // Add wrapper first
        } else if (mediaElement.parentNode === imageContainer) {
             mediaElement.parentNode.insertBefore(wrapper, mediaElement);
        }
        // else if mediaElement.parentNode is already wrapper, do nothing

        wrapper.appendChild(mediaElement); // Ensure mediaElement is inside the wrapper

        // Autoscroll on page load
        let visibilityListenerActive = false;
        function handleVisibilityChange() {
            console.log('Smart Canvas: Visibility changed to: ' + (document.hidden ? 'hidden' : 'visible'));
            if (!document.hidden) {
                scrollToCanvas();
                detachVisibilityListener();
            }
        }
        function attachVisibilityListener() {
            if (!visibilityListenerActive) {
                document.addEventListener('visibilitychange', handleVisibilityChange);
                visibilityListenerActive = true;
            }
        }
        function detachVisibilityListener() {
            if (visibilityListenerActive) {
                document.removeEventListener('visibilitychange', handleVisibilityChange);
                visibilityListenerActive = false;
            }
        }
        if (isAutoscrollEnabled) {
            wrapperResizeObserver = new ResizeObserver(entries => {
                console.log('Smart Canvas: ResizeObserver fired.', !wrapperResizeObserver, !entries, !entries.length, JSON.stringify(entries[0]?.contentRect));
                if (!wrapperResizeObserver || !entries || !entries.length) {
                    return;
                }

                const wrapperRect = entries[0].contentRect;
                if (wrapperRect.width > 0 && wrapperRect.height > 0) {
                    if (document.hidden) {
                        // Tab is hidden, defer the scroll
                        console.log('Smart Canvas: Tab is hidden, attaching visibility listener.');
                        attachVisibilityListener();
                    } else {
                        // Tab is visible, scroll
                        console.log('Smart Canvas: Tab is visible, scrolling now.');
                        scrollToCanvas();
                    }
                }
            });
            wrapperResizeObserver.observe(wrapper);
        }

        // Element to display current zoom scale
        zoomDisplay = document.createElement('div');
        zoomDisplay.className = 'zoom-factor-display';
        zoomDisplay.style.position = 'absolute';
        zoomDisplay.style.top = '10px';
        zoomDisplay.style.right = '10px';
        zoomDisplay.style.padding = '5px 10px';
        zoomDisplay.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
        zoomDisplay.style.color = 'white';
        zoomDisplay.style.borderRadius = '8px';
        zoomDisplay.style.fontSize = ZOOM_DISPLAY_FONT_SIZE + 'px';
        zoomDisplay.style.fontFamily = 'sans-serif';
        zoomDisplay.style.zIndex = '100';
        zoomDisplay.style.backdropFilter = 'blur(5px)';
        zoomDisplay.style.webkitBackdropFilter = 'blur(5px)'; // For Safari
        zoomDisplay.style.border = '1px solid rgba(255, 255, 255, 0.1)';
        zoomDisplay.style.cursor = 'pointer';
        zoomDisplay.style.userSelect = 'none';
        zoomDisplay.addEventListener('mousedown', (event) => {
            event.stopPropagation();
            scrollToCanvas();
        });
        zoomDisplay.addEventListener('mouseup', (event) => {
            event.stopPropagation();
        });
        wrapper.appendChild(zoomDisplay);

        // Prevent default click behavior on Ugoira elements
        if (mediaElement.id === 'image' && mediaElement.classList.contains('ugoira-container')) {
            mediaElement.addEventListener('click', (e) => e.stopPropagation(), true); // Capture and stop click propagation
            
            const ugoiraControls = mediaElement.querySelector('.ugoira-controls');
            if (ugoiraControls) {
                ugoiraControls.style.transition = 'transform 0.1s ease-out';
                ugoiraControls.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
                ugoiraControls.style.setProperty('display', 'flex', 'important');
            }
        }

        mediaElement.style.position = 'absolute';
        mediaElement.style.left = '0';
        mediaElement.style.top = '0';
        mediaElement.style.maxWidth = 'none';
        mediaElement.style.maxHeight = 'none';
        // mediaElement.style.objectFit = ''; // object-fit might not apply to all (e.g. canvas, ruffle)

        // Calculate initial scale and initialize controller
        function setupZoom() {
            const mediaDims = getMediaDimensions(mediaElement);

            if (mediaDims.width === 0 || mediaDims.height === 0) {
                console.warn("Smart Canvas: Media dimensions are zero in setupZoom. Cannot calculate scale.");
                return;
            }

            // Set the base dimensions for the media element itself, not just the canvas/video within it
            mediaElement.style.width = mediaDims.width + 'px';
            mediaElement.style.height = mediaDims.height + 'px';

            const minScale = Math.min(
                wrapper.clientWidth / mediaDims.width,
                wrapper.clientHeight / mediaDims.height
            );
            const initialScale = Math.min(minScale, 1);

            canvasController = CanvasController(initialScale);
            canvasController.resetZoom();

            // Workaround for timing issue with notes for some browsers
            setTimeout(() => {
                if (canvasController) {
                    canvasController.applyTransform();
                }
            }, 0);
        }

        onMediaReady(mediaElement, setupZoom);

        // Add a blacklist observer to center the image when blacklist is disabled
        const blacklistObserver = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
                    const wasHidden = mutation.oldValue?.includes('blacklisted-hidden');
                    const isVisible = !imageContainer.classList.contains('blacklisted-hidden');

                    if (wasHidden && isVisible && canvasController) {
                        setTimeout(() => {
                            canvasController.resetZoom();
                        }, 50);
                    }
                }
            }
        });

        blacklistObserver.observe(imageContainer, {
            attributes: true,
            attributeOldValue: true,
            attributeFilter: ['class']
        });
    }

    function setupCanvas() {
        if (imgObserver) {
            imgObserver.disconnect();
            imgObserver = null;
        }

        initializeCanvas();

        // Observe 'src' attribute changes on the image/video to re-initialize
        const resizeNotice = document.getElementById('image-resize-notice');

        if (imgObserver) {
            imgObserver.disconnect();
            imgObserver = null;
        }

        if (mediaElement && (mediaElement.tagName === 'IMG' || mediaElement.tagName === 'VIDEO')) {
            imgObserver = new MutationObserver(() => {
                // Check if the element being observed is still part of the document
                // and if the resize notice is not displayed
                if (document.body.contains(mediaElement) && (!resizeNotice || getComputedStyle(resizeNotice).display === 'none')) {
                    console.log('Smart Canvas: media src changed, re-initializing.');
                    setupCanvas();
                }
            });
            imgObserver.observe(mediaElement, { attributes: true, attributeFilter: ['src'] });
        }
        // For canvas or ruffle-player, 'src' attribute observation is not standard.
        // A different trigger or no trigger might be needed if content changes without page reload.
    }

    // Initialize menu commands
    panningToggleCommandId = registerOrUpdateMenuCommand(panningToggleCommandId, isPanningEnabled, 'Panning', togglePanning);
    autoscrollToggleCommandId = registerOrUpdateMenuCommand(autoscrollToggleCommandId, isAutoscrollEnabled, 'Autoscrolling', toggleAutoscroll);

    // Start script after page is finished loading
    document.onreadystatechange = function () {
        if (document.readyState == "complete") {
            setupCanvas();
            window.addEventListener('resize', handleResize);
        }
    }
})();