Checkpoint: Ajout de l'outil de zoom/loupe dans le viewport : loupe duale affichant simultanément la zone originale et regénérée, grossissement 2-8×, forme cercle/carré, grille pixel pour comparaison pixel par pixel, verrouillage de position par clic, barre de statut avec coordonnées en temps réel, raccourcis clavier (L/+/-/[/]/G/Esc). Bug de double loupe en mode côte à côte corrigé (un seul overlay au niveau conteneur). 47 tests passants, zéro erreur TS.

This commit is contained in:
Manus 2026-05-20 00:52:41 +00:00
parent 2f0fbc0c02
commit 70eb348cf0
2 changed files with 390 additions and 74 deletions

View file

@ -1,4 +1,4 @@
import { useState, useRef, useCallback } from "react";
import { useState, useRef, useCallback, useEffect } from "react";
import { useShortcuts, ShortcutsProvider } from "@/contexts/ShortcutsContext";
import type { ViewMode } from "@/contexts/ShortcutsContext";
import ShortcutSettings from "@/components/ShortcutSettings";
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Slider } from "@/components/ui/slider";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { trpc } from "@/lib/trpc";
import {
ZoomIn,
@ -21,7 +22,12 @@ import {
ChevronLeft,
ChevronRight,
AlertCircle,
Keyboard,
Search,
Crosshair,
Lock,
Unlock,
Circle,
Square,
} from "lucide-react";
interface ViewportPanelProps {
@ -35,7 +41,6 @@ export default function ViewportPanel({ project, selectedFrame, layers, onShowSh
const [zoom, setZoom] = useState(100);
const [viewMode, setViewMode] = useState<ViewMode>("side_by_side");
// Wrap in ShortcutsProvider so both this component and ShortcutSettings share the same state
return (
<ShortcutsProvider onModeChange={setViewMode}>
<ViewportPanelInner
@ -61,6 +66,18 @@ interface ViewportPanelInnerProps {
setViewMode: (m: ViewMode) => void;
}
// Loupe configuration
type LoupeShape = "circle" | "square";
interface LoupeState {
active: boolean;
locked: boolean;
position: { x: number; y: number }; // normalized 0-1
magnification: number; // 2x to 8x
size: number; // diameter in px (80-240)
shape: LoupeShape;
showGrid: boolean;
}
function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, viewMode, setViewMode }: ViewportPanelInnerProps) {
const { getShortcutLabel } = useShortcuts();
const [splitPosition, setSplitPosition] = useState(50);
@ -70,6 +87,18 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
const [imgErrors, setImgErrors] = useState<Record<string, boolean>>({});
const containerRef = useRef<HTMLDivElement>(null);
// Loupe state
const [loupe, setLoupe] = useState<LoupeState>({
active: false,
locked: false,
position: { x: 0.5, y: 0.5 },
magnification: 4,
size: 160,
shape: "circle",
showGrid: false,
});
const loupeContainerRef = useRef<HTMLDivElement>(null);
const visibleLayers = layers.filter((l) => l.visible);
// Fetch the current frame data from the database
@ -81,8 +110,6 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
// Derive URLs from real frame data
const originalFrameUrl = frameData?.originalUrl || null;
const regeneratedFrameUrl = frameData?.compositedUrl || frameData?.regeneratedBgUrl || null;
const foregroundUrl = frameData?.foregroundUrl || frameData?.regeneratedFgUrl || null;
const backgroundUrl = frameData?.backgroundUrl || frameData?.regeneratedBgUrl || null;
const handleSplitDrag = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isDraggingSplit) return;
@ -98,6 +125,65 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
setImgErrors((prev) => ({ ...prev, [key]: true }));
};
// Loupe mouse tracking
const handleLoupeMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!loupe.active || loupe.locked) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
setLoupe(prev => ({ ...prev, position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) } }));
}, [loupe.active, loupe.locked]);
const handleLoupeClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!loupe.active) return;
e.preventDefault();
setLoupe(prev => ({ ...prev, locked: !prev.locked }));
}, [loupe.active]);
// Keyboard shortcut for loupe toggle (L key)
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const tagName = target.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || target.isContentEditable) return;
if (e.key === "l" || e.key === "L") {
if (!e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
setLoupe(prev => ({ ...prev, active: !prev.active, locked: false }));
}
}
// Escape to deactivate loupe
if (e.key === "Escape" && loupe.active) {
setLoupe(prev => ({ ...prev, active: false, locked: false }));
}
// + / - to adjust magnification when loupe is active
if (loupe.active) {
if (e.key === "+" || e.key === "=") {
setLoupe(prev => ({ ...prev, magnification: Math.min(8, prev.magnification + 1) }));
}
if (e.key === "-" || e.key === "_") {
setLoupe(prev => ({ ...prev, magnification: Math.max(2, prev.magnification - 1) }));
}
// [ / ] to adjust size
if (e.key === "[") {
setLoupe(prev => ({ ...prev, size: Math.max(80, prev.size - 20) }));
}
if (e.key === "]") {
setLoupe(prev => ({ ...prev, size: Math.min(240, prev.size + 20) }));
}
// G to toggle grid
if (e.key === "g" || e.key === "G") {
if (!e.ctrlKey && !e.altKey) {
setLoupe(prev => ({ ...prev, showGrid: !prev.showGrid }));
}
}
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [loupe.active]);
const renderFramePlaceholder = (label: string, icon: React.ReactNode, sublabel?: string, isError?: boolean) => (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="text-center space-y-1.5">
@ -121,7 +207,6 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
className="absolute inset-0 w-full h-full object-contain"
onError={() => handleImgError(errorKey)}
/>
{/* Fallback behind the image in case it fails to render */}
<div className="absolute inset-0 -z-10">
{renderFramePlaceholder(label, fallbackIcon, `Frame #${selectedFrame + 1}`)}
</div>
@ -129,11 +214,147 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
);
}
const isError = url !== null && imgErrors[errorKey];
return renderFramePlaceholder(
label,
fallbackIcon,
`Frame #${selectedFrame + 1}`,
isError
return renderFramePlaceholder(label, fallbackIcon, `Frame #${selectedFrame + 1}`, isError);
};
// Render the loupe overlay for a given image container
const renderLoupeOverlay = (containerClass?: string) => {
if (!loupe.active) return null;
const { position, magnification, size, shape, showGrid, locked } = loupe;
const halfSize = size / 2;
// Background position for magnified view
const bgPosX = position.x * 100;
const bgPosY = position.y * 100;
return (
<div
className={`absolute inset-0 z-30 ${locked ? "cursor-crosshair" : "cursor-none"} ${containerClass || ""}`}
onMouseMove={handleLoupeMouseMove}
onClick={handleLoupeClick}
ref={loupeContainerRef}
>
{/* Loupe lens */}
<div
className="absolute pointer-events-none"
style={{
left: `${position.x * 100}%`,
top: `${position.y * 100}%`,
width: `${size}px`,
height: `${size}px`,
transform: "translate(-50%, -50%)",
}}
>
{/* Dual loupe: left = original, right = regenerated */}
<div className="relative w-full h-full flex overflow-hidden" style={{
borderRadius: shape === "circle" ? "50%" : "8px",
border: `2px solid ${locked ? "oklch(0.7 0.15 145)" : "oklch(0.8 0.12 250)"}`,
boxShadow: `0 0 20px rgba(0,0,0,0.8), inset 0 0 0 1px rgba(255,255,255,0.1)`,
}}>
{/* Left half: Original */}
<div className="w-1/2 h-full relative overflow-hidden">
{originalFrameUrl && !imgErrors["loupe-orig"] ? (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${originalFrameUrl})`,
backgroundSize: `${magnification * 100}%`,
backgroundPosition: `${bgPosX}% ${bgPosY}%`,
backgroundRepeat: "no-repeat",
imageRendering: magnification >= 6 ? "pixelated" : "auto",
}}
/>
) : (
<div className="absolute inset-0 bg-slate-900/80 flex items-center justify-center">
<Image className="h-3 w-3 text-blue-400/40" />
</div>
)}
{/* Label */}
<div className="absolute bottom-0.5 left-0.5 z-10">
<span className="text-[6px] font-mono bg-blue-600/90 text-white px-0.5 rounded-sm">ORI</span>
</div>
</div>
{/* Center divider */}
<div className="w-px h-full bg-white/60 shrink-0 z-10" />
{/* Right half: Regenerated */}
<div className="w-1/2 h-full relative overflow-hidden">
{regeneratedFrameUrl && !imgErrors["loupe-regen"] ? (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${regeneratedFrameUrl})`,
backgroundSize: `${magnification * 100}%`,
backgroundPosition: `${bgPosX}% ${bgPosY}%`,
backgroundRepeat: "no-repeat",
imageRendering: magnification >= 6 ? "pixelated" : "auto",
}}
/>
) : (
<div className="absolute inset-0 bg-slate-900/80 flex items-center justify-center">
<Layers className="h-3 w-3 text-green-400/40" />
</div>
)}
{/* Label */}
<div className="absolute bottom-0.5 right-0.5 z-10">
<span className="text-[6px] font-mono bg-green-600/90 text-white px-0.5 rounded-sm">GEN</span>
</div>
</div>
{/* Pixel grid overlay */}
{showGrid && magnification >= 4 && (
<div
className="absolute inset-0 pointer-events-none z-20"
style={{
backgroundImage: `
linear-gradient(to right, rgba(255,255,255,0.15) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.15) 1px, transparent 1px)
`,
backgroundSize: `${magnification}px ${magnification}px`,
}}
/>
)}
{/* Crosshair center */}
<div className="absolute inset-0 pointer-events-none z-20 flex items-center justify-center">
<div className="w-px h-3 bg-white/40" />
</div>
<div className="absolute inset-0 pointer-events-none z-20 flex items-center justify-center">
<div className="h-px w-3 bg-white/40" />
</div>
</div>
{/* Magnification badge */}
<div className="absolute -top-5 left-1/2 -translate-x-1/2 z-30">
<Badge className="text-[7px] h-3.5 bg-black/80 hover:bg-black/80 border-border/50 font-mono px-1">
{magnification}× {locked && "🔒"}
</Badge>
</div>
{/* Coordinates */}
<div className="absolute -bottom-4 left-1/2 -translate-x-1/2 z-30 whitespace-nowrap">
<span className="text-[7px] font-mono text-muted-foreground bg-black/70 px-1 rounded">
{Math.round(position.x * frameWidth)},{Math.round(position.y * frameHeight)}
</span>
</div>
</div>
{/* Crosshair cursor indicator when not locked */}
{!locked && (
<div
className="absolute pointer-events-none z-20"
style={{
left: `${position.x * 100}%`,
top: `${position.y * 100}%`,
transform: "translate(-50%, -50%)",
}}
>
<Crosshair className="h-4 w-4 text-white/50" />
</div>
)}
</div>
);
};
@ -228,6 +449,105 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
<div className="w-px h-4 bg-border/50" />
{/* Loupe toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={loupe.active ? "secondary" : "ghost"}
size="sm"
className={`h-6 text-[10px] px-1.5 gap-0.5 ${loupe.active ? "ring-1 ring-primary/50" : ""}`}
onClick={() => setLoupe(prev => ({ ...prev, active: !prev.active, locked: false }))}
>
<Search className="h-3 w-3" />
<span className="hidden sm:inline">Loupe</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Loupe de comparaison pixel par pixel <kbd className="ml-1 px-1 py-0.5 bg-muted rounded text-[9px] font-mono">L</kbd>
</TooltipContent>
</Tooltip>
{/* Loupe controls - shown when active */}
{loupe.active && (
<>
<div className="w-px h-4 bg-border/50" />
<div className="flex items-center gap-1.5">
{/* Magnification */}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" onClick={() => setLoupe(prev => ({ ...prev, magnification: Math.max(2, prev.magnification - 1) }))}>
<ZoomOut className="h-2.5 w-2.5" />
</Button>
<span className="text-[9px] font-mono text-primary w-5 text-center">{loupe.magnification}×</span>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" onClick={() => setLoupe(prev => ({ ...prev, magnification: Math.min(8, prev.magnification + 1) }))}>
<ZoomIn className="h-2.5 w-2.5" />
</Button>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Grossissement <kbd className="ml-1 px-1 py-0.5 bg-muted rounded text-[9px] font-mono">+/-</kbd></TooltipContent>
</Tooltip>
{/* Size */}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 min-w-[70px]">
<Slider
value={[loupe.size]}
onValueChange={([v]) => setLoupe(prev => ({ ...prev, size: v }))}
min={80}
max={240}
step={20}
className="w-12"
/>
<span className="text-[8px] font-mono text-muted-foreground w-6">{loupe.size}px</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Taille de la loupe <kbd className="ml-1 px-1 py-0.5 bg-muted rounded text-[9px] font-mono">[ / ]</kbd></TooltipContent>
</Tooltip>
{/* Shape toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => setLoupe(prev => ({ ...prev, shape: prev.shape === "circle" ? "square" : "circle" }))}
>
{loupe.shape === "circle" ? <Circle className="h-3 w-3" /> : <Square className="h-3 w-3" />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Forme: {loupe.shape === "circle" ? "Cercle" : "Carré"}</TooltipContent>
</Tooltip>
{/* Grid toggle */}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<span className="text-[8px] text-muted-foreground">Grille</span>
<Switch
checked={loupe.showGrid}
onCheckedChange={(v) => setLoupe(prev => ({ ...prev, showGrid: v }))}
className="h-3.5 w-6 data-[state=checked]:bg-primary"
/>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Grille pixel <kbd className="ml-1 px-1 py-0.5 bg-muted rounded text-[9px] font-mono">G</kbd></TooltipContent>
</Tooltip>
{/* Lock indicator */}
{loupe.locked && (
<Badge className="text-[7px] h-4 bg-green-600/50 hover:bg-green-600/50 border-0 font-mono gap-0.5">
<Lock className="h-2 w-2" /> Verrouillé
</Badge>
)}
</div>
</>
)}
<div className="w-px h-4 bg-border/50" />
{/* Zoom controls */}
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setZoom(Math.max(25, zoom - 25))}>
@ -303,7 +623,7 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
{/* === SIDE BY SIDE MODE === */}
{viewMode === "side_by_side" && (
<div
className="flex gap-3 items-center"
className="flex gap-3 items-center relative"
style={{ transform: `scale(${zoom / 100})`, transformOrigin: "center" }}
>
{/* Original frame */}
@ -348,15 +668,14 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
</div>
{!regeneratedFrameUrl && (
<div className="absolute bottom-8 left-0 right-0 text-center z-10">
<p className="text-[9px] text-amber-400/70 font-mono">
Aucune regénération
</p>
<p className="text-[8px] text-muted-foreground mt-0.5">
Lancez l'assistant IA
</p>
<p className="text-[9px] text-amber-400/70 font-mono">Aucune regénération</p>
<p className="text-[8px] text-muted-foreground mt-0.5">Lancez l'assistant IA</p>
</div>
)}
</div>
{/* Single loupe overlay at the container level for side-by-side mode */}
{loupe.active && renderLoupeOverlay("rounded")}
</div>
)}
@ -380,10 +699,7 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
</div>
{/* Original frame (clipped by split position) */}
<div
className="absolute inset-0 overflow-hidden"
style={{ width: `${splitPosition}%` }}
>
<div className="absolute inset-0 overflow-hidden" style={{ width: `${splitPosition}%` }}>
<div className="relative w-full h-full" style={{ width: `${100 / (splitPosition / 100)}%` }}>
{renderFrameContent(originalFrameUrl, "Original", <Image className="h-5 w-5 text-blue-400/60" />, "split-orig")}
</div>
@ -398,14 +714,12 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
<div className="absolute top-1/2 -translate-y-1/2 -translate-x-[7px] w-4 h-10 bg-primary rounded-full flex items-center justify-center shadow-lg">
<SplitSquareHorizontal className="h-2.5 w-2.5 text-primary-foreground" />
</div>
{/* Labels */}
<span className="absolute top-2 -translate-x-full -left-2 text-[8px] font-mono bg-blue-600/80 px-1 rounded text-white">
ORIGINAL
</span>
<span className="absolute top-2 left-3 text-[8px] font-mono bg-green-600/80 px-1 rounded text-white">
REGÉNÉRÉ
</span>
<span className="absolute top-2 -translate-x-full -left-2 text-[8px] font-mono bg-blue-600/80 px-1 rounded text-white">ORIGINAL</span>
<span className="absolute top-2 left-3 text-[8px] font-mono bg-green-600/80 px-1 rounded text-white">REGÉNÉRÉ</span>
</div>
{/* Loupe overlay */}
{loupe.active && renderLoupeOverlay()}
</div>
)}
@ -420,17 +734,12 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
maxHeight: "100%",
}}
>
{/* Original frame (base layer) */}
<div className="absolute inset-0">
{renderFrameContent(originalFrameUrl, "Original", <Image className="h-5 w-5 text-blue-400/60" />, "overlay-orig")}
</div>
{/* Regenerated frame (overlay with opacity) */}
<div className="absolute inset-0" style={{ opacity: overlayOpacity / 100 }}>
{renderFrameContent(regeneratedFrameUrl, "Regénéré", <Layers className="h-5 w-5 text-green-400/60" />, "overlay-regen")}
</div>
{/* Opacity indicator */}
<div className="absolute bottom-2 left-2 right-2 flex items-center justify-between z-10">
<Badge className="text-[8px] h-4 bg-blue-600/70 hover:bg-blue-600/70 border-0 font-mono">
Original {100 - overlayOpacity}%
@ -439,6 +748,8 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
Regénéré {overlayOpacity}%
</Badge>
</div>
{/* Loupe overlay */}
{loupe.active && renderLoupeOverlay()}
</div>
)}
@ -453,41 +764,22 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
maxHeight: "100%",
}}
>
{/* Previous frame (red tint) */}
<div className="absolute inset-0 opacity-30 mix-blend-multiply">
{renderFramePlaceholder(
`Frame #${Math.max(1, selectedFrame + 1 - onionFrameOffset)}`,
<Image className="h-5 w-5 text-red-400/60" />,
"Frame précédente"
)}
{renderFramePlaceholder(`Frame #${Math.max(1, selectedFrame + 1 - onionFrameOffset)}`, <Image className="h-5 w-5 text-red-400/60" />, "Frame précédente")}
</div>
{/* Current frame */}
<div className="absolute inset-0">
{renderFrameContent(originalFrameUrl, "Frame courante", <Image className="h-5 w-5 text-white/60" />, "onion-current")}
</div>
{/* Next frame (blue tint) */}
<div className="absolute inset-0 opacity-30 mix-blend-screen">
{renderFramePlaceholder(
`Frame #${selectedFrame + 1 + onionFrameOffset}`,
<Image className="h-5 w-5 text-blue-400/60" />,
"Frame suivante"
)}
{renderFramePlaceholder(`Frame #${selectedFrame + 1 + onionFrameOffset}`, <Image className="h-5 w-5 text-blue-400/60" />, "Frame suivante")}
</div>
{/* Legend */}
<div className="absolute top-2 left-2 flex flex-col gap-1 z-10">
<Badge className="text-[7px] h-3.5 bg-red-600/60 hover:bg-red-600/60 border-0 font-mono">
-{onionFrameOffset} (précédente)
</Badge>
<Badge className="text-[7px] h-3.5 bg-white/80 hover:bg-white/80 border-0 font-mono text-black">
Courante #{selectedFrame + 1}
</Badge>
<Badge className="text-[7px] h-3.5 bg-blue-600/60 hover:bg-blue-600/60 border-0 font-mono">
+{onionFrameOffset} (suivante)
</Badge>
<Badge className="text-[7px] h-3.5 bg-red-600/60 hover:bg-red-600/60 border-0 font-mono">-{onionFrameOffset} (précédente)</Badge>
<Badge className="text-[7px] h-3.5 bg-white/80 hover:bg-white/80 border-0 font-mono text-black">Courante #{selectedFrame + 1}</Badge>
<Badge className="text-[7px] h-3.5 bg-blue-600/60 hover:bg-blue-600/60 border-0 font-mono">+{onionFrameOffset} (suivante)</Badge>
</div>
{/* Loupe overlay */}
{loupe.active && renderLoupeOverlay()}
</div>
)}
@ -502,7 +794,6 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
maxHeight: "100%",
}}
>
{/* Show composited frame if available, otherwise show layers info */}
{frameData?.compositedUrl ? (
renderFrameContent(frameData.compositedUrl, "Composite", <Layers className="h-6 w-6 text-primary/40" />, "composite")
) : (
@ -512,27 +803,22 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
<Layers className="h-6 w-6 text-primary/40" />
</div>
<p className="text-[10px] text-muted-foreground font-mono">Vue Composite</p>
<p className="text-[9px] text-muted-foreground/60">
Frame #{selectedFrame + 1} {frameWidth}×{frameHeight}
</p>
<p className="text-[9px] text-muted-foreground/60">Frame #{selectedFrame + 1} {frameWidth}×{frameHeight}</p>
{visibleLayers.length > 0 && (
<p className="text-[9px] text-primary/60">
{visibleLayers.length} calque{visibleLayers.length > 1 ? "s" : ""} actif{visibleLayers.length > 1 ? "s" : ""}
</p>
<p className="text-[9px] text-primary/60">{visibleLayers.length} calque{visibleLayers.length > 1 ? "s" : ""} actif{visibleLayers.length > 1 ? "s" : ""}</p>
)}
</div>
</div>
)}
{/* Layer indicators */}
{visibleLayers.length > 0 && (
<div className="absolute top-2 left-2 flex flex-col gap-1">
{visibleLayers.map((layer) => (
<Badge key={layer.id} variant="outline" className="text-[8px] bg-black/70 border-border/50 backdrop-blur-sm">
{layer.name}
</Badge>
<Badge key={layer.id} variant="outline" className="text-[8px] bg-black/70 border-border/50 backdrop-blur-sm">{layer.name}</Badge>
))}
</div>
)}
{/* Loupe overlay */}
{loupe.active && renderLoupeOverlay()}
</div>
)}
@ -549,10 +835,10 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
>
{renderFrameContent(originalFrameUrl, "Frame Originale", <Image className="h-6 w-6 text-primary/40" />, "original-full")}
<div className="absolute top-1.5 left-1.5 z-10">
<Badge className="text-[8px] h-4 bg-blue-600/80 hover:bg-blue-600/80 border-0 font-mono">
ORIGINAL
</Badge>
<Badge className="text-[8px] h-4 bg-blue-600/80 hover:bg-blue-600/80 border-0 font-mono">ORIGINAL</Badge>
</div>
{/* Loupe overlay */}
{loupe.active && renderLoupeOverlay()}
</div>
)}
@ -562,6 +848,35 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
<div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-primary/15 rounded-bl" />
<div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-primary/15 rounded-br" />
</div>
{/* Loupe status bar - shown when loupe is active */}
{loupe.active && (
<div className="h-6 border-t border-border/50 flex items-center px-3 gap-3 shrink-0 bg-card/30">
<div className="flex items-center gap-1.5">
<Search className="h-3 w-3 text-primary" />
<span className="text-[9px] font-mono text-primary font-medium">LOUPE ACTIVE</span>
</div>
<div className="w-px h-3 bg-border/50" />
<span className="text-[8px] text-muted-foreground font-mono">
Position: {Math.round(loupe.position.x * frameWidth)}, {Math.round(loupe.position.y * frameHeight)}
</span>
<div className="w-px h-3 bg-border/50" />
<span className="text-[8px] text-muted-foreground font-mono">
{loupe.magnification}× {loupe.size}px {loupe.shape === "circle" ? "Rond" : "Carré"}
</span>
<div className="w-px h-3 bg-border/50" />
<span className="text-[8px] text-muted-foreground">
{loupe.locked ? (
<span className="flex items-center gap-0.5 text-green-400"><Lock className="h-2.5 w-2.5" /> Verrouillé (clic pour libérer)</span>
) : (
<span className="flex items-center gap-0.5"><Unlock className="h-2.5 w-2.5" /> Clic pour verrouiller</span>
)}
</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-[8px] text-muted-foreground/60 font-mono">L: toggle +/-: zoom [/]: taille G: grille Esc: fermer</span>
</div>
</div>
)}
</div>
);
}

View file

@ -100,3 +100,4 @@ via les endpoints API configurables dans l'onglet Services du panneau d'administ
## Nouvelles fonctionnalités demandées
- [x] Prévisualisation côte à côte (original vs regénéré IA) dans le ViewportPanel - 6 modes: Composite, Original, Side-by-Side, Split (curseur), Overlay (opacité), Onion Skin. Branché sur les vraies données DB via trpc.frames.getByIndex, fallback robuste avec icône d'erreur.
- [x] Raccourcis clavier personnalisables pour basculer entre les 6 modes de prévisualisation (hook useKeyboardShortcuts + composant ShortcutSettings + persistance localStorage + tooltips avec raccourcis affichés + 45 tests passants)
- [x] Outil de zoom/loupe dans le viewport pour comparer les détails pixel par pixel entre original et regénéré (loupe duale ORI/GEN, grossissement 2-8×, forme cercle/carré, grille pixel, verrouillage position, raccourcis L/+/-/[/]/G/Esc, barre de statut)