Last active
July 7, 2023 10:16
-
-
Save RazinTeqB/0e70862c6ef7a260c875a4efceeeef00 to your computer and use it in GitHub Desktop.
Image Zoom on mouse over vue component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<div | |
v-show="!zoom || $device.isMobile" | |
class="pointer-events-none sm:pointer-events-auto absolute inset-x-0 top-4 z-10 mx-auto flex max-w-max cursor-pointer gap-3 rounded-full bg-dark/75 px-4 py-2 text-white" | |
role="button" | |
@click="zoom = !zoom" | |
> | |
<nuxt-img src="/images/icons/icon-search-white.svg" width="20" /> | |
<span>Click to zoom</span> | |
</div> | |
</template> | |
<script> | |
/** | |
* if selector have overlay elements like size or discount badge on product page image with position absolute | |
* then set pointer-events to none to avoid zoom out when mouse over overlay element | |
*/ | |
export default { | |
name: 'VZoom', | |
props: { | |
selector: { | |
type: String, | |
required: true, | |
}, | |
transitionDuration: { | |
type: Number, | |
default: 300, | |
}, | |
}, | |
setup(props) { | |
const { $bus, $device } = useNuxtApp() | |
const zoom = ref($device.isMobile) | |
watch(zoom, (value) => { | |
setZoomEvents(!value) | |
const elements = document.querySelectorAll(props.selector) | |
elements.forEach((elm) => { | |
if (value) { | |
elm.style.cursor = 'zoom-in' | |
} else { | |
elm.style.removeProperty('cursor') | |
} | |
}) | |
}) | |
onMounted(() => { | |
$bus.$on(`${props.selector}:setZoom`, (value) => { | |
zoom.value = value | |
}) | |
$bus.$on( | |
`${props.selector}:updateZoom`, | |
() => (zoom.value = !zoom.value) | |
) | |
if (zoom.value) { | |
setZoomEvents() | |
} | |
}) | |
onBeforeUnmount(() => { | |
setZoomEvents(true) | |
}) | |
function setZoomEvents(detach = false) { | |
document.querySelectorAll('.image-zoom').forEach((elm) => { | |
;['mousemove', 'touchstart'].forEach((name) => { | |
if (detach) { | |
elm.removeEventListener(name, focusZoom) | |
} else { | |
elm.addEventListener(name, focusZoom, { passive: true }) | |
} | |
}) | |
;['mouseleave', 'touchend'].forEach((name) => { | |
if (detach) { | |
elm.removeEventListener(name, focusZoomOut) | |
} else { | |
elm.addEventListener(name, focusZoomOut, { | |
passive: true, | |
}) | |
} | |
}) | |
if (detach) { | |
focusZoomOut({ target: elm }) | |
elm.removeEventListener('contextmenu', preventContextMenu) | |
if ( | |
elm.tagName === 'IMG' && | |
elm.parentElement.tagName === 'PICTURE' | |
) { | |
setTimeout(() => { | |
elm.parentElement.style.removeProperty('display') | |
elm.parentElement.style.removeProperty('overflow') | |
elm.style.removeProperty('transition') | |
}, props.transitionDuration) | |
} | |
} else { | |
elm.addEventListener('contextmenu', preventContextMenu) | |
elm.style.transition = `scale ${props.transitionDuration}ms ease-in-out` | |
if ( | |
elm.tagName === 'IMG' && | |
elm.parentElement.tagName === 'PICTURE' | |
) { | |
elm.parentElement.style.display = 'block' | |
elm.parentElement.style.overflow = 'hidden' | |
} | |
} | |
}) | |
} | |
function preventContextMenu(e) { | |
e.preventDefault() | |
} | |
function focusZoom(e) { | |
const img = e.target | |
const imgRect = img.getBoundingClientRect() | |
let pageX = e.pageX | |
let pageY = e.pageY | |
if (e.constructor.name === 'TouchEvent') { | |
pageX = e.changedTouches[0].pageX | |
pageY = e.changedTouches[0].pageY | |
} | |
const offsetX = ((pageX - imgRect.left) / imgRect.width) * 100 | |
const offsetY = ((pageY - imgRect.top) / imgRect.height) * 100 | |
img.style.scale = 1.6 | |
img.style.transformOrigin = `${offsetX}% ${offsetY}%` | |
} | |
function focusZoomOut(e) { | |
const img = e.target | |
img.style.removeProperty('scale') | |
setTimeout(() => { | |
img.style.removeProperty('transform-origin') | |
}, props.transitionDuration) | |
} | |
return { | |
zoom, | |
} | |
}, | |
} | |
</script> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<div | |
v-show="!zoom || $device.isMobile" | |
class="pointer-events-none absolute inset-x-0 top-4 z-10 mx-auto max-w-max cursor-pointer rounded-full bg-dark/75 text-white min-h-[30px]" | |
role="button" | |
:class="zoomedIn ? 'px-2 py-2' : 'px-4 py-2'" | |
> | |
<div | |
v-if="($device.isMobile && !zoomedIn) || $device.isDesktop" | |
class="flex gap-3" | |
> | |
<nuxt-img src="/images/icons/icon-search-white.svg" width="20" /> | |
<span>{{ $n('general.click-to-zoom') }}</span> | |
</div> | |
<nuxt-img | |
v-else | |
src="/images/icons/icon-cross-black.svg" | |
class="filter invert" | |
width="20" | |
/> | |
</div> | |
</template> | |
<script> | |
/** | |
* if selector have overlay elements like size or discount badge on product page image with position absolute | |
* then set pointer-events to none to avoid zoom out when mouse over overlay element | |
*/ | |
export default { | |
name: 'VZoom', | |
props: { | |
selector: { | |
type: String, | |
required: true, | |
}, | |
transitionDuration: { | |
type: Number, | |
default: 300, | |
}, | |
}, | |
setup(props) { | |
const { $bus, $device } = useNuxtApp() | |
const zoom = ref(false) | |
const zoomedIn = ref(false) | |
watch(zoom, (value) => { | |
setZoomEvents(!value) | |
const elements = document.querySelectorAll(props.selector) | |
elements.forEach((elm) => { | |
if (value) { | |
elm.style.cursor = 'zoom-in' | |
} else { | |
elm.style.removeProperty('cursor') | |
} | |
}) | |
value ? zoomIn() : zoomOut() | |
}) | |
watch(zoomedIn, (value, old) => { | |
if(value !== old && $device.isMobile){ | |
const event = value ? 'zoomIn' : 'zoomOut'; | |
$bus.$emit(`${props.selector}:${event}`) | |
value ? zoomIn() : zoomOut() | |
} | |
}) | |
onMounted(() => { | |
$bus.$on(`${props.selector}:setZoom`, (value) => zoom.value = value) | |
$bus.$on(`${props.selector}:updateZoom`, () => zoom.value = !zoom.value) | |
}) | |
onBeforeUnmount(() => { | |
setZoomEvents(true) | |
}) | |
function zoomIn() { | |
const elements = document.querySelectorAll(props.selector) | |
elements.forEach(elm => { | |
// default style first to avoid jerk on first click on mobile | |
elm.style.transition = `scale ${props.transitionDuration}ms ease-in-out`; | |
focusZoom({target: elm}, true) | |
}) | |
} | |
function zoomOut() { | |
const elements = document.querySelectorAll(props.selector) | |
elements.forEach(elm => { | |
focusZoomOut({target: elm},) | |
}) | |
} | |
function enableZoom(value) { | |
zoom.value = value | |
value ? zoomIn() : zoomOut() | |
} | |
function setZoomEvents(detach = false) { | |
document.querySelectorAll('.image-zoom').forEach((elm) => { | |
;['touchstart'].forEach((name) => { | |
if (detach) { | |
elm.removeEventListener(name, enableZoom(!zoom.value)) | |
} else { | |
elm.addEventListener(name, enableZoom(true), { passive: true }) | |
} | |
}) | |
;['mousemove','touchmove'].forEach((name) => { | |
if (detach) { | |
elm.removeEventListener(name, focusZoom) | |
} else { | |
elm.addEventListener(name, focusZoom, { passive: true }) | |
} | |
}) | |
;['mouseleave'].forEach((name) => { | |
if (detach) { | |
elm.removeEventListener(name, focusZoomOut) | |
} else { | |
elm.addEventListener(name, focusZoomOut, { | |
passive: true, | |
}) | |
} | |
}) | |
if (detach) { | |
focusZoomOut({ target: elm }) | |
elm.removeEventListener('contextmenu', preventContextMenu) | |
if ( | |
elm.tagName === 'IMG' && | |
elm.parentElement.tagName === 'PICTURE' | |
) { | |
setTimeout(() => { | |
elm.parentElement.style.removeProperty('display') | |
elm.parentElement.style.removeProperty('overflow') | |
elm.style.removeProperty('transition') | |
}, props.transitionDuration) | |
} | |
} else { | |
elm.addEventListener('contextmenu', preventContextMenu) | |
elm.style.transition = `scale ${props.transitionDuration}ms ease-in-out` | |
if ( | |
elm.tagName === 'IMG' && | |
elm.parentElement.tagName === 'PICTURE' | |
) { | |
elm.parentElement.style.display = 'block' | |
elm.parentElement.style.overflow = 'hidden' | |
} | |
} | |
}) | |
} | |
function preventContextMenu(e) { | |
e.preventDefault() | |
} | |
function inBoundaries(bounds,x,y) { | |
const l = bounds.left; | |
const t = bounds.top; | |
const h = bounds.height; | |
const w = bounds.width; | |
const maxX = l + w; | |
const maxY = t + h; | |
return (y <= maxY && y >= t) && (x <= maxX && x >= l); | |
} | |
function focusZoom(e, force = false) { | |
const img = e.target | |
const imgRect = img.getBoundingClientRect() | |
let pageX = e.pageX | |
let pageY = e.pageY | |
if (e.constructor.name === 'TouchEvent') { | |
pageX = e.changedTouches[0].pageX | |
pageY = e.changedTouches[0].pageY | |
} | |
// prevent image move when cursor is out of bound | |
if(!force && !inBoundaries(imgRect, pageX, pageY)) return; | |
const offsetX = ((pageX - imgRect.left) / imgRect.width) * 100 | |
const offsetY = ((pageY - imgRect.top) / imgRect.height) * 100 | |
img.style.scale = 1.6 | |
img.style.transformOrigin = `${offsetX}% ${offsetY}%` | |
zoomedIn.value = true | |
} | |
function focusZoomOut(e) { | |
const img = e.target | |
img.style.removeProperty('scale') | |
setTimeout(() => { | |
img.style.removeProperty('transform-origin') | |
}, props.transitionDuration) | |
zoomedIn.value = false | |
} | |
return { | |
zoom, | |
zoomedIn, | |
} | |
}, | |
} | |
</script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment