Files
chatwoot/app/javascript/dashboard/composables/useImageZoom.js
Sivin Varghese bbfcdb3d42 chore: Improvements in image attachment viewer (#11040)
This PR includes improvements in image attachment/gallery viewer:

1. Added double-click zoom functionality (depreciated click to zoom)
2. Implemented scroll zoom based on cursor position
3. Increase the zoom scale
4. Improved layout and styling for better usability

Fixes
https://linear.app/chatwoot/issue/CW-4127/zoom-images-from-a-specific-location


## How Has This Been Tested?

Loom video

https://www.loom.com/share/b21e00db3bc74231a90202eb6eb2fb5a?sid=a0651bf1-0952-430b-a5a9-83bf0858e059

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-03-18 14:01:18 +05:30

187 lines
5.5 KiB
JavaScript

import { ref, computed } from 'vue';
import {
debounce,
calculateCenterOffset,
applyRotationTransform,
normalizeToPercentage,
} from '@chatwoot/utils';
// Composable for images in gallery view
export const useImageZoom = imageRef => {
const MAX_ZOOM_LEVEL = 3;
const MIN_ZOOM_LEVEL = 1;
const ZOOM_INCREMENT = 0.2;
const MOUSE_MOVE_DEBOUNCE_MS = 100;
const MOUSE_LEAVE_DEBOUNCE_MS = 110;
const DEFAULT_IMG_TRANSFORM_ORIGIN = 'center center';
const zoomScale = ref(1);
const imgTransformOriginPoint = ref(DEFAULT_IMG_TRANSFORM_ORIGIN);
const activeImageRotation = ref(0);
const imageWrapperStyle = computed(() => ({
transform: `rotate(${activeImageRotation.value}deg)`,
}));
const imageStyle = computed(() => ({
transform: `scale(${zoomScale.value})`,
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
transformOrigin: `${imgTransformOriginPoint.value}`,
}));
// Resets the transform origin to center
const resetTransformOrigin = () => {
if (imageRef.value) {
imgTransformOriginPoint.value = DEFAULT_IMG_TRANSFORM_ORIGIN;
}
};
// Rotates the current image
const onRotate = type => {
if (!imageRef.value) return;
resetTransformOrigin();
const rotation = type === 'clockwise' ? 90 : -90;
// ensure that the value of the rotation is within the range of -360 to 360 degrees
activeImageRotation.value = (activeImageRotation.value + rotation) % 360;
// Reset zoom when rotating
zoomScale.value = 1;
resetTransformOrigin();
};
/**
* Calculates the appropriate transform origin point based on mouse position and image rotation
* Used to create a natural zoom behavior where the image zooms toward/from the cursor position
*
* @param {number} x - The client X coordinate of the mouse pointer
* @param {number} y - The client Y coordinate of the mouse pointer
* @returns {{x: number, y: number}} Object containing the transform origin coordinates as percentages
*/
const getZoomOrigin = (x, y) => {
// Default to center
if (!imageRef.value) return { x: 50, y: 50 };
const rect = imageRef.value.getBoundingClientRect();
// Step 1: Calculate offset from center
const { relativeX, relativeY } = calculateCenterOffset(x, y, rect);
// Step 2: Apply rotation transformation
const { rotatedX, rotatedY } = applyRotationTransform(
relativeX,
relativeY,
activeImageRotation.value
);
// Step 3: Convert to percentage coordinates
return normalizeToPercentage(rotatedX, rotatedY, rect.width, rect.height);
};
// Handles zooming the image
const onZoom = (scale, x, y) => {
if (!imageRef.value) return;
// Calculate new scale within bounds
const newScale = Math.max(
MIN_ZOOM_LEVEL,
Math.min(MAX_ZOOM_LEVEL, zoomScale.value + scale)
);
// Skip if no change
if (newScale === zoomScale.value) return;
// Update transform origin based on mouse position and zoom scale is minimum
if (x != null && y != null && zoomScale.value === MIN_ZOOM_LEVEL) {
const { x: originX, y: originY } = getZoomOrigin(x, y);
imgTransformOriginPoint.value = `${originX}% ${originY}%`;
}
// Apply the new scale
zoomScale.value = newScale;
};
// Handles double-click zoom toggling
const onDoubleClickZoomImage = e => {
if (!imageRef.value) return;
e.preventDefault();
// Toggle between max zoom and min zoom
const newScale =
zoomScale.value >= MAX_ZOOM_LEVEL ? MIN_ZOOM_LEVEL : MAX_ZOOM_LEVEL;
// Update transform origin based on mouse position
const { x: originX, y: originY } = getZoomOrigin(e.clientX, e.clientY);
imgTransformOriginPoint.value = `${originX}% ${originY}%`;
// Apply the new scale
zoomScale.value = newScale;
};
// Handles mouse wheel zooming for images
const onWheelImageZoom = e => {
if (!imageRef.value) return;
e.preventDefault();
const scale = e.deltaY > 0 ? -ZOOM_INCREMENT : ZOOM_INCREMENT;
onZoom(scale, e.clientX, e.clientY);
};
/**
* Sets transform origin to mouse position during hover.
* Enables precise scroll/double-click zoom targeting by updating the
* transform origin to cursor position. Only active at minimum zoom level.
* Debounced (100ms) to improve performance during rapid mouse movement.
*/
const onMouseMove = debounce(
e => {
if (!imageRef.value) return;
if (zoomScale.value !== MIN_ZOOM_LEVEL) return;
const { x: originX, y: originY } = getZoomOrigin(e.clientX, e.clientY);
imgTransformOriginPoint.value = `${originX}% ${originY}%`;
},
MOUSE_MOVE_DEBOUNCE_MS,
false
);
/**
* Resets transform origin to center when mouse leaves image.
* Ensures button-based zooming works predictably after hover ends.
* Uses slightly longer debounce (110ms) to avoid conflicts with onMouseMove.
*/
const onMouseLeave = debounce(
() => {
if (!imageRef.value) return;
if (zoomScale.value !== MIN_ZOOM_LEVEL) return;
imgTransformOriginPoint.value = DEFAULT_IMG_TRANSFORM_ORIGIN;
},
MOUSE_LEAVE_DEBOUNCE_MS,
false
);
const resetZoomAndRotation = () => {
activeImageRotation.value = 0;
zoomScale.value = 1;
resetTransformOrigin();
};
return {
zoomScale,
imgTransformOriginPoint,
activeImageRotation,
imageWrapperStyle,
imageStyle,
getZoomOrigin,
resetTransformOrigin,
onRotate,
onZoom,
onDoubleClickZoomImage,
onWheelImageZoom,
onMouseMove,
onMouseLeave,
resetZoomAndRotation,
};
};