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:
parent
2f0fbc0c02
commit
70eb348cf0
2 changed files with 390 additions and 74 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
1
todo.md
1
todo.md
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue