From 70eb348cf030bf764670945a6cd2d71b8f8d7c31 Mon Sep 17 00:00:00 2001 From: Manus Date: Wed, 20 May 2026 00:52:41 +0000 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Ajout=20de=20l'outil=20de=20zoom/?= =?UTF-8?q?loupe=20dans=20le=20viewport=20:=20loupe=20duale=20affichant=20?= =?UTF-8?q?simultan=C3=A9ment=20la=20zone=20originale=20et=20reg=C3=A9n?= =?UTF-8?q?=C3=A9r=C3=A9e,=20grossissement=202-8=C3=97,=20forme=20cercle/c?= =?UTF-8?q?arr=C3=A9,=20grille=20pixel=20pour=20comparaison=20pixel=20par?= =?UTF-8?q?=20pixel,=20verrouillage=20de=20position=20par=20clic,=20barre?= =?UTF-8?q?=20de=20statut=20avec=20coordonn=C3=A9es=20en=20temps=20r=C3=A9?= =?UTF-8?q?el,=20raccourcis=20clavier=20(L/+/-/[/]/G/Esc).=20Bug=20de=20do?= =?UTF-8?q?uble=20loupe=20en=20mode=20c=C3=B4te=20=C3=A0=20c=C3=B4te=20cor?= =?UTF-8?q?rig=C3=A9=20(un=20seul=20overlay=20au=20niveau=20conteneur).=20?= =?UTF-8?q?47=20tests=20passants,=20z=C3=A9ro=20erreur=20TS.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/ViewportPanel.tsx | 463 ++++++++++++++++++++---- todo.md | 1 + 2 files changed, 390 insertions(+), 74 deletions(-) diff --git a/client/src/components/ViewportPanel.tsx b/client/src/components/ViewportPanel.tsx index 053c04b..40d2b2d 100644 --- a/client/src/components/ViewportPanel.tsx +++ b/client/src/components/ViewportPanel.tsx @@ -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("side_by_side"); - // Wrap in ShortcutsProvider so both this component and ShortcutSettings share the same state return ( 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>({}); const containerRef = useRef(null); + // Loupe state + const [loupe, setLoupe] = useState({ + active: false, + locked: false, + position: { x: 0.5, y: 0.5 }, + magnification: 4, + size: 160, + shape: "circle", + showGrid: false, + }); + const loupeContainerRef = useRef(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) => { 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) => { + 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) => { + 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) => (
@@ -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 */}
{renderFramePlaceholder(label, fallbackIcon, `Frame #${selectedFrame + 1}`)}
@@ -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 ( +
+ {/* Loupe lens */} +
+ {/* Dual loupe: left = original, right = regenerated */} +
+ {/* Left half: Original */} +
+ {originalFrameUrl && !imgErrors["loupe-orig"] ? ( +
= 6 ? "pixelated" : "auto", + }} + /> + ) : ( +
+ +
+ )} + {/* Label */} +
+ ORI +
+
+ + {/* Center divider */} +
+ + {/* Right half: Regenerated */} +
+ {regeneratedFrameUrl && !imgErrors["loupe-regen"] ? ( +
= 6 ? "pixelated" : "auto", + }} + /> + ) : ( +
+ +
+ )} + {/* Label */} +
+ GEN +
+
+ + {/* Pixel grid overlay */} + {showGrid && magnification >= 4 && ( +
+ )} + + {/* Crosshair center */} +
+
+
+
+
+
+
+ + {/* Magnification badge */} +
+ + {magnification}Ă— {locked && "đź”’"} + +
+ + {/* Coordinates */} +
+ + {Math.round(position.x * frameWidth)},{Math.round(position.y * frameHeight)} + +
+
+ + {/* Crosshair cursor indicator when not locked */} + {!locked && ( +
+ +
+ )} +
); }; @@ -228,6 +449,105 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
+ {/* Loupe toggle */} + + + + + + Loupe de comparaison pixel par pixel L + + + + {/* Loupe controls - shown when active */} + {loupe.active && ( + <> +
+
+ {/* Magnification */} + + +
+ + {loupe.magnification}Ă— + +
+
+ Grossissement +/- +
+ + {/* Size */} + + +
+ setLoupe(prev => ({ ...prev, size: v }))} + min={80} + max={240} + step={20} + className="w-12" + /> + {loupe.size}px +
+
+ Taille de la loupe [ / ] +
+ + {/* Shape toggle */} + + + + + Forme: {loupe.shape === "circle" ? "Cercle" : "Carré"} + + + {/* Grid toggle */} + + +
+ Grille + setLoupe(prev => ({ ...prev, showGrid: v }))} + className="h-3.5 w-6 data-[state=checked]:bg-primary" + /> +
+
+ Grille pixel G +
+ + {/* Lock indicator */} + {loupe.locked && ( + + Verrouillé + + )} +
+ + )} + +
+ {/* Zoom controls */}
+ + {/* Single loupe overlay at the container level for side-by-side mode */} + {loupe.active && renderLoupeOverlay("rounded")}
)} @@ -380,10 +699,7 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
{/* Original frame (clipped by split position) */} -
+
{renderFrameContent(originalFrameUrl, "Original", , "split-orig")}
@@ -398,14 +714,12 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
- {/* Labels */} - - ORIGINAL - - - REGÉNÉRÉ - + ORIGINAL + REGÉNÉRÉ
+ + {/* Loupe overlay */} + {loupe.active && renderLoupeOverlay()}
)} @@ -420,17 +734,12 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie maxHeight: "100%", }} > - {/* Original frame (base layer) */}
{renderFrameContent(originalFrameUrl, "Original", , "overlay-orig")}
- - {/* Regenerated frame (overlay with opacity) */}
{renderFrameContent(regeneratedFrameUrl, "Regénéré", , "overlay-regen")}
- - {/* Opacity indicator */}
Original {100 - overlayOpacity}% @@ -439,6 +748,8 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie Regénéré {overlayOpacity}%
+ {/* Loupe overlay */} + {loupe.active && renderLoupeOverlay()}
)} @@ -453,41 +764,22 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie maxHeight: "100%", }} > - {/* Previous frame (red tint) */}
- {renderFramePlaceholder( - `Frame #${Math.max(1, selectedFrame + 1 - onionFrameOffset)}`, - , - "Frame précédente" - )} + {renderFramePlaceholder(`Frame #${Math.max(1, selectedFrame + 1 - onionFrameOffset)}`, , "Frame précédente")}
- - {/* Current frame */}
{renderFrameContent(originalFrameUrl, "Frame courante", , "onion-current")}
- - {/* Next frame (blue tint) */}
- {renderFramePlaceholder( - `Frame #${selectedFrame + 1 + onionFrameOffset}`, - , - "Frame suivante" - )} + {renderFramePlaceholder(`Frame #${selectedFrame + 1 + onionFrameOffset}`, , "Frame suivante")}
- - {/* Legend */}
- - -{onionFrameOffset} (précédente) - - - Courante #{selectedFrame + 1} - - - +{onionFrameOffset} (suivante) - + -{onionFrameOffset} (précédente) + Courante #{selectedFrame + 1} + +{onionFrameOffset} (suivante)
+ {/* Loupe overlay */} + {loupe.active && renderLoupeOverlay()}
)} @@ -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", , "composite") ) : ( @@ -512,27 +803,22 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie

Vue Composite

-

- Frame #{selectedFrame + 1} — {frameWidth}×{frameHeight} -

+

Frame #{selectedFrame + 1} — {frameWidth}×{frameHeight}

{visibleLayers.length > 0 && ( -

- {visibleLayers.length} calque{visibleLayers.length > 1 ? "s" : ""} actif{visibleLayers.length > 1 ? "s" : ""} -

+

{visibleLayers.length} calque{visibleLayers.length > 1 ? "s" : ""} actif{visibleLayers.length > 1 ? "s" : ""}

)}
)} - {/* Layer indicators */} {visibleLayers.length > 0 && (
{visibleLayers.map((layer) => ( - - {layer.name} - + {layer.name} ))}
)} + {/* Loupe overlay */} + {loupe.active && renderLoupeOverlay()}
)} @@ -549,10 +835,10 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie > {renderFrameContent(originalFrameUrl, "Frame Originale", , "original-full")}
- - ORIGINAL - + ORIGINAL
+ {/* Loupe overlay */} + {loupe.active && renderLoupeOverlay()}
)} @@ -562,6 +848,35 @@ function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, vie
+ + {/* Loupe status bar - shown when loupe is active */} + {loupe.active && ( +
+
+ + LOUPE ACTIVE +
+
+ + Position: {Math.round(loupe.position.x * frameWidth)}, {Math.round(loupe.position.y * frameHeight)} + +
+ + {loupe.magnification}× • {loupe.size}px • {loupe.shape === "circle" ? "Rond" : "Carré"} + +
+ + {loupe.locked ? ( + Verrouillé (clic pour libérer) + ) : ( + Clic pour verrouiller + )} + +
+ L: toggle • +/-: zoom • [/]: taille • G: grille • Esc: fermer +
+
+ )}
); } diff --git a/todo.md b/todo.md index 5dffbc0..1d8f212 100644 --- a/todo.md +++ b/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)