feat: add force reload functionality to PhotoswipeReader for refreshing images and improve memory management by revoking blob URLs

This commit is contained in:
Julien Froidefond
2025-10-17 22:53:58 +02:00
parent cfcf79cb7d
commit f5e1332e21
7 changed files with 107 additions and 20 deletions

View File

@@ -31,6 +31,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
const [loadedImages, setLoadedImages] = useState<Record<number, { width: number; height: number }>>({}); const [loadedImages, setLoadedImages] = useState<Record<number, { width: number; height: number }>>({});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [secondPageLoading, setSecondPageLoading] = useState(true); const [secondPageLoading, setSecondPageLoading] = useState(true);
const [imageBlobUrls, setImageBlobUrls] = useState<Record<number, string>>({});
const isLandscape = useOrientation(); const isLandscape = useOrientation();
const { direction, toggleDirection, isRTL } = useReadingDirection(); const { direction, toggleDirection, isRTL } = useReadingDirection();
const { isFullscreen, toggleFullscreen } = useFullscreen(); const { isFullscreen, toggleFullscreen } = useFullscreen();
@@ -320,8 +321,74 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
if (pswpRef.current) { if (pswpRef.current) {
pswpRef.current.close(); pswpRef.current.close();
} }
// Révoquer toutes les blob URLs
Object.values(imageBlobUrls).forEach(url => {
if (url) URL.revokeObjectURL(url);
});
}; };
}, []); }, [imageBlobUrls]);
// Force reload handler
const handleForceReload = useCallback(async () => {
setIsLoading(true);
setSecondPageLoading(true);
// Révoquer les anciennes URLs blob
if (imageBlobUrls[currentPage]) {
URL.revokeObjectURL(imageBlobUrls[currentPage]);
}
if (imageBlobUrls[currentPage + 1]) {
URL.revokeObjectURL(imageBlobUrls[currentPage + 1]);
}
try {
// Fetch page 1 avec cache: reload
const response1 = await fetch(getPageUrl(currentPage), {
cache: 'reload',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
if (!response1.ok) {
throw new Error(`HTTP ${response1.status}`);
}
const blob1 = await response1.blob();
const blobUrl1 = URL.createObjectURL(blob1);
const newUrls: Record<number, string> = {
...imageBlobUrls,
[currentPage]: blobUrl1
};
// Fetch page 2 si double page
if (isDoublePage && shouldShowDoublePage(currentPage)) {
const response2 = await fetch(getPageUrl(currentPage + 1), {
cache: 'reload',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
if (!response2.ok) {
throw new Error(`HTTP ${response2.status}`);
}
const blob2 = await response2.blob();
const blobUrl2 = URL.createObjectURL(blob2);
newUrls[currentPage + 1] = blobUrl2;
}
setImageBlobUrls(newUrls);
} catch (error) {
console.error('Error reloading images:', error);
setIsLoading(false);
setSecondPageLoading(false);
}
}, [currentPage, imageBlobUrls, isDoublePage, shouldShowDoublePage, getPageUrl]);
return ( return (
<div <div
@@ -363,6 +430,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
showThumbnails={showThumbnails} showThumbnails={showThumbnails}
onToggleThumbnails={() => setShowThumbnails(!showThumbnails)} onToggleThumbnails={() => setShowThumbnails(!showThumbnails)}
onZoom={handleZoom} onZoom={handleZoom}
onForceReload={handleForceReload}
/> />
{/* Reader content */} {/* Reader content */}
@@ -390,7 +458,8 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
</div> </div>
)} )}
<img <img
src={getPageUrl(currentPage)} key={`page-${currentPage}-${imageBlobUrls[currentPage] || ''}`}
src={imageBlobUrls[currentPage] || getPageUrl(currentPage)}
alt={`Page ${currentPage}`} alt={`Page ${currentPage}`}
className={cn( className={cn(
"max-h-full max-w-full object-contain transition-opacity", "max-h-full max-w-full object-contain transition-opacity",
@@ -428,7 +497,8 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
</div> </div>
)} )}
<img <img
src={getPageUrl(currentPage + 1)} key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ''}`}
src={imageBlobUrls[currentPage + 1] || getPageUrl(currentPage + 1)}
alt={`Page ${currentPage + 1}`} alt={`Page ${currentPage + 1}`}
className={cn( className={cn(
"max-h-full max-w-full object-contain transition-opacity", "max-h-full max-w-full object-contain transition-opacity",

View File

@@ -11,6 +11,7 @@ import {
MoveLeft, MoveLeft,
Images, Images,
ZoomIn, ZoomIn,
RotateCw,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PageInput } from "./PageInput"; import { PageInput } from "./PageInput";
@@ -35,6 +36,7 @@ export const ControlButtons = ({
showThumbnails, showThumbnails,
onToggleThumbnails, onToggleThumbnails,
onZoom, onZoom,
onForceReload,
}: ControlButtonsProps) => { }: ControlButtonsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -125,6 +127,18 @@ export const ControlButtons = ({
iconClassName="h-6 w-6" iconClassName="h-6 w-6"
className="rounded-full" className="rounded-full"
/> />
<IconButton
variant="ghost"
size="icon"
icon={RotateCw}
onClick={(e) => {
e.stopPropagation();
onForceReload();
}}
tooltip={t("reader.controls.reload")}
iconClassName="h-6 w-6"
className="rounded-full"
/>
<div className="p-2 rounded-full" onClick={(e) => e.stopPropagation()}> <div className="p-2 rounded-full" onClick={(e) => e.stopPropagation()}>
<PageInput <PageInput
currentPage={currentPage} currentPage={currentPage}

View File

@@ -53,6 +53,7 @@ export interface ControlButtonsProps {
showThumbnails: boolean; showThumbnails: boolean;
onToggleThumbnails: () => void; onToggleThumbnails: () => void;
onZoom: () => void; onZoom: () => void;
onForceReload: () => void;
} }
export interface UsePageNavigationProps { export interface UsePageNavigationProps {

View File

@@ -418,6 +418,7 @@
"hide": "Hide thumbnails" "hide": "Hide thumbnails"
}, },
"zoom": "Zoom", "zoom": "Zoom",
"reload": "Reload page",
"close": "Close", "close": "Close",
"previousPage": "Previous page", "previousPage": "Previous page",
"nextPage": "Next page" "nextPage": "Next page"

View File

@@ -416,6 +416,7 @@
"hide": "Masquer les vignettes" "hide": "Masquer les vignettes"
}, },
"zoom": "Zoom", "zoom": "Zoom",
"reload": "Recharger la page",
"close": "Fermer", "close": "Fermer",
"previousPage": "Page précédente", "previousPage": "Page précédente",
"nextPage": "Page suivante" "nextPage": "Page suivante"

View File

@@ -94,7 +94,14 @@ export class BookService extends BaseApiService {
const response: ImageResponse = await ImageService.getImage( const response: ImageResponse = await ImageService.getImage(
`books/${bookId}/pages/${adjustedPageNumber}?zero_based=true` `books/${bookId}/pages/${adjustedPageNumber}?zero_based=true`
); );
return new Response(response.buffer.buffer as ArrayBuffer, {
// Convertir le Buffer Node.js en ArrayBuffer proprement
const arrayBuffer = response.buffer.buffer.slice(
response.buffer.byteOffset,
response.buffer.byteOffset + response.buffer.byteLength
);
return new Response(arrayBuffer, {
headers: { headers: {
"Content-Type": response.contentType || "image/jpeg", "Content-Type": response.contentType || "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable", "Cache-Control": "public, max-age=31536000, immutable",

View File

@@ -12,23 +12,16 @@ export class ImageService extends BaseApiService {
try { try {
const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" }; const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" };
const result = await this.fetchWithCache<ImageResponse>( // NE PAS mettre en cache - les images sont trop grosses et les Buffers ne sérialisent pas bien
`image-${path}`, const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true });
async () => { const contentType = response.headers.get("content-type");
const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true }); const arrayBuffer = await response.arrayBuffer();
const contentType = response.headers.get("content-type"); const buffer = Buffer.from(arrayBuffer);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return { return {
buffer, buffer,
contentType, contentType,
}; };
},
"IMAGES"
);
return result;
} catch (error) { } catch (error) {
console.error("Erreur lors de la récupération de l'image:", error); console.error("Erreur lors de la récupération de l'image:", error);
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error);