mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
1 line
31 KiB
JSON
1 line
31 KiB
JSON
{"name":"DomeGallery","title":"DomeGallery","description":"Immersive 3D dome gallery projecting images on a hemispheric surface.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<template>\n <div\n ref=\"rootRef\"\n class=\"relative w-full h-full box-border\"\n :style=\"{\n '--segments-x': segments,\n '--segments-y': segments,\n '--overlay-blur-color': overlayBlurColor,\n '--tile-radius': imageBorderRadius,\n '--enlarge-radius': openedImageBorderRadius,\n '--image-filter': grayscale ? 'grayscale(1)' : 'none',\n '--radius': '520px',\n '--viewer-pad': '72px',\n '--circ': 'calc(var(--radius) * 3.14)',\n '--rot-y': 'calc((360deg / var(--segments-x)) / 2)',\n '--rot-x': 'calc((360deg / var(--segments-y)) / 2)',\n '--item-width': 'calc(var(--circ) / var(--segments-x))',\n '--item-height': 'calc(var(--circ) / var(--segments-y))'\n }\"\n >\n <main\n ref=\"mainRef\"\n class=\"absolute inset-0 grid place-items-center overflow-hidden touch-none select-none bg-transparent\"\n >\n <div\n class=\"w-full h-full grid place-items-center contain-layout contain-paint contain-size\"\n :style=\"{\n perspective: 'calc(var(--radius) * 2)',\n perspectiveOrigin: '50% 50%'\n }\"\n >\n <div\n ref=\"sphereRef\"\n class=\"will-change-transform\"\n style=\"transform-style: preserve-3d; transform: translateZ(calc(var(--radius) * -1))\"\n >\n <div\n v-for=\"(item, i) in items\"\n :key=\"`${item.x},${item.y},${i}`\"\n class=\"absolute -top-[999px] -bottom-[999px] -left-[999px] -right-[999px] m-auto transition-transform duration-300\"\n :data-src=\"item.src\"\n :data-offset-x=\"item.x\"\n :data-offset-y=\"item.y\"\n :data-size-x=\"item.sizeX\"\n :data-size-y=\"item.sizeY\"\n :style=\"{\n '--offset-x': item.x,\n '--offset-y': item.y,\n '--item-size-x': item.sizeX,\n '--item-size-y': item.sizeY,\n width: 'calc(var(--item-width) * var(--item-size-x))',\n height: 'calc(var(--item-height) * var(--item-size-y))',\n transformStyle: 'preserve-3d',\n transformOrigin: '50% 50%',\n backfaceVisibility: 'hidden',\n transform: `rotateY(calc(var(--rot-y) * (var(--offset-x) + ((var(--item-size-x) - 1) / 2)) + var(--rot-y-delta, 0deg))) rotateX(calc(var(--rot-x) * (var(--offset-y) - ((var(--item-size-y) - 1) / 2)) + var(--rot-x-delta, 0deg))) translateZ(var(--radius))`\n }\"\n >\n <div\n class=\"absolute block inset-[10px] bg-transparent overflow-hidden transition-transform duration-300 cursor-pointer pointer-events-auto transform translate-z-0 focus:outline-none\"\n role=\"button\"\n tabindex=\"0\"\n :aria-label=\"item.alt || 'Open image'\"\n @click=\"onTileClick\"\n @pointerup=\"onTilePointerUp\"\n @touchend=\"onTileTouchEnd\"\n :style=\"{\n borderRadius: 'var(--tile-radius, 12px)',\n transformStyle: 'preserve-3d',\n backfaceVisibility: 'hidden',\n touchAction: 'manipulation',\n WebkitTapHighlightColor: 'transparent',\n WebkitTransform: 'translateZ(0)'\n }\"\n >\n <img\n :src=\"item.src\"\n draggable=\"false\"\n :alt=\"item.alt\"\n class=\"w-full h-full object-cover pointer-events-none\"\n :style=\"{\n backfaceVisibility: 'hidden',\n filter: 'var(--image-filter, none)'\n }\"\n />\n </div>\n </div>\n </div>\n </div>\n\n <div\n class=\"absolute inset-0 m-auto z-[3] pointer-events-none\"\n :style=\"{\n backgroundImage: 'radial-gradient(rgba(235, 235, 235, 0) 65%, var(--overlay-blur-color, #060010) 100%)'\n }\"\n />\n <div\n class=\"absolute inset-0 m-auto z-[3] pointer-events-none\"\n :style=\"{\n WebkitMaskImage: 'radial-gradient(rgba(235, 235, 235, 0) 70%, var(--overlay-blur-color, #060010) 90%)',\n maskImage: 'radial-gradient(rgba(235, 235, 235, 0) 70%, var(--overlay-blur-color, #060010) 90%)',\n backdropFilter: 'blur(3px)'\n }\"\n />\n <div\n class=\"absolute left-0 right-0 h-[120px] z-[5] pointer-events-none top-0 rotate-180\"\n :style=\"{\n background: 'linear-gradient(to bottom, transparent, var(--overlay-blur-color, #060010))'\n }\"\n />\n <div\n class=\"absolute left-0 right-0 h-[120px] z-[5] pointer-events-none bottom-0\"\n :style=\"{\n background: 'linear-gradient(to bottom, transparent, var(--overlay-blur-color, #060010))'\n }\"\n />\n\n <div\n ref=\"viewerRef\"\n class=\"absolute inset-0 z-20 pointer-events-none flex items-center justify-center\"\n :style=\"{ padding: 'var(--viewer-pad)' }\"\n >\n <div\n ref=\"scrimRef\"\n class=\"absolute inset-0 z-10 bg-black/40 pointer-events-none opacity-0 transition-opacity duration-500 ease-linear\"\n :style=\"{ backdropFilter: 'blur(3px)' }\"\n />\n <div\n ref=\"frameRef\"\n class=\"h-full aspect-square flex max-[1/1]:h-auto max-[1/1]:w-full\"\n :style=\"{ borderRadius: 'var(--enlarge-radius, 32px)' }\"\n />\n </div>\n </main>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted, computed, ref, useTemplateRef, watch } from 'vue';\n\ninterface ImageItem {\n src: string;\n alt?: string;\n}\n\ninterface DomeGalleryProps {\n images?: (string | ImageItem)[];\n fit?: number;\n fitBasis?: 'auto' | 'min' | 'max' | 'width' | 'height';\n minRadius?: number;\n maxRadius?: number;\n padFactor?: number;\n overlayBlurColor?: string;\n maxVerticalRotationDeg?: number;\n dragSensitivity?: number;\n enlargeTransitionMs?: number;\n segments?: number;\n dragDampening?: number;\n openedImageWidth?: string;\n openedImageHeight?: string;\n imageBorderRadius?: string;\n openedImageBorderRadius?: string;\n grayscale?: boolean;\n}\n\nconst DEFAULT_IMAGES: ImageItem[] = [\n {\n src: 'https://images.unsplash.com/photo-1755331039789-7e5680e26e8f?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',\n alt: 'Abstract art'\n },\n {\n src: 'https://images.unsplash.com/photo-1755569309049-98410b94f66d?q=80&w=772&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',\n alt: 'Modern sculpture'\n },\n {\n src: 'https://images.unsplash.com/photo-1755497595318-7e5e3523854f?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',\n alt: 'Digital artwork'\n },\n {\n src: 'https://images.unsplash.com/photo-1755353985163-c2a0fe5ac3d8?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',\n alt: 'Contemporary art'\n },\n {\n src: 'https://images.unsplash.com/photo-1745965976680-d00be7dc0377?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',\n alt: 'Geometric pattern'\n },\n {\n src: 'https://images.unsplash.com/photo-1752588975228-21f44630bb3c?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',\n alt: 'Textured surface'\n },\n { src: 'https://pbs.twimg.com/media/Gyla7NnXMAAXSo_?format=jpg&name=large', alt: 'Social media image' }\n];\n\nconst AUTO_ROTATE_SPEED_DEG_PER_MS = 0.008;\n\nconst props = withDefaults(defineProps<DomeGalleryProps>(), {\n fit: 0.5,\n fitBasis: 'auto',\n minRadius: 600,\n maxRadius: Infinity,\n padFactor: 0.25,\n overlayBlurColor: '#060010',\n maxVerticalRotationDeg: 5,\n dragSensitivity: 20,\n enlargeTransitionMs: 300,\n segments: 35,\n dragDampening: 2,\n openedImageWidth: '400px',\n openedImageHeight: '400px',\n imageBorderRadius: '30px',\n openedImageBorderRadius: '30px',\n grayscale: true\n});\n\n// Use computed to provide default images if none provided\nconst imagesSource = computed(() => props.images || DEFAULT_IMAGES);\n\n// Template refs\nconst rootRef = useTemplateRef<HTMLDivElement>('rootRef');\nconst mainRef = useTemplateRef<HTMLElement>('mainRef');\nconst sphereRef = useTemplateRef<HTMLDivElement>('sphereRef');\nconst viewerRef = useTemplateRef<HTMLDivElement>('viewerRef');\nconst scrimRef = useTemplateRef<HTMLDivElement>('scrimRef');\nconst frameRef = useTemplateRef<HTMLDivElement>('frameRef');\n\n// State refs\nconst rotation = ref({ x: 0, y: 0 });\nconst startRotation = ref({ x: 0, y: 0 });\nconst startPosition = ref<{ x: number; y: number } | null>(null);\nconst isDragging = ref(false);\nconst hasMoved = ref(false);\nconst isOpening = ref(false);\nconst focusedElement = ref<HTMLElement | null>(null);\nconst originalTilePosition = ref<DOMRect | null>(null);\nconst scrollLocked = ref(false);\nconst openStartedAt = ref(0);\nconst lastDragEndAt = ref(0);\n\nlet inertiaAnimationFrame: number | null = null;\nlet resizeObserver: ResizeObserver | null = null;\nlet keydownHandler: ((e: KeyboardEvent) => void) | null = null;\nlet autoRotateAnimationFrame: number | null = null;\nlet lastAutoRotateTime = 0;\n\n// Utility functions\nconst clamp = (v: number, min: number, max: number): number => Math.min(Math.max(v, min), max);\nconst normalizeAngle = (d: number): number => ((d % 360) + 360) % 360;\nconst wrapAngleSigned = (deg: number): number => {\n const a = (((deg + 180) % 360) + 360) % 360;\n return a - 180;\n};\n\nconst getDataNumber = (el: HTMLElement, name: string, fallback: number): number => {\n const attr = el.dataset[name] ?? el.getAttribute(`data-${name}`);\n const n = attr == null ? NaN : parseFloat(attr);\n return Number.isFinite(n) ? n : fallback;\n};\n\n// Build items function\nfunction buildItems(pool: (string | ImageItem)[], seg: number) {\n const xCols = Array.from({ length: seg }, (_, i) => -37 + i * 2);\n const evenYs = [-4, -2, 0, 2, 4];\n const oddYs = [-3, -1, 1, 3, 5];\n\n const coords = xCols.flatMap((x, c) => {\n const ys = c % 2 === 0 ? evenYs : oddYs;\n return ys.map(y => ({ x, y, sizeX: 2, sizeY: 2 }));\n });\n\n const totalSlots = coords.length;\n if (pool.length === 0) {\n return coords.map(c => ({ ...c, src: '', alt: '' }));\n }\n if (pool.length > totalSlots) {\n console.warn(\n `[DomeGallery] Provided image count (${pool.length}) exceeds available tiles (${totalSlots}). Some images will not be shown.`\n );\n }\n\n const normalizedImages = pool.map(image => {\n if (typeof image === 'string') {\n return { src: image, alt: '' };\n }\n return { src: image.src || '', alt: image.alt || '' };\n });\n\n const usedImages = Array.from({ length: totalSlots }, (_, i) => normalizedImages[i % normalizedImages.length]);\n\n // Shuffle to avoid adjacent duplicates\n for (let i = 1; i < usedImages.length; i++) {\n if (usedImages[i].src === usedImages[i - 1].src) {\n for (let j = i + 1; j < usedImages.length; j++) {\n if (usedImages[j].src !== usedImages[i].src) {\n const tmp = usedImages[i];\n usedImages[i] = usedImages[j];\n usedImages[j] = tmp;\n break;\n }\n }\n }\n }\n\n return coords.map((c, i) => ({\n ...c,\n src: usedImages[i].src,\n alt: usedImages[i].alt\n }));\n}\n\n// Compute items\nconst items = computed(() => buildItems(imagesSource.value, props.segments));\n\n// Compute item base rotation\nfunction computeItemBaseRotation(offsetX: number, offsetY: number, sizeX: number, sizeY: number, segments: number) {\n const unit = 360 / segments / 2;\n const rotateY = unit * (offsetX + (sizeX - 1) / 2);\n const rotateX = unit * (offsetY - (sizeY - 1) / 2);\n return { rotateX, rotateY };\n}\n\n// Apply transform\nconst applyTransform = (xDeg: number, yDeg: number) => {\n const el = sphereRef.value;\n if (el) {\n el.style.transform = `translateZ(calc(var(--radius) * -1)) rotateX(${xDeg}deg) rotateY(${yDeg}deg)`;\n }\n};\n\n// Scroll lock functions\nconst lockScroll = () => {\n if (scrollLocked.value) return;\n scrollLocked.value = true;\n document.body.classList.add('overflow-hidden');\n};\n\nconst unlockScroll = () => {\n if (!scrollLocked.value) return;\n if (rootRef.value?.getAttribute('data-enlarging') === 'true') return;\n scrollLocked.value = false;\n document.body.classList.remove('overflow-hidden');\n};\n\n// Inertia functions\nconst stopInertia = () => {\n if (inertiaAnimationFrame) {\n cancelAnimationFrame(inertiaAnimationFrame);\n inertiaAnimationFrame = null;\n }\n};\n\nconst startInertia = (vx: number, vy: number) => {\n const MAX_V = 1.4;\n let vX = clamp(vx, -MAX_V, MAX_V) * 80;\n let vY = clamp(vy, -MAX_V, MAX_V) * 80;\n let frames = 0;\n const d = clamp(props.dragDampening ?? 0.6, 0, 1);\n const frictionMul = 0.94 + 0.055 * d;\n const stopThreshold = 0.015 - 0.01 * d;\n const maxFrames = Math.round(90 + 270 * d);\n\n const step = () => {\n vX *= frictionMul;\n vY *= frictionMul;\n if (Math.abs(vX) < stopThreshold && Math.abs(vY) < stopThreshold) {\n inertiaAnimationFrame = null;\n return;\n }\n if (++frames > maxFrames) {\n inertiaAnimationFrame = null;\n return;\n }\n const nextX = clamp(rotation.value.x - vY / 200, -props.maxVerticalRotationDeg, props.maxVerticalRotationDeg);\n const nextY = wrapAngleSigned(rotation.value.y + vX / 200);\n rotation.value = { x: nextX, y: nextY };\n applyTransform(nextX, nextY);\n inertiaAnimationFrame = requestAnimationFrame(step);\n };\n\n stopInertia();\n inertiaAnimationFrame = requestAnimationFrame(step);\n};\n\nconst stopAutoRotate = () => {\n if (autoRotateAnimationFrame) {\n cancelAnimationFrame(autoRotateAnimationFrame);\n autoRotateAnimationFrame = null;\n }\n lastAutoRotateTime = 0;\n};\n\nconst autoRotateStep = (now: number) => {\n if (!lastAutoRotateTime) {\n lastAutoRotateTime = now;\n }\n const deltaMs = now - lastAutoRotateTime;\n lastAutoRotateTime = now;\n\n const canSpin = !isDragging.value && !isOpening.value && !focusedElement.value && inertiaAnimationFrame === null;\n\n if (canSpin && deltaMs > 0) {\n const nextY = wrapAngleSigned(rotation.value.y + deltaMs * AUTO_ROTATE_SPEED_DEG_PER_MS);\n if (nextY !== rotation.value.y) {\n rotation.value = { x: rotation.value.x, y: nextY };\n }\n }\n\n autoRotateAnimationFrame = requestAnimationFrame(autoRotateStep);\n};\n\nconst startAutoRotate = () => {\n if (autoRotateAnimationFrame !== null) return;\n lastAutoRotateTime = 0;\n autoRotateAnimationFrame = requestAnimationFrame(autoRotateStep);\n};\n\n// Gesture handling\nconst onDragStart = (e: MouseEvent | TouchEvent) => {\n if (focusedElement.value) return;\n stopInertia();\n\n isDragging.value = true;\n hasMoved.value = false;\n startRotation.value = { ...rotation.value };\n\n const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;\n const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;\n startPosition.value = { x: clientX, y: clientY };\n};\n\nconst onDragMove = (e: MouseEvent | TouchEvent) => {\n if (focusedElement.value || !isDragging.value || !startPosition.value) return;\n\n const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;\n const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;\n\n const dxTotal = clientX - startPosition.value.x;\n const dyTotal = clientY - startPosition.value.y;\n\n if (!hasMoved.value) {\n const dist2 = dxTotal * dxTotal + dyTotal * dyTotal;\n if (dist2 > 16) hasMoved.value = true;\n }\n\n const nextX = clamp(\n startRotation.value.x - dyTotal / props.dragSensitivity,\n -props.maxVerticalRotationDeg,\n props.maxVerticalRotationDeg\n );\n const nextY = wrapAngleSigned(startRotation.value.y + dxTotal / props.dragSensitivity);\n\n if (rotation.value.x !== nextX || rotation.value.y !== nextY) {\n rotation.value = { x: nextX, y: nextY };\n applyTransform(nextX, nextY);\n }\n};\n\nconst onDragEnd = (e: MouseEvent | TouchEvent) => {\n if (!isDragging.value) return;\n\n isDragging.value = false;\n\n // Calculate velocity for inertia (simplified version)\n if (hasMoved.value && startPosition.value) {\n const clientX = 'touches' in e ? (e.changedTouches?.[0]?.clientX ?? 0) : e.clientX;\n const clientY = 'touches' in e ? (e.changedTouches?.[0]?.clientY ?? 0) : e.clientY;\n\n const dxTotal = clientX - startPosition.value.x;\n const dyTotal = clientY - startPosition.value.y;\n\n // Simple velocity calculation based on total movement\n const vx = clamp((dxTotal / props.dragSensitivity) * 0.02, -1.2, 1.2);\n const vy = clamp((dyTotal / props.dragSensitivity) * 0.02, -1.2, 1.2);\n\n if (Math.abs(vx) > 0.005 || Math.abs(vy) > 0.005) {\n startInertia(vx, vy);\n }\n\n lastDragEndAt.value = performance.now();\n }\n\n hasMoved.value = false;\n};\n\n// Image enlargement functionality\nconst openItemFromElement = (el: HTMLElement) => {\n if (isOpening.value) return;\n isOpening.value = true;\n openStartedAt.value = performance.now();\n lockScroll();\n\n const parent = el.parentElement;\n if (!parent) return;\n\n focusedElement.value = el;\n el.setAttribute('data-focused', 'true');\n\n const offsetX = getDataNumber(parent, 'offsetX', 0);\n const offsetY = getDataNumber(parent, 'offsetY', 0);\n const sizeX = getDataNumber(parent, 'sizeX', 2);\n const sizeY = getDataNumber(parent, 'sizeY', 2);\n\n const parentRot = computeItemBaseRotation(offsetX, offsetY, sizeX, sizeY, props.segments);\n const parentY = normalizeAngle(parentRot.rotateY);\n const globalY = normalizeAngle(rotation.value.y);\n let rotY = -(parentY + globalY) % 360;\n if (rotY < -180) rotY += 360;\n const rotX = -parentRot.rotateX - rotation.value.x;\n\n parent.style.setProperty('--rot-y-delta', `${rotY}deg`);\n parent.style.setProperty('--rot-x-delta', `${rotX}deg`);\n\n const refDiv = document.createElement('div');\n refDiv.className = 'item__image item__image--reference';\n refDiv.style.opacity = '0';\n refDiv.style.transform = `rotateX(${-parentRot.rotateX}deg) rotateY(${-parentRot.rotateY}deg)`;\n parent.appendChild(refDiv);\n\n const tileR = refDiv.getBoundingClientRect();\n const mainR = mainRef.value?.getBoundingClientRect();\n const frameR = frameRef.value?.getBoundingClientRect();\n\n if (!mainR || !frameR) return;\n\n originalTilePosition.value = {\n left: tileR.left,\n top: tileR.top,\n width: tileR.width,\n height: tileR.height\n } as DOMRect;\n\n el.style.visibility = 'hidden';\n el.style.zIndex = '0';\n\n const overlay = document.createElement('div');\n overlay.className = 'enlarge';\n overlay.style.position = 'absolute';\n overlay.style.left = `${frameR.left - mainR.left}px`;\n overlay.style.top = `${frameR.top - mainR.top}px`;\n overlay.style.width = `${frameR.width}px`;\n overlay.style.height = `${frameR.height}px`;\n overlay.style.opacity = '0';\n overlay.style.zIndex = '30';\n overlay.style.willChange = 'transform, opacity';\n overlay.style.transformOrigin = 'top left';\n overlay.style.transition = `transform ${props.enlargeTransitionMs}ms ease, opacity ${props.enlargeTransitionMs}ms ease`;\n\n const rawSrc = parent.dataset.src || el.querySelector('img')?.src || '';\n const img = document.createElement('img');\n img.src = rawSrc;\n overlay.appendChild(img);\n viewerRef.value?.appendChild(overlay);\n\n const tx0 = tileR.left - frameR.left;\n const ty0 = tileR.top - frameR.top;\n const sx0 = tileR.width / frameR.width;\n const sy0 = tileR.height / frameR.height;\n overlay.style.transform = `translate(${tx0}px, ${ty0}px) scale(${sx0}, ${sy0})`;\n\n requestAnimationFrame(() => {\n overlay.style.opacity = '1';\n overlay.style.transform = 'translate(0px, 0px) scale(1,1)';\n rootRef.value?.setAttribute('data-enlarging', 'true');\n scrimRef.value?.classList.add('opacity-100', 'pointer-events-auto');\n scrimRef.value?.classList.remove('opacity-0', 'pointer-events-none');\n });\n\n const wantsResize = props.openedImageWidth || props.openedImageHeight;\n if (wantsResize) {\n const onFirstEnd = (ev: TransitionEvent) => {\n if (ev.propertyName !== 'transform') return;\n overlay.removeEventListener('transitionend', onFirstEnd);\n const prevTransition = overlay.style.transition;\n overlay.style.transition = 'none';\n const tempWidth = props.openedImageWidth || `${frameR.width}px`;\n const tempHeight = props.openedImageHeight || `${frameR.height}px`;\n overlay.style.width = tempWidth;\n overlay.style.height = tempHeight;\n const newRect = overlay.getBoundingClientRect();\n overlay.style.width = `${frameR.width}px`;\n overlay.style.height = `${frameR.height}px`;\n void overlay.offsetWidth;\n overlay.style.transition = `left ${props.enlargeTransitionMs}ms ease, top ${props.enlargeTransitionMs}ms ease, width ${props.enlargeTransitionMs}ms ease, height ${props.enlargeTransitionMs}ms ease`;\n const centeredLeft = frameR.left - mainR.left + (frameR.width - newRect.width) / 2;\n const centeredTop = frameR.top - mainR.top + (frameR.height - newRect.height) / 2;\n requestAnimationFrame(() => {\n overlay.style.left = `${centeredLeft}px`;\n overlay.style.top = `${centeredTop}px`;\n overlay.style.width = tempWidth;\n overlay.style.height = tempHeight;\n });\n const cleanupSecond = () => {\n overlay.removeEventListener('transitionend', cleanupSecond);\n overlay.style.transition = prevTransition;\n };\n overlay.addEventListener('transitionend', cleanupSecond, { once: true });\n };\n overlay.addEventListener('transitionend', onFirstEnd);\n }\n};\n\nconst closeEnlargedImage = () => {\n if (performance.now() - openStartedAt.value < 250) return;\n const el = focusedElement.value;\n if (!el) return;\n const parent = el.parentElement;\n const overlay = viewerRef.value?.querySelector('.enlarge') as HTMLElement;\n if (!overlay || !parent) return;\n const refDiv = parent.querySelector('.item__image--reference');\n const originalPos = originalTilePosition.value;\n\n if (!originalPos) {\n overlay.remove();\n if (refDiv) refDiv.remove();\n parent.style.setProperty('--rot-y-delta', '0deg');\n parent.style.setProperty('--rot-x-delta', '0deg');\n el.style.visibility = '';\n el.style.zIndex = '0';\n focusedElement.value = null;\n rootRef.value?.removeAttribute('data-enlarging');\n scrimRef.value?.classList.add('opacity-0', 'pointer-events-none');\n scrimRef.value?.classList.remove('opacity-100', 'pointer-events-auto');\n isOpening.value = false;\n unlockScroll();\n return;\n }\n\n const currentRect = overlay.getBoundingClientRect();\n const rootRect = rootRef.value?.getBoundingClientRect();\n if (!rootRect) return;\n\n const originalPosRelativeToRoot = {\n left: originalPos.left - rootRect.left,\n top: originalPos.top - rootRect.top,\n width: originalPos.width,\n height: originalPos.height\n };\n\n const overlayRelativeToRoot = {\n left: currentRect.left - rootRect.left,\n top: currentRect.top - rootRect.top,\n width: currentRect.width,\n height: currentRect.height\n };\n\n const animatingOverlay = document.createElement('div');\n animatingOverlay.className = 'enlarge-closing';\n animatingOverlay.style.cssText = `position:absolute;left:${overlayRelativeToRoot.left}px;top:${overlayRelativeToRoot.top}px;width:${overlayRelativeToRoot.width}px;height:${overlayRelativeToRoot.height}px;z-index:9999;border-radius: var(--enlarge-radius, 32px);overflow:hidden;box-shadow:0 10px 30px rgba(0,0,0,.35);transition:all ${props.enlargeTransitionMs}ms ease-out;pointer-events:none;margin:0;transform:none;`;\n\n const originalImg = overlay.querySelector('img');\n if (originalImg) {\n const img = originalImg.cloneNode() as HTMLImageElement;\n img.style.cssText = 'width:100%;height:100%;object-fit:cover;';\n animatingOverlay.appendChild(img);\n }\n\n overlay.remove();\n rootRef.value?.appendChild(animatingOverlay);\n void animatingOverlay.getBoundingClientRect();\n\n requestAnimationFrame(() => {\n animatingOverlay.style.left = `${originalPosRelativeToRoot.left}px`;\n animatingOverlay.style.top = `${originalPosRelativeToRoot.top}px`;\n animatingOverlay.style.width = `${originalPosRelativeToRoot.width}px`;\n animatingOverlay.style.height = `${originalPosRelativeToRoot.height}px`;\n animatingOverlay.style.opacity = '0';\n });\n\n const cleanup = () => {\n animatingOverlay.remove();\n originalTilePosition.value = null;\n if (refDiv) refDiv.remove();\n parent.style.transition = 'none';\n el.style.transition = 'none';\n parent.style.setProperty('--rot-y-delta', '0deg');\n parent.style.setProperty('--rot-x-delta', '0deg');\n requestAnimationFrame(() => {\n el.style.visibility = '';\n el.style.opacity = '0';\n el.style.zIndex = '0';\n focusedElement.value = null;\n rootRef.value?.removeAttribute('data-enlarging');\n scrimRef.value?.classList.add('opacity-0', 'pointer-events-none');\n scrimRef.value?.classList.remove('opacity-100', 'pointer-events-auto');\n requestAnimationFrame(() => {\n parent.style.transition = '';\n el.style.transition = 'opacity 300ms ease-out';\n requestAnimationFrame(() => {\n el.style.opacity = '1';\n setTimeout(() => {\n el.style.transition = '';\n el.style.opacity = '';\n isOpening.value = false;\n unlockScroll();\n }, 300);\n });\n });\n });\n };\n\n animatingOverlay.addEventListener('transitionend', cleanup, { once: true });\n};\n\n// Event handlers for tile interaction\nconst onTileClick = (e: Event) => {\n if (isDragging.value) return;\n if (performance.now() - lastDragEndAt.value < 80) return;\n if (isOpening.value) return;\n openItemFromElement(e.currentTarget as HTMLElement);\n};\n\nconst onTilePointerUp = (e: PointerEvent) => {\n if (e.pointerType !== 'touch') return;\n if (isDragging.value) return;\n if (performance.now() - lastDragEndAt.value < 80) return;\n if (isOpening.value) return;\n openItemFromElement(e.currentTarget as HTMLElement);\n};\n\nconst onTileTouchEnd = (e: TouchEvent) => {\n if (isDragging.value) return;\n if (performance.now() - lastDragEndAt.value < 80) return;\n if (isOpening.value) return;\n openItemFromElement(e.currentTarget as HTMLElement);\n};\n\n// Setup ResizeObserver and event listeners\nonMounted(() => {\n // Initialize transform\n applyTransform(rotation.value.x, rotation.value.y);\n startAutoRotate();\n\n // Setup ResizeObserver\n const root = rootRef.value;\n const main = mainRef.value;\n if (!root || !main) return;\n\n resizeObserver = new ResizeObserver(entries => {\n const cr = entries[0].contentRect;\n const w = Math.max(1, cr.width);\n const h = Math.max(1, cr.height);\n const minDim = Math.min(w, h);\n const maxDim = Math.max(w, h);\n const aspect = w / h;\n\n let basis: number;\n switch (props.fitBasis) {\n case 'min':\n basis = minDim;\n break;\n case 'max':\n basis = maxDim;\n break;\n case 'width':\n basis = w;\n break;\n case 'height':\n basis = h;\n break;\n default:\n basis = aspect >= 1.3 ? w : minDim;\n }\n\n let radius = basis * props.fit;\n const heightGuard = h * 1.35;\n radius = Math.min(radius, heightGuard);\n radius = clamp(radius, props.minRadius, props.maxRadius);\n\n const viewerPad = Math.max(8, Math.round(minDim * props.padFactor));\n const roundedRadius = Math.round(radius);\n\n root.style.setProperty('--radius', `${roundedRadius}px`);\n root.style.setProperty('--viewer-pad', `${viewerPad}px`);\n\n const overlay = viewerRef.value?.querySelector('.enlarge') as HTMLElement | null;\n if (overlay && frameRef.value && mainRef.value) {\n const frameR = frameRef.value.getBoundingClientRect();\n const mainR = mainRef.value.getBoundingClientRect();\n\n if (props.openedImageWidth && props.openedImageHeight) {\n const tempDiv = document.createElement('div');\n tempDiv.style.cssText = `position:absolute;visibility:hidden;width:${props.openedImageWidth};height:${props.openedImageHeight};pointer-events:none;`;\n document.body.appendChild(tempDiv);\n const tempRect = tempDiv.getBoundingClientRect();\n document.body.removeChild(tempDiv);\n\n const centeredLeft = frameR.left - mainR.left + (frameR.width - tempRect.width) / 2;\n const centeredTop = frameR.top - mainR.top + (frameR.height - tempRect.height) / 2;\n overlay.style.left = `${centeredLeft}px`;\n overlay.style.top = `${centeredTop}px`;\n overlay.style.width = props.openedImageWidth;\n overlay.style.height = props.openedImageHeight;\n } else {\n overlay.style.left = `${frameR.left - mainR.left}px`;\n overlay.style.top = `${frameR.top - mainR.top}px`;\n overlay.style.width = `${frameR.width}px`;\n overlay.style.height = `${frameR.height}px`;\n }\n }\n });\n\n resizeObserver.observe(root);\n\n // Add gesture event listeners\n main.addEventListener('mousedown', onDragStart, { passive: true });\n main.addEventListener('touchstart', onDragStart, { passive: true });\n\n window.addEventListener('mousemove', onDragMove, { passive: true });\n window.addEventListener('touchmove', onDragMove, { passive: true });\n\n window.addEventListener('mouseup', onDragEnd);\n window.addEventListener('touchend', onDragEnd);\n\n // Add enlargement event listeners\n const scrim = scrimRef.value;\n if (scrim) {\n scrim.addEventListener('click', closeEnlargedImage);\n }\n\n keydownHandler = (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n closeEnlargedImage();\n }\n };\n window.addEventListener('keydown', keydownHandler);\n});\n\n// Cleanup on unmount\nonUnmounted(() => {\n stopInertia();\n stopAutoRotate();\n if (resizeObserver) {\n resizeObserver.disconnect();\n }\n\n // Remove event listeners\n const main = mainRef.value;\n const scrim = scrimRef.value;\n\n if (main) {\n main.removeEventListener('mousedown', onDragStart);\n main.removeEventListener('touchstart', onDragStart);\n }\n\n if (scrim) {\n scrim.removeEventListener('click', closeEnlargedImage);\n }\n\n window.removeEventListener('mousemove', onDragMove);\n window.removeEventListener('touchmove', onDragMove);\n window.removeEventListener('mouseup', onDragEnd);\n window.removeEventListener('touchend', onDragEnd);\n\n if (keydownHandler) {\n window.removeEventListener('keydown', keydownHandler);\n }\n\n document.body.classList.remove('overflow-hidden');\n});\n\n// Watch for rotation changes\nwatch(rotation, newRotation => {\n applyTransform(newRotation.x, newRotation.y);\n});\n</script>\n","path":"DomeGallery/DomeGallery.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[],"devDependencies":[],"categories":["Components"]} |