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

À partir de 2025-06-02. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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