您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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); })();