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

当前为 2025-06-02 提交的版本,查看 最新版本

// ==UserScript==
// @name         Danbooru Smart Canvas
// @namespace    https://sleazyfork.org/
// @version      1
// @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 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;

    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';
                }
            }
        }

        function applyTransform() {
            constrainPosition();
            img.style.transform = `translate(${posX}px, ${posY}px) scale(${scale})`;
            img.style.transformOrigin = '0 0';
            updateZoomDisplay();
        }

        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 = '12px';
        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();
        }

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