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/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/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 60930824..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,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 (bounds constraint violated). 400 */ + ANNOTATION_UPDATE_FAILED, // --- Transcription Blocks --- /** The transcription block with the given ID does not exist. 404 */ 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..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,18 +1,22 @@ 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; 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 { @@ -61,6 +65,26 @@ 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()); + + 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 public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) { DocumentAnnotation annotation = annotationRepository 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 + ); 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..317cb4fa 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,109 @@ 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()); + } + + @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 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() { 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..bd3064a9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java @@ -6,10 +6,12 @@ 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; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.springframework.dao.DataIntegrityViolationException; import java.util.List; import java.util.Optional; @@ -21,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; @@ -203,6 +206,62 @@ 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 + 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 diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 48135368..aea547f0 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1,6 +1,10 @@ { "$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_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 8f55b111..f4b958d0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1,6 +1,10 @@ { "$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_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 b4b0ba65..2a784daa 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1,6 +1,10 @@ { "$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_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.", diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte new file mode 100644 index 00000000..d4da250e --- /dev/null +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -0,0 +1,349 @@ + + +
+ {m.annotation_edit_mode_active()} +
+ + + handlePointerDown(e, 'move')} + /> + + {#if dragState || hasLiveChanges} + + {/if} + + + + + {#each handles as handle (handle.id)} + handlePointerDown(e, 'handle', handle.id)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') e.preventDefault(); + }} + > + + + + {/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..61ab18d6 --- /dev/null +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts @@ -0,0 +1,71 @@ +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('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(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(); + }); + + 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'); + }); +}); diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte index 65e87b42..ac7dbc7c 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -107,6 +107,7 @@ const containerStyle = $derived( annotation={annotation} isHovered={hoveredId === annotation.id} isActive={annotation.id === activeAnnotationId} + isResizable={canDraw && annotation.id === activeAnnotationId && !annotation.polygon} faded={!dimmed && !!activeAnnotationId && annotation.id !== activeAnnotationId} dimmed={dimmed} blockNumber={blockNumbers[annotation.id]} diff --git a/frontend/src/lib/components/AnnotationLayer.svelte.test.ts b/frontend/src/lib/components/AnnotationLayer.svelte.test.ts index 1f549cd3..5c78f3a2 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte.test.ts +++ b/frontend/src/lib/components/AnnotationLayer.svelte.test.ts @@ -16,6 +16,17 @@ const annotation: Annotation = { createdAt: '2026-01-01T00:00:00Z' }; +const polygonAnnotation: Annotation = { + ...annotation, + id: 'ann-poly', + polygon: [ + [0.1, 0.2], + [0.4, 0.21], + [0.39, 0.29], + [0.11, 0.28] + ] +}; + describe('AnnotationLayer', () => { describe('dimmed prop', () => { it('should hide block number badges when dimmed is true', async () => { @@ -65,6 +76,60 @@ describe('AnnotationLayer', () => { }); }); + describe('isResizable computation', () => { + it('passes isResizable=true when canDraw, annotation is active, and has no polygon', async () => { + render(AnnotationLayer, { + annotations: [annotation], + canDraw: true, + color: '#00c7b1', + activeAnnotationId: 'ann-1', + onDraw: () => {} + }); + + const handles = document.querySelectorAll('[data-handle]'); + expect(handles).toHaveLength(8); + }); + + it('passes isResizable=false when annotation has a polygon', async () => { + render(AnnotationLayer, { + annotations: [polygonAnnotation], + canDraw: true, + color: '#00c7b1', + activeAnnotationId: 'ann-poly', + onDraw: () => {} + }); + + const handles = document.querySelectorAll('[data-handle]'); + expect(handles).toHaveLength(0); + }); + + it('passes isResizable=false when canDraw is false', async () => { + render(AnnotationLayer, { + annotations: [annotation], + canDraw: false, + color: '#00c7b1', + activeAnnotationId: 'ann-1', + onDraw: () => {} + }); + + const handles = document.querySelectorAll('[data-handle]'); + expect(handles).toHaveLength(0); + }); + + it('passes isResizable=false when annotation is not active', async () => { + render(AnnotationLayer, { + annotations: [annotation], + canDraw: true, + color: '#00c7b1', + activeAnnotationId: 'other-id', + onDraw: () => {} + }); + + const handles = document.querySelectorAll('[data-handle]'); + expect(handles).toHaveLength(0); + }); + }); + describe('flashAnnotationId prop', () => { it('should apply annotation-flash class when flashAnnotationId matches', async () => { render(AnnotationLayer, { 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 @@