Checkpoint: Refactoring complet du système de raccourcis clavier : remplacement du hook useKeyboardShortcuts par un ShortcutsContext React partagé. Le ViewportPanel et le ShortcutSettings partagent désormais le même état via un Provider unique. Les listeners sont automatiquement désactivés pendant l'enregistrement d'un nouveau raccourci pour éviter les conflits. Les modifications sont immédiatement reflétées dans les tooltips et le listener actif. 47 tests passants, zéro erreur TypeScript.

This commit is contained in:
Manus 2026-05-20 00:37:46 +00:00
parent 6b95c1a362
commit 2f0fbc0c02
6 changed files with 416 additions and 10 deletions

View file

@ -0,0 +1,107 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Keyboard, RotateCcw, CircleDot } from "lucide-react";
import { useShortcuts, VIEW_MODE_LABELS } from "@/contexts/ShortcutsContext";
export default function ShortcutSettings() {
const [open, setOpen] = useState(false);
const {
shortcuts,
isRecording,
startRecording,
cancelRecording,
resetToDefaults,
} = useShortcuts();
return (
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) cancelRecording(); }}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" title="Raccourcis clavier">
<Keyboard className="h-3.5 w-3.5" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="h-5 w-5 text-primary" />
Raccourcis clavier Modes de prévisualisation
</DialogTitle>
<DialogDescription>
Cliquez sur un raccourci pour le modifier. Appuyez sur la nouvelle touche (avec ou sans modificateurs) pour l'assigner. Appuyez sur Échap pour annuler.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 mt-4">
{shortcuts.map((shortcut, index) => (
<div
key={shortcut.mode}
className={`flex items-center justify-between p-2.5 rounded-md border transition-all ${
isRecording === index
? "border-primary bg-primary/10 ring-2 ring-primary/30"
: "border-border/50 bg-card/50 hover:bg-card/80"
}`}
>
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-[10px] font-mono h-5 min-w-[80px] justify-center">
{VIEW_MODE_LABELS[shortcut.mode]}
</Badge>
</div>
<div className="flex items-center gap-2">
{isRecording === index ? (
<div className="flex items-center gap-2">
<span className="text-xs text-primary animate-pulse flex items-center gap-1">
<CircleDot className="h-3 w-3" />
Appuyez sur une touche...
</span>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2"
onClick={cancelRecording}
>
Annuler
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="h-7 min-w-[60px] font-mono text-xs hover:border-primary hover:text-primary transition-colors"
onClick={() => startRecording(index)}
>
{shortcut.label}
</Button>
)}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border/50">
<p className="text-[10px] text-muted-foreground">
Les raccourcis sont sauvegardés localement dans votre navigateur.
</p>
<Button
variant="outline"
size="sm"
className="gap-1 text-xs"
onClick={resetToDefaults}
>
<RotateCcw className="h-3 w-3" />
Réinitialiser
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,4 +1,7 @@
import { useState, useRef, useCallback } from "react";
import { useShortcuts, ShortcutsProvider } from "@/contexts/ShortcutsContext";
import type { ViewMode } from "@/contexts/ShortcutsContext";
import ShortcutSettings from "@/components/ShortcutSettings";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Slider } from "@/components/ui/slider";
@ -18,19 +21,48 @@ import {
ChevronLeft,
ChevronRight,
AlertCircle,
Keyboard,
} from "lucide-react";
interface ViewportPanelProps {
project: any;
selectedFrame: number;
layers: any[];
onShowShortcutSettings?: () => void;
}
type ViewMode = "composite" | "original" | "split" | "side_by_side" | "overlay" | "onion";
export default function ViewportPanel({ project, selectedFrame, layers }: ViewportPanelProps) {
export default function ViewportPanel({ project, selectedFrame, layers, onShowShortcutSettings }: ViewportPanelProps) {
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
project={project}
selectedFrame={selectedFrame}
layers={layers}
zoom={zoom}
setZoom={setZoom}
viewMode={viewMode}
setViewMode={setViewMode}
/>
</ShortcutsProvider>
);
}
interface ViewportPanelInnerProps {
project: any;
selectedFrame: number;
layers: any[];
zoom: number;
setZoom: (z: number) => void;
viewMode: ViewMode;
setViewMode: (m: ViewMode) => void;
}
function ViewportPanelInner({ project, selectedFrame, layers, zoom, setZoom, viewMode, setViewMode }: ViewportPanelInnerProps) {
const { getShortcutLabel } = useShortcuts();
const [splitPosition, setSplitPosition] = useState(50);
const [isDraggingSplit, setIsDraggingSplit] = useState(false);
const [overlayOpacity, setOverlayOpacity] = useState(50);
@ -122,7 +154,7 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo
<Layers className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Composite (tous calques)</TooltipContent>
<TooltipContent side="bottom" className="text-xs">Composite (tous calques) <kbd className="ml-1 px-1 py-0.5 bg-muted rounded text-[9px] font-mono">{getShortcutLabel("composite")}</kbd></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@ -135,7 +167,7 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo
<Film className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Original (frame source)</TooltipContent>
<TooltipContent side="bottom" className="text-xs">Original (frame source) <kbd className="ml-1 px-1 py-0.5 bg-muted rounded text-[9px] font-mono">{getShortcutLabel("original")}</kbd></TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border/50 mx-0.5" />
@ -151,7 +183,7 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo
<Columns2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Côte à côte (Original | Regénéré)</TooltipContent>
<TooltipContent side="bottom" className="text-xs">Côte à côte (Original | Regénéré) <kbd className="ml-1 px-1 py-0.5 bg-muted rounded text-[9px] font-mono">{getShortcutLabel("side_by_side")}</kbd></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@ -164,7 +196,7 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo
<SplitSquareHorizontal className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Split (curseur glissant)</TooltipContent>
<TooltipContent side="bottom" className="text-xs">Split (curseur glissant) <kbd className="ml-1 px-1 py-0.5 bg-muted rounded text-[9px] font-mono">{getShortcutLabel("split")}</kbd></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@ -177,7 +209,7 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo
<Blend className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Superposition (opacité variable)</TooltipContent>
<TooltipContent side="bottom" className="text-xs">Superposition (opacité variable) <kbd className="ml-1 px-1 py-0.5 bg-muted rounded text-[9px] font-mono">{getShortcutLabel("overlay")}</kbd></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@ -190,7 +222,7 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo
<ScanSearch className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Onion skin (frames adjacentes)</TooltipContent>
<TooltipContent side="bottom" className="text-xs">Onion skin (frames adjacentes) <kbd className="ml-1 px-1 py-0.5 bg-muted rounded text-[9px] font-mono">{getShortcutLabel("onion")}</kbd></TooltipContent>
</Tooltip>
</div>
@ -246,6 +278,10 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo
</>
)}
{/* Shortcut settings button */}
<div className="w-px h-4 bg-border/50" />
<ShortcutSettings />
{/* Frame info */}
<div className="ml-auto flex items-center gap-2">
{frameData && (

View file

@ -0,0 +1,193 @@
import { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from "react";
export type ViewMode = "composite" | "original" | "split" | "side_by_side" | "overlay" | "onion";
export interface ShortcutBinding {
key: string;
label: string;
mode: ViewMode;
modifiers?: { ctrl?: boolean; shift?: boolean; alt?: boolean };
}
const DEFAULT_SHORTCUTS: ShortcutBinding[] = [
{ key: "1", label: "1", mode: "composite" },
{ key: "2", label: "2", mode: "original" },
{ key: "3", label: "3", mode: "side_by_side" },
{ key: "4", label: "4", mode: "split" },
{ key: "5", label: "5", mode: "overlay" },
{ key: "6", label: "6", mode: "onion" },
];
const STORAGE_KEY = "retrotoon-viewport-shortcuts";
function loadShortcuts(): ShortcutBinding[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as ShortcutBinding[];
if (Array.isArray(parsed) && parsed.length === 6) {
return parsed;
}
}
} catch {
// fallback to defaults
}
return DEFAULT_SHORTCUTS;
}
function saveShortcuts(shortcuts: ShortcutBinding[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(shortcuts));
}
export function formatKeyLabel(key: string, ctrl: boolean, shift: boolean, alt: boolean): string {
const parts: string[] = [];
if (ctrl) parts.push("Ctrl");
if (shift) parts.push("Shift");
if (alt) parts.push("Alt");
const displayKey = key.length === 1 ? key.toUpperCase() : key;
parts.push(displayKey);
return parts.join("+");
}
export const VIEW_MODE_LABELS: Record<ViewMode, string> = {
composite: "Composite",
original: "Original",
side_by_side: "Côte à côte",
split: "Split",
overlay: "Superposition",
onion: "Onion Skin",
};
interface ShortcutsContextValue {
shortcuts: ShortcutBinding[];
isRecording: number | null;
startRecording: (index: number) => void;
cancelRecording: () => void;
resetToDefaults: () => void;
getShortcutLabel: (mode: ViewMode) => string;
}
const ShortcutsContext = createContext<ShortcutsContextValue | null>(null);
interface ShortcutsProviderProps {
children: ReactNode;
onModeChange: (mode: ViewMode) => void;
}
export function ShortcutsProvider({ children, onModeChange }: ShortcutsProviderProps) {
const [shortcuts, setShortcuts] = useState<ShortcutBinding[]>(loadShortcuts);
const [isRecording, setIsRecording] = useState<number | null>(null);
// Use ref to always have latest onModeChange without re-creating the listener
const onModeChangeRef = useRef(onModeChange);
onModeChangeRef.current = onModeChange;
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs/textareas
const target = e.target as HTMLElement;
const tagName = target.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || target.isContentEditable) {
return;
}
// If recording a new shortcut binding - all keys are captured for recording
if (isRecording !== null) {
e.preventDefault();
e.stopPropagation();
const newKey = e.key;
// Ignore modifier-only presses
if (["Control", "Shift", "Alt", "Meta"].includes(newKey)) return;
// Escape cancels recording
if (newKey === "Escape") {
setIsRecording(null);
return;
}
setShortcuts(prev => {
const newBinding: ShortcutBinding = {
...prev[isRecording],
key: newKey,
label: formatKeyLabel(newKey, e.ctrlKey, e.shiftKey, e.altKey),
modifiers: {
ctrl: e.ctrlKey,
shift: e.shiftKey,
alt: e.altKey,
},
};
const updated = [...prev];
updated[isRecording] = newBinding;
saveShortcuts(updated);
return updated;
});
setIsRecording(null);
return;
}
// Match against configured shortcuts (use functional access to avoid stale closure)
setShortcuts(currentShortcuts => {
for (const shortcut of currentShortcuts) {
const modifiers = shortcut.modifiers || {};
const ctrlMatch = modifiers.ctrl ? e.ctrlKey || e.metaKey : !e.ctrlKey && !e.metaKey;
const shiftMatch = modifiers.shift ? e.shiftKey : !e.shiftKey;
const altMatch = modifiers.alt ? e.altKey : !e.altKey;
if (e.key === shortcut.key && ctrlMatch && shiftMatch && altMatch) {
e.preventDefault();
onModeChangeRef.current(shortcut.mode);
break;
}
}
return currentShortcuts; // No state change, just reading
});
},
[isRecording]
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleKeyDown]);
const startRecording = useCallback((index: number) => {
setIsRecording(index);
}, []);
const cancelRecording = useCallback(() => {
setIsRecording(null);
}, []);
const resetToDefaults = useCallback(() => {
setShortcuts(DEFAULT_SHORTCUTS);
saveShortcuts(DEFAULT_SHORTCUTS);
setIsRecording(null);
}, []);
const getShortcutLabel = useCallback((mode: ViewMode): string => {
const binding = shortcuts.find(s => s.mode === mode);
return binding ? binding.label : "";
}, [shortcuts]);
return (
<ShortcutsContext.Provider value={{
shortcuts,
isRecording,
startRecording,
cancelRecording,
resetToDefaults,
getShortcutLabel,
}}>
{children}
</ShortcutsContext.Provider>
);
}
export function useShortcuts(): ShortcutsContextValue {
const ctx = useContext(ShortcutsContext);
if (!ctx) {
throw new Error("useShortcuts must be used within a ShortcutsProvider");
}
return ctx;
}

View file

@ -218,7 +218,7 @@ export default function ProjectWorkspace() {
<div className="flex-1 flex overflow-hidden">
{/* Left: Viewport + Timeline */}
<div className="flex-1 flex flex-col min-w-0">
{/* Viewport */}
{/* Viewport with shared shortcuts context */}
<div className="flex-1 min-h-0">
<ViewportPanel
project={project}

View file

@ -0,0 +1,69 @@
import { describe, expect, it, beforeEach } from "vitest";
// Unit test the pure logic of shortcut formatting and defaults
// (We test the non-hook parts since hooks require a React render context)
const STORAGE_KEY = "retrotoon-viewport-shortcuts";
describe("ShortcutsContext - pure logic", () => {
beforeEach(() => {
// Clear localStorage mock
if (typeof globalThis.localStorage !== "undefined") {
globalThis.localStorage.removeItem(STORAGE_KEY);
}
});
it("defines 6 default shortcuts for all view modes", () => {
const DEFAULT_MODES = ["composite", "original", "side_by_side", "split", "overlay", "onion"];
const DEFAULT_KEYS = ["1", "2", "3", "4", "5", "6"];
expect(DEFAULT_MODES).toHaveLength(6);
expect(DEFAULT_KEYS).toHaveLength(6);
// Verify each mode has a unique key
const uniqueKeys = new Set(DEFAULT_KEYS);
expect(uniqueKeys.size).toBe(6);
});
it("VIEW_MODE_LABELS covers all 6 modes", async () => {
const { VIEW_MODE_LABELS } = await import("../client/src/contexts/ShortcutsContext");
expect(VIEW_MODE_LABELS.composite).toBe("Composite");
expect(VIEW_MODE_LABELS.original).toBe("Original");
expect(VIEW_MODE_LABELS.side_by_side).toBe("Côte à côte");
expect(VIEW_MODE_LABELS.split).toBe("Split");
expect(VIEW_MODE_LABELS.overlay).toBe("Superposition");
expect(VIEW_MODE_LABELS.onion).toBe("Onion Skin");
});
it("formatKeyLabel produces correct labels for modifier combinations", async () => {
const { formatKeyLabel } = await import("../client/src/contexts/ShortcutsContext");
expect(formatKeyLabel("a", false, false, false)).toBe("A");
expect(formatKeyLabel("1", false, false, false)).toBe("1");
expect(formatKeyLabel("a", true, false, false)).toBe("Ctrl+A");
expect(formatKeyLabel("b", true, true, false)).toBe("Ctrl+Shift+B");
expect(formatKeyLabel("F1", false, false, false)).toBe("F1");
expect(formatKeyLabel("a", true, true, true)).toBe("Ctrl+Shift+Alt+A");
});
it("formatKeyLabel handles special keys", async () => {
const { formatKeyLabel } = await import("../client/src/contexts/ShortcutsContext");
expect(formatKeyLabel("ArrowUp", false, false, false)).toBe("ArrowUp");
expect(formatKeyLabel("Enter", true, false, false)).toBe("Ctrl+Enter");
expect(formatKeyLabel("Escape", false, false, true)).toBe("Alt+Escape");
});
it("localStorage key is correctly defined", () => {
expect(STORAGE_KEY).toBe("retrotoon-viewport-shortcuts");
});
it("ShortcutsProvider and useShortcuts are exported", async () => {
const mod = await import("../client/src/contexts/ShortcutsContext");
expect(mod.ShortcutsProvider).toBeDefined();
expect(mod.useShortcuts).toBeDefined();
expect(mod.formatKeyLabel).toBeDefined();
expect(mod.VIEW_MODE_LABELS).toBeDefined();
});
});

View file

@ -99,3 +99,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)