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