refactor(fileloader): extract createFileLoader hook from document/enrich pages
Move blob URL lifecycle management into a reusable createFileLoader() hook that owns revoke-before-create and revoke-on-destroy. Replace identical inline logic in documents/[id] and enrich/[id] with the hook. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { createFileLoader } from '../useFileLoader.svelte';
|
||||||
|
|
||||||
|
const FAKE_URL = 'blob:fake-url';
|
||||||
|
|
||||||
|
function setupFetch(ok: boolean, body?: Blob) {
|
||||||
|
const blob = body ?? new Blob(['fake'], { type: 'application/pdf' });
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok,
|
||||||
|
blob: vi.fn().mockResolvedValue(blob)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createFileLoader', () => {
|
||||||
|
it('sets fileUrl after a successful fetch', async () => {
|
||||||
|
vi.stubGlobal('URL', {
|
||||||
|
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
|
||||||
|
revokeObjectURL: vi.fn()
|
||||||
|
});
|
||||||
|
setupFetch(true);
|
||||||
|
|
||||||
|
const loader = createFileLoader();
|
||||||
|
await loader.loadFile('/api/documents/1/file');
|
||||||
|
|
||||||
|
expect(loader.fileUrl).toBe(FAKE_URL);
|
||||||
|
expect(loader.isLoading).toBe(false);
|
||||||
|
expect(loader.fileError).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets fileError on a failed fetch (non-ok response)', async () => {
|
||||||
|
vi.stubGlobal('URL', {
|
||||||
|
createObjectURL: vi.fn(),
|
||||||
|
revokeObjectURL: vi.fn()
|
||||||
|
});
|
||||||
|
setupFetch(false);
|
||||||
|
|
||||||
|
const loader = createFileLoader();
|
||||||
|
await loader.loadFile('/api/documents/1/file');
|
||||||
|
|
||||||
|
expect(loader.fileUrl).toBe('');
|
||||||
|
expect(loader.fileError).not.toBe('');
|
||||||
|
expect(loader.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes the previous URL before creating a new one', async () => {
|
||||||
|
const revokeObjectURL = vi.fn();
|
||||||
|
vi.stubGlobal('URL', {
|
||||||
|
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
|
||||||
|
revokeObjectURL
|
||||||
|
});
|
||||||
|
setupFetch(true);
|
||||||
|
|
||||||
|
const loader = createFileLoader();
|
||||||
|
await loader.loadFile('/api/documents/1/file');
|
||||||
|
// First load: no previous URL to revoke
|
||||||
|
expect(revokeObjectURL).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await loader.loadFile('/api/documents/2/file');
|
||||||
|
// Second load: previous URL should be revoked
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes the URL on destroy', async () => {
|
||||||
|
const revokeObjectURL = vi.fn();
|
||||||
|
vi.stubGlobal('URL', {
|
||||||
|
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
|
||||||
|
revokeObjectURL
|
||||||
|
});
|
||||||
|
setupFetch(true);
|
||||||
|
|
||||||
|
const loader = createFileLoader();
|
||||||
|
await loader.loadFile('/api/documents/1/file');
|
||||||
|
loader.destroy();
|
||||||
|
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not revoke when no URL has been set', () => {
|
||||||
|
const revokeObjectURL = vi.fn();
|
||||||
|
vi.stubGlobal('URL', {
|
||||||
|
createObjectURL: vi.fn(),
|
||||||
|
revokeObjectURL
|
||||||
|
});
|
||||||
|
|
||||||
|
const loader = createFileLoader();
|
||||||
|
loader.destroy();
|
||||||
|
|
||||||
|
expect(revokeObjectURL).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
41
frontend/src/lib/hooks/useFileLoader.svelte.ts
Normal file
41
frontend/src/lib/hooks/useFileLoader.svelte.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export function createFileLoader() {
|
||||||
|
let fileUrl = $state('');
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let fileError = $state('');
|
||||||
|
|
||||||
|
async function loadFile(url: string): Promise<void> {
|
||||||
|
isLoading = true;
|
||||||
|
fileError = '';
|
||||||
|
if (fileUrl) URL.revokeObjectURL(fileUrl);
|
||||||
|
fileUrl = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error('Failed to load file');
|
||||||
|
const blob = await response.blob();
|
||||||
|
fileUrl = URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
fileError = 'Vorschau konnte nicht geladen werden.';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy(): void {
|
||||||
|
if (fileUrl) URL.revokeObjectURL(fileUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get fileUrl() {
|
||||||
|
return fileUrl;
|
||||||
|
},
|
||||||
|
get isLoading() {
|
||||||
|
return isLoading;
|
||||||
|
},
|
||||||
|
get fileError() {
|
||||||
|
return fileError;
|
||||||
|
},
|
||||||
|
loadFile,
|
||||||
|
destroy
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import TranscriptionPanelHeader from '$lib/components/TranscriptionPanelHeader.s
|
|||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
||||||
|
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -18,43 +19,15 @@ const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
|||||||
|
|
||||||
// ── File loading ──────────────────────────────────────────────────────────────
|
// ── File loading ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let fileUrl = $state('');
|
const fileLoader = createFileLoader();
|
||||||
let isLoading = $state(false);
|
|
||||||
let fileError = $state('');
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (doc?.id && doc?.filePath) {
|
if (doc?.id && doc?.filePath) {
|
||||||
loadFile(doc.id);
|
fileLoader.loadFile(`/api/documents/${doc.id}/file`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadFile(id: string) {
|
onDestroy(() => fileLoader.destroy());
|
||||||
isLoading = true;
|
|
||||||
fileError = '';
|
|
||||||
if (fileUrl) URL.revokeObjectURL(fileUrl);
|
|
||||||
fileUrl = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/documents/${id}/file`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) throw new Error('Nicht eingeloggt');
|
|
||||||
throw new Error('Fehler beim Laden der Datei');
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
fileUrl = URL.createObjectURL(blob);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
fileError = 'Vorschau konnte nicht geladen werden.';
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (fileUrl) URL.revokeObjectURL(fileUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Mode state ───────────────────────────────────────────────────────────────
|
// ── Mode state ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -350,7 +323,7 @@ onMount(() => {
|
|||||||
<DocumentTopBar
|
<DocumentTopBar
|
||||||
doc={doc}
|
doc={doc}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileLoader.fileUrl}
|
||||||
bind:transcribeMode={transcribeMode}
|
bind:transcribeMode={transcribeMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -362,9 +335,9 @@ onMount(() => {
|
|||||||
>
|
>
|
||||||
<DocumentViewer
|
<DocumentViewer
|
||||||
doc={doc}
|
doc={doc}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileLoader.fileUrl}
|
||||||
isLoading={isLoading}
|
isLoading={fileLoader.isLoading}
|
||||||
error={fileError}
|
error={fileLoader.fileError}
|
||||||
transcribeMode={transcribeMode && !ocrRunning}
|
transcribeMode={transcribeMode && !ocrRunning}
|
||||||
blockNumbers={blockNumbers}
|
blockNumbers={blockNumbers}
|
||||||
annotationReloadKey={annotationReloadKey}
|
annotationReloadKey={annotationReloadKey}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { onMount, onDestroy, untrack } from 'svelte';
|
import { onMount, onDestroy, untrack } from 'svelte';
|
||||||
|
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
||||||
@@ -11,9 +12,7 @@ let { data, form } = $props();
|
|||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
|
|
||||||
// File preview state
|
// File preview state
|
||||||
let fileUrl = $state('');
|
const fileLoader = createFileLoader();
|
||||||
let fileError = $state('');
|
|
||||||
let isLoading = $state(false);
|
|
||||||
|
|
||||||
let navHeight = $state(0);
|
let navHeight = $state(0);
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -27,30 +26,11 @@ let activeAnnotationPage = $state<number | null>(null);
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (doc?.id && doc?.filePath) {
|
if (doc?.id && doc?.filePath) {
|
||||||
loadFile(doc.id);
|
fileLoader.loadFile(`/api/documents/${doc.id}/file`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadFile(id: string) {
|
onDestroy(() => fileLoader.destroy());
|
||||||
isLoading = true;
|
|
||||||
fileError = '';
|
|
||||||
if (fileUrl) URL.revokeObjectURL(fileUrl);
|
|
||||||
fileUrl = '';
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/documents/${id}/file`);
|
|
||||||
if (!response.ok) throw new Error('Fehler');
|
|
||||||
const blob = await response.blob();
|
|
||||||
fileUrl = URL.createObjectURL(blob);
|
|
||||||
} catch {
|
|
||||||
fileError = m.doc_file_error_preview();
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (fileUrl) URL.revokeObjectURL(fileUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
|
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
|
||||||
@@ -93,9 +73,9 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
|
|||||||
<div class="relative flex-[6] overflow-hidden border-r border-line">
|
<div class="relative flex-[6] overflow-hidden border-r border-line">
|
||||||
<DocumentViewer
|
<DocumentViewer
|
||||||
doc={doc}
|
doc={doc}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileLoader.fileUrl}
|
||||||
isLoading={isLoading}
|
isLoading={fileLoader.isLoading}
|
||||||
error={fileError}
|
error={fileLoader.fileError}
|
||||||
bind:annotateMode={annotateMode}
|
bind:annotateMode={annotateMode}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
bind:activeAnnotationPage={activeAnnotationPage}
|
bind:activeAnnotationPage={activeAnnotationPage}
|
||||||
|
|||||||
Reference in New Issue
Block a user