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 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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