From f76a6c0ee56b421fdbb9bfa12f90a6927fd1022c Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 10:38:11 +0200 Subject: [PATCH 01/25] migration(annotations): add chk_annotation_bounds CHECK constraint (V33) Co-Authored-By: Claude Sonnet 4.6 --- .../V33__add_annotation_bounds_constraint.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V33__add_annotation_bounds_constraint.sql diff --git a/backend/src/main/resources/db/migration/V33__add_annotation_bounds_constraint.sql b/backend/src/main/resources/db/migration/V33__add_annotation_bounds_constraint.sql new file mode 100644 index 00000000..608ebe99 --- /dev/null +++ b/backend/src/main/resources/db/migration/V33__add_annotation_bounds_constraint.sql @@ -0,0 +1,12 @@ +-- Enforce valid normalized coordinate ranges for annotation bounding boxes. +-- x and y must be within [0, 1]; width and height must be at least 1% of the +-- document dimension and at most 100%. +-- Consistent with the application-layer minimum draw threshold (0.01). +ALTER TABLE document_annotations + ADD CONSTRAINT chk_annotation_bounds + CHECK ( + x >= 0 AND x <= 1 AND + y >= 0 AND y <= 1 AND + width >= 0.01 AND width <= 1 AND + height >= 0.01 AND height <= 1 + ); -- 2.49.1 From 26c7181ba40cc104e9aea187e343f52073788d43 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 10:38:33 +0200 Subject: [PATCH 02/25] feat(annotations): add ANNOTATION_UPDATE_FAILED error code Co-Authored-By: Claude Sonnet 4.6 --- .../java/org/raddatz/familienarchiv/exception/ErrorCode.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 60930824..236317c7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -49,6 +49,8 @@ public enum ErrorCode { // --- Annotations --- /** The annotation with the given ID does not exist. 404 */ ANNOTATION_NOT_FOUND, + /** The annotation position/size could not be saved. 500 */ + ANNOTATION_UPDATE_FAILED, // --- Transcription Blocks --- /** The transcription block with the given ID does not exist. 404 */ -- 2.49.1 From 1558881c01d199f2d51b5c095dfb314651e8b9b9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 10:39:50 +0200 Subject: [PATCH 03/25] feat(annotations): add updateAnnotation service method with partial-update DTO Co-Authored-By: Claude Sonnet 4.6 --- .../dto/UpdateAnnotationDTO.java | 29 +++++++++++++++ .../service/AnnotationService.java | 16 ++++++++ .../service/AnnotationServiceTest.java | 37 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateAnnotationDTO.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateAnnotationDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateAnnotationDTO.java new file mode 100644 index 00000000..19fa3c7d --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateAnnotationDTO.java @@ -0,0 +1,29 @@ +package org.raddatz.familienarchiv.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +/** + * Partial update payload for annotation position and size. + * All fields are optional — only non-null values are applied. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateAnnotationDTO { + + @DecimalMin("0.0") @DecimalMax("1.0") + private Double x; + + @DecimalMin("0.0") @DecimalMax("1.0") + private Double y; + + @DecimalMin("0.01") @DecimalMax("1.0") + private Double width; + + @DecimalMin("0.01") @DecimalMax("1.0") + private Double height; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java index 7b3e1252..e43b78b5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.DocumentAnnotation; @@ -61,6 +62,21 @@ public class AnnotationService { return annotationRepository.save(annotation); } + @Transactional + public DocumentAnnotation updateAnnotation(UUID documentId, UUID annotationId, UpdateAnnotationDTO dto) { + DocumentAnnotation annotation = annotationRepository + .findByIdAndDocumentId(annotationId, documentId) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.ANNOTATION_NOT_FOUND, "Annotation not found: " + annotationId)); + + if (dto.getX() != null) annotation.setX(dto.getX()); + if (dto.getY() != null) annotation.setY(dto.getY()); + if (dto.getWidth() != null) annotation.setWidth(dto.getWidth()); + if (dto.getHeight() != null) annotation.setHeight(dto.getHeight()); + + return annotationRepository.save(annotation); + } + @Transactional public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) { DocumentAnnotation annotation = annotationRepository diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java index cd736005..549edd00 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java @@ -6,6 +6,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.repository.AnnotationRepository; @@ -203,6 +204,42 @@ class AnnotationServiceTest { .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN)); } + // ─── updateAnnotation ───────────────────────────────────────────────────── + + @Test + void updateAnnotation_throwsNotFound_whenAnnotationNotInDocument() { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + when(annotationRepository.findByIdAndDocumentId(annotId, docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> annotationService.updateAnnotation(docId, annotId, new UpdateAnnotationDTO())) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND)); + } + + @Test + void updateAnnotation_updatesOnlyPresentFields() { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + DocumentAnnotation annotation = DocumentAnnotation.builder() + .id(annotId).documentId(docId) + .x(0.1).y(0.2).width(0.3).height(0.4).build(); + when(annotationRepository.findByIdAndDocumentId(annotId, docId)) + .thenReturn(Optional.of(annotation)); + when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + UpdateAnnotationDTO dto = new UpdateAnnotationDTO(); + dto.setX(0.5); + dto.setY(0.6); + + DocumentAnnotation result = annotationService.updateAnnotation(docId, annotId, dto); + + assertThat(result.getX()).isEqualTo(0.5); + assertThat(result.getY()).isEqualTo(0.6); + assertThat(result.getWidth()).isEqualTo(0.3); // unchanged + assertThat(result.getHeight()).isEqualTo(0.4); // unchanged + } + // ─── listAnnotations ────────────────────────────────────────────────────── @Test -- 2.49.1 From ff231db67177ab108f95972fbe1aec722dc6e37f Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 10:42:08 +0200 Subject: [PATCH 04/25] feat(annotations): add PATCH endpoint for annotation resize/move Co-Authored-By: Claude Sonnet 4.6 --- .../controller/AnnotationController.java | 11 +++ .../controller/AnnotationControllerTest.java | 86 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java index 43b715d7..01471222 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; @@ -11,6 +12,7 @@ import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.AnnotationService; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.UserService; +import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -45,6 +47,15 @@ public class AnnotationController { return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash()); } + @PatchMapping("/{annotationId}") + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) + public DocumentAnnotation updateAnnotation( + @PathVariable UUID documentId, + @PathVariable UUID annotationId, + @Valid @RequestBody UpdateAnnotationDTO dto) { + return annotationService.updateAnnotation(documentId, annotationId, dto); + } + @DeleteMapping("/{annotationId}") @ResponseStatus(HttpStatus.NO_CONTENT) @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java index 4b95d877..0da8d073 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java @@ -27,6 +27,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -160,6 +161,91 @@ class AnnotationControllerTest { .andExpect(status().isNoContent()); } + // ─── PATCH /api/documents/{documentId}/annotations/{annotationId} ───────── + + private static final String PATCH_JSON = "{\"x\":0.2,\"y\":0.3}"; + + @Test + void patchAnnotation_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void patchAnnotation_returns403_withoutPermission() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns200_withWriteAllPermission() throws Exception { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + DocumentAnnotation updated = DocumentAnnotation.builder() + .id(annotId).documentId(docId).pageNumber(1) + .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); + when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); + + mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.x").value(0.2)) + .andExpect(jsonPath("$.y").value(0.3)); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void patchAnnotation_returns200_withAnnotateAllPermission() throws Exception { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + DocumentAnnotation updated = DocumentAnnotation.builder() + .id(annotId).documentId(docId).pageNumber(1) + .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); + when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); + + mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns404_whenAnnotationBelongsToDifferentDocument() throws Exception { + when(annotationService.updateAnnotation(any(), any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found")); + + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"x\":-0.1,\"y\":0.3}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"width\":0.005}")) + .andExpect(status().isBadRequest()); + } + // ─── resolveUserId — unauthenticated / null user / exception branches ───── @Test -- 2.49.1 From 953cb2c91009d1f17e92032500580cec6b50608a Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 10:43:10 +0200 Subject: [PATCH 05/25] feat(i18n): add ANNOTATION_UPDATE_FAILED error code and annotation_edit_mode_active translation Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 2 ++ frontend/messages/en.json | 2 ++ frontend/messages/es.json | 2 ++ frontend/src/lib/errors.ts | 3 +++ 4 files changed, 9 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 48135368..e3f2b2d1 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1,6 +1,8 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "error_annotation_not_found": "Die Annotation wurde nicht gefunden.", + "error_annotation_update_failed": "Annotation konnte nicht gespeichert werden.", + "annotation_edit_mode_active": "Annotation ausgewählt — Ziehpunkte sichtbar.", "annotation_outdated_notice": "Einige Annotationen beziehen sich auf eine frühere Dateiversion und werden nicht angezeigt.", "error_document_not_found": "Das Dokument wurde nicht gefunden.", "error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8f55b111..e059f9b0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1,6 +1,8 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "error_annotation_not_found": "Annotation not found.", + "error_annotation_update_failed": "Could not save annotation position.", + "annotation_edit_mode_active": "Annotation selected — resize handles visible.", "annotation_outdated_notice": "Some annotations refer to an earlier file version and are not shown.", "error_document_not_found": "Document not found.", "error_document_no_file": "No file is associated with this document.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index b4b0ba65..7fbd8c0c 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1,6 +1,8 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "error_annotation_not_found": "Anotación no encontrada.", + "error_annotation_update_failed": "No se pudo guardar la posición de la anotación.", + "annotation_edit_mode_active": "Anotación seleccionada — tiradores visibles.", "annotation_outdated_notice": "Algunas anotaciones hacen referencia a una versión anterior del archivo y no se muestran.", "error_document_not_found": "Documento no encontrado.", "error_document_no_file": "No hay ningún archivo asociado a este documento.", diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index da769462..23a76344 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -18,6 +18,7 @@ export type ErrorCode = | 'IMPORT_ALREADY_RUNNING' | 'INVALID_RESET_TOKEN' | 'ANNOTATION_NOT_FOUND' + | 'ANNOTATION_UPDATE_FAILED' | 'TRANSCRIPTION_BLOCK_NOT_FOUND' | 'TRANSCRIPTION_BLOCK_CONFLICT' | 'COMMENT_NOT_FOUND' @@ -81,6 +82,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_invalid_reset_token(); case 'ANNOTATION_NOT_FOUND': return m.error_annotation_not_found(); + case 'ANNOTATION_UPDATE_FAILED': + return m.error_annotation_update_failed(); case 'TRANSCRIPTION_BLOCK_NOT_FOUND': return m.error_transcription_block_not_found(); case 'TRANSCRIPTION_BLOCK_CONFLICT': -- 2.49.1 From f5362a58508efe151865eb69662109a8ba80205c Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 10:52:07 +0200 Subject: [PATCH 06/25] feat(annotations): add AnnotationEditOverlay component with resize handles and drag Co-Authored-By: Claude Sonnet 4.6 --- .../components/AnnotationEditOverlay.svelte | 259 ++++++++++++++++++ .../AnnotationEditOverlay.svelte.test.ts | 50 ++++ 2 files changed, 309 insertions(+) create mode 100644 frontend/src/lib/components/AnnotationEditOverlay.svelte create mode 100644 frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte new file mode 100644 index 00000000..45605f13 --- /dev/null +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -0,0 +1,259 @@ + + +
+ {m.annotation_edit_mode_active()} +
+ + + + handlePointerDown(e, 'move')} + /> + + {#if dragState} + + {/if} + + {#each handles as handle (handle.id)} + handlePointerDown(e, 'handle', handle.id)} + > + + + + {/each} + + + diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts b/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts new file mode 100644 index 00000000..1afa47eb --- /dev/null +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import AnnotationEditOverlay from './AnnotationEditOverlay.svelte'; +import type { Annotation } from '$lib/types'; + +const annotation: Annotation = { + id: 'ann-1', + documentId: 'doc-1', + pageNumber: 1, + x: 0.1, + y: 0.2, + width: 0.3, + height: 0.4, + color: '#00c7b1', + createdAt: '2026-01-01T00:00:00Z' +}; + +describe('AnnotationEditOverlay', () => { + it('renders 8 handle elements', async () => { + render(AnnotationEditOverlay, { annotation }); + + const handles = document.querySelectorAll('[data-handle]'); + expect(handles).toHaveLength(8); + }); + + it('each handle has a 44x44 hit area', async () => { + render(AnnotationEditOverlay, { annotation }); + + const hitAreas = document.querySelectorAll('[data-handle-hit]'); + expect(hitAreas).toHaveLength(8); + hitAreas.forEach((el) => { + expect(el.getAttribute('width')).toBe('44'); + expect(el.getAttribute('height')).toBe('44'); + }); + }); + + it('renders a move area covering the full box', async () => { + render(AnnotationEditOverlay, { annotation }); + + const moveArea = document.querySelector('[data-move-area]'); + expect(moveArea).not.toBeNull(); + }); + + it('renders an aria-live region for screen reader announcement', async () => { + render(AnnotationEditOverlay, { annotation }); + + const liveRegion = document.querySelector('[aria-live="polite"]'); + expect(liveRegion).not.toBeNull(); + }); +}); -- 2.49.1 From 3b756cd718d235c1ae6730f07c70ba34bcdb3c63 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 10:55:13 +0200 Subject: [PATCH 07/25] feat(annotations): add isResizable prop to AnnotationShape to render edit overlay Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/AnnotationShape.svelte | 6 +++ .../components/AnnotationShape.svelte.test.ts | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 frontend/src/lib/components/AnnotationShape.svelte.test.ts diff --git a/frontend/src/lib/components/AnnotationShape.svelte b/frontend/src/lib/components/AnnotationShape.svelte index 69c41223..a7c667f2 100644 --- a/frontend/src/lib/components/AnnotationShape.svelte +++ b/frontend/src/lib/components/AnnotationShape.svelte @@ -1,5 +1,6 @@
@@ -195,8 +212,7 @@ let previewH = $derived((liveHeight / annotation.height) * 100); handlePointerDown(e, 'move')} -- 2.49.1 From fcc0efbf023f21891bdd7037a7ea601cbccabac0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 11:14:30 +0200 Subject: [PATCH 11/25] refactor(annotations): replace 8-square handles with 4 corner L-brackets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4 corner-only handles (nw/ne/sw/se), no edge midpoints - Each handle renders as two short perpendicular lines meeting at the corner (10px arms, navy, square linecap) — no fill, no box - Thin dashed selection border added to SVG overlay to signal edit mode - Simplify applyHandleDrag to remove dead n/s/e/w branches Co-Authored-By: Claude Sonnet 4.6 --- .../components/AnnotationEditOverlay.svelte | 55 +++++++++++++------ .../AnnotationEditOverlay.svelte.test.ts | 15 ++++- .../components/AnnotationLayer.svelte.test.ts | 2 +- .../components/AnnotationShape.svelte.test.ts | 2 +- 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte index 93f1403c..58cff56e 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -42,7 +42,10 @@ $effect(() => { return () => ro.disconnect(); }); -type HandleId = 'nw' | 'n' | 'ne' | 'w' | 'e' | 'sw' | 's' | 'se'; +type HandleId = 'nw' | 'ne' | 'sw' | 'se'; + +// L-bracket arm length in pixels. Each corner shows two short lines meeting at 90°. +const ARM = 10; type DragState = { type: 'handle' | 'move'; @@ -57,16 +60,20 @@ type DragState = { let dragState = $state(null); -// Handle positions in pixel space — always physically square regardless of annotation shape. -const handles = $derived>([ - { id: 'nw', cx: 0, cy: 0, cursor: 'nwse-resize' }, - { id: 'n', cx: svgWidth / 2, cy: 0, cursor: 'ns-resize' }, - { id: 'ne', cx: svgWidth, cy: 0, cursor: 'nesw-resize' }, - { id: 'w', cx: 0, cy: svgHeight / 2, cursor: 'ew-resize' }, - { id: 'e', cx: svgWidth, cy: svgHeight / 2, cursor: 'ew-resize' }, - { id: 'sw', cx: 0, cy: svgHeight, cursor: 'nesw-resize' }, - { id: 's', cx: svgWidth / 2, cy: svgHeight, cursor: 'ns-resize' }, - { id: 'se', cx: svgWidth, cy: svgHeight, cursor: 'nwse-resize' } +// Corner handles only — 4 L-bracket shapes. Each `path` is relative to the corner centre (0,0). +const handles = $derived< + Array<{ id: HandleId; cx: number; cy: number; cursor: string; path: string }> +>([ + { id: 'nw', cx: 0, cy: 0, cursor: 'nwse-resize', path: `M ${ARM},0 L 0,0 L 0,${ARM}` }, + { id: 'ne', cx: svgWidth, cy: 0, cursor: 'nesw-resize', path: `M ${-ARM},0 L 0,0 L 0,${ARM}` }, + { id: 'sw', cx: 0, cy: svgHeight, cursor: 'nesw-resize', path: `M ${ARM},0 L 0,0 L 0,${-ARM}` }, + { + id: 'se', + cx: svgWidth, + cy: svgHeight, + cursor: 'nwse-resize', + path: `M ${-ARM},0 L 0,0 L 0,${-ARM}` + } ]); function pixelToNorm(dx: number, dy: number): { nx: number; ny: number } { @@ -85,19 +92,21 @@ function applyHandleDrag(handleId: HandleId, nx: number, ny: number, ds: DragSta w = ds.preDragWidth, h = ds.preDragHeight; - if (['nw', 'w', 'sw'].includes(handleId)) { + // Horizontal axis + if (handleId === 'nw' || handleId === 'sw') { const newX = Math.max(0, Math.min(x + w - MIN, x + nx)); w = w - (newX - x); x = newX; - } else if (['ne', 'e', 'se'].includes(handleId)) { + } else { w = Math.max(MIN, Math.min(1 - x, w + nx)); } - if (['nw', 'n', 'ne'].includes(handleId)) { + // Vertical axis + if (handleId === 'nw' || handleId === 'ne') { const newY = Math.max(0, Math.min(y + h - MIN, y + ny)); h = h - (newY - y); y = newY; - } else if (['sw', 's', 'se'].includes(handleId)) { + } else { h = Math.max(MIN, Math.min(1 - y, h + ny)); } @@ -246,6 +255,20 @@ let previewH = $derived((liveHeight / annotation.height) * svgHeight); /> {/if} + + + {#each handles as handle (handle.id)} handlePointerDown(e, 'handle', handle.id)} > - + {/each} diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts b/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts index 1afa47eb..e6557342 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts @@ -16,18 +16,27 @@ const annotation: Annotation = { }; describe('AnnotationEditOverlay', () => { - it('renders 8 handle elements', async () => { + it('renders 4 corner handle elements', async () => { render(AnnotationEditOverlay, { annotation }); const handles = document.querySelectorAll('[data-handle]'); - expect(handles).toHaveLength(8); + expect(handles).toHaveLength(4); + }); + + it('renders handles for all four corners', async () => { + render(AnnotationEditOverlay, { annotation }); + + expect(document.querySelector('[data-handle="nw"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="ne"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="sw"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="se"]')).not.toBeNull(); }); it('each handle has a 44x44 hit area', async () => { render(AnnotationEditOverlay, { annotation }); const hitAreas = document.querySelectorAll('[data-handle-hit]'); - expect(hitAreas).toHaveLength(8); + expect(hitAreas).toHaveLength(4); hitAreas.forEach((el) => { expect(el.getAttribute('width')).toBe('44'); expect(el.getAttribute('height')).toBe('44'); diff --git a/frontend/src/lib/components/AnnotationLayer.svelte.test.ts b/frontend/src/lib/components/AnnotationLayer.svelte.test.ts index 5c78f3a2..5a242288 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte.test.ts +++ b/frontend/src/lib/components/AnnotationLayer.svelte.test.ts @@ -87,7 +87,7 @@ describe('AnnotationLayer', () => { }); const handles = document.querySelectorAll('[data-handle]'); - expect(handles).toHaveLength(8); + expect(handles).toHaveLength(4); }); it('passes isResizable=false when annotation has a polygon', async () => { diff --git a/frontend/src/lib/components/AnnotationShape.svelte.test.ts b/frontend/src/lib/components/AnnotationShape.svelte.test.ts index f5ad754e..7c7058b8 100644 --- a/frontend/src/lib/components/AnnotationShape.svelte.test.ts +++ b/frontend/src/lib/components/AnnotationShape.svelte.test.ts @@ -44,7 +44,7 @@ describe('AnnotationShape', () => { }); const handles = document.querySelectorAll('[data-handle]'); - expect(handles).toHaveLength(8); + expect(handles).toHaveLength(4); }); }); }); -- 2.49.1 From 9fe5b32a69276871569558984c76490671a809ff Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 11:40:39 +0200 Subject: [PATCH 12/25] feat(annotations): add N/S/E/W edge midpoint handles to resize overlay Extends the 4-corner L-bracket handles with 4 tick-mark edge handles (short lines along each edge), enabling single-axis resize from any edge. Updates applyHandleDrag to route each handle to the correct axis. Co-Authored-By: Claude Sonnet 4.6 --- .../components/AnnotationEditOverlay.svelte | 32 +++++++++++++------ .../AnnotationEditOverlay.svelte.test.ts | 12 ++++--- .../components/AnnotationLayer.svelte.test.ts | 2 +- .../components/AnnotationShape.svelte.test.ts | 2 +- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte index 58cff56e..9793287f 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -42,7 +42,7 @@ $effect(() => { return () => ro.disconnect(); }); -type HandleId = 'nw' | 'ne' | 'sw' | 'se'; +type HandleId = 'nw' | 'ne' | 'sw' | 'se' | 'n' | 's' | 'e' | 'w'; // L-bracket arm length in pixels. Each corner shows two short lines meeting at 90°. const ARM = 10; @@ -60,7 +60,8 @@ type DragState = { let dragState = $state(null); -// Corner handles only — 4 L-bracket shapes. Each `path` is relative to the corner centre (0,0). +// 8 handles: 4 L-bracket corners + 4 tick-mark edge midpoints. +// Each `path` is relative to the handle centre (0,0). const handles = $derived< Array<{ id: HandleId; cx: number; cy: number; cursor: string; path: string }> >([ @@ -73,7 +74,17 @@ const handles = $derived< cy: svgHeight, cursor: 'nwse-resize', path: `M ${-ARM},0 L 0,0 L 0,${-ARM}` - } + }, + { id: 'n', cx: svgWidth / 2, cy: 0, cursor: 'ns-resize', path: `M ${-ARM},0 L ${ARM},0` }, + { + id: 's', + cx: svgWidth / 2, + cy: svgHeight, + cursor: 'ns-resize', + path: `M ${-ARM},0 L ${ARM},0` + }, + { id: 'e', cx: svgWidth, cy: svgHeight / 2, cursor: 'ew-resize', path: `M 0,${-ARM} L 0,${ARM}` }, + { id: 'w', cx: 0, cy: svgHeight / 2, cursor: 'ew-resize', path: `M 0,${-ARM} L 0,${ARM}` } ]); function pixelToNorm(dx: number, dy: number): { nx: number; ny: number } { @@ -92,21 +103,24 @@ function applyHandleDrag(handleId: HandleId, nx: number, ny: number, ds: DragSta w = ds.preDragWidth, h = ds.preDragHeight; - // Horizontal axis - if (handleId === 'nw' || handleId === 'sw') { + const movesLeftEdge = handleId === 'nw' || handleId === 'sw' || handleId === 'w'; + const movesRightEdge = handleId === 'ne' || handleId === 'se' || handleId === 'e'; + const movesTopEdge = handleId === 'nw' || handleId === 'ne' || handleId === 'n'; + const movesBottomEdge = handleId === 'sw' || handleId === 'se' || handleId === 's'; + + if (movesLeftEdge) { const newX = Math.max(0, Math.min(x + w - MIN, x + nx)); w = w - (newX - x); x = newX; - } else { + } else if (movesRightEdge) { w = Math.max(MIN, Math.min(1 - x, w + nx)); } - // Vertical axis - if (handleId === 'nw' || handleId === 'ne') { + if (movesTopEdge) { const newY = Math.max(0, Math.min(y + h - MIN, y + ny)); h = h - (newY - y); y = newY; - } else { + } else if (movesBottomEdge) { h = Math.max(MIN, Math.min(1 - y, h + ny)); } diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts b/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts index e6557342..e6af36e8 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts @@ -16,27 +16,31 @@ const annotation: Annotation = { }; describe('AnnotationEditOverlay', () => { - it('renders 4 corner handle elements', async () => { + it('renders 8 handle elements', async () => { render(AnnotationEditOverlay, { annotation }); const handles = document.querySelectorAll('[data-handle]'); - expect(handles).toHaveLength(4); + expect(handles).toHaveLength(8); }); - it('renders handles for all four corners', async () => { + it('renders handles for all four corners and four edge midpoints', async () => { render(AnnotationEditOverlay, { annotation }); expect(document.querySelector('[data-handle="nw"]')).not.toBeNull(); expect(document.querySelector('[data-handle="ne"]')).not.toBeNull(); expect(document.querySelector('[data-handle="sw"]')).not.toBeNull(); expect(document.querySelector('[data-handle="se"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="n"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="s"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="e"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="w"]')).not.toBeNull(); }); it('each handle has a 44x44 hit area', async () => { render(AnnotationEditOverlay, { annotation }); const hitAreas = document.querySelectorAll('[data-handle-hit]'); - expect(hitAreas).toHaveLength(4); + expect(hitAreas).toHaveLength(8); hitAreas.forEach((el) => { expect(el.getAttribute('width')).toBe('44'); expect(el.getAttribute('height')).toBe('44'); diff --git a/frontend/src/lib/components/AnnotationLayer.svelte.test.ts b/frontend/src/lib/components/AnnotationLayer.svelte.test.ts index 5a242288..5c78f3a2 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte.test.ts +++ b/frontend/src/lib/components/AnnotationLayer.svelte.test.ts @@ -87,7 +87,7 @@ describe('AnnotationLayer', () => { }); const handles = document.querySelectorAll('[data-handle]'); - expect(handles).toHaveLength(4); + expect(handles).toHaveLength(8); }); it('passes isResizable=false when annotation has a polygon', async () => { diff --git a/frontend/src/lib/components/AnnotationShape.svelte.test.ts b/frontend/src/lib/components/AnnotationShape.svelte.test.ts index 7c7058b8..f5ad754e 100644 --- a/frontend/src/lib/components/AnnotationShape.svelte.test.ts +++ b/frontend/src/lib/components/AnnotationShape.svelte.test.ts @@ -44,7 +44,7 @@ describe('AnnotationShape', () => { }); const handles = document.querySelectorAll('[data-handle]'); - expect(handles).toHaveLength(4); + expect(handles).toHaveLength(8); }); }); }); -- 2.49.1 From 2350b4f8455380ba5ac61022625708a352dfeb1a Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 11:47:41 +0200 Subject: [PATCH 13/25] fix(annotations): make resize overlay keyboard-interactive - Add tabindex="0" so the SVG can receive DOM focus - Auto-focus the SVG on mount so arrow keys work immediately after clicking an annotation to select it - Show preview rect during keyboard nudging (not just pointer drag) by checking hasLiveChanges instead of only checking dragState - Suppress default browser focus outline (outline: none) on the SVG Co-Authored-By: Claude Sonnet 4.6 --- .../components/AnnotationEditOverlay.svelte | 20 +++++++++++++++---- .../AnnotationEditOverlay.svelte.test.ts | 8 ++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte index 9793287f..f7ce3c95 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -42,6 +42,11 @@ $effect(() => { return () => ro.disconnect(); }); +// Auto-focus the SVG when the overlay mounts so arrow keys work immediately. +$effect(() => { + svgEl?.focus({ preventScroll: true }); +}); + type HandleId = 'nw' | 'ne' | 'sw' | 'se' | 'n' | 's' | 'e' | 'w'; // L-bracket arm length in pixels. Each corner shows two short lines meeting at 90°. @@ -221,24 +226,31 @@ function handleKeyDown(event: KeyboardEvent): void { }, 300); } -// Preview rect in pixel space (maps live normalized coords back to SVG pixel coordinates) +// Preview rect in pixel space (maps live normalized coords back to SVG pixel coordinates). +// Shown during pointer drag and during keyboard nudging (whenever live coords differ from stored). let previewX = $derived(((liveX - annotation.x) / annotation.width) * svgWidth); let previewY = $derived(((liveY - annotation.y) / annotation.height) * svgHeight); let previewW = $derived((liveWidth / annotation.width) * svgWidth); let previewH = $derived((liveHeight / annotation.height) * svgHeight); +let hasLiveChanges = $derived( + liveX !== annotation.x || + liveY !== annotation.y || + liveWidth !== annotation.width || + liveHeight !== annotation.height +);
{m.annotation_edit_mode_active()}
- handlePointerDown(e, 'move')} /> - {#if dragState} + {#if dragState || hasLiveChanges} { const liveRegion = document.querySelector('[aria-live="polite"]'); expect(liveRegion).not.toBeNull(); }); + + it('SVG root has tabindex="0" so it can receive keyboard focus', async () => { + render(AnnotationEditOverlay, { annotation }); + + const svg = document.querySelector('svg[role="application"]'); + expect(svg).not.toBeNull(); + expect(svg!.getAttribute('tabindex')).toBe('0'); + }); }); -- 2.49.1 From 4d3207fc27bd028ad2d97648e5ab0ef4f709b469 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 14:30:50 +0200 Subject: [PATCH 14/25] test(annotations): verify save() is called in updateAnnotation test [M5] Co-Authored-By: Claude Sonnet 4.6 --- .../raddatz/familienarchiv/service/AnnotationServiceTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java index 549edd00..0534a7a3 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java @@ -238,6 +238,7 @@ class AnnotationServiceTest { assertThat(result.getY()).isEqualTo(0.6); assertThat(result.getWidth()).isEqualTo(0.3); // unchanged assertThat(result.getHeight()).isEqualTo(0.4); // unchanged + verify(annotationRepository).save(annotation); } // ─── listAnnotations ────────────────────────────────────────────────────── -- 2.49.1 From 65d606d8bb0c13475e815051e58d2b8d181eb43f Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 14:31:07 +0200 Subject: [PATCH 15/25] test(annotations): add missing height and x boundary validation tests [M4] Co-Authored-By: Claude Sonnet 4.6 --- .../controller/AnnotationControllerTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java index 0da8d073..317cb4fa 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java @@ -246,6 +246,24 @@ class AnnotationControllerTest { .andExpect(status().isBadRequest()); } + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"height\":0.005}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns400_withXAboveMaximum() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"x\":1.1}")) + .andExpect(status().isBadRequest()); + } + // ─── resolveUserId — unauthenticated / null user / exception branches ───── @Test -- 2.49.1 From f00b4709285d69702f7ea7a169bb423772f5df00 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 14:33:43 +0200 Subject: [PATCH 16/25] test(annotations): add failing test for DataIntegrityViolationException defense [M2 red] Co-Authored-By: Claude Sonnet 4.6 --- .../service/AnnotationServiceTest.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java index 0534a7a3..bd3064a9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java @@ -11,6 +11,7 @@ import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.repository.AnnotationRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.springframework.dao.DataIntegrityViolationException; import java.util.List; import java.util.Optional; @@ -22,6 +23,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.FORBIDDEN; import static org.springframework.http.HttpStatus.NOT_FOUND; @@ -241,6 +243,25 @@ class AnnotationServiceTest { verify(annotationRepository).save(annotation); } + @Test + void updateAnnotation_throwsAnnotationUpdateFailed_whenDbConstraintViolated() { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + DocumentAnnotation annotation = DocumentAnnotation.builder() + .id(annotId).documentId(docId) + .x(0.1).y(0.2).width(0.3).height(0.4).build(); + when(annotationRepository.findByIdAndDocumentId(annotId, docId)) + .thenReturn(Optional.of(annotation)); + when(annotationRepository.save(any())).thenThrow(new DataIntegrityViolationException("constraint")); + + UpdateAnnotationDTO dto = new UpdateAnnotationDTO(); + dto.setX(1.5); + + assertThatThrownBy(() -> annotationService.updateAnnotation(docId, annotId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(BAD_REQUEST)); + } + // ─── listAnnotations ────────────────────────────────────────────────────── @Test -- 2.49.1 From a19faa38065a7c3aa07247a106ee179decce59c6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 14:34:03 +0200 Subject: [PATCH 17/25] feat(annotations): add @Slf4j and DataIntegrityViolationException catch to updateAnnotation [M2] Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/service/AnnotationService.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java index e43b78b5..7616a9a0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java @@ -1,6 +1,7 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO; import org.raddatz.familienarchiv.exception.DomainException; @@ -8,12 +9,14 @@ import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.repository.AnnotationRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.UUID; +@Slf4j @Service @RequiredArgsConstructor public class AnnotationService { @@ -74,7 +77,12 @@ public class AnnotationService { if (dto.getWidth() != null) annotation.setWidth(dto.getWidth()); if (dto.getHeight() != null) annotation.setHeight(dto.getHeight()); - return annotationRepository.save(annotation); + try { + return annotationRepository.save(annotation); + } catch (DataIntegrityViolationException e) { + log.warn("Annotation bounds constraint violated for {}: {}", annotationId, e.getMessage()); + throw DomainException.badRequest(ErrorCode.ANNOTATION_UPDATE_FAILED, "Bounds out of range"); + } } @Transactional -- 2.49.1 From 40c8f548db16b0bd306b1143c198b10557704613 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 14:34:55 +0200 Subject: [PATCH 18/25] docs(annotations): fix ANNOTATION_UPDATE_FAILED Javadoc to reflect 400 status [M3] Co-Authored-By: Claude Sonnet 4.6 --- .../java/org/raddatz/familienarchiv/exception/ErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 236317c7..26aab838 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -49,7 +49,7 @@ public enum ErrorCode { // --- Annotations --- /** The annotation with the given ID does not exist. 404 */ ANNOTATION_NOT_FOUND, - /** The annotation position/size could not be saved. 500 */ + /** The annotation position/size could not be saved (bounds constraint violated). 400 */ ANNOTATION_UPDATE_FAILED, // --- Transcription Blocks --- -- 2.49.1 From 72700bd28ff94b5f67f8637c5d17c1a154bd213c Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 14:36:37 +0200 Subject: [PATCH 19/25] test(annotations): add Testcontainers integration tests for V33 chk_annotation_bounds [B1] Co-Authored-By: Claude Sonnet 4.6 --- .../repository/MigrationIntegrationTest.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java index 7fb3c406..db5b98a3 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java @@ -121,6 +121,53 @@ class MigrationIntegrationTest { assertThat(rows2).isEqualTo(1); } + // ─── V33: chk_annotation_bounds CHECK constraint ───────────────────────── + + @Test + void v33_boundsCheckConstraint_rejectsXAboveOne() { + UUID docId = createDocument(); + + assertThatThrownBy(() -> + jdbc.update( + """ + INSERT INTO document_annotations + (id, document_id, page_number, x, y, width, height, color) + VALUES (gen_random_uuid(), ?, 1, 1.5, 0.1, 0.3, 0.1, '#ff0000') + """, + docId) + ).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void v33_boundsCheckConstraint_rejectsHeightBelowMinimum() { + UUID docId = createDocument(); + + assertThatThrownBy(() -> + jdbc.update( + """ + INSERT INTO document_annotations + (id, document_id, page_number, x, y, width, height, color) + VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.3, 0.005, '#ff0000') + """, + docId) + ).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void v33_boundsCheckConstraint_acceptsValidAnnotation() { + UUID docId = createDocument(); + + int rows = jdbc.update( + """ + INSERT INTO document_annotations + (id, document_id, page_number, x, y, width, height, color) + VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.3, 0.1, '#ff0000') + """, + docId); + + assertThat(rows).isEqualTo(1); + } + // ─── helpers ───────────────────────────────────────────────────────────── private UUID createDocument() { -- 2.49.1 From 060d1c0515ac7287e8661f6a3de56c86bf6d64e2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 14:38:10 +0200 Subject: [PATCH 20/25] feat(i18n): add annotation_resize_area and annotation_resize_handle message keys [B2, B3] Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 2 ++ frontend/messages/en.json | 2 ++ frontend/messages/es.json | 2 ++ 3 files changed, 6 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index e3f2b2d1..aea547f0 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -3,6 +3,8 @@ "error_annotation_not_found": "Die Annotation wurde nicht gefunden.", "error_annotation_update_failed": "Annotation konnte nicht gespeichert werden.", "annotation_edit_mode_active": "Annotation ausgewählt — Ziehpunkte sichtbar.", + "annotation_resize_area": "Annotationsgröße und -position ändern", + "annotation_resize_handle": "Ziehpunkt {direction}", "annotation_outdated_notice": "Einige Annotationen beziehen sich auf eine frühere Dateiversion und werden nicht angezeigt.", "error_document_not_found": "Das Dokument wurde nicht gefunden.", "error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e059f9b0..f4b958d0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -3,6 +3,8 @@ "error_annotation_not_found": "Annotation not found.", "error_annotation_update_failed": "Could not save annotation position.", "annotation_edit_mode_active": "Annotation selected — resize handles visible.", + "annotation_resize_area": "Resize and reposition annotation", + "annotation_resize_handle": "Resize handle: {direction}", "annotation_outdated_notice": "Some annotations refer to an earlier file version and are not shown.", "error_document_not_found": "Document not found.", "error_document_no_file": "No file is associated with this document.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 7fbd8c0c..2a784daa 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -3,6 +3,8 @@ "error_annotation_not_found": "Anotación no encontrada.", "error_annotation_update_failed": "No se pudo guardar la posición de la anotación.", "annotation_edit_mode_active": "Anotación seleccionada — tiradores visibles.", + "annotation_resize_area": "Cambiar tamaño y posición de la anotación", + "annotation_resize_handle": "Control de redimensión: {direction}", "annotation_outdated_notice": "Algunas anotaciones hacen referencia a una versión anterior del archivo y no se muestran.", "error_document_not_found": "Documento no encontrado.", "error_document_no_file": "No hay ningún archivo asociado a este documento.", -- 2.49.1 From 4d9145e49fd79d3d2c2d2b69d81429d9f4a1c6c0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 14:39:35 +0200 Subject: [PATCH 21/25] feat(annotations): wire SVG aria-label to Paraglide i18n [B3] Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/AnnotationEditOverlay.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte index f7ce3c95..595459f3 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -249,7 +249,7 @@ let hasLiveChanges = $derived( viewBox="0 0 {svgWidth} {svgHeight}" role="application" tabindex="0" - aria-label="Annotation resize handles" + aria-label={m.annotation_resize_area()} style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; touch-action: none; overflow: visible; outline: none;" onpointermove={handlePointerMove} onpointerup={handlePointerUp} -- 2.49.1 From 7097f991fef17d8fc0f6580982a33f6403bc800c Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 14:40:30 +0200 Subject: [PATCH 22/25] feat(annotations): add keyboard accessibility to resize handles [B2] Co-Authored-By: Claude Sonnet 4.6 --- .../components/AnnotationEditOverlay.svelte | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte index 595459f3..bf920f2f 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -226,6 +226,17 @@ function handleKeyDown(event: KeyboardEvent): void { }, 300); } +const directionLabels: Record = { + nw: 'NW', + ne: 'NE', + sw: 'SW', + se: 'SE', + n: 'N', + s: 'S', + e: 'E', + w: 'W' +}; + // Preview rect in pixel space (maps live normalized coords back to SVG pixel coordinates). // Shown during pointer drag and during keyboard nudging (whenever live coords differ from stored). let previewX = $derived(((liveX - annotation.x) / annotation.width) * svgWidth); @@ -298,10 +309,15 @@ let hasLiveChanges = $derived( {#each handles as handle (handle.id)} handlePointerDown(e, 'handle', handle.id)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') e.preventDefault(); + }} > -- 2.49.1 From 7125a0a8eb6d6f027b05a99ca12c0f888e715d15 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 14:40:55 +0200 Subject: [PATCH 23/25] fix(annotations): reset liveWidth/liveHeight in handleKeyDown error rollback [M1, M6] Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/AnnotationEditOverlay.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte index bf920f2f..baf9afaa 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -219,9 +219,12 @@ function handleKeyDown(event: KeyboardEvent): void { width: liveWidth, height: liveHeight }); - } catch { + } catch (err) { + console.error('annotation keyboard update failed', err); liveX = annotation.x; liveY = annotation.y; + liveWidth = annotation.width; + liveHeight = annotation.height; } }, 300); } -- 2.49.1 From 76828a95e3c58b4511e521f620e75617d4eb0bb5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 14:41:21 +0200 Subject: [PATCH 24/25] fix(annotations): add catch(err) binding to handlePointerUp error handler [M6] Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/AnnotationEditOverlay.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte index baf9afaa..367a9e7a 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -190,7 +190,8 @@ async function handlePointerUp(event: PointerEvent): Promise { width: liveWidth, height: liveHeight }); - } catch { + } catch (err) { + console.error('annotation drag update failed', err); liveX = ds.preDragX; liveY = ds.preDragY; liveWidth = ds.preDragWidth; -- 2.49.1 From 28ac90b529546efd8de60e5def9514886bdbf58a Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 14:42:01 +0200 Subject: [PATCH 25/25] fix(annotations): replace outline:none with focus-visible ring for keyboard accessibility [M7] Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/AnnotationEditOverlay.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte index 367a9e7a..d4da250e 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -265,7 +265,7 @@ let hasLiveChanges = $derived( role="application" tabindex="0" aria-label={m.annotation_resize_area()} - style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; touch-action: none; overflow: visible; outline: none;" + style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; touch-action: none; overflow: visible;" onpointermove={handlePointerMove} onpointerup={handlePointerUp} onkeydown={handleKeyDown} @@ -330,6 +330,11 @@ let hasLiveChanges = $derived(