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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Danbooru Smart Canvas
// @namespace    https://sleazyfork.org/
// @version      1.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 - 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 img = null;
    let canvasController = null;
    let zoomDisplay = null;
    const originalNoteDimensions = new WeakMap();

    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() {
        const wrapperTop = wrapper.getBoundingClientRect().top + window.scrollY;
        window.scrollTo({ top: wrapperTop - 16, behavior: 'smooth' });

        if (wrapperResizeObserver) {
            wrapperResizeObserver.unobserve(wrapper);
            wrapperResizeObserver.disconnect();
            wrapperResizeObserver = null;
        }
    }

    // Adjusts layout and initial scale on window resize
    function handleResize() {
        if (!wrapper || !imageContainer || !img | !canvasController) {
            return;
        }

        img.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 calcScale = Math.min(
            wrapper.clientWidth / img.naturalWidth,
            wrapper.clientHeight / img.naturalHeight
        );
        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;

        img.style.transition = TRANSITION;

        // Keep image within the canvas bounds
        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 (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');
            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 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 applyTransform() {
            constrainPosition();
            img.style.transform = `translate(${posX}px, ${posY}px) scale(${scale})`;
            img.style.transformOrigin = '0 0';
            updateZoomDisplay();
            updateNotePositionsInternal();
            updateCursorStateInternal();
        }

        function resetZoom() {
            scale = minScale;
            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();
        }

        // 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(minScale, Math.min(newScale, MAX_ZOOM_SCALE));

            if (newScale !== scale) {
                e.preventDefault();
                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;

                if (isPanningEnabled && scale > minScale) {
                    isDragging = true;
                    lastX = e.clientX; lastY = e.clientY;
                    img.style.transition = 'none'; // Disable transition during drag
                    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;

                img.style.transition = TRANSITION; // Re-enable transition

                const isClick = (
                    clickEndTime - clickStartTime < RESET_CLICK_DURATION &&
                    Math.abs(clickFinalX - clickStartX) < RESET_CLICK_THRESHOLD &&
                    Math.abs(clickFinalY - 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(minScale - 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();
                    } else { // If scale is already 1.0, reset to minScale
                        resetZoom();
                    }
                } else if (wasDragging) {
                    applyTransform();
                }
            }
        };

        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');
        img = document.querySelector('img#image');

        if (!img || !imageContainer) return;

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

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

        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;

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

        // Autoscroll on page load
        let visibilityListenerActive = false;

        function handleVisibilityChange() {
            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 => {
                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
                        attachVisibilityListener();
                    } else {
                        // Tab is visible, scroll
                        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);

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

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

    function setupCanvas() {
        if (imgObserver) {
            imgObserver.disconnect();
            imgObserver = null;
        }

        initializeCanvas();

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

    // Initialize menu commands
    panningToggleCommandId = registerOrUpdateMenuCommand(panningToggleCommandId, isPanningEnabled, 'Panning', togglePanning);
    autoscrollToggleCommandId = registerOrUpdateMenuCommand(autoscrollToggleCommandId, isAutoscrollEnabled, 'Autoscrolling', toggleAutoscroll);

    // Start script
    setupCanvas();
    window.addEventListener('resize', handleResize);
})();