From 02a5bb40946ce7c1f150e3fd7e8d5592163e7169 Mon Sep 17 00:00:00 2001 From: Francesc Arpi Roca Date: Fri, 6 Nov 2026 07:32:37 +0100 Subject: [PATCH] fix(location): enable pinch-to-zoom on venue floor plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problema Los planos de la sede añadidos en #261 funcionaban con el ratón, pero en móvil no hacían nada: al tocar la imagen con el dedo se ampliaba la página entera (el zoom nativo del navegador) y las herramientas de zoom no respondían. El componente nunca tuvo soporte multitáctil. El script solo escuchaba `wheel` y `dblclick`, ambos de ratón, mientras que el CSS ponía `touch-action: none` sobre el viewport: se desactivaba el comportamiento nativo del navegador sin sustituirlo. Además `pointerdown` salía con `if (zoom <= MIN_ZOOM) return`, de modo que el arrastre con un dedo siempre se descartaba porque el plano empieza al 100%. ## Cambio Reescrita la capa de gestos de `src/components/FloorPlanLightbox.astro`: - **Pellizco con dos dedos**: zoom continuo entre 1x y 4x, anclado en el punto medio entre los dedos. - **Arrastre con un dedo**: mueve el plano, con límites para no revelar zonas vacías. - **Doble toque**: alterna entre 100% y 200%, centrado en el punto tocado. - Los eventos se escuchan en `window` en lugar de usar `setPointerCapture`, que se comporta mal en iOS Safari en cuanto hay más de un dedo. - `preventDefault()` sobre los eventos táctiles multi-dedo para que el navegador no haga su propio zoom de página. Se actualiza también el texto `location.floorplans.hint` en español, inglés y catalán, que hasta ahora solo mencionaba la rueda del ratón. ## Notas - El `dblclick` que Chrome sintetiza tras un doble toque se ignora cuando el evento viene de un toque: deshacía el zoom al 200% al instante. - La transformación es `translate(-50%,-50%) translate(offset) scale(zoom)`, así que el offset va en píxeles de pantalla y no se escala con el zoom. El anclaje se calcula sobre esa base (deriva medida: 0.00px). - El desplazamiento se limita en los ejes donde el plano ya es mayor que el viewport, para que nunca quede un hueco negro en pantalla. En los ejes donde el plano es más pequeño se mueve libre, que es lo que permite conservar el ancla durante el pellizco. - Sin dependencias nuevas. `package.json`: `1.17.28` → `1.17.29`. --- package.json | 2 +- src/components/FloorPlanLightbox.astro | 268 ++++++++++++++++++++++--- src/i18n/location/ca.ts | 2 +- src/i18n/location/en.ts | 3 +- src/i18n/location/es.ts | 2 +- 5 files changed, 243 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index e9f4d88..abfeb21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "2026.es.pycon.org", - "version": "1.17.28", + "version": "1.17.29", "description": "", "main": "index.js", "scripts": { diff --git a/src/components/FloorPlanLightbox.astro b/src/components/FloorPlanLightbox.astro index 90a203e..e618f0c 100644 --- a/src/components/FloorPlanLightbox.astro +++ b/src/components/FloorPlanLightbox.astro @@ -129,7 +129,11 @@ const dialogId = `floor-plan-dialog-${Math.random().toString(36).slice(2, 9)}` .floor-plan-viewport { cursor: grab; + /* The gestures are handled in JS: blocking the native ones keeps the + browser from zooming the page behind the dialog. */ touch-action: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; } .floor-plan-viewport.is-panning { @@ -150,6 +154,8 @@ const dialogId = `floor-plan-dialog-${Math.random().toString(36).slice(2, 9)}` const MIN_ZOOM = 1 const MAX_ZOOM = 4 const ZOOM_STEP = 0.5 + const DOUBLE_TAP_DELAY = 300 + const DOUBLE_TAP_SLOP = 30 const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) @@ -167,17 +173,57 @@ const dialogId = `floor-plan-dialog-${Math.random().toString(36).slice(2, 9)}` let zoom = MIN_ZOOM let offsetX = 0 let offsetY = 0 - let panning = false - let startX = 0 - let startY = 0 - let startOffsetX = 0 - let startOffsetY = 0 + + // Active touch/pointer points, keyed by pointerId. One point pans the + // plan, two points pinch-zoom it around the midpoint between them. + const pointers = new Map() + let gestureStartPoint: { x: number; y: number } | null = null + let gestureStartDistance = 0 + let gestureStartZoom = MIN_ZOOM + let gestureStartMidpoint: { x: number; y: number } | null = null + let gestureStartOffsetX = 0 + let gestureStartOffsetY = 0 + // Point of the plan that stays under the fingers while pinching, in + // coordinates relative to the centre of the unscaled image. + let gestureAnchorX = 0 + let gestureAnchorY = 0 + + // Double tap detection, used to toggle between the fitted view and 200%. + let lastTapTime = 0 + let lastTapX = 0 + let lastTapY = 0 + let doubleTapTimer: number | undefined + // Marks the moment a touch double tap was handled, so the native + // `dblclick` event that browsers synthesise afterwards can be ignored. + let touchDoubleTapAt = 0 const applyTransform = () => { image.style.transform = `translate(-50%, -50%) translate(${offsetX}px, ${offsetY}px) scale(${zoom})` zoomLevel.textContent = `${Math.round(zoom * 100)}%` } + // Keep the plan from being dragged away from the viewport. On the axes + // where the scaled plan is bigger than the viewport it is allowed to + // overflow equally on both sides, but never pulled so far that an edge of + // the viewport is left empty. On the axes where the plan is smaller it can + // be moved freely, otherwise the pinch gesture would lose its anchor. + const clampOffsets = () => { + const viewportWidth = viewport.clientWidth + const viewportHeight = viewport.clientHeight + const renderedWidth = image.offsetWidth * zoom + const renderedHeight = image.offsetHeight * zoom + + // When the plan is at least as big as the viewport on an axis it is + // pinned to that axis, so the viewport is never left empty. + const maxOffsetX = + renderedWidth >= viewportWidth ? (renderedWidth - viewportWidth) / 2 : viewportWidth / 2 + const maxOffsetY = + renderedHeight >= viewportHeight ? (renderedHeight - viewportHeight) / 2 : viewportHeight / 2 + + offsetX = clamp(offsetX, -maxOffsetX, maxOffsetX) + offsetY = clamp(offsetY, -maxOffsetY, maxOffsetY) + } + const fitToViewport = () => { if (!image) return const naturalWidth = image.naturalWidth || 1 @@ -199,6 +245,7 @@ const dialogId = `floor-plan-dialog-${Math.random().toString(36).slice(2, 9)}` zoom = MIN_ZOOM offsetX = 0 offsetY = 0 + pointers.clear() fitToViewport() applyTransform() } @@ -208,13 +255,20 @@ const dialogId = `floor-plan-dialog-${Math.random().toString(36).slice(2, 9)}` requestAnimationFrame(() => reset()) }) + // Zoom to `nextZoom` keeping the point of the plan that currently sits + // at (originX, originY) — in viewport coordinates — under that same + // place on the screen. const setZoom = (nextZoom: number, originX = 0, originY = 0) => { const clamped = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM) if (clamped === zoom) return - const factor = clamped / zoom - offsetX = originX - (originX - offsetX) * factor - offsetY = originY - (originY - offsetY) * factor + // Point of the plan under the origin, relative to the centre of the + // unscaled image. + const anchorX = (originX - viewport.clientWidth / 2 - offsetX) / zoom + const anchorY = (originY - viewport.clientHeight / 2 - offsetY) / zoom + offsetX = originX - viewport.clientWidth / 2 - anchorX * clamped + offsetY = originY - viewport.clientHeight / 2 - anchorY * clamped zoom = clamped + clampOffsets() applyTransform() } @@ -240,37 +294,188 @@ const dialogId = `floor-plan-dialog-${Math.random().toString(36).slice(2, 9)}` { passive: false }, ) - viewport.addEventListener('pointerdown', (event: PointerEvent) => { - if (zoom <= MIN_ZOOM) return - panning = true - startX = event.clientX - startY = event.clientY - startOffsetX = offsetX - startOffsetY = offsetY + const getPoint = (event: PointerEvent) => { + const rect = viewport.getBoundingClientRect() + return { x: event.clientX - rect.left, y: event.clientY - rect.top } + } + + const getMidpoint = () => { + const points = Array.from(pointers.values()) + if (points.length < 2) return null + return { + x: (points[0].x + points[1].x) / 2, + y: (points[0].y + points[1].y) / 2, + } + } + + const getDistance = () => { + const points = Array.from(pointers.values()) + if (points.length < 2) return 0 + return Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y) + } + + // Snapshot of the gesture, used as the reference frame for pan and pinch. + const startGesture = () => { + const points = Array.from(pointers.values()) + gestureStartPoint = points[0] ?? null + gestureStartMidpoint = points.length >= 2 ? getMidpoint() : null + gestureStartDistance = points.length >= 2 ? getDistance() : 0 + gestureStartZoom = zoom + gestureStartOffsetX = offsetX + gestureStartOffsetY = offsetY + + if (gestureStartMidpoint) { + // Remember which point of the plan sits under the fingers, in + // coordinates relative to the centre of the unscaled image, so it can + // be kept in place while the plan is scaled. + gestureAnchorX = (gestureStartMidpoint.x - viewport.clientWidth / 2 - offsetX) / zoom + gestureAnchorY = (gestureStartMidpoint.y - viewport.clientHeight / 2 - offsetY) / zoom + } + } + + const handlePointerDown = (event: PointerEvent) => { + if (event.pointerType === 'mouse' && event.button !== 0) return + + pointers.set(event.pointerId, getPoint(event)) viewport.classList.add('is-panning') - viewport.setPointerCapture(event.pointerId) - }) - viewport.addEventListener('pointermove', (event: PointerEvent) => { - if (!panning) return - offsetX = startOffsetX + (event.clientX - startX) - offsetY = startOffsetY + (event.clientY - startY) + // A second finger turns the gesture into a pinch, so the reference + // frame has to take both points into account. + startGesture() + if (pointers.size > 1) cancelDoubleTap() + } + + const handlePointerMove = (event: PointerEvent) => { + if (!pointers.has(event.pointerId)) return + pointers.set(event.pointerId, getPoint(event)) + + if (pointers.size >= 2) { + // Pinch: scale continuously around the midpoint between the fingers. + const midpoint = getMidpoint() + const distance = getDistance() + if (!midpoint || !gestureStartMidpoint || !gestureStartDistance) return + + const scaleFactor = distance / gestureStartDistance + const nextZoom = clamp(gestureStartZoom * scaleFactor, MIN_ZOOM, MAX_ZOOM) + + // Keep the point of the plan that was under the fingers under the + // fingers, following the midpoint as it moves. + offsetX = midpoint.x - viewport.clientWidth / 2 - gestureAnchorX * nextZoom + offsetY = midpoint.y - viewport.clientHeight / 2 - gestureAnchorY * nextZoom + zoom = nextZoom + clampOffsets() + applyTransform() + return + } + + // Single finger: pan the plan. + const point = pointers.get(event.pointerId) + if (!point || !gestureStartPoint) return + offsetX = gestureStartOffsetX + (point.x - gestureStartPoint.x) + offsetY = gestureStartOffsetY + (point.y - gestureStartPoint.y) + clampOffsets() applyTransform() - }) + } - const stopPanning = (event: PointerEvent) => { - if (!panning) return - panning = false - viewport.classList.remove('is-panning') - if (viewport.hasPointerCapture(event.pointerId)) { - viewport.releasePointerCapture(event.pointerId) + const handlePointerUp = (event: PointerEvent) => { + const point = pointers.get(event.pointerId) + pointers.delete(event.pointerId) + + if (pointers.size === 0) { + viewport.classList.remove('is-panning') + gestureStartPoint = null + gestureStartMidpoint = null + gestureStartDistance = 0 + if (event.pointerType !== 'mouse') { + handleTap(point, event) + } + } else { + // A finger was lifted mid-pinch: rebase the remaining gesture. + startGesture() } } - viewport.addEventListener('pointerup', stopPanning) - viewport.addEventListener('pointercancel', stopPanning) + // Double tap toggles between the fitted view and 200%, centred on the tap. + const handleTap = (point: { x: number; y: number } | undefined, event: PointerEvent) => { + if (!point) return + const now = Date.now() + const isDoubleTap = + now - lastTapTime < DOUBLE_TAP_DELAY && + Math.hypot(point.x - lastTapX, point.y - lastTapY) < DOUBLE_TAP_SLOP + + if (!isDoubleTap) { + lastTapTime = now + lastTapX = point.x + lastTapY = point.y + doubleTapTimer = window.setTimeout(() => { + doubleTapTimer = undefined + }, DOUBLE_TAP_DELAY) + return + } + + cancelDoubleTap() + lastTapTime = 0 + touchDoubleTapAt = Date.now() + if (zoom > MIN_ZOOM) { + setZoom(MIN_ZOOM, point.x, point.y) + } else { + setZoom(2, point.x, point.y) + } + event.preventDefault() + } + + const cancelDoubleTap = () => { + if (doubleTapTimer !== undefined) { + window.clearTimeout(doubleTapTimer) + doubleTapTimer = undefined + } + lastTapTime = 0 + } + + viewport.addEventListener('pointerdown', handlePointerDown) + // Listen on the window so the gesture keeps working when a finger slides + // outside of the viewport. Pointer capture is avoided on purpose: it + // misbehaves on iOS Safari once more than one pointer is involved. + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + window.addEventListener('pointercancel', handlePointerUp) + + // Block the browser's own pinch and scroll gestures on the plan so they do + // not zoom the page behind the dialog. + viewport.addEventListener( + 'touchstart', + (event: TouchEvent) => { + if (event.touches.length > 1) event.preventDefault() + }, + { passive: false }, + ) + viewport.addEventListener( + 'touchmove', + (event: TouchEvent) => { + if (event.touches.length > 1) event.preventDefault() + }, + { passive: false }, + ) + viewport.addEventListener( + 'touchend', + (event: TouchEvent) => { + if (event.touches.length > 0) event.preventDefault() + }, + { passive: false }, + ) viewport.addEventListener('dblclick', (event: MouseEvent) => { + // Browsers synthesise a `dblclick` after a touch double tap. That one + // is already handled by `handleTap`, so it must not undo the zoom. + const sourceCapabilities = ( + event as MouseEvent & { + sourceCapabilities?: { firesTouchEvents?: boolean } + } + ).sourceCapabilities + const isTouchGenerated = sourceCapabilities?.firesTouchEvents === true + if (isTouchGenerated || Date.now() - touchDoubleTapAt < 500) return + + event.preventDefault() const rect = viewport.getBoundingClientRect() const originX = event.clientX - rect.left const originY = event.clientY - rect.top @@ -289,6 +494,9 @@ const dialogId = `floor-plan-dialog-${Math.random().toString(36).slice(2, 9)}` zoom = MIN_ZOOM offsetX = 0 offsetY = 0 + pointers.clear() + cancelDoubleTap() + viewport.classList.remove('is-panning') applyTransform() }) } diff --git a/src/i18n/location/ca.ts b/src/i18n/location/ca.ts index 1e1068a..6494223 100644 --- a/src/i18n/location/ca.ts +++ b/src/i18n/location/ca.ts @@ -31,7 +31,7 @@ export const ca = { 'location.floorplans.zoomOut': 'Allunya', 'location.floorplans.reset': 'Encaixa', 'location.floorplans.hint': - 'Fes servir la roda del ratolí o els botons per fer zoom, i arrossega per moure-te.', + 'Fes servir la roda del ratolí o els botons per fer zoom, i arrossega o pessiga amb els dits per moure-te.', 'location.spaces.title': 'Espais Oberts', 'location.spaces.gallery.title': 'Galeria del Paranimf', 'location.spaces.gallery.desc': 'Ideal per a acreditacions i pòsters acadèmics (250 persones).', diff --git a/src/i18n/location/en.ts b/src/i18n/location/en.ts index 302bbda..96a1643 100644 --- a/src/i18n/location/en.ts +++ b/src/i18n/location/en.ts @@ -30,7 +30,8 @@ export const en = { 'location.floorplans.zoomIn': 'Zoom in', 'location.floorplans.zoomOut': 'Zoom out', 'location.floorplans.reset': 'Fit', - 'location.floorplans.hint': 'Use the mouse wheel or the buttons to zoom, and drag to pan.', + 'location.floorplans.hint': + 'Use the mouse wheel or the buttons to zoom, and drag or pinch with your fingers to pan.', 'location.spaces.title': 'Open Spaces', 'location.spaces.gallery.title': 'Paranimf Gallery', 'location.spaces.gallery.desc': 'Perfect for registrations and academic posters (250 people).', diff --git a/src/i18n/location/es.ts b/src/i18n/location/es.ts index 13ec524..bb93581 100644 --- a/src/i18n/location/es.ts +++ b/src/i18n/location/es.ts @@ -31,7 +31,7 @@ export const es = { 'location.floorplans.zoomOut': 'Alejar', 'location.floorplans.reset': 'Encajar', 'location.floorplans.hint': - 'Usa la rueda del ratón o los botones para hacer zoom, y arrastra para moverte.', + 'Usa la rueda del ratón o los botones para hacer zoom, y arrastra o pellizca con los dedos para moverte.', 'location.spaces.title': 'Espacios Abiertos', 'location.spaces.gallery.title': 'Galería del Paranimf', 'location.spaces.gallery.desc': 'Ideal para acreditaciones y pósters académicos (250 personas).',