@@ -73,7 +73,7 @@ import config from 'virtual:starlight-image-zoom-config'
7373 width: 44px;
7474 }
7575
76- .starlight-image-zoom-opened .starlight-image-zoom-control {
76+ :is( .starlight-image-zoom-opened, .starlight-image-zoom-transition) .starlight-image-zoom-control {
7777 inset: 20px 20px auto auto;
7878 }
7979
@@ -166,6 +166,9 @@ import config from 'virtual:starlight-image-zoom-config'
166166<script >
167167 import { STARLIGHT_IMAGE_ZOOM_ZOOMABLE_TAG } from '../libs/constants'
168168
169+ // https://caniuse.com/requestidlecallback
170+ const onIdle = window.requestIdleCallback ?? ((cb) => setTimeout(cb, 1))
171+
169172 /**
170173 * Based on:
171174 * - https://github.com/francoischalifour/medium-zoom
@@ -183,16 +186,16 @@ import config from 'virtual:starlight-image-zoom-config'
183186 image: 'starlight-image-zoom-image',
184187 opened: 'starlight-image-zoom-opened',
185188 source: 'starlight-image-zoom-source',
189+ transition: 'starlight-image-zoom-transition',
186190 }
191+ #dataZoomTransformKey = 'zoomTransform'
192+
187193 static #initialized = false
188194
189195 constructor() {
190196 super()
191197
192198 const initialize = () => {
193- // https://caniuse.com/requestidlecallback
194- const onIdle = window.requestIdleCallback ?? ((cb) => setTimeout(cb, 1))
195-
196199 onIdle(() => {
197200 const zoomables = [...document.querySelectorAll(STARLIGHT_IMAGE_ZOOM_ZOOMABLE_TAG)]
198201 if (zoomables.length === 0) return
@@ -281,8 +284,8 @@ import config from 'virtual:starlight-image-zoom-config'
281284 .querySelector('header')
282285 ?.style.setProperty('padding-inline-end', `calc(var(--sl-nav-pad-x) + ${window.innerWidth - clientWidth}px)`)
283286
284- // Clone the image to zoom at the same position of the original image .
285- const zoomedImage = this.#cloneImageAtPosition (image)
287+ // Clone the image and apply a zoom effect to it .
288+ const zoomedImage = this.#cloneAndZoomImage (image)
286289
287290 // Apply CSS classes to hide the source image and transition the zoomed image.
288291 image.classList.add(this.#classList.source)
@@ -302,9 +305,11 @@ import config from 'virtual:starlight-image-zoom-config'
302305 dialog.addEventListener('cancel', this.#onCancel)
303306 dialog.showModal()
304307
305- // Apply a zoom effect to the zoomed image.
306- zoomedImage.style.transform = this.#getZoomEffectTransform(image, figure)
307- document.body.classList.add(this.#classList.opened)
308+ // Apply a zoom effect to zoom the image in.
309+ onIdle(() => {
310+ zoomedImage.style.transform = ''
311+ document.body.classList.add(this.#classList.opened)
312+ })
308313
309314 this.#currentZoom = { body, dialog, image, zoomedImage }
310315 }
@@ -317,7 +322,8 @@ import config from 'virtual:starlight-image-zoom-config'
317322 const { zoomedImage } = this.#currentZoom
318323
319324 // Remove the zoom effect from the zoomed image.
320- zoomedImage.style.transform = ''
325+ zoomedImage.style.transform = zoomedImage.dataset[this.#dataZoomTransformKey] ?? ''
326+ document.body.classList.add(this.#classList.transition)
321327 document.body.classList.remove(this.#classList.opened)
322328
323329 const { matches: prefersReducedMotion } = window.matchMedia('(prefers-reduced-motion: reduce)')
@@ -335,6 +341,7 @@ import config from 'virtual:starlight-image-zoom-config'
335341 const { dialog, image } = this.#currentZoom
336342
337343 // Show the source image.
344+ document.body.classList.remove(this.#classList.transition)
338345 image.classList.remove(this.#classList.source)
339346
340347 // Remove the portaled dialog.
@@ -368,8 +375,22 @@ import config from 'virtual:starlight-image-zoom-config'
368375 figure.append(caption)
369376 }
370377
371- #cloneImageAtPosition(image: HTMLImageElement) {
372- const { height, left, top, width } = image.getBoundingClientRect()
378+ #cloneAndZoomImage(image: HTMLImageElement) {
379+ const imageRect = image.getBoundingClientRect()
380+
381+ // Zoom SVGs at the figure's size, not the image's size.
382+ const isSVG = this.#isSVGImage(image)
383+ const naturalWidth = isSVG ? window.innerWidth : image.naturalWidth
384+ const naturalHeight = isSVG ? window.innerHeight : image.naturalHeight
385+
386+ const maxWidth = Math.min(window.innerWidth, naturalWidth)
387+ const maxHeight = Math.min(window.innerHeight, naturalHeight)
388+ const scale = Math.min(maxWidth / naturalWidth, maxHeight / naturalHeight)
389+
390+ const width = (isSVG ? window.innerWidth : image.naturalWidth) * scale
391+ const height = (isSVG ? window.innerHeight : image.naturalHeight) * scale
392+ const top = (window.innerHeight - height) / 2
393+ const left = (window.innerWidth - width) / 2
373394
374395 const clone = image.cloneNode(true) as HTMLImageElement
375396 clone.removeAttribute('id')
@@ -381,6 +402,20 @@ import config from 'virtual:starlight-image-zoom-config'
381402 clone.style.left = `${left}px`
382403 clone.style.transform = ''
383404
405+ // Finds out the scale transformations so that the zoomed image fits within the image rect.
406+ const scaleX = imageRect.width / width
407+ const scaleY = imageRect.height / height
408+
409+ // Calculate the translation to align the zoomed image within the image rect.
410+ const translateX = (-left + (imageRect.width - width) / 2 + imageRect.left) / scaleX
411+ const translateY = (-top + (imageRect.height - height) / 2 + imageRect.top) / scaleY
412+
413+ // Apply the scale and translation to the zoomed image.
414+ clone.style.transform = `scale(${scaleX}, ${scaleY}) translate3d(${translateX}px, ${translateY}px, 0)`
415+
416+ // Save the transform to a data attribute to be able to animate it back to the original position.
417+ clone.dataset[this.#dataZoomTransformKey] = clone.style.transform
418+
384419 // If the image is inside a `<picture>` element, we need to update the `src` attribute and use the correct
385420 // source.
386421 if (image.parentElement?.tagName === 'PICTURE' && image.currentSrc) {
@@ -390,28 +425,6 @@ import config from 'virtual:starlight-image-zoom-config'
390425 return clone
391426 }
392427
393- #getZoomEffectTransform(image: HTMLImageElement, figure: HTMLElement) {
394- const isSVG = this.#isSVGImage(image)
395-
396- const imageRect = image.getBoundingClientRect()
397- const figureRect = figure.getBoundingClientRect()
398-
399- // Zoom SVGs at the figure's size, not the image's size.
400- const zoomedHeight = isSVG ? figureRect.height : image.naturalHeight
401- const zoomedWidth = isSVG ? figureRect.width : image.naturalWidth
402-
403- // Finds out the scale so that the zoomed image fits within the figure element.
404- const scaleX = Math.min(Math.max(imageRect.width, zoomedWidth), figureRect.width) / imageRect.width
405- const scaleY = Math.min(Math.max(imageRect.height, zoomedHeight), figureRect.height) / imageRect.height
406- const scale = Math.min(scaleX, scaleY)
407-
408- // Calculate the translation to center the zoomed image within the figure element.
409- const translateX = (-imageRect.left + (figureRect.width - imageRect.width) / 2 + figureRect.left) / scale
410- const translateY = (-imageRect.top + (figureRect.height - imageRect.height) / 2 + figureRect.top) / scale
411-
412- return `scale(${scale}) translate3d(${translateX}px, ${translateY}px, 0)`
413- }
414-
415428 #isSVGImage(image: HTMLImageElement) {
416429 return image.currentSrc.toLowerCase().endsWith('.svg')
417430 }
0 commit comments