ultisuite-client/components/landing/product/scaled-preview-iframe.tsx
R3D347HR4Y efaaf16f60
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: update metadata and layout for new product pages
- 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.
2026-06-19 22:11:42 +02:00

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>
)
}