feat(annotations): resize and move annotations in document view #235
Reference in New Issue
Block a user
Delete Branch "feat/issue-233-annotation-resize-move"
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?
Closes #233
Summary
/api/annotations/{id}): accepts partial updates forx,y,width,heightwith aV33migration adding achk_annotation_boundsCHECK constraint.AnnotationEditOverlay: SVG overlay rendered over a selected annotation in transcribe mode, with 4 corner L-bracket handles and 4 edge midpoint handles for resizing, plus full-annotation drag for moving.tabIndex=0) and respond to arrow keys.updateAnnotationis wired into the PdfViewer context; failures surface as an inline error message.ANNOTATION_UPDATE_FAILEDerror code andannotation_edit_mode_activetranslation key added to all three locales.Test plan
🤖 Generated with Claude Code
Felix Brandt (@felixbrandt) — Developer Review
Verdict: APPROVE with minor suggestions
The implementation is clean and well-structured. TDD was followed correctly: service tests cover the not-found path and the partial-update logic; controller tests cover both permission paths plus validation boundaries. The component split is sensible —
AnnotationEditOverlayis a well-named, single-purpose component.Findings
1. Missing
@Slf4jonAnnotationService(minor)The service already has a
@Transactionalmethod doing a find + save. If the DB constraint fires (the newchk_annotation_boundsfrom V33), the exception propagates as a rawDataIntegrityViolationException, not aDomainException. This is likely fine in practice because the DTO's@DecimalMin/@DecimalMaxshould catch it first — but a defense-in-depth catch and a log line would close that gap:2.
ANNOTATION_UPDATE_FAILEDis documented as HTTP 500 but the service never throws it (minor)The
ErrorCodeJavadoc says/* ... 500 */. That code is never thrown by the service — the frontend uses it as a fallback string when no code is returned. Mismatched doc is harmless but confusing. Either remove the HTTP status hint from the Javadoc, or actually throw it from the service when the DB constraint fires (see #1 above).3.
AnnotationEditOverlayis 324 lines — splitting signal is 40 lines of template (minor)The component is logically one visual concern (the edit overlay) and the size is mostly justified by the complex drag math. No split needed — but the
handleKeyDowndebounce logic could move to a$derived-style helper or a small extracted function to keep the event handler thin.**4.
handlePointerUpsilently swallows the error on rollback — no user feedback fromcatch {}** (minor)** On a failed save,liveX/Yare restored, but there is nothrowout of thecatchblock. TheupdateAnnotationcontext function does throw (perPdfViewer), but the overlay itself does not relay the rejection to any local state. The error banner is shown inPdfViewerbecauseupdateAnnotation` throws — this is fine. Just worth noting that the overlay relies on the context function rethrowing to trigger the banner.5. Keyboard rollback in
handleKeyDownonly resets X/Y, not width/height (minor)If a keyboard resize (arrow keys on a resize handle) fails, width and height stay at the post-key value while the backend has the old value. Width/height are not modified by the current keyboard handler (it only moves), so this is not a bug today — but it is a latent inconsistency that will matter if keyboard resize is added later. Suggest aligning with the pointer-up rollback:
6. Service unit test:
AnnotationServiceTest.updateAnnotation_updatesOnlyPresentFields— missing assertion on save being called (cosmetic)The test asserts on the returned values but not that
annotationRepository.save(annotation)was called once.verify(annotationRepository).save(annotation)would make the test more complete.Overall this is solid work. The pixel-space viewBox approach for square handles is an elegant solution. Test coverage across all three layers (unit, controller slice, component) is thorough. The five minor points are suggestions, not blockers.
Markus Keller (@mkeller) — Architect Review
Verdict: APPROVE with one structural note
The implementation follows the project's layering rules correctly. The controller stays thin, the service owns all domain logic, the DTO is kept in
dto/where it belongs, and there is no cross-domain repository access. The Flyway migration is appropriately minimal.Findings
1. The
PATCH /api/documents/{documentId}/annotations/{annotationId}URL scope is correct — goodNesting the annotation under the document route ensures the document ID is part of the ownership check (
findByIdAndDocumentId). This is the right pattern; it prevents IDOR by design.2.
setContext('annotationUpdate', updateAnnotation)places a server-side concern in a client-side context — structural note (minor)setContextis called unconditionally at module level inPdfViewer.svelte. TheupdateAnnotationfunction usesfetchdirectly (bypassing the typed API client). This is the documented pattern for multipart endpoints inCLAUDE.md, so the rawfetchis acceptable here. However, the context is set even whenPdfVieweris not in transcribe mode and there is no active annotation. This is harmless today but couples the context provisioning to rendering lifetime rather than feature activation. Consider: is there a scenario where twoPdfViewerinstances on the same page conflict over the context key? If yes, scope the context to the overlay's parent, not the viewer root.3.
UpdateAnnotationDTOvsDocumentUpdateDTO— good separationCreating a dedicated
UpdateAnnotationDTOinstead of reusingDocumentUpdateDTOis the right call. Shared DTOs between unrelated features create silent coupling. The new DTO is self-contained and independently validatable.4. V33 migration is additive only — correct
Adding a
CHECKconstraint to existing data can fail if rows already violate it. Given that all annotation coordinates are written by controlled code, this should be safe. However, the migration comment does not acknowledge this risk. Adding a note like-- All existing rows must satisfy this constraint; if any do not, this migration will fail.would be consistent with the project's migration documentation style seen in earlier migrations.5.
ANNOTATION_UPDATE_FAILEDerror code inErrorCode.javais documented as HTTP 500 but is never thrown from the service (minor)This was already noted by Felix. From an architecture standpoint: if the error code exists in the enum, a corresponding throw site should exist. Either remove the enum value and rely on the default
getErrorMessage()fallback in the frontend, or add the throw site as described in the developer review. Having dead enum values creates confusion during maintenance.6. No ADR needed — the pixel-space viewBox decision is an implementation detail, not an architectural choice.
Good call on not over-documenting this.
The layering is clean, the migration is sound, and the feature boundary is well-contained. The structural note on context scoping is worth discussing but not a blocker for this PR.
Sara Holt (@saraholt) — QA / Test Review
Verdict: APPROVE — test coverage is solid, with two gaps worth addressing
The test pyramid is well-represented: Mockito unit tests for the service,
@WebMvcTestslice for the controller, andvitest-browser-sveltecomponent tests for the three frontend components. Test names are sentence-style and descriptive. Factory patterns are reused correctly.Findings
1. BLOCKER (soft): No integration test for the V33 migration constraint
The
chk_annotation_boundsCHECK constraint is added in V33, but there is no Testcontainers integration test that verifies the constraint actually fires when out-of-bounds values reach the database. The controller test at layer exercises Bean Validation (@DecimalMin/@DecimalMax), which rejects bad input before it reaches the DB. But the test does not verify what happens if someone bypasses the DTO validation (e.g., via a future internal call) — the DB constraint is the last line of defense and it should be tested:This test also verifies Flyway ran V33 correctly. The constraint is tested at the layer it actually exists.
2. Missing test:
handleKeyDownrollback resets only X/Y, not width/heightFelix flagged this as a code smell; from a QA perspective, there should be a test that confirms the rollback is complete after a failed keyboard nudge. Once the production code is fixed, a Vitest unit test on the rollback behaviour would pin it permanently.
3. Component tests use
document.querySelectorAllinstead of role-based queries (minor)The
data-handleattribute approach is acceptable for structural assertions (handle count), but the accessibility-related tests (tabindex, aria-live) should usegetByRole/getByLabelTextfrom@testing-library/svelteor Vitest'spagelocators to test what users and assistive technology actually experience, not internal DOM attributes.4.
AnnotationControllerTest.patchAnnotation_returns400_withWidthBelowMinimum— good, but missing symmetrical test forheight(minor)The test only verifies
width: 0.005. A parallel test forheight: 0.005and forx: 1.1(above max) would make the validation coverage exhaustive.5. Service test
updateAnnotation_updatesOnlyPresentFields— good partial-update test (positive callout)Testing that unchanged fields stay unchanged is exactly the right assertion for a PATCH endpoint. This test would catch a regression if someone accidentally changed the
if (dto.getX() != null)guards.6. No E2E test for the drag-to-resize flow (acceptable, noting gap)
The test plan in the PR description lists manual steps. Given the complexity of pointer event simulation in Playwright, an E2E test here would be high-value but also expensive to write. Logging a follow-up issue (
#236: E2E Playwright test for annotation drag resize) is a reasonable trade-off for this PR.The soft blocker on the constraint integration test is the only item I would ask to be resolved before merge. Everything else is a suggestion or a follow-up.
Nora "NullX" Steiner — Security Review
Verdict: APPROVE — no critical vulnerabilities; one IDOR concern to verify
The security posture of this PR is generally sound. Permission checks use the existing
@RequirePermissionAOP mechanism correctly, and the new endpoint follows the document-scoped URL pattern that prevents cross-document annotation access. Here is my full analysis.Findings
1. IDOR check —
findByIdAndDocumentIdis the right pattern (positive, with a note)The service uses
annotationRepository.findByIdAndDocumentId(annotationId, documentId)to scope the annotation lookup to the document. This is the correct defense against IDOR: an attacker who knowsannotationIdfrom document A cannot move it by sending a PATCH to document B's URL.Verify the repository method signature: I cannot see
AnnotationRepository.javain this diff. Please confirm thatfindByIdAndDocumentIdis implemented as a Spring Data derived query (or an explicit@Query) and is not doing afindByIdfollowed by an application-layer check. The DB query must be the gate, not application code.2.
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})— correct (positive)The PATCH endpoint accepts either
ANNOTATE_ALLorWRITE_ALL, which matches the existingDELETEendpoint's permission set. A user who can annotate should be able to move and resize annotations. A user who only hasREAD_ALLcannot. This is consistent and tested by the controller tests.3. No authentication bypass risk from
documentIdin path vs. ownership (info)The URL is
/api/documents/{documentId}/annotations/{annotationId}. ThedocumentIdis used as a query scope parameter, not to authorize access to the document itself. A user withANNOTATE_ALLcan annotate any document. This is consistent with existingCREATEandDELETEbehaviour — there is no document-level ownership enforcement for annotations in this system. This is an existing design decision, not introduced by this PR.4. Input validation is present at both layers (positive)
UpdateAnnotationDTOuses@DecimalMin/@DecimalMaxon all four fields, and the controller applies@Valid. The Flyway V33 migration adds aCHECKconstraint as a second layer. Defense in depth is implemented correctly.5.
fetchinPdfViewer.sveltesends user-controlled data as JSON — no XSS riskThe
coordsobject is serialized withJSON.stringifyand sent withContent-Type: application/json. It is not reflected into the DOM. No injection risk here.6. Error message exposure — acceptable
On failure,
getErrorMessage(err?.code ?? 'ANNOTATION_UPDATE_FAILED')maps the backend error code to an i18n string. Raw backend messages, stack traces, and class names are never shown to the user. This is the correct pattern.7.
annotationUpdateErrorauto-clears after 4 seconds — minor UX-security noteThe 4-second auto-dismiss is acceptable here (it is an error notification, not a success notification). The
aria-live="assertive"on the alert div means screen readers will announce it before it disappears. No security issue; just confirming the dismiss timer does not create a race condition where an error is silently lost.8. The
catch {}inAnnotationEditOverlay.handlePointerUphas no error variable — potential silent swallowIn TypeScript
catch {}without a binding is valid but hides what was caught. IfupdateAnnotationthrows something unexpected (not a network error), it will be silently rolled back. This is a code-quality concern, not a security vulnerability, but in a security-sensitive path (user action that modifies data) I prefercatch (err) { console.error('annotation update failed', err); ... }to preserve observability.No blockers from a security perspective. Verify finding #1 (repository method is a DB-scoped query) and you are good to go.
Leonie Voss (@leonievoss) — UI/UX & Accessibility Review
Verdict: APPROVE with accessibility improvements requested before merge
The feature is functional and the drag interaction model is well-conceived. The pixel-space viewBox approach for handles is smart — it avoids the squished-handle problem on narrow annotations. My review focuses on accessibility and brand compliance.
Findings
1. BLOCKER: Handle
<g>elements haverole="none"but are interactive — missing keyboard accessEach handle
<g>responds toonpointerdownbut hasrole="none"and notabindex. The PR description says handles are keyboard-accessible via arrow keys on the SVG root, but that only provides move functionality (nudging X/Y). There is no way to keyboard-navigate to a specific corner handle and resize from it. The SVG roottabindex="0"captures all keyboard events; individual handles are mouse-only.This is a real accessibility gap for users who cannot use a pointer. Recommendation:
At minimum, the handles need
tabindex="0"andaria-labelwith a meaningful description (e.g., "Resize from top-left corner"). Without this, keyboard-only users can only move annotations, not resize them.2. BLOCKER:
aria-label="Annotation resize handles"on the SVG is in English onlyThe label is hardcoded in English. All user-visible strings in this project use Paraglide for i18n. Add a translation key:
3. Focus ring missing on the SVG root
The SVG has
style="... outline: none;". This removes the browser focus indicator for keyboard users navigating to the SVG. Per the project's own style guidelines:should be replaced with a CSS
focus-visiblerule:Or in Tailwind via a wrapper class. Without this, keyboard users have no visual indication that the annotation is focused and accepting keyboard input.
4. The
aria-live="polite"region announces once on mount and never again (minor)The
annotation_edit_mode_activemessage is placed in thesr-onlydiv and will be read by screen readers when the overlay mounts. This is correct. However, the message fires every time the overlay mounts (each time a different annotation is selected). Confirm that the message is meaningful on re-selection — it says "Annotation selected — resize handles visible" which is still accurate. No change needed, just confirming intent.5. Error state color —
text-red-300on dark background passes contrast, but confirm (minor)The error banner uses
bg-red-500/10(very light red) withtext-red-300. Light red text on a near-white background may fail WCAG AA (4.5:1 for normal text). Check with a contrast tool:text-red-300(#FCA5A5) onbg-red-500/10(≈ rgba(239, 68, 68, 0.1) over the PDF viewer's dark background) — the actual contrast depends on the PDF viewer's background color. If the viewer is dark, this likely passes. If it appears on a light background, it may fail. Usetext-red-700for safety.6. The
movecursor is set via inline style — should use Tailwind utility or CSS class (cosmetic)This is consistent with the rest of the SVG inline styles in this component and acceptable given SVG's limited Tailwind support. Not a blocker.
7. Touch target size — 44×44px hit area on handles is correct (positive callout)
The
<rect data-handle-hit x="-22" y="-22" width="44" height="44">ensures WCAG 2.2 compliant touch targets. This is exactly the right approach and critical for the senior audience (60+). Well done.The two blockers (individual handle keyboard accessibility and missing i18n on the aria-label) should be resolved before merge. Finding #3 (focus ring) is a high-priority follow-up if not fixed in this PR. Everything else is a suggestion.
Tobias Wendt (@tobiwendt) — DevOps & Platform Review
Verdict: APPROVE — no infrastructure changes in this PR
This PR is purely application code (backend Java, frontend Svelte, one Flyway migration). There are no Docker Compose changes, no CI workflow changes, no new dependencies, and no new environment variables. My review is brief.
Findings
1. Flyway V33 migration — no issues
V33__add_annotation_bounds_constraint.sqlis a pure DDL change (adds a CHECK constraint). It follows the naming convention, has a descriptive comment, and is non-destructive. The migration will run cleanly on the existing schema. Worth noting: if any existingdocument_annotationsrows have coordinates outside the constraint's range (unlikely given they were all written by controlled code), the migration will fail. This is not a DevOps issue to solve, but worth confirming before deploying to production if there is existing annotation data.2. No new Docker/Compose changes (positive)
The OCR service's resource requirements, the database, and MinIO are all unchanged. No additional infrastructure needed for this feature.
3. No new environment variables or secrets (positive)
updateAnnotationinPdfViewer.sveltecallsfetch('/api/...')using a relative URL with no additional API keys or tokens. The existing session-based auth handles authentication.4. No CI workflow changes — this PR will exercise the existing pipeline
The added tests (JUnit + Vitest) will run in the existing CI jobs without modification. Build time impact is minimal: 2 new JUnit test classes (~6 tests), 3 new Vitest test files (~20 tests).
5.
setTimeoutinPdfViewer.sveltefor auto-dismissing the error banner — no concernA 4-second
setTimeoutin the browser is not a platform concern. Just noting it exists so no one removes it thinking it is dead code.Nothing actionable from the infrastructure side. The migration is clean, no new services are added, and CI should handle this without changes.
Review fixes applied
All 3 blockers and 7 minor items from the multi-persona review have been addressed. Branch pushed, all tests green (897 backend, 720 frontend).
Blockers resolved
B1 — Sara: No Testcontainers test for V33
chk_annotation_bounds✅Added 3 integration tests to
MigrationIntegrationTest.javausing JdbcTemplate + Testcontainers (PostgreSQL), verifying the constraint rejectsx > 1, rejectsheight < 0.01, and accepts valid coordinates.→ commit
72700bdB2 — Leonie: Handle
<g>elements not keyboard-reachable ✅Changed
role="none"→role="button", addedtabindex="0"andaria-label={m.annotation_resize_handle({ direction: ... })}to all 8 handle groups. Added adirectionLabelsmap (NW/NE/SW/SE/N/S/E/W). Addedonkeydownthat suppresses default on Enter/Space.→ commit
7097f99B3 — Leonie: SVG
aria-labelhardcoded English ✅Replaced
aria-label="Annotation resize handles"witharia-label={m.annotation_resize_area()}. Addedannotation_resize_areaandannotation_resize_handlekeys to de/en/es message files.→ commits
060d1c0,4d9145eMinor items resolved
M1 — Felix/Markus:
handleKeyDowncatch resets only liveX/liveY ✅Added
liveWidth = annotation.widthandliveHeight = annotation.heightto the catch rollback.→ commit
7125a0aM2 — Felix: Missing
@Slf4j+ noDataIntegrityViolationExceptiondefense ✅Added
@Slf4jtoAnnotationService. WrappedannotationRepository.save()in try/catch that logs a warning and re-throws asDomainException.badRequest(ANNOTATION_UPDATE_FAILED, ...). Test written red-first.→ commits
f00b470(test),a19faa3(impl)M3 — Felix:
ANNOTATION_UPDATE_FAILEDJavadoc says 500 ✅Updated Javadoc to "400" now that a
badRequestthrow site exists.→ commit
40c8f54M4 — Sara: Missing controller validation boundary tests ✅
Added
patchAnnotation_returns400_withHeightBelowMinimum({"height":0.005}) andpatchAnnotation_returns400_withXAboveMaximum({"x":1.1}) toAnnotationControllerTest.→ commit
65d606dM5 — Felix:
updateAnnotation_updatesOnlyPresentFieldsmissingverify(save)✅Added
verify(annotationRepository).save(annotation)after the value assertions.→ commit
4d3207fM6 — Nora: Silent
catch {}blocks ✅Changed both
} catch {to} catch (err) {withconsole.error(...)before rollback —handlePointerUpandhandleKeyDown.→ commits
7125a0a,76828a9M7 — Leonie:
outline: noneremoves focus ring ✅Removed
outline: nonefrom the SVG inline style. Addedsvg[role='application']:focus-visible { outline: 2px solid #002850; outline-offset: 2px; }to the<style>block.→ commit
28ac90bNot addressed (deferred per plan)
data-handleselectors (Sara #3)