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:
@@ -1,10 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useImageZoom } from 'dashboard/composables/useImageZoom';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
|
||||
@@ -26,7 +27,6 @@ const emit = defineEmits(['close']);
|
||||
const show = defineModel('show', { type: Boolean, default: false });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const ALLOWED_FILE_TYPES = {
|
||||
@@ -36,11 +36,7 @@ const ALLOWED_FILE_TYPES = {
|
||||
AUDIO: 'audio',
|
||||
};
|
||||
|
||||
const MAX_ZOOM_LEVEL = 2;
|
||||
const MIN_ZOOM_LEVEL = 1;
|
||||
|
||||
const isDownloading = ref(false);
|
||||
const zoomScale = ref(1);
|
||||
const activeAttachment = ref({});
|
||||
const activeFileType = ref('');
|
||||
const activeImageIndex = ref(
|
||||
@@ -48,10 +44,23 @@ const activeImageIndex = ref(
|
||||
attachment => attachment.message_id === props.attachment.message_id
|
||||
) || 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 hasMoreThanOneAttachment = computed(
|
||||
() => props.allAttachments.length > 1
|
||||
);
|
||||
@@ -65,10 +74,10 @@ const readableTime = computed(() => {
|
||||
const isImage = computed(
|
||||
() => activeFileType.value === ALLOWED_FILE_TYPES.IMAGE
|
||||
);
|
||||
const isVideo = computed(
|
||||
() =>
|
||||
activeFileType.value === ALLOWED_FILE_TYPES.VIDEO ||
|
||||
activeFileType.value === ALLOWED_FILE_TYPES.IG_REEL
|
||||
const isVideo = computed(() =>
|
||||
[ALLOWED_FILE_TYPES.VIDEO, ALLOWED_FILE_TYPES.IG_REEL].includes(
|
||||
activeFileType.value
|
||||
)
|
||||
);
|
||||
const isAudio = computed(
|
||||
() => activeFileType.value === ALLOWED_FILE_TYPES.AUDIO
|
||||
@@ -82,9 +91,9 @@ const senderDetails = computed(() => {
|
||||
thumbnail,
|
||||
id,
|
||||
} = activeAttachment.value?.sender || props.attachment?.sender || {};
|
||||
const currentUserID = currentUser.value?.id;
|
||||
|
||||
return {
|
||||
name: currentUserID === id ? 'You' : name || availableName || '',
|
||||
name: currentUser.value?.id === id ? 'You' : name || availableName || '',
|
||||
avatar: thumbnail || avatar_url || '',
|
||||
};
|
||||
});
|
||||
@@ -92,43 +101,32 @@ const senderDetails = computed(() => {
|
||||
const fileNameFromDataUrl = computed(() => {
|
||||
const { data_url: dataUrl } = activeAttachment.value;
|
||||
if (!dataUrl) return '';
|
||||
const fileName = dataUrl?.split('/').pop();
|
||||
return decodeURIComponent(fileName || '');
|
||||
|
||||
const fileName = dataUrl.split('/').pop();
|
||||
return fileName ? decodeURIComponent(fileName) : '';
|
||||
});
|
||||
|
||||
const imageRotationStyle = computed(() => ({
|
||||
transform: `rotate(${activeImageRotation.value}deg) scale(${zoomScale.value})`,
|
||||
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
|
||||
}));
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
const onClose = () => emit('close');
|
||||
|
||||
const setImageAndVideoSrc = attachment => {
|
||||
const { file_type: type } = attachment;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
||||
return;
|
||||
}
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
|
||||
|
||||
activeAttachment.value = attachment;
|
||||
activeFileType.value = type;
|
||||
};
|
||||
|
||||
const onClickChangeAttachment = (attachment, index) => {
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
if (!attachment) return;
|
||||
|
||||
activeImageIndex.value = index;
|
||||
setImageAndVideoSrc(attachment);
|
||||
activeImageRotation.value = 0;
|
||||
zoomScale.value = 1;
|
||||
resetZoomAndRotation();
|
||||
};
|
||||
|
||||
const onClickDownload = async () => {
|
||||
const { file_type: type, data_url: url, extension } = activeAttachment.value;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
||||
return;
|
||||
}
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
|
||||
|
||||
try {
|
||||
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 = {
|
||||
Escape: {
|
||||
action: () => {
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
Escape: { action: onClose },
|
||||
ArrowLeft: {
|
||||
action: () => {
|
||||
onClickChangeAttachment(
|
||||
@@ -217,6 +157,7 @@ const keyboardEvents = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
onMounted(() => {
|
||||
@@ -232,50 +173,46 @@ onMounted(() => {
|
||||
:on-close="onClose"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
class="z-10 flex items-center justify-between w-full h-16 px-6 py-2 bg-white dark:bg-slate-900"
|
||||
<header
|
||||
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
|
||||
>
|
||||
<div
|
||||
v-if="senderDetails"
|
||||
class="items-center flex justify-start min-w-[15rem]"
|
||||
class="flex items-center min-w-[15rem] shrink-0"
|
||||
>
|
||||
<Thumbnail
|
||||
v-if="senderDetails.avatar"
|
||||
:username="senderDetails.name"
|
||||
:src="senderDetails.avatar"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col items-start justify-center ml-2 rtl:ml-0 rtl:mr-2"
|
||||
>
|
||||
<h3 class="text-base inline-block leading-[1.4] m-0 p-0 capitalize">
|
||||
<div class="flex flex-col ml-2 rtl:ml-0 rtl:mr-2 overflow-hidden">
|
||||
<h3 class="text-base leading-5 m-0 font-medium">
|
||||
<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 }}
|
||||
</span>
|
||||
</h3>
|
||||
<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 }}
|
||||
</span>
|
||||
</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
|
||||
v-dompurify-html="fileNameFromDataUrl"
|
||||
class="overflow-hidden text-slate-700 dark:text-slate-100 whitespace-nowrap text-ellipsis"
|
||||
/>
|
||||
<span v-dompurify-html="fileNameFromDataUrl" class="truncate" />
|
||||
</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
|
||||
v-if="isImage"
|
||||
icon="i-lucide-zoom-in"
|
||||
@@ -314,13 +251,14 @@ onMounted(() => {
|
||||
/>
|
||||
<NextButton icon="i-lucide-x" slate ghost @click="onClose" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center w-full h-full">
|
||||
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
|
||||
</header>
|
||||
|
||||
<main class="flex items-stretch flex-1 h-full overflow-hidden">
|
||||
<div class="flex items-center justify-center w-16 shrink-0">
|
||||
<NextButton
|
||||
v-if="hasMoreThanOneAttachment"
|
||||
icon="i-lucide-chevron-left"
|
||||
class="z-10 disabled:pointer-events-auto"
|
||||
class="z-10"
|
||||
blue
|
||||
faded
|
||||
lg
|
||||
@@ -333,42 +271,60 @@ onMounted(() => {
|
||||
"
|
||||
/>
|
||||
</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
|
||||
v-if="isImage"
|
||||
ref="imageRef"
|
||||
:key="activeAttachment.message_id"
|
||||
:src="activeAttachment.data_url"
|
||||
class="mx-auto my-0 duration-150 ease-in-out transform modal-image skip-context-menu"
|
||||
:style="imageRotationStyle"
|
||||
@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"
|
||||
:style="imageStyle"
|
||||
class="max-h-full max-w-full object-contain duration-100 ease-in-out transform select-none"
|
||||
@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>
|
||||
|
||||
<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 class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
|
||||
|
||||
<div class="flex items-center justify-center w-16 shrink-0">
|
||||
<NextButton
|
||||
v-if="hasMoreThanOneAttachment"
|
||||
icon="i-lucide-chevron-right"
|
||||
class="z-10 disabled:pointer-events-auto"
|
||||
class="z-10"
|
||||
blue
|
||||
faded
|
||||
lg
|
||||
@@ -381,16 +337,17 @@ onMounted(() => {
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="z-10 flex items-center justify-center w-full h-16 px-6 py-2">
|
||||
</main>
|
||||
|
||||
<footer
|
||||
class="z-10 flex items-center justify-center h-12 border-t border-n-weak"
|
||||
>
|
||||
<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}` }}
|
||||
</span>
|
||||
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user