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 { useState, useEffect } from 'react';
|
||||||
import type { Capture } from '../types';
|
import type { Capture } from '../types';
|
||||||
|
import {
|
||||||
|
loadLibrary,
|
||||||
|
saveLibrary,
|
||||||
|
compressThumbnail,
|
||||||
|
} from '../services/captureLibraryStorage';
|
||||||
|
|
||||||
interface LibraryItem {
|
interface LibraryItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,58 +27,43 @@ export default function CaptureLibrary({ currentCapture, onSelectCapture, onCapt
|
|||||||
const [viewMode, setViewMode] = useState<'all' | 'session' | 'favorites'>('session');
|
const [viewMode, setViewMode] = useState<'all' | 'session' | 'favorites'>('session');
|
||||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
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(() => {
|
useEffect(() => {
|
||||||
// Essayer la nouvelle clé d'abord
|
const loaded = loadLibrary(currentSessionId) as LibraryItem[];
|
||||||
let stored = sessionStorage.getItem('captureLibrary_v2');
|
setLibrary(
|
||||||
|
loaded.map((item) => ({
|
||||||
// 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) => ({
|
|
||||||
...item,
|
...item,
|
||||||
timestamp: new Date(item.timestamp)
|
timestamp:
|
||||||
})));
|
typeof item.timestamp === 'string'
|
||||||
}
|
? new Date(item.timestamp)
|
||||||
|
: item.timestamp,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}, [currentSessionId]);
|
}, [currentSessionId]);
|
||||||
|
|
||||||
// Sauvegarder la bibliothèque
|
// Sauvegarder la bibliothèque (localStorage + gestion de quota)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionStorage.setItem('captureLibrary_v2', JSON.stringify(library));
|
saveLibrary(library);
|
||||||
}, [library]);
|
}, [library]);
|
||||||
|
|
||||||
// Ajouter capture à la bibliothèque
|
// Ajouter capture à la bibliothèque (thumbnail compressé JPEG 320x240)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentCapture) {
|
if (!currentCapture) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const compressed = await compressThumbnail(currentCapture.screenshot_base64);
|
||||||
|
if (cancelled) return;
|
||||||
const newItem: LibraryItem = {
|
const newItem: LibraryItem = {
|
||||||
id: `cap_${Date.now()}`,
|
id: `cap_${Date.now()}`,
|
||||||
capture: currentCapture,
|
capture: { ...currentCapture, screenshot_base64: compressed },
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
sessionId: currentSessionId,
|
sessionId: currentSessionId,
|
||||||
favorite: false
|
favorite: false,
|
||||||
};
|
};
|
||||||
setLibrary(prev => [newItem, ...prev.slice(0, 49)]); // Max 50 captures
|
setLibrary(prev => [newItem, ...prev.slice(0, 49)]); // Max 50 captures
|
||||||
}
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [currentCapture, currentSessionId]);
|
}, [currentCapture, currentSessionId]);
|
||||||
|
|
||||||
// Filtrer selon le mode de vue
|
// Filtrer selon le mode de vue
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import type { Capture, ExecutionMode } from '../types';
|
import type { Capture, ExecutionMode } from '../types';
|
||||||
import type { UIElement } from '../services/uiDetection';
|
import type { UIElement } from '../services/uiDetection';
|
||||||
|
import {
|
||||||
|
loadLibrary,
|
||||||
|
saveLibrary,
|
||||||
|
compressThumbnail,
|
||||||
|
} from '../services/captureLibraryStorage';
|
||||||
|
|
||||||
interface DetectionZone {
|
interface DetectionZone {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -23,6 +28,8 @@ interface LibraryItem {
|
|||||||
id: string;
|
id: string;
|
||||||
capture: Capture;
|
capture: Capture;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
|
sessionId?: string;
|
||||||
|
favorite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CapturePanel({
|
export default function CapturePanel({
|
||||||
@@ -48,30 +55,43 @@ export default function CapturePanel({
|
|||||||
|
|
||||||
const isDebugMode = executionMode === 'debug';
|
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(() => {
|
useEffect(() => {
|
||||||
const stored = sessionStorage.getItem('captureLibrary');
|
const loaded = loadLibrary() as LibraryItem[];
|
||||||
if (stored) {
|
setLibrary(
|
||||||
setLibrary(JSON.parse(stored));
|
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(() => {
|
useEffect(() => {
|
||||||
sessionStorage.setItem('captureLibrary', JSON.stringify(library));
|
saveLibrary(library);
|
||||||
}, [library]);
|
}, [library]);
|
||||||
|
|
||||||
// Ajouter capture à la bibliothèque
|
// Ajouter capture à la bibliothèque (thumbnail compressé JPEG 320x240)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (capture) {
|
if (!capture) return;
|
||||||
setCurrentCapture(capture);
|
setCurrentCapture(capture);
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const compressed = await compressThumbnail(capture.screenshot_base64);
|
||||||
|
if (cancelled) return;
|
||||||
const newItem: LibraryItem = {
|
const newItem: LibraryItem = {
|
||||||
id: `cap_${Date.now()}`,
|
id: `cap_${Date.now()}`,
|
||||||
capture,
|
capture: { ...capture, screenshot_base64: compressed },
|
||||||
timestamp: new Date()
|
timestamp: new Date(),
|
||||||
|
favorite: false,
|
||||||
};
|
};
|
||||||
setLibrary(prev => [newItem, ...prev.slice(0, 19)]);
|
setLibrary(prev => [newItem, ...prev.slice(0, 19)]);
|
||||||
}
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [capture]);
|
}, [capture]);
|
||||||
|
|
||||||
// Détecter les éléments UI quand une capture arrive
|
// 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