Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Refactored metadata for contacts, administration, and Ulticards pages to utilize dynamic app names and descriptions. - Introduced new product pages for Ultiai, Ultical, Ulticards, Ultidrive, Ultimail, and Ultimeet with appropriate metadata. - Enhanced layout components to ensure consistent styling and functionality across new product sections. - Updated various components to replace hardcoded labels with dynamic references to improve maintainability and consistency.
140 lines
3.9 KiB
TypeScript
140 lines
3.9 KiB
TypeScript
"use client"
|
|
|
|
import { useLayoutEffect, useRef, useState } from "react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
export type ScaledPreviewFit = "contain" | "cover"
|
|
|
|
export type ScaledPreviewIframeProps = {
|
|
src: string
|
|
title: string
|
|
viewportWidth: number
|
|
viewportHeight: number
|
|
ready?: boolean
|
|
active?: boolean
|
|
/**
|
|
* contain = letterbox si le ratio parent ≠ ratio viewport ;
|
|
* cover = remplit (recadrage éventuel, seulement les coins si ratios proches).
|
|
* Idéalement le parent porte un aspect-ratio = viewportWidth/viewportHeight,
|
|
* auquel cas contain et cover sont équivalents (aucune coupe).
|
|
*/
|
|
fit?: ScaledPreviewFit
|
|
/** Débord de sécurité (px) en mode cover — évite les hairlines aux bords/coins. */
|
|
coverEpsilonPx?: number
|
|
maxScale?: number
|
|
className?: string
|
|
placeholderClassName?: string
|
|
}
|
|
|
|
/**
|
|
* Iframe à viewport logique fixe (rendu « grand écran »), affichée à la taille
|
|
* du parent. Le `transform: scale` est porté par un <div> wrapper dimensionné
|
|
* au viewport ; l'iframe est en `absolute inset-0` et remplit ce wrapper à
|
|
* 100 % (WebKit scale mal une iframe directement). Aucun aspect-ratio n'est
|
|
* posé sur l'iframe : c'est au(x) parent(s) redimensionnable(s) de le porter.
|
|
*/
|
|
export function ScaledPreviewIframe({
|
|
src,
|
|
title,
|
|
viewportWidth,
|
|
viewportHeight,
|
|
ready = true,
|
|
active = true,
|
|
fit = "cover",
|
|
coverEpsilonPx = 1,
|
|
maxScale,
|
|
className,
|
|
placeholderClassName,
|
|
}: ScaledPreviewIframeProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
/** null = pas encore mesuré (évite un iframe à scale par défaut au 1er paint). */
|
|
const [scale, setScale] = useState<number | null>(null)
|
|
|
|
useLayoutEffect(() => {
|
|
if (!ready) {
|
|
setScale(null)
|
|
return
|
|
}
|
|
|
|
const node = containerRef.current
|
|
if (!node) return
|
|
|
|
const update = () => {
|
|
// offsetWidth/Height = taille de layout SANS les transforms des ancêtres.
|
|
const width = node.offsetWidth
|
|
const height = node.offsetHeight
|
|
if (width <= 0 || height <= 0) return
|
|
|
|
let next: number
|
|
if (fit === "cover") {
|
|
const eps = coverEpsilonPx
|
|
next = Math.max(
|
|
(width + eps) / viewportWidth,
|
|
(height + eps) / viewportHeight
|
|
)
|
|
} else {
|
|
next = Math.min(width / viewportWidth, height / viewportHeight)
|
|
}
|
|
|
|
if (maxScale !== undefined) next = Math.min(next, maxScale)
|
|
setScale(next)
|
|
}
|
|
|
|
update()
|
|
const raf1 = requestAnimationFrame(() => {
|
|
update()
|
|
requestAnimationFrame(update)
|
|
})
|
|
|
|
const observer = new ResizeObserver(update)
|
|
observer.observe(node)
|
|
window.addEventListener("load", update, { once: true })
|
|
|
|
return () => {
|
|
cancelAnimationFrame(raf1)
|
|
observer.disconnect()
|
|
window.removeEventListener("load", update)
|
|
}
|
|
}, [ready, viewportWidth, viewportHeight, fit, coverEpsilonPx, maxScale])
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn("relative h-full w-full overflow-hidden", className)}
|
|
>
|
|
{ready && scale !== null ? (
|
|
<div
|
|
className="absolute left-1/2 top-1/2"
|
|
style={{
|
|
width: viewportWidth,
|
|
height: viewportHeight,
|
|
transform: `translate(-50%, -50%) scale(${scale})`,
|
|
transformOrigin: "center center",
|
|
}}
|
|
>
|
|
<iframe
|
|
src={src}
|
|
title={title}
|
|
loading="lazy"
|
|
scrolling="no"
|
|
tabIndex={active ? 0 : -1}
|
|
className={cn(
|
|
"absolute inset-0 h-full w-full border-0",
|
|
active ? "pointer-events-auto" : "pointer-events-none"
|
|
)}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={cn(
|
|
"flex h-full w-full items-center justify-center text-xs text-[var(--landing-muted)]",
|
|
placeholderClassName
|
|
)}
|
|
>
|
|
Chargement…
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|