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.2
// @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 mediaElement = null;
let canvasController = null;
let zoomDisplay = null;
const originalNoteDimensions = new WeakMap();
function getMediaDimensions(element) {
if (!element) return { width: 0, height: 0 };
const tagName = element.tagName;
if (tagName === 'IMG') {
return { width: element.naturalWidth || 0, height: element.naturalHeight || 0 };
} else if (tagName === 'VIDEO') {
return { width: element.videoWidth || 0, height: element.videoHeight || 0 };
} else if (tagName === 'CANVAS') {
return { width: element.width || 0, height: element.height || 0 };
} else if (tagName === 'DIV' && element.id === 'image') {
const ugoiraCanvas = element.querySelector('canvas');
if (ugoiraCanvas) {
return { width: ugoiraCanvas.width || 0, height: ugoiraCanvas.height || 0 };
}
return { width: element.offsetWidth || 0, height: element.offsetHeight || 0 }; // Generic fallback
} else if (tagName === 'RUFFLE-PLAYER') {
const ruffleInnerCanvas = element.querySelector('div#container > canvas');
if (ruffleInnerCanvas) {
return { width: ruffleInnerCanvas.width || 0, height: ruffleInnerCanvas.height || 0 };
}
return { width: element.offsetWidth || 0, height: element.offsetHeight || 0 };
}
return { width: element.offsetWidth || 0, height: element.offsetHeight || 0 };
}
function onMediaReady(element, callback) {
if (!element) return;
const tagName = element.tagName;
if (tagName === 'IMG') {
const resizeNotice = document.getElementById('image-resize-notice');
if (resizeNotice && getComputedStyle(resizeNotice).display !== 'none') {
console.log('Smart Canvas: Image resize notice active, not initializing for this image.');
return; // Don't proceed if it's a sample image
}
if (element.complete) {
callback();
} else {
element.addEventListener('load', callback, { once: true });
}
} else if (tagName === 'VIDEO') {
if (element.readyState >= 1) { // HAVE_METADATA
callback();
} else {
element.addEventListener('loadedmetadata', callback, { once: true });
}
} else if (tagName === 'CANVAS') {
if (element.width > 0 && element.height > 0) {
callback();
} else {
let attempts = 0;
const interval = setInterval(() => {
attempts++;
if (element.width > 0 && element.height > 0) {
clearInterval(interval);
callback();
} else if (attempts > 20) {
clearInterval(interval);
callback();
}
}, 100);
}
} else if (tagName === 'DIV' && element.id === 'image') {
let attempts = 0;
const interval = setInterval(() => {
attempts++;
const ugoiraCanvasDims = getMediaDimensions(element);
if (ugoiraCanvasDims.width > 0 && ugoiraCanvasDims.height > 0) {
clearInterval(interval);
callback();
} else if (attempts > 20) {
clearInterval(interval);
callback();
}
}, 100);
} else if (tagName === 'RUFFLE-PLAYER') {
let attempts = 0;
const interval = setInterval(() => {
attempts++;
const ruffleDims = getMediaDimensions(element);
if (ruffleDims.width > 0 && ruffleDims.height > 0) {
clearInterval(interval);
callback();
} else if (attempts > 20) {
clearInterval(interval);
callback();
}
}, 100);
} else {
setTimeout(callback, 50);
}
}
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() {
if (!wrapper) return;
const targetY = wrapper.getBoundingClientRect().top + window.scrollY - 16;
window.scrollTo({ top: targetY, behavior: 'smooth' });
// Backup scroll if smooth scroll didn't work
setTimeout(() => {
if (Math.abs(window.scrollY - targetY) > 2) {
window.scrollTo({ top: targetY, behavior: 'instant' });
}
if (wrapperResizeObserver) {
wrapperResizeObserver.unobserve(wrapper);
wrapperResizeObserver.disconnect();
wrapperResizeObserver = null;
}
}, 500);
}
// Adjusts layout and initial scale on window resize
function handleResize() {
if (!wrapper || !imageContainer || !mediaElement || !canvasController) {
return;
}
mediaElement.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 mediaDims = getMediaDimensions(mediaElement);
if (mediaDims.width === 0 || mediaDims.height === 0) {
console.warn("Smart Canvas: Media dimensions are zero in handleResize. Cannot calculate scale.");
return; // Avoid division by zero
}
const calcScale = Math.min(
wrapper.clientWidth / mediaDims.width,
wrapper.clientHeight / mediaDims.height
);
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;
let clickStartTarget = null;
// Danbooru wraps videos in div#image.video-component with custom controls instead of native ones
const innerVideo = mediaElement.querySelector('video');
const videoControlsEl = mediaElement.querySelector('.video-controls');
const ugoiraControlsEl = mediaElement.querySelector('.ugoira-controls');
const isVideoComponent = !!innerVideo && mediaElement.classList.contains('video-component');
const isUgoiraContainer = mediaElement.classList.contains('ugoira-container');
// Drag-end click suppressor. Registered on document/capture so it fires during
// phase 1, before any AT_TARGET handler on the media (browser-default play/pause
// on <video>, Alpine handlers on the canvas frame renderer for ugoiras served
// through the video-component player, etc).
function swallowDragClickOnce(clickEvent) {
if (mediaElement.contains(clickEvent.target)) {
clickEvent.stopImmediatePropagation();
clickEvent.preventDefault();
}
}
mediaElement.style.transition = TRANSITION;
// Keep image within the canvas bounds
function constrainPosition() {
const containerWidth = wrapper.clientWidth;
const containerHeight = wrapper.clientHeight;
const mediaWidth = mediaElement.offsetWidth * scale;
const mediaHeight = mediaElement.offsetHeight * scale;
if (mediaWidth <= containerWidth) {
posX = Math.max(0, Math.min(posX, containerWidth - mediaWidth));
} else {
posX = Math.min(0, Math.max(posX, containerWidth - mediaWidth));
}
if (mediaHeight <= containerHeight) {
posY = Math.max(0, Math.min(posY, containerHeight - mediaHeight));
} else {
posY = Math.min(0, Math.max(posY, containerHeight - mediaHeight));
}
}
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');
const mediaDims = getMediaDimensions(mediaElement);
if (!noteBoxes.length || mediaDims.width === 0 || mediaDims.height === 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) * mediaDims.width * scale;
const newHeightPx = (dimensions.height / 100) * mediaDims.height * scale;
const newTopPx = (dimensions.top / 100) * mediaDims.height * scale + posY;
const newLeftPx = (dimensions.left / 100) * mediaDims.width * 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 updateVideoControlsTransform() {
const videoControls = mediaElement.querySelector('.video-controls');
if (!videoControls) return;
// Pre-stretch the controls' CSS width so the flex layout gives the extra space
// to the scrub bar (flex-grow) instead of x-stretching the icons. Then uniform
// counter-scale around the bottom-left brings everything back to native pixel
// size while staying anchored to the bottom edge of the video.
// left:0 overrides the parent's justify-center, which would otherwise center
// the oversized controls box (and drift it left as zoom grows).
videoControls.style.left = '0';
videoControls.style.width = (mediaElement.offsetWidth * scale) + 'px';
videoControls.style.transform = `scale(${1 / scale})`;
videoControls.style.transformOrigin = '0 100%';
}
function updateUgoiraControlsTransform() {
const ugoiraControls = mediaElement.querySelector('.ugoira-controls');
if (!ugoiraControls) return;
const inverseScale = 1 / scale;
const controlHeight = ugoiraControls.offsetHeight;
// Counteract parent translation and scale, then move to the bottom of the wrapper
const newX = -posX;
const newY = (wrapper.clientHeight - posY) - (controlHeight * scale);
ugoiraControls.style.transform = `scale(${inverseScale}) translate(${newX}px, ${newY}px)`;
ugoiraControls.style.transformOrigin = '0 0';
}
function applyTransform() {
constrainPosition();
mediaElement.style.transform = `translate(${posX}px, ${posY}px) scale(${scale})`;
mediaElement.style.transformOrigin = '0 0';
updateZoomDisplay();
updateNotePositionsInternal();
updateCursorStateInternal();
if (mediaElement.classList.contains('ugoira-container')) {
updateUgoiraControlsTransform();
} else if (isVideoComponent) {
updateVideoControlsTransform();
}
}
function resetZoom() {
scale = minScale;
const containerWidth = wrapper.clientWidth;
const containerHeight = wrapper.clientHeight;
const mediaWidth = mediaElement.offsetWidth * scale;
const mediaHeight = mediaElement.offsetHeight * scale;
posX = (containerWidth - mediaWidth) / 2;
posY = (containerHeight - mediaHeight) / 2;
applyTransform();
}
// Event Handlers
const handleWheel = (e) => {
// Holding `shift` while scrolling on the canvas overrides zoom, scrolling the page instead
if (e.shiftKey) return;
else e.preventDefault();
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) {
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;
clickStartTarget = e.target;
// Let media-native controls (video scrub/volume, ugoira playback) handle their own drags
if (videoControlsEl && videoControlsEl.contains(e.target)) return;
if (ugoiraControlsEl && ugoiraControlsEl.contains(e.target)) return;
if (isPanningEnabled && scale > minScale) {
isDragging = true;
lastX = e.clientX; lastY = e.clientY;
mediaElement.style.transition = 'none'; // Disable transition during drag for performance
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;
mediaElement.style.transition = TRANSITION; // Re-enable transition
const wasGestureClick = (
clickEndTime - clickStartTime < RESET_CLICK_DURATION &&
Math.abs(clickFinalX - clickStartX) < RESET_CLICK_THRESHOLD &&
Math.abs(clickFinalY - clickStartY) < RESET_CLICK_THRESHOLD
);
const clickedInsideVideoComponent = isVideoComponent && mediaElement.contains(clickStartTarget);
const isClick = wasGestureClick && !clickedInsideVideoComponent;
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
const mediaDims = getMediaDimensions(mediaElement);
if (Math.abs(minScale - 1.0) <= 0.001 && mediaDims.width > 0 && mediaDims.height > 0) {
posX = (wrapper.clientWidth - mediaDims.width) / 2;
posY = (wrapper.clientHeight - mediaDims.height) / 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();
// Only swallow the trailing click on an actual drag, not a click-while-zoomed
if (!wasGestureClick && (isVideoComponent || isUgoiraContainer) && mediaElement.contains(clickStartTarget)) {
document.addEventListener('click', swallowDragClickOnce, { capture: true, once: true });
}
}
}
};
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');
if (!imageContainer) return;
mediaElement = imageContainer.querySelector('img#image') || // Images
imageContainer.querySelector('video#image') || // Videos
imageContainer.querySelector('div#image') || // Ugoira
imageContainer.querySelector('ruffle-player > div#container > canvas') || // Flash
imageContainer.querySelector('ruffle-player'); // Flash fallback
if (!mediaElement) return; // Exit if no suitable media element is found
if (mediaElement.tagName === 'IMG') {
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';
// Ensure the media element is a direct child of the imageContainer for styling,
// or becomes a child of the wrapper.
// The wrapper will be inserted into imageContainer, and mediaElement into wrapper.
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;
if (mediaElement.parentNode !== imageContainer && mediaElement.parentNode !== wrapper) {
// If mediaElement is deeply nested (e.g. ruffle), this might need adjustment
// For now, assume it's either in imageContainer or needs to be moved
imageContainer.appendChild(wrapper); // Add wrapper first
} else if (mediaElement.parentNode === imageContainer) {
mediaElement.parentNode.insertBefore(wrapper, mediaElement);
}
// else if mediaElement.parentNode is already wrapper, do nothing
wrapper.appendChild(mediaElement); // Ensure mediaElement is inside the wrapper
// Autoscroll on page load
let visibilityListenerActive = false;
function handleVisibilityChange() {
console.log('Smart Canvas: Visibility changed to: ' + (document.hidden ? 'hidden' : 'visible'));
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 => {
console.log('Smart Canvas: ResizeObserver fired.', !wrapperResizeObserver, !entries, !entries.length, JSON.stringify(entries[0]?.contentRect));
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
console.log('Smart Canvas: Tab is hidden, attaching visibility listener.');
attachVisibilityListener();
} else {
// Tab is visible, scroll
console.log('Smart Canvas: Tab is visible, scrolling now.');
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);
// Prevent default click behavior on Ugoira elements
if (mediaElement.id === 'image' && mediaElement.classList.contains('ugoira-container')) {
mediaElement.addEventListener('click', (e) => e.stopPropagation(), true); // Capture and stop click propagation
const ugoiraControls = mediaElement.querySelector('.ugoira-controls');
if (ugoiraControls) {
ugoiraControls.style.transition = 'transform 0.1s ease-out';
ugoiraControls.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
ugoiraControls.style.setProperty('display', 'flex', 'important');
}
}
mediaElement.style.position = 'absolute';
mediaElement.style.left = '0';
mediaElement.style.top = '0';
mediaElement.style.maxWidth = 'none';
mediaElement.style.maxHeight = 'none';
// mediaElement.style.objectFit = ''; // object-fit might not apply to all (e.g. canvas, ruffle)
// Calculate initial scale and initialize controller
function setupZoom() {
const mediaDims = getMediaDimensions(mediaElement);
if (mediaDims.width === 0 || mediaDims.height === 0) {
console.warn("Smart Canvas: Media dimensions are zero in setupZoom. Cannot calculate scale.");
return;
}
// Set the base dimensions for the media element itself, not just the canvas/video within it
mediaElement.style.width = mediaDims.width + 'px';
mediaElement.style.height = mediaDims.height + 'px';
const minScale = Math.min(
wrapper.clientWidth / mediaDims.width,
wrapper.clientHeight / mediaDims.height
);
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);
}
onMediaReady(mediaElement, setupZoom);
// Add a blacklist observer to center the image when blacklist is disabled
const blacklistObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const wasHidden = mutation.oldValue?.includes('blacklisted-hidden');
const isVisible = !imageContainer.classList.contains('blacklisted-hidden');
if (wasHidden && isVisible && canvasController) {
setTimeout(() => {
canvasController.resetZoom();
}, 50);
}
}
}
});
blacklistObserver.observe(imageContainer, {
attributes: true,
attributeOldValue: true,
attributeFilter: ['class']
});
}
function setupCanvas() {
if (imgObserver) {
imgObserver.disconnect();
imgObserver = null;
}
initializeCanvas();
// Observe 'src' attribute changes on the image/video to re-initialize
const resizeNotice = document.getElementById('image-resize-notice');
if (imgObserver) {
imgObserver.disconnect();
imgObserver = null;
}
if (mediaElement && (mediaElement.tagName === 'IMG' || mediaElement.tagName === 'VIDEO')) {
imgObserver = new MutationObserver(() => {
// Check if the element being observed is still part of the document
// and if the resize notice is not displayed
if (document.body.contains(mediaElement) && (!resizeNotice || getComputedStyle(resizeNotice).display === 'none')) {
console.log('Smart Canvas: media src changed, re-initializing.');
setupCanvas();
}
});
imgObserver.observe(mediaElement, { attributes: true, attributeFilter: ['src'] });
}
// For canvas or ruffle-player, 'src' attribute observation is not standard.
// A different trigger or no trigger might be needed if content changes without page reload.
}
// Initialize menu commands
panningToggleCommandId = registerOrUpdateMenuCommand(panningToggleCommandId, isPanningEnabled, 'Panning', togglePanning);
autoscrollToggleCommandId = registerOrUpdateMenuCommand(autoscrollToggleCommandId, isAutoscrollEnabled, 'Autoscrolling', toggleAutoscroll);
// Start script after page is finished loading
document.onreadystatechange = function () {
if (document.readyState == "complete") {
setupCanvas();
window.addEventListener('resize', handleResize);
}
}
})();