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>
This commit is contained in:
parent
3dc7045340
commit
bbfcdb3d42
@ -1,10 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted, useTemplateRef } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
|
||||||
import { useStoreGetters } from 'dashboard/composables/store';
|
import { useStoreGetters } from 'dashboard/composables/store';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import { useImageZoom } from 'dashboard/composables/useImageZoom';
|
||||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||||
import { downloadFile } from '@chatwoot/utils';
|
import { downloadFile } from '@chatwoot/utils';
|
||||||
|
|
||||||
@ -26,7 +27,6 @@ const emit = defineEmits(['close']);
|
|||||||
const show = defineModel('show', { type: Boolean, default: false });
|
const show = defineModel('show', { type: Boolean, default: false });
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const getters = useStoreGetters();
|
const getters = useStoreGetters();
|
||||||
|
|
||||||
const ALLOWED_FILE_TYPES = {
|
const ALLOWED_FILE_TYPES = {
|
||||||
@ -36,11 +36,7 @@ const ALLOWED_FILE_TYPES = {
|
|||||||
AUDIO: 'audio',
|
AUDIO: 'audio',
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_ZOOM_LEVEL = 2;
|
|
||||||
const MIN_ZOOM_LEVEL = 1;
|
|
||||||
|
|
||||||
const isDownloading = ref(false);
|
const isDownloading = ref(false);
|
||||||
const zoomScale = ref(1);
|
|
||||||
const activeAttachment = ref({});
|
const activeAttachment = ref({});
|
||||||
const activeFileType = ref('');
|
const activeFileType = ref('');
|
||||||
const activeImageIndex = ref(
|
const activeImageIndex = ref(
|
||||||
@ -48,10 +44,23 @@ const activeImageIndex = ref(
|
|||||||
attachment => attachment.message_id === props.attachment.message_id
|
attachment => attachment.message_id === props.attachment.message_id
|
||||||
) || 0
|
) || 0
|
||||||
);
|
);
|
||||||
const activeImageRotation = ref(0);
|
|
||||||
|
const imageRef = useTemplateRef('imageRef');
|
||||||
|
|
||||||
|
const {
|
||||||
|
imageWrapperStyle,
|
||||||
|
imageStyle,
|
||||||
|
onRotate,
|
||||||
|
activeImageRotation,
|
||||||
|
onZoom,
|
||||||
|
onDoubleClickZoomImage,
|
||||||
|
onWheelImageZoom,
|
||||||
|
onMouseMove,
|
||||||
|
onMouseLeave,
|
||||||
|
resetZoomAndRotation,
|
||||||
|
} = useImageZoom(imageRef);
|
||||||
|
|
||||||
const currentUser = computed(() => getters.getCurrentUser.value);
|
const currentUser = computed(() => getters.getCurrentUser.value);
|
||||||
|
|
||||||
const hasMoreThanOneAttachment = computed(
|
const hasMoreThanOneAttachment = computed(
|
||||||
() => props.allAttachments.length > 1
|
() => props.allAttachments.length > 1
|
||||||
);
|
);
|
||||||
@ -65,10 +74,10 @@ const readableTime = computed(() => {
|
|||||||
const isImage = computed(
|
const isImage = computed(
|
||||||
() => activeFileType.value === ALLOWED_FILE_TYPES.IMAGE
|
() => activeFileType.value === ALLOWED_FILE_TYPES.IMAGE
|
||||||
);
|
);
|
||||||
const isVideo = computed(
|
const isVideo = computed(() =>
|
||||||
() =>
|
[ALLOWED_FILE_TYPES.VIDEO, ALLOWED_FILE_TYPES.IG_REEL].includes(
|
||||||
activeFileType.value === ALLOWED_FILE_TYPES.VIDEO ||
|
activeFileType.value
|
||||||
activeFileType.value === ALLOWED_FILE_TYPES.IG_REEL
|
)
|
||||||
);
|
);
|
||||||
const isAudio = computed(
|
const isAudio = computed(
|
||||||
() => activeFileType.value === ALLOWED_FILE_TYPES.AUDIO
|
() => activeFileType.value === ALLOWED_FILE_TYPES.AUDIO
|
||||||
@ -82,9 +91,9 @@ const senderDetails = computed(() => {
|
|||||||
thumbnail,
|
thumbnail,
|
||||||
id,
|
id,
|
||||||
} = activeAttachment.value?.sender || props.attachment?.sender || {};
|
} = activeAttachment.value?.sender || props.attachment?.sender || {};
|
||||||
const currentUserID = currentUser.value?.id;
|
|
||||||
return {
|
return {
|
||||||
name: currentUserID === id ? 'You' : name || availableName || '',
|
name: currentUser.value?.id === id ? 'You' : name || availableName || '',
|
||||||
avatar: thumbnail || avatar_url || '',
|
avatar: thumbnail || avatar_url || '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -92,43 +101,32 @@ const senderDetails = computed(() => {
|
|||||||
const fileNameFromDataUrl = computed(() => {
|
const fileNameFromDataUrl = computed(() => {
|
||||||
const { data_url: dataUrl } = activeAttachment.value;
|
const { data_url: dataUrl } = activeAttachment.value;
|
||||||
if (!dataUrl) return '';
|
if (!dataUrl) return '';
|
||||||
const fileName = dataUrl?.split('/').pop();
|
|
||||||
return decodeURIComponent(fileName || '');
|
const fileName = dataUrl.split('/').pop();
|
||||||
|
return fileName ? decodeURIComponent(fileName) : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageRotationStyle = computed(() => ({
|
const onClose = () => emit('close');
|
||||||
transform: `rotate(${activeImageRotation.value}deg) scale(${zoomScale.value})`,
|
|
||||||
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
emit('close');
|
|
||||||
};
|
|
||||||
|
|
||||||
const setImageAndVideoSrc = attachment => {
|
const setImageAndVideoSrc = attachment => {
|
||||||
const { file_type: type } = attachment;
|
const { file_type: type } = attachment;
|
||||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeAttachment.value = attachment;
|
activeAttachment.value = attachment;
|
||||||
activeFileType.value = type;
|
activeFileType.value = type;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickChangeAttachment = (attachment, index) => {
|
const onClickChangeAttachment = (attachment, index) => {
|
||||||
if (!attachment) {
|
if (!attachment) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeImageIndex.value = index;
|
activeImageIndex.value = index;
|
||||||
setImageAndVideoSrc(attachment);
|
setImageAndVideoSrc(attachment);
|
||||||
activeImageRotation.value = 0;
|
resetZoomAndRotation();
|
||||||
zoomScale.value = 1;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickDownload = async () => {
|
const onClickDownload = async () => {
|
||||||
const { file_type: type, data_url: url, extension } = activeAttachment.value;
|
const { file_type: type, data_url: url, extension } = activeAttachment.value;
|
||||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isDownloading.value = true;
|
isDownloading.value = true;
|
||||||
@ -140,66 +138,8 @@ const onClickDownload = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRotate = type => {
|
|
||||||
if (!isImage.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rotation = type === 'clockwise' ? 90 : -90;
|
|
||||||
|
|
||||||
// Reset rotation if it is 360
|
|
||||||
if (Math.abs(activeImageRotation.value) === 360) {
|
|
||||||
activeImageRotation.value = rotation;
|
|
||||||
} else {
|
|
||||||
activeImageRotation.value += rotation;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onZoom = scale => {
|
|
||||||
if (!isImage.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newZoomScale = zoomScale.value + scale;
|
|
||||||
// Check if the new zoom scale is within the allowed range
|
|
||||||
if (newZoomScale > MAX_ZOOM_LEVEL) {
|
|
||||||
// Set zoom to max but do not reset to default
|
|
||||||
zoomScale.value = MAX_ZOOM_LEVEL;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (newZoomScale < MIN_ZOOM_LEVEL) {
|
|
||||||
// Set zoom to min but do not reset to default
|
|
||||||
zoomScale.value = MIN_ZOOM_LEVEL;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// If within bounds, update the zoom scale
|
|
||||||
zoomScale.value = newZoomScale;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClickZoomImage = () => {
|
|
||||||
// If already at max zoom, clicking should zoom out to minimum
|
|
||||||
if (zoomScale.value >= MAX_ZOOM_LEVEL) {
|
|
||||||
zoomScale.value = MIN_ZOOM_LEVEL;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Otherwise zoom in
|
|
||||||
onZoom(0.1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onWheelImageZoom = e => {
|
|
||||||
if (!isImage.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const scale = e.deltaY > 0 ? -0.1 : 0.1;
|
|
||||||
onZoom(scale);
|
|
||||||
};
|
|
||||||
|
|
||||||
const keyboardEvents = {
|
const keyboardEvents = {
|
||||||
Escape: {
|
Escape: { action: onClose },
|
||||||
action: () => {
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ArrowLeft: {
|
ArrowLeft: {
|
||||||
action: () => {
|
action: () => {
|
||||||
onClickChangeAttachment(
|
onClickChangeAttachment(
|
||||||
@ -217,6 +157,7 @@ const keyboardEvents = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@ -232,50 +173,46 @@ onMounted(() => {
|
|||||||
:on-close="onClose"
|
:on-close="onClose"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
|
class="bg-n-background flex flex-col h-[inherit] w-[inherit] overflow-hidden select-none"
|
||||||
@click="onClose"
|
@click="onClose"
|
||||||
>
|
>
|
||||||
<div
|
<header
|
||||||
class="z-10 flex items-center justify-between w-full h-16 px-6 py-2 bg-white dark:bg-slate-900"
|
class="z-10 flex items-center justify-between w-full h-16 px-6 py-2 bg-n-background border-b border-n-weak"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="senderDetails"
|
v-if="senderDetails"
|
||||||
class="items-center flex justify-start min-w-[15rem]"
|
class="flex items-center min-w-[15rem] shrink-0"
|
||||||
>
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
v-if="senderDetails.avatar"
|
v-if="senderDetails.avatar"
|
||||||
:username="senderDetails.name"
|
:username="senderDetails.name"
|
||||||
:src="senderDetails.avatar"
|
:src="senderDetails.avatar"
|
||||||
|
class="flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<div
|
<div class="flex flex-col ml-2 rtl:ml-0 rtl:mr-2 overflow-hidden">
|
||||||
class="flex flex-col items-start justify-center ml-2 rtl:ml-0 rtl:mr-2"
|
<h3 class="text-base leading-5 m-0 font-medium">
|
||||||
>
|
|
||||||
<h3 class="text-base inline-block leading-[1.4] m-0 p-0 capitalize">
|
|
||||||
<span
|
<span
|
||||||
class="overflow-hidden text-slate-800 dark:text-slate-100 whitespace-nowrap text-ellipsis"
|
class="overflow-hidden text-n-slate-12 whitespace-nowrap text-ellipsis"
|
||||||
>
|
>
|
||||||
{{ senderDetails.name }}
|
{{ senderDetails.name }}
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
<span
|
||||||
class="p-0 m-0 overflow-hidden text-xs text-slate-400 dark:text-slate-200 whitespace-nowrap text-ellipsis"
|
class="text-xs text-n-slate-11 whitespace-nowrap text-ellipsis"
|
||||||
>
|
>
|
||||||
{{ readableTime }}
|
{{ readableTime }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-start w-auto min-w-0 p-1 text-sm font-semibold text-slate-700 dark:text-slate-100"
|
class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12"
|
||||||
>
|
>
|
||||||
<span
|
<span v-dompurify-html="fileNameFromDataUrl" class="truncate" />
|
||||||
v-dompurify-html="fileNameFromDataUrl"
|
|
||||||
class="overflow-hidden text-slate-700 dark:text-slate-100 whitespace-nowrap text-ellipsis"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]"
|
<div class="flex items-center gap-2 ml-2 shrink-0">
|
||||||
>
|
|
||||||
<NextButton
|
<NextButton
|
||||||
v-if="isImage"
|
v-if="isImage"
|
||||||
icon="i-lucide-zoom-in"
|
icon="i-lucide-zoom-in"
|
||||||
@ -314,13 +251,14 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
<NextButton icon="i-lucide-x" slate ghost @click="onClose" />
|
<NextButton icon="i-lucide-x" slate ghost @click="onClose" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
<div class="flex items-center justify-center w-full h-full">
|
|
||||||
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
|
<main class="flex items-stretch flex-1 h-full overflow-hidden">
|
||||||
|
<div class="flex items-center justify-center w-16 shrink-0">
|
||||||
<NextButton
|
<NextButton
|
||||||
v-if="hasMoreThanOneAttachment"
|
v-if="hasMoreThanOneAttachment"
|
||||||
icon="i-lucide-chevron-left"
|
icon="i-lucide-chevron-left"
|
||||||
class="z-10 disabled:pointer-events-auto"
|
class="z-10"
|
||||||
blue
|
blue
|
||||||
faded
|
faded
|
||||||
lg
|
lg
|
||||||
@ -333,42 +271,60 @@ onMounted(() => {
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-center justify-center w-full h-full">
|
|
||||||
<div>
|
<div class="flex-1 flex items-center justify-center overflow-hidden">
|
||||||
|
<div
|
||||||
|
v-if="isImage"
|
||||||
|
:style="imageWrapperStyle"
|
||||||
|
class="flex items-center justify-center origin-center"
|
||||||
|
:class="{
|
||||||
|
// Adjust dimensions when rotated 90/270 degrees to maintain visibility
|
||||||
|
// and prevent image from overflowing container in different aspect ratios
|
||||||
|
'w-[calc(100dvh-8rem)] h-[calc(100dvw-7rem)]':
|
||||||
|
activeImageRotation % 180 !== 0,
|
||||||
|
'size-full': activeImageRotation % 180 === 0,
|
||||||
|
}"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
v-if="isImage"
|
ref="imageRef"
|
||||||
:key="activeAttachment.message_id"
|
:key="activeAttachment.message_id"
|
||||||
:src="activeAttachment.data_url"
|
:src="activeAttachment.data_url"
|
||||||
class="mx-auto my-0 duration-150 ease-in-out transform modal-image skip-context-menu"
|
:style="imageStyle"
|
||||||
:style="imageRotationStyle"
|
class="max-h-full max-w-full object-contain duration-100 ease-in-out transform select-none"
|
||||||
@click.stop="onClickZoomImage"
|
|
||||||
@wheel.stop="onWheelImageZoom"
|
|
||||||
/>
|
|
||||||
<video
|
|
||||||
v-if="isVideo"
|
|
||||||
:key="activeAttachment.message_id"
|
|
||||||
:src="activeAttachment.data_url"
|
|
||||||
controls
|
|
||||||
playsInline
|
|
||||||
class="mx-auto my-0 modal-video skip-context-menu"
|
|
||||||
@click.stop
|
@click.stop
|
||||||
|
@dblclick.stop="onDoubleClickZoomImage"
|
||||||
|
@wheel.prevent.stop="onWheelImageZoom"
|
||||||
|
@mousemove="onMouseMove"
|
||||||
|
@mouseleave="onMouseLeave"
|
||||||
/>
|
/>
|
||||||
<audio
|
|
||||||
v-if="isAudio"
|
|
||||||
:key="activeAttachment.message_id"
|
|
||||||
controls
|
|
||||||
class="skip-context-menu"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
|
|
||||||
</audio>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<video
|
||||||
|
v-if="isVideo"
|
||||||
|
:key="activeAttachment.message_id"
|
||||||
|
:src="activeAttachment.data_url"
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
class="max-h-full max-w-full object-contain"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
|
||||||
|
<audio
|
||||||
|
v-if="isAudio"
|
||||||
|
:key="activeAttachment.message_id"
|
||||||
|
controls
|
||||||
|
class="w-full max-w-md"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
|
||||||
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
|
|
||||||
|
<div class="flex items-center justify-center w-16 shrink-0">
|
||||||
<NextButton
|
<NextButton
|
||||||
v-if="hasMoreThanOneAttachment"
|
v-if="hasMoreThanOneAttachment"
|
||||||
icon="i-lucide-chevron-right"
|
icon="i-lucide-chevron-right"
|
||||||
class="z-10 disabled:pointer-events-auto"
|
class="z-10"
|
||||||
blue
|
blue
|
||||||
faded
|
faded
|
||||||
lg
|
lg
|
||||||
@ -381,16 +337,17 @@ onMounted(() => {
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
<div class="z-10 flex items-center justify-center w-full h-16 px-6 py-2">
|
|
||||||
|
<footer
|
||||||
|
class="z-10 flex items-center justify-center h-12 border-t border-n-weak"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="items-center rounded-sm flex font-semibold justify-center min-w-[5rem] p-1 bg-slate-25 dark:bg-slate-800 text-slate-600 dark:text-slate-200 text-sm"
|
class="rounded-md flex items-center justify-center px-3 py-1 bg-n-slate-3 text-n-slate-12 text-sm font-medium"
|
||||||
>
|
>
|
||||||
<span class="count">
|
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }}
|
||||||
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</woot-modal>
|
</woot-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
141
app/javascript/dashboard/composables/spec/useImageZoom.spec.js
Normal file
141
app/javascript/dashboard/composables/spec/useImageZoom.spec.js
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { useImageZoom } from 'dashboard/composables/useImageZoom';
|
||||||
|
|
||||||
|
describe('useImageZoom', () => {
|
||||||
|
let imageRef;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock imageRef element with getBoundingClientRect method
|
||||||
|
imageRef = ref({
|
||||||
|
getBoundingClientRect: () => ({
|
||||||
|
left: 100,
|
||||||
|
top: 100,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with default values', () => {
|
||||||
|
const { zoomScale, imgTransformOriginPoint, activeImageRotation } =
|
||||||
|
useImageZoom(imageRef);
|
||||||
|
|
||||||
|
expect(zoomScale.value).toBe(1);
|
||||||
|
expect(imgTransformOriginPoint.value).toBe('center center');
|
||||||
|
expect(activeImageRotation.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update zoom scale when onZoom is called', () => {
|
||||||
|
const { zoomScale, onZoom } = useImageZoom(imageRef);
|
||||||
|
|
||||||
|
onZoom(0.5);
|
||||||
|
expect(zoomScale.value).toBe(1.5);
|
||||||
|
|
||||||
|
// Should respect max zoom level
|
||||||
|
onZoom(10);
|
||||||
|
expect(zoomScale.value).toBe(3);
|
||||||
|
|
||||||
|
// Should respect min zoom level
|
||||||
|
onZoom(-10);
|
||||||
|
expect(zoomScale.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update rotation when onRotate is called', () => {
|
||||||
|
const { activeImageRotation, onRotate } = useImageZoom(imageRef);
|
||||||
|
|
||||||
|
onRotate('clockwise');
|
||||||
|
expect(activeImageRotation.value).toBe(90);
|
||||||
|
|
||||||
|
onRotate('counter-clockwise');
|
||||||
|
expect(activeImageRotation.value).toBe(0);
|
||||||
|
|
||||||
|
// Test full rotation reset
|
||||||
|
onRotate('clockwise');
|
||||||
|
onRotate('clockwise');
|
||||||
|
onRotate('clockwise');
|
||||||
|
onRotate('clockwise');
|
||||||
|
onRotate('clockwise');
|
||||||
|
// After 360 degrees, it should reset and add the new rotation
|
||||||
|
expect(activeImageRotation.value).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset zoom and rotation', () => {
|
||||||
|
const {
|
||||||
|
zoomScale,
|
||||||
|
activeImageRotation,
|
||||||
|
onZoom,
|
||||||
|
onRotate,
|
||||||
|
resetZoomAndRotation,
|
||||||
|
} = useImageZoom(imageRef);
|
||||||
|
|
||||||
|
onZoom(0.5);
|
||||||
|
onRotate('clockwise');
|
||||||
|
expect(zoomScale.value).toBe(1); // Rotation resets zoom
|
||||||
|
expect(activeImageRotation.value).toBe(90);
|
||||||
|
|
||||||
|
onZoom(0.5);
|
||||||
|
expect(zoomScale.value).toBe(1.5);
|
||||||
|
|
||||||
|
resetZoomAndRotation();
|
||||||
|
expect(zoomScale.value).toBe(1);
|
||||||
|
expect(activeImageRotation.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle double click zoom', () => {
|
||||||
|
const { zoomScale, onDoubleClickZoomImage } = useImageZoom(imageRef);
|
||||||
|
|
||||||
|
// Mock event
|
||||||
|
const event = {
|
||||||
|
clientX: 150,
|
||||||
|
clientY: 150,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
onDoubleClickZoomImage(event);
|
||||||
|
expect(zoomScale.value).toBe(3); // Max zoom
|
||||||
|
expect(event.preventDefault).toHaveBeenCalled();
|
||||||
|
|
||||||
|
onDoubleClickZoomImage(event);
|
||||||
|
expect(zoomScale.value).toBe(1); // Min zoom
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle wheel zoom', () => {
|
||||||
|
const { zoomScale, onWheelImageZoom } = useImageZoom(imageRef);
|
||||||
|
|
||||||
|
// Mock event
|
||||||
|
const event = {
|
||||||
|
clientX: 150,
|
||||||
|
clientY: 150,
|
||||||
|
deltaY: -10, // Zoom in
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
onWheelImageZoom(event);
|
||||||
|
expect(zoomScale.value).toBe(1.2);
|
||||||
|
expect(event.preventDefault).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Zoom out
|
||||||
|
event.deltaY = 10;
|
||||||
|
onWheelImageZoom(event);
|
||||||
|
expect(zoomScale.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly compute zoom origin', () => {
|
||||||
|
const { getZoomOrigin } = useImageZoom(imageRef);
|
||||||
|
|
||||||
|
// Test center point
|
||||||
|
const centerOrigin = getZoomOrigin(200, 200);
|
||||||
|
expect(centerOrigin.x).toBeCloseTo(50);
|
||||||
|
expect(centerOrigin.y).toBeCloseTo(50);
|
||||||
|
|
||||||
|
// Test top-left corner
|
||||||
|
const topLeftOrigin = getZoomOrigin(100, 100);
|
||||||
|
expect(topLeftOrigin.x).toBeCloseTo(0);
|
||||||
|
expect(topLeftOrigin.y).toBeCloseTo(0);
|
||||||
|
|
||||||
|
// Test bottom-right corner
|
||||||
|
const bottomRightOrigin = getZoomOrigin(300, 300);
|
||||||
|
expect(bottomRightOrigin.x).toBeCloseTo(100);
|
||||||
|
expect(bottomRightOrigin.y).toBeCloseTo(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
186
app/javascript/dashboard/composables/useImageZoom.js
Normal file
186
app/javascript/dashboard/composables/useImageZoom.js
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -34,7 +34,7 @@
|
|||||||
"@breezystack/lamejs": "^1.2.7",
|
"@breezystack/lamejs": "^1.2.7",
|
||||||
"@chatwoot/ninja-keys": "1.2.3",
|
"@chatwoot/ninja-keys": "1.2.3",
|
||||||
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
||||||
"@chatwoot/utils": "^0.0.40",
|
"@chatwoot/utils": "^0.0.41",
|
||||||
"@formkit/core": "^1.6.7",
|
"@formkit/core": "^1.6.7",
|
||||||
"@formkit/vue": "^1.6.7",
|
"@formkit/vue": "^1.6.7",
|
||||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@ -23,8 +23,8 @@ importers:
|
|||||||
specifier: 1.1.1-next
|
specifier: 1.1.1-next
|
||||||
version: 1.1.1-next
|
version: 1.1.1-next
|
||||||
'@chatwoot/utils':
|
'@chatwoot/utils':
|
||||||
specifier: ^0.0.40
|
specifier: ^0.0.41
|
||||||
version: 0.0.40
|
version: 0.0.41
|
||||||
'@formkit/core':
|
'@formkit/core':
|
||||||
specifier: ^1.6.7
|
specifier: ^1.6.7
|
||||||
version: 1.6.7
|
version: 1.6.7
|
||||||
@ -403,8 +403,8 @@ packages:
|
|||||||
'@chatwoot/prosemirror-schema@1.1.1-next':
|
'@chatwoot/prosemirror-schema@1.1.1-next':
|
||||||
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
||||||
|
|
||||||
'@chatwoot/utils@0.0.40':
|
'@chatwoot/utils@0.0.41':
|
||||||
resolution: {integrity: sha512-Rg5wwQXi4mnEiv8scw66QVIgal9F1di+UDg4hHCw37zLXrqExQQ9CL7jDvO+ddvzEl6TUXw91Lu5L/qVlEyvdg==}
|
resolution: {integrity: sha512-f0D+XArVYbc9m9M7KZpCaVJ+EUVzobX+D9P5Vt/h2jUipg706GoBhGwsP8kjfWhUdNdcS+H+OB4ZCKGF1NIkTQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
'@codemirror/commands@6.7.0':
|
'@codemirror/commands@6.7.0':
|
||||||
@ -5244,7 +5244,7 @@ snapshots:
|
|||||||
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
||||||
prosemirror-view: 1.34.1
|
prosemirror-view: 1.34.1
|
||||||
|
|
||||||
'@chatwoot/utils@0.0.40':
|
'@chatwoot/utils@0.0.41':
|
||||||
dependencies:
|
dependencies:
|
||||||
date-fns: 2.30.0
|
date-fns: 2.30.0
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user