diff --git a/visual_workflow_builder/frontend_v4/src/components/CaptureLibrary.tsx b/visual_workflow_builder/frontend_v4/src/components/CaptureLibrary.tsx index ec85cde60..fb8706d43 100644 --- a/visual_workflow_builder/frontend_v4/src/components/CaptureLibrary.tsx +++ b/visual_workflow_builder/frontend_v4/src/components/CaptureLibrary.tsx @@ -1,5 +1,10 @@ import { useState, useEffect } from 'react'; import type { Capture } from '../types'; +import { + loadLibrary, + saveLibrary, + compressThumbnail, +} from '../services/captureLibraryStorage'; interface LibraryItem { id: string; @@ -22,58 +27,43 @@ export default function CaptureLibrary({ currentCapture, onSelectCapture, onCapt const [viewMode, setViewMode] = useState<'all' | 'session' | 'favorites'>('session'); const [selectedItems, setSelectedItems] = useState>(new Set()); - // Charger la bibliothèque depuis sessionStorage (avec migration de l'ancienne clé) + // Charger la bibliothèque depuis localStorage (persiste entre onglets/sessions). + // Le helper loadLibrary() gère la migration des anciennes clés et de sessionStorage. useEffect(() => { - // Essayer la nouvelle clé d'abord - let stored = sessionStorage.getItem('captureLibrary_v2'); - - // Migration depuis l'ancienne clé si nécessaire - if (!stored) { - const oldStored = sessionStorage.getItem('captureLibrary'); - if (oldStored) { - try { - const oldData = JSON.parse(oldStored); - // Migrer les anciennes données vers le nouveau format - const migrated = oldData.map((item: any) => ({ - ...item, - sessionId: currentSessionId, - favorite: false - })); - sessionStorage.setItem('captureLibrary_v2', JSON.stringify(migrated)); - stored = JSON.stringify(migrated); - console.log(`✅ Migration de ${oldData.length} captures vers le nouveau format`); - } catch (e) { - console.error('Erreur migration captures:', e); - } - } - } - - if (stored) { - const parsed = JSON.parse(stored); - setLibrary(parsed.map((item: any) => ({ + const loaded = loadLibrary(currentSessionId) as LibraryItem[]; + setLibrary( + loaded.map((item) => ({ ...item, - timestamp: new Date(item.timestamp) - }))); - } + timestamp: + typeof item.timestamp === 'string' + ? new Date(item.timestamp) + : item.timestamp, + })) + ); }, [currentSessionId]); - // Sauvegarder la bibliothèque + // Sauvegarder la bibliothèque (localStorage + gestion de quota) useEffect(() => { - sessionStorage.setItem('captureLibrary_v2', JSON.stringify(library)); + saveLibrary(library); }, [library]); - // Ajouter capture à la bibliothèque + // Ajouter capture à la bibliothèque (thumbnail compressé JPEG 320x240) useEffect(() => { - if (currentCapture) { + if (!currentCapture) return; + let cancelled = false; + (async () => { + const compressed = await compressThumbnail(currentCapture.screenshot_base64); + if (cancelled) return; const newItem: LibraryItem = { id: `cap_${Date.now()}`, - capture: currentCapture, + capture: { ...currentCapture, screenshot_base64: compressed }, timestamp: new Date(), sessionId: currentSessionId, - favorite: false + favorite: false, }; setLibrary(prev => [newItem, ...prev.slice(0, 49)]); // Max 50 captures - } + })(); + return () => { cancelled = true; }; }, [currentCapture, currentSessionId]); // Filtrer selon le mode de vue diff --git a/visual_workflow_builder/frontend_v4/src/components/CapturePanel.tsx b/visual_workflow_builder/frontend_v4/src/components/CapturePanel.tsx index 6a9252ef9..4077022db 100644 --- a/visual_workflow_builder/frontend_v4/src/components/CapturePanel.tsx +++ b/visual_workflow_builder/frontend_v4/src/components/CapturePanel.tsx @@ -1,6 +1,11 @@ import { useState, useRef, useEffect } from 'react'; import type { Capture, ExecutionMode } from '../types'; import type { UIElement } from '../services/uiDetection'; +import { + loadLibrary, + saveLibrary, + compressThumbnail, +} from '../services/captureLibraryStorage'; interface DetectionZone { x: number; @@ -23,6 +28,8 @@ interface LibraryItem { id: string; capture: Capture; timestamp: Date; + sessionId?: string; + favorite?: boolean; } export default function CapturePanel({ @@ -48,30 +55,43 @@ export default function CapturePanel({ const isDebugMode = executionMode === 'debug'; - // Charger la bibliothèque depuis sessionStorage + // Charger la bibliothèque depuis localStorage (clé unifiée 'captureLibrary_v2'). + // Le helper loadLibrary() migre aussi les données de l'ancienne clé 'captureLibrary'. useEffect(() => { - const stored = sessionStorage.getItem('captureLibrary'); - if (stored) { - setLibrary(JSON.parse(stored)); - } + const loaded = loadLibrary() as LibraryItem[]; + setLibrary( + loaded.map((item) => ({ + ...item, + timestamp: + typeof item.timestamp === 'string' + ? new Date(item.timestamp) + : item.timestamp, + })) + ); }, []); - // Sauvegarder la bibliothèque + // Sauvegarder la bibliothèque (localStorage + gestion de quota) useEffect(() => { - sessionStorage.setItem('captureLibrary', JSON.stringify(library)); + saveLibrary(library); }, [library]); - // Ajouter capture à la bibliothèque + // Ajouter capture à la bibliothèque (thumbnail compressé JPEG 320x240) useEffect(() => { - if (capture) { - setCurrentCapture(capture); + if (!capture) return; + setCurrentCapture(capture); + let cancelled = false; + (async () => { + const compressed = await compressThumbnail(capture.screenshot_base64); + if (cancelled) return; const newItem: LibraryItem = { id: `cap_${Date.now()}`, - capture, - timestamp: new Date() + capture: { ...capture, screenshot_base64: compressed }, + timestamp: new Date(), + favorite: false, }; setLibrary(prev => [newItem, ...prev.slice(0, 19)]); - } + })(); + return () => { cancelled = true; }; }, [capture]); // Détecter les éléments UI quand une capture arrive diff --git a/visual_workflow_builder/frontend_v4/src/services/captureLibraryStorage.ts b/visual_workflow_builder/frontend_v4/src/services/captureLibraryStorage.ts new file mode 100644 index 000000000..7ce941395 --- /dev/null +++ b/visual_workflow_builder/frontend_v4/src/services/captureLibraryStorage.ts @@ -0,0 +1,175 @@ +/** + * Stockage unifié de la bibliothèque de captures. + * + * CONTEXTE (bug B1, 16 avril 2026) : + * Avant ce module, deux composants manipulaient la bibliothèque avec des + * politiques divergentes : + * - CaptureLibrary.tsx : sessionStorage + clé 'captureLibrary_v2' + * - CapturePanel.tsx : sessionStorage + clé 'captureLibrary' + * Résultat : + * 1. Bibliothèque purgée à la fermeture de l'onglet (sessionStorage). + * 2. Deux listes désynchronisées (clés différentes). + * + * Ce module centralise : + * - localStorage (persiste entre sessions) + * - clé unique 'captureLibrary_v2' + * - compression JPEG 80% / max 320×240 des thumbnails avant stockage + * pour rester sous le quota navigateur (typiquement 5–10 MB). + */ + +import type { Capture } from '../types'; + +export interface LibraryItem { + id: string; + capture: Capture; + timestamp: Date | string; // JSON.parse ne restaure pas les Date + sessionId?: string; + favorite?: boolean; +} + +const STORAGE_KEY = 'captureLibrary_v2'; +const LEGACY_KEY = 'captureLibrary'; +const THUMB_MAX_WIDTH = 320; +const THUMB_MAX_HEIGHT = 240; +const THUMB_QUALITY = 0.8; + +/** + * Charge la bibliothèque depuis localStorage. Migre depuis sessionStorage + * et l'ancienne clé 'captureLibrary' si présentes. + */ +export function loadLibrary(defaultSessionId?: string): LibraryItem[] { + // 1) Clé principale en localStorage + let raw = localStorage.getItem(STORAGE_KEY); + + // 2) Migration sessionStorage → localStorage (même clé) + if (!raw) { + const fromSession = sessionStorage.getItem(STORAGE_KEY); + if (fromSession) { + raw = fromSession; + try { + localStorage.setItem(STORAGE_KEY, raw); + sessionStorage.removeItem(STORAGE_KEY); + console.log('[CaptureLibrary] Migration sessionStorage → localStorage'); + } catch (e) { + console.warn('[CaptureLibrary] Échec migration sessionStorage → localStorage', e); + } + } + } + + // 3) Migration ancienne clé 'captureLibrary' (CapturePanel legacy) + if (!raw) { + const legacy = + localStorage.getItem(LEGACY_KEY) || sessionStorage.getItem(LEGACY_KEY); + if (legacy) { + try { + const parsed = JSON.parse(legacy) as LibraryItem[]; + const migrated = parsed.map((item) => ({ + ...item, + sessionId: item.sessionId ?? defaultSessionId, + favorite: item.favorite ?? false, + })); + raw = JSON.stringify(migrated); + localStorage.setItem(STORAGE_KEY, raw); + localStorage.removeItem(LEGACY_KEY); + sessionStorage.removeItem(LEGACY_KEY); + console.log( + `[CaptureLibrary] Migration ancienne clé → ${migrated.length} captures` + ); + } catch (e) { + console.warn('[CaptureLibrary] Erreur migration ancienne clé', e); + } + } + } + + if (!raw) return []; + + try { + const parsed = JSON.parse(raw) as LibraryItem[]; + return parsed.map((item) => ({ + ...item, + timestamp: + typeof item.timestamp === 'string' + ? new Date(item.timestamp) + : item.timestamp, + })); + } catch (e) { + console.error('[CaptureLibrary] JSON invalide, reset', e); + return []; + } +} + +/** + * Sauvegarde la bibliothèque dans localStorage. Gère les erreurs de quota + * en élaguant les items les plus anciens jusqu'à ce que ça passe. + */ +export function saveLibrary(library: LibraryItem[]): void { + let toStore = library; + // Jusqu'à 5 tentatives : si QuotaExceededError, on tronque de moitié. + for (let attempt = 0; attempt < 5; attempt++) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); + return; + } catch (e: any) { + const isQuota = + e?.name === 'QuotaExceededError' || + e?.code === 22 || + e?.code === 1014; // Firefox + if (!isQuota) { + console.error('[CaptureLibrary] Erreur save', e); + return; + } + // Garder la moitié la plus récente + const half = Math.max(1, Math.floor(toStore.length / 2)); + console.warn( + `[CaptureLibrary] Quota dépassé, élagage ${toStore.length} → ${half}` + ); + toStore = toStore.slice(0, half); + } + } + console.error('[CaptureLibrary] Impossible de sauvegarder même après élagage'); +} + +/** + * Compresse une image base64 (PNG ou JPEG) en JPEG basse qualité pour la + * bibliothèque. Retourne la base64 JPEG sans le préfixe data: URL. + * Fallback : renvoie la base64 d'origine si la compression échoue. + */ +export async function compressThumbnail(base64Png: string): Promise { + return new Promise((resolve) => { + try { + const img = new Image(); + img.onload = () => { + try { + const ratio = Math.min( + THUMB_MAX_WIDTH / img.width, + THUMB_MAX_HEIGHT / img.height, + 1 + ); + const w = Math.max(1, Math.round(img.width * ratio)); + const h = Math.max(1, Math.round(img.height * ratio)); + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) { + resolve(base64Png); + return; + } + ctx.drawImage(img, 0, 0, w, h); + const dataUrl = canvas.toDataURL('image/jpeg', THUMB_QUALITY); + // Retirer le préfixe 'data:image/jpeg;base64,' + const prefixEnd = dataUrl.indexOf(','); + resolve(prefixEnd >= 0 ? dataUrl.slice(prefixEnd + 1) : base64Png); + } catch (e) { + console.warn('[CaptureLibrary] Compression échouée', e); + resolve(base64Png); + } + }; + img.onerror = () => resolve(base64Png); + img.src = `data:image/png;base64,${base64Png}`; + } catch (e) { + console.warn('[CaptureLibrary] Compression échouée (sync)', e); + resolve(base64Png); + } + }); +}