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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user