From 33aeefbb5bed0f2953c8bea7cb6aefc990ee8d00 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 12:19:54 +0200 Subject: [PATCH] feat(ui): confine read-only users to the transcription read view (#697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the document detail page, pass canEdit={canWrite} to the panel header, guard onModeChange so a reader can never flip to edit, and default panelMode to 'read' for readers. Thread canAnnotate={canWrite} through DocumentViewer to PdfViewer so the annotation layer's canDraw (which also gates delete and resize) is off for readers — they can open and read, but not draw, edit, or delete. The writer-only OCR status check is also skipped for readers. Co-Authored-By: Claude Opus 4.8 --- .../src/lib/document/DocumentViewer.svelte | 3 ++ .../src/lib/document/viewer/PdfViewer.svelte | 4 ++- .../document/viewer/PdfViewer.svelte.test.ts | 32 +++++++++++++++++++ .../src/routes/documents/[id]/+page.svelte | 12 ++++--- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/document/DocumentViewer.svelte b/frontend/src/lib/document/DocumentViewer.svelte index 789a68d7..b0ce8af6 100644 --- a/frontend/src/lib/document/DocumentViewer.svelte +++ b/frontend/src/lib/document/DocumentViewer.svelte @@ -17,6 +17,7 @@ type Props = { isLoading: boolean; error: string; transcribeMode?: boolean; + canAnnotate?: boolean; blockNumbers?: Record; annotationReloadKey?: number; activeAnnotationId: string | null; @@ -33,6 +34,7 @@ let { isLoading, error, transcribeMode = false, + canAnnotate = false, blockNumbers = {}, annotationReloadKey = 0, activeAnnotationId = $bindable(), @@ -93,6 +95,7 @@ let { url={fileUrl} documentId={doc.id} transcribeMode={transcribeMode} + canAnnotate={canAnnotate} blockNumbers={blockNumbers} annotationReloadKey={annotationReloadKey} bind:activeAnnotationId={activeAnnotationId} diff --git a/frontend/src/lib/document/viewer/PdfViewer.svelte b/frontend/src/lib/document/viewer/PdfViewer.svelte index 5b6d09c3..e9169c5d 100644 --- a/frontend/src/lib/document/viewer/PdfViewer.svelte +++ b/frontend/src/lib/document/viewer/PdfViewer.svelte @@ -14,6 +14,7 @@ let { url, documentId = '', transcribeMode = false, + canAnnotate = false, blockNumbers = {}, annotationReloadKey = 0, activeAnnotationId = $bindable(null), @@ -28,6 +29,7 @@ let { url: string; documentId?: string; transcribeMode?: boolean; + canAnnotate?: boolean; blockNumbers?: Record; annotationReloadKey?: number; activeAnnotationId?: string | null; @@ -262,7 +264,7 @@ function handleAnnotationClick(id: string) { annotations={visibleAnnotations.filter( (a) => a.pageNumber === renderer.currentPage )} - canDraw={transcribeMode} + canDraw={transcribeMode && canAnnotate} color={TRANSCRIPTION_COLOR} blockNumbers={blockNumbers} activeAnnotationId={activeAnnotationId} diff --git a/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts b/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts index 17d1827c..e528b443 100644 --- a/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts +++ b/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts @@ -86,6 +86,38 @@ describe('PdfViewer — loaded state', () => { } }); + it('makes the annotation surface drawable (crosshair) when transcribeMode and canAnnotate', async () => { + render(PdfViewer, { + url: '/api/documents/test/file', + documentId: 'test', + transcribeMode: true, + canAnnotate: true, + libLoader: makeFakeLibLoader() + }); + + await vi.waitFor(() => { + const surface = document.querySelector('[role="presentation"]'); + expect(surface).not.toBeNull(); + expect(surface?.getAttribute('style') ?? '').toContain('crosshair'); + }); + }); + + it('does not make the annotation surface drawable when canAnnotate is false (read-only user)', async () => { + render(PdfViewer, { + url: '/api/documents/test/file', + documentId: 'test', + transcribeMode: true, + canAnnotate: false, + libLoader: makeFakeLibLoader() + }); + + await vi.waitFor(() => { + expect(document.querySelector('.bg-pdf-bg')).not.toBeNull(); + }); + const surface = document.querySelector('[role="presentation"]'); + expect(surface?.getAttribute('style') ?? '').not.toContain('crosshair'); + }); + it('renders the canvas region when documentFileHash is provided', async () => { render(PdfViewer, { url: '/api/documents/test/file', diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index ef03a55f..32cd894e 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -71,7 +71,7 @@ const ocrJob = createOcrJob({ onJobFinished: async () => { await transcription.load(); transcription.bumpAnnotationReloadKey(); - panelMode = transcription.hasBlocks ? 'read' : 'edit'; + panelMode = canWrite && !transcription.hasBlocks ? 'edit' : 'read'; } }); @@ -148,10 +148,10 @@ $effect(() => { if (skipInitialPanelMode) { skipInitialPanelMode = false; } else { - panelMode = transcription.hasBlocks ? 'read' : 'edit'; + panelMode = canWrite && !transcription.hasBlocks ? 'edit' : 'read'; } }); - ocrJob.checkStatus(); + if (canWrite) ocrJob.checkStatus(); } }); @@ -252,6 +252,7 @@ onMount(() => { isLoading={fileLoader.isLoading} error={fileLoader.fileError} transcribeMode={transcribeMode && !ocrJob.running} + canAnnotate={canWrite} blockNumbers={transcription.blockNumbers} annotationReloadKey={transcription.annotationReloadKey} annotationsDimmed={transcribeMode && panelMode === 'read'} @@ -293,7 +294,10 @@ onMount(() => { hasBlocks={transcription.hasBlocks} blockCount={transcription.blocks.length} lastEditedAt={transcription.lastEditedAt} - onModeChange={(newMode) => (panelMode = newMode)} + canEdit={canWrite} + onModeChange={(newMode) => { + if (canWrite) panelMode = newMode; + }} onClose={() => (transcribeMode = false)} />