Files
parenthese/frontend/src/components/ZoomControl.jsx
T
IliesAllaliandClaude Opus 5.5 f595af581a Téléphone : plus de réglette de zoom, la loupe seule reste (on zoome à deux doigts) (#199)
La notification n'a plus à se décaler pour l'éviter.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:35:38 +01:00

150 lines
4.8 KiB
React

import { useRef, useCallback, useEffect, useState } from 'react'
import { ZoomIn, ZoomOut } from 'lucide-react'
import { t } from '../i18n/index.js'
import './ZoomControl.css'
const TRACK_HEIGHT = 120
const THUMB_SIZE = 14
// Paliers de zoom au clic (step 0 = fit via resetZoom)
const ZOOM_STEPS = [1.0, 1.8, 2.8]
export default function ZoomControl({ zoomApiRef }) {
const [scale, setScale] = useState(0.25)
// Bornes de zoom de Galaxy, relevées dans la boucle rAF : lire zoomApiRef.current pendant le rendu
// est signalé par react-hooks/refs
const [bounds, setBounds] = useState({ min: 0.25, max: 3.2 })
const [isHovered, setIsHovered] = useState(false)
const [isMobile, setIsMobile] = useState(() =>
typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches
)
const sliderTrackRef = useRef(null)
const hideTimerRef = useRef(null)
const isSliderDraggingRef = useRef(false)
useEffect(() => {
const mql = window.matchMedia('(max-width: 768px)')
const handler = (e) => setIsMobile(e.matches)
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [])
// Poll scale depuis la boucle RAF de Galaxy
useEffect(() => {
let rafId
const tick = () => {
const api = zoomApiRef?.current
const s = api?.getScale?.()
if (s !== undefined) {
setScale(prev => Math.abs(prev - s) > 0.004 ? s : prev)
}
const min = api?.MIN_SCALE ?? 0.25
const max = api?.MAX_SCALE ?? 3.2
setBounds(prev => (prev.min === min && prev.max === max ? prev : { min, max }))
rafId = requestAnimationFrame(tick)
}
rafId = requestAnimationFrame(tick)
return () => cancelAnimationFrame(rafId)
}, [zoomApiRef])
// Valeurs dérivées
const MIN_SCALE = bounds.min
const MAX_SCALE = bounds.max
const sliderRatio = Math.max(0, Math.min(1, (scale - MIN_SCALE) / (MAX_SCALE - MIN_SCALE)))
const thumbCenter = (THUMB_SIZE / 2) + sliderRatio * (TRACK_HEIGHT - THUMB_SIZE)
// Hover (desktop)
const showSlider = useCallback(() => {
clearTimeout(hideTimerRef.current)
setIsHovered(true)
}, [])
const hideSlider = useCallback(() => {
hideTimerRef.current = setTimeout(() => {
if (!isSliderDraggingRef.current) setIsHovered(false)
}, 250)
}, [])
// Click = cycle paliers : step1 → step2 → step3 → fit → step1…
const stepZoom = useCallback(() => {
const api = zoomApiRef?.current
if (!api) return
const currentScale = api.getScale()
const next = ZOOM_STEPS.find(s => s > currentScale + 0.05)
if (next) {
api.zoomTo(next)
} else {
api.resetZoom()
}
}, [zoomApiRef])
// Slider pointer drag (fonctionne desktop et mobile via pointer events)
const handleSliderPointerDown = useCallback((e) => {
e.preventDefault()
isSliderDraggingRef.current = true
const applyPos = (ev) => {
const rect = sliderTrackRef.current?.getBoundingClientRect()
if (!rect) return
const ratio = 1 - (ev.clientY - rect.top) / rect.height
const clamped = Math.max(0, Math.min(1, ratio))
const api = zoomApiRef?.current
if (!api) return
api.zoomTo(api.MIN_SCALE + clamped * (api.MAX_SCALE - api.MIN_SCALE))
}
applyPos(e)
const onMove = (ev) => applyPos(ev)
const onUp = () => {
isSliderDraggingRef.current = false
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', onUp)
}
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
}, [zoomApiRef])
// Sur téléphone, on zoome à deux doigts : la réglette n'apparaît jamais, la loupe seule reste
const panelVisible = isHovered && !isMobile
return (
<div
className="zoom-control"
onMouseEnter={showSlider}
onMouseLeave={hideSlider}
>
{/* Panel slider — au survol sur ordinateur, jamais sur téléphone */}
<div
className={`zoom-slider-panel ${panelVisible ? 'visible' : ''}`}
onMouseEnter={showSlider}
onMouseLeave={hideSlider}
>
<ZoomIn size={13} strokeWidth={1.5} className="zoom-panel-icon" />
<div
ref={sliderTrackRef}
className="zoom-slider-track"
onPointerDown={handleSliderPointerDown}
>
<div className="zoom-slider-fill" style={{ height: thumbCenter }} />
<div
className="zoom-slider-thumb"
style={{ bottom: thumbCenter - THUMB_SIZE / 2 }}
/>
</div>
<ZoomOut size={13} strokeWidth={1.5} className="zoom-panel-icon" />
</div>
{/* Bouton principal */}
<button
className="zoom-button"
onClick={stepZoom}
aria-label={t('Zoomer')}
>
<ZoomIn size={20} strokeWidth={1.5} className="zoom-btn-icon" />
</button>
</div>
)
}