mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-10 07:52:13 +00:00
Module frontend/src/i18n : le texte français sert de clé, dictionnaires anglais par zone, langue du navigateur puis choix mémorisé, ?lang=en pour forcer. Bascule dans le menu du compte, la pastille visiteur, le menu Plus, l'écran d'accès et la connexion. Démo traduite (noms et lieux gardés). Liens d'aide et de vie privée selon la langue. Textes français sans accents corrigés. Test : chaque texte passé à t() a sa traduction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
4.7 KiB
React
149 lines
4.7 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])
|
|
|
|
const panelVisible = isHovered || isMobile
|
|
|
|
return (
|
|
<div
|
|
className="zoom-control"
|
|
onMouseEnter={showSlider}
|
|
onMouseLeave={hideSlider}
|
|
>
|
|
{/* Panel slider — hover sur desktop, toujours visible sur mobile */}
|
|
<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('Contrôle du zoom')}
|
|
>
|
|
<ZoomIn size={20} strokeWidth={1.5} className="zoom-btn-icon" />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|