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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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