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

Versión del día 02/06/2025. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Danbooru Smart Canvas
// @namespace    https://sleazyfork.org/
// @version      1.0.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 ===
    const MAX_ZOOM_SCALE = 3; // 3 = 300% max zoom
    const ZOOM_FACTOR = 1.1; // 1.1 = 10% per scroll
    const ZOOM_DISPLAY_FONT_SIZE = 15; // Font size (px) for the top-right zoom display
    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
    const TRANSITION = 'none'; // CSS transition for image panning/zooming
    //const TRANSITION = 'transform 0.1s ease-out'; // Causes slightly glitchy panning atm
    const CONTAINER_BG_COLOR = 'rgba(0, 0, 0, 0.05)'; // Background color for the image container ('rgba(0, 0, 0, 0)' for none)

    // Script options
    let isPanningEnabled = true;
    let panningToggleCommandId = null;
    if (typeof GM_getValue === 'function' && typeof GM_setValue === 'function') {
        isPanningEnabled = GM_getValue('panningEnabledUserSetting', true);
    }

    // Global observer to watch for full image if sample is shown
    let imgObserver = null;

    // Global references for resize handling
    let currentWrapper = null;
    let currentImageContainer = null;
    let currentImg = null;
    let currentZoomController = null;
    let currentZoomDisplayElement = null;
    const originalNoteDimensions = new WeakMap();

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

        if (!isPanningEnabled && currentWrapper) {
            currentWrapper.style.cursor = 'default';
        }
    }

    function registerPanningMenuCommand() {
        if (typeof GM_registerMenuCommand === 'function') {
            const commandText = isPanningEnabled ? 'Disable Panning' : 'Enable Panning';
            if (panningToggleCommandId !== null && typeof GM_unregisterMenuCommand === 'function') {
                try {
                    GM_unregisterMenuCommand(panningToggleCommandId);
                } catch(e) {
                    console.warn("Smart Canvas: Could not unregister old menu command:", e);
                }
            }
            panningToggleCommandId = GM_registerMenuCommand(commandText, togglePanning);
        }
    }

    // Adjusts layout and starting scale on window resize
    function handleResize() {
        if (!currentWrapper || !currentImageContainer || !currentImg) {
            return;
        }

        const header = document.querySelector('header#top');
        const headerHeight = header ? header.offsetHeight : 0;
        const availableHeight = window.innerHeight - headerHeight - 32;

        currentImageContainer.style.maxHeight = availableHeight + 'px';
        currentWrapper.style.height = availableHeight + 'px';

        const newMinScale = Math.min(
            currentWrapper.clientWidth / currentImg.naturalWidth,
            currentWrapper.clientHeight / currentImg.naturalHeight
        );
        const newStartScale = Math.min(newMinScale, 1);

        if (currentZoomController) {
            currentZoomController.destroy();
        }
        currentZoomController = ZoomPanController(currentImg, currentWrapper, currentZoomDisplayElement, newStartScale);
        currentZoomController.resetZoom();
    }

    // Manages image zoom and pan
    function ZoomPanController(img, wrapper, zoomDisplayElement, startScale = 1) {
        let scale = startScale;
        let posX = 0;
        let posY = 0;
        let isDragging = false;
        let lastX = 0;
        let lastY = 0;
        let clickStartTime = 0;
        let clickStartX = 0;
        let clickStartY = 0;
    
        img.style.transition = TRANSITION;

        // Keep image within wrapper boundaries
        function constrainPosition() {
            const containerWidth = wrapper.clientWidth;
            const containerHeight = wrapper.clientHeight;
            const imgWidth = img.offsetWidth * scale;
            const imgHeight = img.offsetHeight * scale;

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

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

        function updateZoomDisplay() {
            if (zoomDisplayElement) {
                zoomDisplayElement.textContent = `x${scale.toFixed(1)}`;
                if (Math.abs(scale - 1.0) <= 0.001) {
                    //zoomDisplayElement.style.boxShadow = '0 0 6px 3px rgba(0, 155, 230, 1)';
                    zoomDisplayElement.style.backgroundColor = 'rgba(0, 155, 230, 0.4)';
                    zoomDisplayElement.style.color = 'black';
                    zoomDisplayElement.style.fontWeight = 'bold';
                    zoomDisplayElement.style.textShadow = '0 0 8px white';
                } else {
                    //zoomDisplayElement.style.boxShadow = 'none';
                    zoomDisplayElement.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
                    zoomDisplayElement.style.color = 'white';
                    zoomDisplayElement.style.fontWeight = 'normal';
                    zoomDisplayElement.style.textShadow = 'unset';
                }
            }
        }
    
        // Modify note positions to match image scale and position
        function updateNotePositionsInternal() {
            const noteBoxes = document.querySelectorAll('.note-box');
            if (!noteBoxes.length || !img.naturalWidth || !img.naturalHeight || img.naturalWidth === 0 || img.naturalHeight === 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) * img.naturalWidth * scale;
                const newHeightPx = (dimensions.height / 100) * img.naturalHeight * scale;
                const newTopPx = (dimensions.top / 100) * img.naturalHeight * scale + posY;
                const newLeftPx = (dimensions.left / 100) * img.naturalWidth * scale + posX;
    
                note.style.width = newWidthPx + 'px';
                note.style.height = newHeightPx + 'px';
                note.style.top = newTopPx + 'px';
                note.style.left = newLeftPx + 'px';
            });
        }
    
        function applyTransform() {
            constrainPosition();
            img.style.transform = `translate(${posX}px, ${posY}px) scale(${scale})`;
            img.style.transformOrigin = '0 0';
            updateZoomDisplay();
            updateNotePositionsInternal();
        }
    
        function resetZoom() {
            scale = startScale;
            const containerWidth = wrapper.clientWidth;
            const containerHeight = wrapper.clientHeight;
            const imgWidth = img.offsetWidth * scale;
            const imgHeight = img.offsetHeight * scale;
            posX = (containerWidth - imgWidth) / 2;
            posY = (containerHeight - imgHeight) / 2;
            applyTransform();
            wrapper.style.cursor = 'default';
        }

        // Event Handlers
        const handleWheel = (e) => {
            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(startScale, Math.min(newScale, MAX_ZOOM_SCALE));

            if (newScale !== scale) {
                e.preventDefault();
                if (newScale == startScale) {
                    resetZoom();
                } else {
                    scale = newScale;
                    posX = mouseX - imgX * scale;
                    posY = mouseY - imgY * scale;
                    applyTransform();
                    if (isPanningEnabled) {
                        wrapper.style.cursor = scale > startScale ? 'grab' : 'default';
                    } else {
                        wrapper.style.cursor = 'default';
                    }
                }
            }
        };

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

                if (isPanningEnabled && scale > startScale) {
                    isDragging = true;
                    lastX = e.clientX;
                    lastY = e.clientY;
                    e.preventDefault();
                    wrapper.style.cursor = 'grabbing';
                }
            }
        };

        const handleMouseUp = (e) => {
            if (e.button === 0) {
                if (isDragging) isDragging = false;

                const isClick = (
                    Date.now() - clickStartTime < RESET_CLICK_DURATION &&
                    Math.abs(e.clientX - clickStartX) < RESET_CLICK_THRESHOLD &&
                    Math.abs(e.clientY - clickStartY) < RESET_CLICK_THRESHOLD
                );

                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
                        if (Math.abs(startScale - 1.0) <= 0.001) {
                            posX = (wrapper.clientWidth - img.naturalWidth) / 2;
                            posY = (wrapper.clientHeight - img.naturalHeight) / 2;
                        } else {
                            posX = mouseX - imgX * scale;
                            posY = mouseY - imgY * scale;
                        }

                        applyTransform();
                        if (isPanningEnabled) {
                            wrapper.style.cursor = scale > startScale ? 'grab' : 'default';
                        } else {
                            wrapper.style.cursor = 'default';
                        }
                    } else {
                        resetZoom();
                    }
                } else {
                    if (isPanningEnabled) {
                        wrapper.style.cursor = scale > startScale ? 'grab' : 'default';
                    } else {
                        wrapper.style.cursor = 'default';
                    }
                }
            }
        };

        const handleMouseMove = (e) => {
            if (isDragging && scale > startScale) {
                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);
        }

        return {
            resetZoom,
            applyTransform,
            destroy
        };
    }

    // Sets up the image, wrapper, and controller
    function initializeZoomPan() {
        const imageContainer = document.querySelector('section.image-container');
        const img = document.querySelector('img#image');

        if (!img || !imageContainer) return;

        currentImageContainer = imageContainer;
        currentImg = img;

        // Don't run if Danbooru's resize notice is active (image is sample)
        const resizeNotice = document.getElementById('image-resize-notice');
        if (resizeNotice && getComputedStyle(resizeNotice).display !== 'none') return;

        const header = document.querySelector('header#top');
        const headerHeight = header ? header.offsetHeight : 0;
        const availableHeight = window.innerHeight - headerHeight - 32; // Doesn't accommodate 'related posts' bar or notices, but figure a little scrolling is better than a smaller canvas

        imageContainer.style.maxHeight = availableHeight + 'px';
        imageContainer.style.width = '100%';
        imageContainer.style.overflow = 'hidden';
        imageContainer.style.margin = '0 0 16px 0';

        if (img.parentElement !== imageContainer) {
            imageContainer.appendChild(img);
        }

        const wrapper = document.createElement('div');
        currentWrapper = wrapper;
        wrapper.className = 'danbooru-zoom-wrapper';
        wrapper.style.position = 'relative';
        wrapper.style.display = 'block';
        wrapper.style.overflow = 'hidden';
        wrapper.style.width = '100%';
        wrapper.style.height = availableHeight + 'px';
        wrapper.style.backgroundColor = CONTAINER_BG_COLOR;

        img.parentNode.insertBefore(wrapper, img);
        wrapper.appendChild(img);

        const zoomDisplayElement = document.createElement('div');
        zoomDisplayElement.className = 'zoom-factor-display';
        zoomDisplayElement.style.position = 'absolute';
        zoomDisplayElement.style.top = '10px';
        zoomDisplayElement.style.right = '10px';
        zoomDisplayElement.style.padding = '5px 10px';
        zoomDisplayElement.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
        zoomDisplayElement.style.color = 'white';
        zoomDisplayElement.style.borderRadius = '8px';
        zoomDisplayElement.style.fontSize = ZOOM_DISPLAY_FONT_SIZE + 'px';
        zoomDisplayElement.style.fontFamily = 'sans-serif';
        zoomDisplayElement.style.zIndex = '100';
        zoomDisplayElement.style.backdropFilter = 'blur(5px)';
        zoomDisplayElement.style.webkitBackdropFilter = 'blur(5px)'; // For Safari
        zoomDisplayElement.style.border = '1px solid rgba(255, 255, 255, 0.1)';
        wrapper.appendChild(zoomDisplayElement);
        currentZoomDisplayElement = zoomDisplayElement;

        img.style.position = 'absolute';
        img.style.left = '0';
        img.style.top = '0';
        img.style.maxWidth = 'none';
        img.style.maxHeight = 'none';
        img.style.objectFit = '';

        // Calculate initial scale and initialize controller
        function setupZoom() {
            img.style.width = img.naturalWidth + 'px';
            img.style.height = img.naturalHeight + 'px';

            const minScale = Math.min(
                wrapper.clientWidth / img.naturalWidth,
                wrapper.clientHeight / img.naturalHeight
            );
            const startScale = Math.min(minScale, 1);

            const controller = ZoomPanController(img, wrapper, currentZoomDisplayElement, startScale);
            currentZoomController = controller;
            controller.resetZoom();

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

        if (img.complete) {
            setupZoom();
        } else {
            img.addEventListener('load', setupZoom, { once: true });
        }
    }

    // Initializes zoom and sets up observer for image changes
    function setupZoomAndObserver() {
        // Disconnect old observer if it exists
        if (imgObserver) {
            imgObserver.disconnect();
            imgObserver = null;
        }

        initializeZoomPan();
        
        // Observe 'src' attribute changes on the image to re-initialize when full image is loaded
        const resizeNotice = document.getElementById('image-resize-notice');
        const img = document.querySelector('img#image');
        if (img) {
            imgObserver = new MutationObserver(() => {
                if (!resizeNotice || getComputedStyle(resizeNotice).display === 'none') {
                    setupZoomAndObserver();
                }
            });
            imgObserver.observe(img, { attributes: true, attributeFilter: ['src'] });
        }
    }

    // Initial run
    setupZoomAndObserver();
    // Handle window resize
    window.addEventListener('resize', handleResize);
    registerPanningMenuCommand();
})();