fix(vwb): bibliothèque de captures persistée en localStorage (B1)

Avant : CaptureLibrary.tsx utilisait sessionStorage (purgé à la
fermeture d'onglet), et CapturePanel.tsx maintenait une liste
concurrente sous une clé différente (captureLibrary vs
captureLibrary_v2) → deux vues désynchronisées qui s'effacent
toutes les deux dès qu'on ferme le navigateur.

Après :
- Nouveau service captureLibraryStorage.ts (load/save/compress)
  comme point unique d'accès.
- Stockage en localStorage (persiste entre onglets et sessions).
- Clé unifiée 'captureLibrary_v2'.
- Migration automatique de sessionStorage → localStorage et de
  l'ancienne clé 'captureLibrary' → nouvelle, lors du premier load.
- Thumbnails compressés JPEG qualité 80% et redimensionnés à
  320×240 max avant stockage pour rester sous le quota navigateur
  (5–10 MB selon navigateur).
- Gestion QuotaExceededError dans saveLibrary : élague les items
  les plus anciens jusqu'à ce que ça passe (5 tentatives).
- Les deux composants consomment le même helper : fin de la
  divergence de format (sessionId/favorite).

Diagnostic (bug reproduit par lecture du code, pas besoin de
navigateur) :
- CaptureLibrary.tsx:28,42,62 → sessionStorage/captureLibrary_v2
- CapturePanel.tsx:53,61       → sessionStorage/captureLibrary
→ Deux sources, toutes deux éphémères.

Vérif : `npx tsc --noEmit` passe (EXITCODE=0).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-16 08:40:01 +02:00
parent 26b4e6d8ce
commit 39bea1b042
3 changed files with 236 additions and 51 deletions

View File

@@ -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<Set<string>>(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

View File

@@ -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

View File

@@ -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 510 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<string> {
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);
}
});
}