diff --git a/client/src/components/ShortcutSettings.tsx b/client/src/components/ShortcutSettings.tsx new file mode 100644 index 0000000..dbeea7a --- /dev/null +++ b/client/src/components/ShortcutSettings.tsx @@ -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 ( + { setOpen(v); if (!v) cancelRecording(); }}> + + + + + + + + Raccourcis clavier — Modes de prévisualisation + + + Cliquez sur un raccourci pour le modifier. Appuyez sur la nouvelle touche (avec ou sans modificateurs) pour l'assigner. Appuyez sur Échap pour annuler. + + + +
+ {shortcuts.map((shortcut, index) => ( +
+
+ + {VIEW_MODE_LABELS[shortcut.mode]} + +
+ +
+ {isRecording === index ? ( +
+ + + Appuyez sur une touche... + + +
+ ) : ( + + )} +
+
+ ))} +
+ +
+

+ Les raccourcis sont sauvegardés localement dans votre navigateur. +

+ +
+
+
+ ); +} diff --git a/client/src/components/ViewportPanel.tsx b/client/src/components/ViewportPanel.tsx index b74ef5b..053c04b 100644 --- a/client/src/components/ViewportPanel.tsx +++ b/client/src/components/ViewportPanel.tsx @@ -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("side_by_side"); + + // Wrap in ShortcutsProvider so both this component and ShortcutSettings share the same state + return ( + + + + ); +} + +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 - Composite (tous calques) + Composite (tous calques) {getShortcutLabel("composite")} @@ -135,7 +167,7 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo - Original (frame source) + Original (frame source) {getShortcutLabel("original")}
@@ -151,7 +183,7 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo - Côte à côte (Original | Regénéré) + Côte à côte (Original | Regénéré) {getShortcutLabel("side_by_side")} @@ -164,7 +196,7 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo - Split (curseur glissant) + Split (curseur glissant) {getShortcutLabel("split")} @@ -177,7 +209,7 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo - Superposition (opacité variable) + Superposition (opacité variable) {getShortcutLabel("overlay")} @@ -190,7 +222,7 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo - Onion skin (frames adjacentes) + Onion skin (frames adjacentes) {getShortcutLabel("onion")}
@@ -246,6 +278,10 @@ export default function ViewportPanel({ project, selectedFrame, layers }: Viewpo )} + {/* Shortcut settings button */} +
+ + {/* Frame info */}
{frameData && ( diff --git a/client/src/contexts/ShortcutsContext.tsx b/client/src/contexts/ShortcutsContext.tsx new file mode 100644 index 0000000..d90e492 --- /dev/null +++ b/client/src/contexts/ShortcutsContext.tsx @@ -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 = { + 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(null); + +interface ShortcutsProviderProps { + children: ReactNode; + onModeChange: (mode: ViewMode) => void; +} + +export function ShortcutsProvider({ children, onModeChange }: ShortcutsProviderProps) { + const [shortcuts, setShortcuts] = useState(loadShortcuts); + const [isRecording, setIsRecording] = useState(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 ( + + {children} + + ); +} + +export function useShortcuts(): ShortcutsContextValue { + const ctx = useContext(ShortcutsContext); + if (!ctx) { + throw new Error("useShortcuts must be used within a ShortcutsProvider"); + } + return ctx; +} diff --git a/client/src/pages/ProjectWorkspace.tsx b/client/src/pages/ProjectWorkspace.tsx index 7ca5c6e..6e87194 100644 --- a/client/src/pages/ProjectWorkspace.tsx +++ b/client/src/pages/ProjectWorkspace.tsx @@ -218,7 +218,7 @@ export default function ProjectWorkspace() {
{/* Left: Viewport + Timeline */}
- {/* Viewport */} + {/* Viewport with shared shortcuts context */}
{ + 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(); + }); +}); diff --git a/todo.md b/todo.md index 22eda9a..5dffbc0 100644 --- a/todo.md +++ b/todo.md @@ -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)