feat(viewer): show delete icon directly on transcription annotation #339
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
User story
As a transcriber, I want to delete a transcription block directly from its annotation in the PDF viewer, so that I don't have to navigate to Bearbeiten → Block → Block löschen.
Context
Current flow is 3+ steps: open Bearbeiten tab → find the block in the list → click Delete. Users expect a trash icon directly on (or adjacent to) the highlighted annotation region, making deletion a single gesture.
Acceptance criteria
NFRs
👨💼 Markus Keller — Application Architect
Observations
TranscriptionBlockController.deleteBlock()already exists atDELETE /api/documents/{documentId}/transcription-blocks/{blockId}, anddeleteBlock()in+page.sveltealready wires it correctly. No backend change is needed.AnnotationShape.svelte(the clickable PDF overlay) andTranscriptionBlock.svelte(the sidebar card). The delete gesture must be initiated on the annotation region but the block ID lives in the sidebar data model, linked viaannotationIdinTranscriptionBlockData.AnnotationLayer.svelte→AnnotationShape.svelte→+page.svelteevent chain already carriesannotationId. The+page.sveltehastranscriptionBlocksin scope. The architectural path is:AnnotationShapeemitsonDeleteRequest(annotationId),+page.svelteresolvesblockIdfromannotationId, calls existingdeleteBlock(blockId).DocumentViewer.svelte→PdfViewer.svelte→AnnotationLayer.svelte→AnnotationShape.svelteunless a full delete capability is wired into each layer. An alternative is to keep the delete icon purely in theAnnotationShape, but only render it in transcribe-edit mode (controlled by the existingcanDrawprop already passed through the chain).Recommendations
onDeleteRequest?: (annotationId: string) => voidtoAnnotationLayerandAnnotationShape. Wire it throughDocumentViewerfrom theonTranscriptionDraw-like slot that+page.sveltealready manages. ThecanDrawflag already gates transcribe-edit mode — use it as the condition to show the delete icon.+page.svelte(wheredeleteBlockalready lives with access to the confirm service context), not insideAnnotationShape.AnnotationShapeshould only emit the intent; the page orchestrates the confirmation and API call.annotationsDimmedflag (passed when in read mode) can double as the "hide delete icon" signal — if annotations are dimmed, the delete affordance should not appear.Open Decisions
onDeleteRequestwire through all four layers (PdfViewer→AnnotationLayer→AnnotationShape→+page.svelte), or shouldAnnotationShapereceive the block ID directly so it can trigger confirmation inline? The prop-drilling path preserves the layering boundary; inline would reduce the chain at the cost of a new dependency between annotation and transcription domains.👩💻 Felix Brandt — Fullstack Developer
Observations
AnnotationShape.sveltealready renders a numbered badge in the top-left corner. The delete icon should follow the same pattern: absolutely positioned,pointer-events: auto, conditionally rendered on hover/focus.isHoveredandisActiveprops are already tracked inAnnotationLayer.sveltevia thehoveredIdstate and passed intoAnnotationShape. Show the trash icon whenisHovered || isActiveandcanDrawis true — no new state needed.AnnotationShapealready handlesonkeydownfor Enter/Space. Adde.key === 'Delete'to the same handler.TranscriptionBlock.svelte(the existinghandleDeletefunction usesgetConfirmService()). Reuse the same pattern. The translation keytranscription_block_delete_confirmalready exists inde.json.deleteBlockin+page.sveltetakes ablockId, not anannotationId. The wiring must resolve viatranscriptionBlocks.find(b => b.annotationId === annotationId)— the same lookup thathandleAnnotationClickalready does.Recommendations
AnnotationShape.svelte, add a trash button inside the existing<div>conditionally whenisHovered || isActive. Place it top-right to avoid collision with the numbered badge (top-left). Example structure:showDelete?: booleanandonDeleteRequest?: () => voidtoAnnotationShapeprops rather than a fullannotationId— keeps the component's API surface minimal.e.key === 'Delete'to the existingonkeydownhandler inAnnotationShape:AnnotationLayer.svelte, passshowDelete={canDraw}andonDeleteRequest={() => onDeleteRequest?.(annotation.id)}through to eachAnnotationShape.onDeleteRequestthroughDocumentViewerand handle it in+page.svelteusing the existinghandleAnnotationClicklookup pattern followed bydeleteBlock.Open Decisions (omit if none)
TranscriptionBlock.sveltedelete flow uses a modal confirm. The issue mentions "confirmation or undo-toast." Picking one interaction model is a product decision — see Decision Queue.🛡️ Nora "NullX" Steiner — Security Expert
Observations
DELETE /api/documents/{documentId}/transcription-blocks/{blockId}is already protected with@RequirePermission(Permission.WRITE_ALL). No new backend permission surface is introduced by this feature.deleteBlock()in+page.sveltecorrectly checksres.okbefore mutating local state. No change needed there.canWriteflag (derived fromdata.canWrite) gates transcribe mode and the edit panel. However, I don't seecanWritecurrently being threaded intoAnnotationLayerto suppress the delete affordance for read-only users. ThetranscribeMode && !ocrRunningflag is passed astranscribeModetoDocumentViewer, which maps tocanDrawinAnnotationLayer. IfcanDrawisfalse, the delete icon should not appear — this is the correct guard.canDrawisfalsefor users with onlyREAD_ALLpermission. The currenttranscribeModestate starts asfalseand is toggled byDocumentTopBar, butcanWritemust control whether the mode can even be entered.Recommendations
canDraw(not just hover state). SincecanDrawalready reflectstranscribeMode && !ocrRunning, addingshowDelete={canDraw}as a prop is sufficient — do not add a separate permission check insideAnnotationShape, which has no business knowing about permissions.@WebMvcTesttest verifying thatDELETE /api/documents/{id}/transcription-blocks/{blockId}returns 403 when the authenticated user has onlyREAD_ALL. This is the standard security regression test for any write endpoint and should be added alongside the implementation.🎨 Leonie Voss — UI/UX Expert
Observations
AnnotationShapeis 20×20 px with inline styles. The new trash icon button must be at minimum 44×44 px touch target (WCAG 2.2 SC 2.5.8) — this is the stated NFR in the issue. The 20 px badge is decorative (number label), but the delete button is interactive.annotation-flashanimation already respectsprefers-reduced-motionwith a fallback outline. Any hover/appear animation on the delete icon must do the same.AnnotationShapeuses inlinestylefor color viahexToRgba. The delete button's background and focus ring must use brand tokens, not hardcoded hex. Usevar(--color-error)for the destructive hover state.Recommendations
position: absolute; top: -8px; right: -8px;mirroring the badge pattern. Setmin-width: 44px; min-height: 44pxwithdisplay: flex; align-items: center; justify-content: center.isActive(tab focus or click) unconditionally, and on hover only as a secondary trigger. An annotation that has focus must always show the affordance — keyboard users cannot rely on hover.aria-label={m.btn_delete()}to the trash button (the existingbtn_deletekey translates to "Löschen").focus-visible:ring-2 focus-visible:ring-errorfor the focus state of the trash button, consistent with the project's focus indicator convention.@media (prefers-reduced-motion: reduce)block inAnnotationShapealready exists — add the delete icon's appear animation to that same block.Open Decisions
🧪 Sara Holt — QA Engineer
Observations
TranscriptionBlock.sveltedelete path already has an integration path in+page.svelte(deleteBlock→ fetch DELETE → filtertranscriptionBlocks). No new API behavior is introduced — only a new trigger point.data-testid="annotation-{annotation.id}"attribute onAnnotationShapeprovides a stable Playwright selector. The delete button should carrydata-testid="annotation-delete-{annotation.id}"for test targeting.ConfirmDialogis already tested viaconfirm.svelte.test.ts. The new flow reuses the samegetConfirmService()pattern — no new modal infrastructure to test.page.keyboard.press('Delete')handles this cleanly after afocus()call.Recommendations
Unit / component level:
AnnotationShapeverifying that:isHovered=trueandshowDelete=true.showDelete=false.onDeleteRequestis called when the trash button is clicked.onDeleteRequestis called when Delete key is pressed while the annotation has focus.Integration / E2E level:
[data-testid="annotation-{id}"]→ verify[data-testid="annotation-delete-{id}"]becomes visible.Regression guard:
Add a
@WebMvcTesttest for the 403 response when aREAD_ALL-only user attemptsDELETE /api/documents/{id}/transcription-blocks/{blockId}(this is currently missing fromTranscriptionBlockControllerTest).The secondary path (Bearbeiten → Block → Block löschen) must remain tested — existing tests for
TranscriptionBlock.sveltecover this and should not be removed.Open Decisions
deleteBlockimplementation calls fetch immediately — an undo-toast would require buffering the delete. Define which pattern before implementation starts.🏗️ Tobias Wendt — DevOps
Observations
TranscriptionBlockControllerTest. CI will pick up any new tests without configuration changes.Recommendations
<8 minutesE2E budget. A hover + confirm + verify flow is fast; no concern there.setTimeout-based flakiness risk in Playwright tests. Usepage.waitForSelectorwith explicit visibility assertions instead of time-based waits.Nothing else to flag on the infrastructure side — straightforward frontend change with an existing, tested backend endpoint.
📋 Elicit — Requirements Engineer
Observations
ConfirmService. The current phrasing leaves this as an open implementation choice that will be revisited in code review — it should be resolved before implementation starts.ocrRunningis true. The current code passestranscribeMode && !ocrRunningastranscribeModetoDocumentViewer, which controlscanDraw. IfcanDrawdrivesshowDelete, this edge case is already handled. Confirm this is intentional.TranscriptionBlock(e.g., a newly drawn region that hasn't been typed into yet). The delete icon in this case deletes the annotation record via the transcription block delete. If no block is linked, the icon must either be hidden or trigger annotation-only deletion. The issue doesn't address this.transcription_block_delete_confirmkey already exists inde.json,en.json, andes.json. The trash button'saria-labelshould usebtn_delete(also already present). No new translation keys needed if the confirm-dialog pattern is chosen. An undo-toast would need a new "Rückgängig" key —btn_undodoes not appear inde.json.Recommendations
Open Decisions
ConfirmServicewith zero new infrastructure. Undo-toast is lower friction but requires buffered delete logic and a new i18n key. Which fits the Demo Day timeline?🗳️ Decision Queue
Three genuine tradeoffs that require a human decision before implementation starts.
1. Confirm-dialog vs. undo-toast
Raised by: Felix, Sara, Elicit
The acceptance criteria say "confirmation or undo-toast" — but these are architecturally different:
ConfirmServicetranscription_block_delete_confirm+btn_deleteexist)btn_undo(not inde.json)Recommendation from the team: Modal confirm — it is already used for the same action in the sidebar (
TranscriptionBlock.svelte), costs nothing new, and is consistent. Undo-toast is a worthwhile upgrade but a separate issue.2. Prop-drilling depth for
onDeleteRequestRaised by: Markus
The event must travel:
+page.svelte→DocumentViewer→PdfViewer→AnnotationLayer→AnnotationShape. That is four component layers.Option A — Full prop chain: Each component passes
onDeleteRequestdown. Preserves strict layering. Four small prop additions.Option B —
AnnotationShapereceives block ID directly and calls confirm inline: Reduces the chain but couples annotation rendering to the transcription-block domain and theConfirmServicecontext.Recommendation from the team: Option A (full prop chain). It mirrors the existing
onAnnotationClickandonTranscriptionDrawpatterns already in place. The chain is mechanical, not complex.3. Orphaned annotation (no linked block) — show or hide the trash icon?
Raised by: Elicit
A user can draw an annotation region on the PDF but leave the text field empty (or the block may not yet be saved). If the trash icon appears on these orphaned annotations, clicking it must either:
blockNumbers[annotation.id]is defined).Recommendation from the team: Option A — hide the trash icon for annotations without a block number. The block number badge is already suppressed in this case (
{#if !dimmed && blockNumber}). Apply the same condition to the delete icon. This keeps the feature scope tight for Demo Day.Implemented on branch
feat/issue-339-delete-icon-on-annotation— commitb13c1093.Changes (8 files):
AnnotationShape.svelte— trash button (44×44 px, top-right corner, teal on hover) withshowDelete/onDeleteRequestprops; Delete key support; button hidden when not hovered/activeAnnotationLayer.svelte— passesshowDelete={canDraw}andonDeleteRequestdown to each shapePdfViewer.svelte— accepts and forwardsonDeleteAnnotationRequestpropDocumentViewer.svelte— accepts and forwardsonDeleteAnnotationRequestprop+page.svelte—handleAnnotationDeleteRequest(): confirm dialog → delete block (if linked) or DELETE/annotations/{id}directly (orphaned annotation fallback) → reload annotationsAnnotationShape.svelte.spec.ts(new) — 8 browser-mode unit tests covering all visibility/interaction casesAnnotationLayer.svelte.spec.ts— updated tests to match newshowDeleteprop behaviourTranscriptionBlockControllerTest.java— added security regression:deleteBlock_returns403_whenUserHasOnlyReadAllPermissionAll tests green: 15/15 frontend (AnnotationShape + AnnotationLayer), 31/31 backend (TranscriptionBlockControllerTest).