feat(annotations): resize and move annotations in document view #235
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
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.AnnotationService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -45,6 +47,15 @@ public class AnnotationController {
|
|||||||
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
|
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}")
|
@DeleteMapping("/{annotationId}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -49,6 +49,8 @@ public enum ErrorCode {
|
|||||||
// --- Annotations ---
|
// --- Annotations ---
|
||||||
/** The annotation with the given ID does not exist. 404 */
|
/** The annotation with the given ID does not exist. 404 */
|
||||||
ANNOTATION_NOT_FOUND,
|
ANNOTATION_NOT_FOUND,
|
||||||
|
/** The annotation position/size could not be saved (bounds constraint violated). 400 */
|
||||||
|
ANNOTATION_UPDATE_FAILED,
|
||||||
|
|
||||||
// --- Transcription Blocks ---
|
// --- Transcription Blocks ---
|
||||||
/** The transcription block with the given ID does not exist. 404 */
|
/** The transcription block with the given ID does not exist. 404 */
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AnnotationService {
|
public class AnnotationService {
|
||||||
@@ -61,6 +65,26 @@ public class AnnotationService {
|
|||||||
return annotationRepository.save(annotation);
|
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
|
@Transactional
|
||||||
public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) {
|
public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) {
|
||||||
DocumentAnnotation annotation = annotationRepository
|
DocumentAnnotation annotation = annotationRepository
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -27,6 +27,7 @@ import static org.mockito.ArgumentMatchers.any;
|
|||||||
import static org.mockito.Mockito.when;
|
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.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
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.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
@@ -160,6 +161,109 @@ class AnnotationControllerTest {
|
|||||||
.andExpect(status().isNoContent());
|
.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 ─────
|
// ─── resolveUserId — unauthenticated / null user / exception branches ─────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -121,6 +121,53 @@ class MigrationIntegrationTest {
|
|||||||
assertThat(rows2).isEqualTo(1);
|
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 ─────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private UUID createDocument() {
|
private UUID createDocument() {
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
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.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
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.FORBIDDEN;
|
||||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
@@ -203,6 +206,62 @@ class AnnotationServiceTest {
|
|||||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
.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 ──────────────────────────────────────────────────────
|
// ─── listAnnotations ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
|
"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.",
|
"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_not_found": "Das Dokument wurde nicht gefunden.",
|
||||||
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"error_annotation_not_found": "Annotation not found.",
|
"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.",
|
"annotation_outdated_notice": "Some annotations refer to an earlier file version and are not shown.",
|
||||||
"error_document_not_found": "Document not found.",
|
"error_document_not_found": "Document not found.",
|
||||||
"error_document_no_file": "No file is associated with this document.",
|
"error_document_no_file": "No file is associated with this document.",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"error_annotation_not_found": "Anotación no encontrada.",
|
"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.",
|
"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_not_found": "Documento no encontrado.",
|
||||||
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
||||||
|
|||||||
349
frontend/src/lib/components/AnnotationEditOverlay.svelte
Normal file
349
frontend/src/lib/components/AnnotationEditOverlay.svelte
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import type { Annotation } from '$lib/types';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type UpdateAnnotationFn = (
|
||||||
|
id: string,
|
||||||
|
coords: { x: number; y: number; width: number; height: number }
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
const updateAnnotation: UpdateAnnotationFn =
|
||||||
|
getContext('annotationUpdate') ?? (() => Promise.resolve());
|
||||||
|
|
||||||
|
let { annotation }: { annotation: Annotation } = $props();
|
||||||
|
|
||||||
|
let liveX = $state<number>(0);
|
||||||
|
let liveY = $state<number>(0);
|
||||||
|
let liveWidth = $state<number>(0);
|
||||||
|
let liveHeight = $state<number>(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
liveX = annotation.x;
|
||||||
|
liveY = annotation.y;
|
||||||
|
liveWidth = annotation.width;
|
||||||
|
liveHeight = annotation.height;
|
||||||
|
});
|
||||||
|
|
||||||
|
let svgEl = $state<SVGSVGElement | null>(null);
|
||||||
|
|
||||||
|
// Actual rendered pixel dimensions of the SVG — updated by ResizeObserver.
|
||||||
|
// Used as the viewBox so handles are always physically 16×16px regardless of annotation aspect ratio.
|
||||||
|
let svgWidth = $state(1);
|
||||||
|
let svgHeight = $state(1);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!svgEl) return;
|
||||||
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
|
svgWidth = entry.contentRect.width || 1;
|
||||||
|
svgHeight = entry.contentRect.height || 1;
|
||||||
|
});
|
||||||
|
ro.observe(svgEl);
|
||||||
|
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°.
|
||||||
|
const ARM = 10;
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
type: 'handle' | 'move';
|
||||||
|
handleId?: HandleId;
|
||||||
|
startPointerX: number;
|
||||||
|
startPointerY: number;
|
||||||
|
preDragX: number;
|
||||||
|
preDragY: number;
|
||||||
|
preDragWidth: number;
|
||||||
|
preDragHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let dragState = $state<DragState | null>(null);
|
||||||
|
|
||||||
|
// 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 }>
|
||||||
|
>([
|
||||||
|
{ 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}`
|
||||||
|
},
|
||||||
|
{ 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 } {
|
||||||
|
if (!svgEl) return { nx: 0, ny: 0 };
|
||||||
|
const rect = svgEl.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
nx: (dx / rect.width) * annotation.width,
|
||||||
|
ny: (dy / rect.height) * annotation.height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHandleDrag(handleId: HandleId, nx: number, ny: number, ds: DragState): void {
|
||||||
|
const MIN = 0.01;
|
||||||
|
let x = ds.preDragX,
|
||||||
|
y = ds.preDragY,
|
||||||
|
w = ds.preDragWidth,
|
||||||
|
h = ds.preDragHeight;
|
||||||
|
|
||||||
|
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 if (movesRightEdge) {
|
||||||
|
w = Math.max(MIN, Math.min(1 - x, w + nx));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (movesTopEdge) {
|
||||||
|
const newY = Math.max(0, Math.min(y + h - MIN, y + ny));
|
||||||
|
h = h - (newY - y);
|
||||||
|
y = newY;
|
||||||
|
} else if (movesBottomEdge) {
|
||||||
|
h = Math.max(MIN, Math.min(1 - y, h + ny));
|
||||||
|
}
|
||||||
|
|
||||||
|
liveX = x;
|
||||||
|
liveY = y;
|
||||||
|
liveWidth = w;
|
||||||
|
liveHeight = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(
|
||||||
|
event: PointerEvent,
|
||||||
|
type: 'handle' | 'move',
|
||||||
|
handleId?: HandleId
|
||||||
|
): void {
|
||||||
|
if (!event.isPrimary) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
(event.currentTarget as Element).setPointerCapture(event.pointerId);
|
||||||
|
dragState = {
|
||||||
|
type,
|
||||||
|
handleId,
|
||||||
|
startPointerX: event.clientX,
|
||||||
|
startPointerY: event.clientY,
|
||||||
|
preDragX: liveX,
|
||||||
|
preDragY: liveY,
|
||||||
|
preDragWidth: liveWidth,
|
||||||
|
preDragHeight: liveHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent): void {
|
||||||
|
if (!dragState || !event.isPrimary) return;
|
||||||
|
const dx = event.clientX - dragState.startPointerX;
|
||||||
|
const dy = event.clientY - dragState.startPointerY;
|
||||||
|
const { nx, ny } = pixelToNorm(dx, dy);
|
||||||
|
|
||||||
|
if (dragState.type === 'move') {
|
||||||
|
liveX = Math.max(0, Math.min(1 - dragState.preDragWidth, dragState.preDragX + nx));
|
||||||
|
liveY = Math.max(0, Math.min(1 - dragState.preDragHeight, dragState.preDragY + ny));
|
||||||
|
} else if (dragState.handleId) {
|
||||||
|
applyHandleDrag(dragState.handleId, nx, ny, dragState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePointerUp(event: PointerEvent): Promise<void> {
|
||||||
|
if (!dragState || !event.isPrimary) return;
|
||||||
|
const ds = dragState;
|
||||||
|
dragState = null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
liveX === ds.preDragX &&
|
||||||
|
liveY === ds.preDragY &&
|
||||||
|
liveWidth === ds.preDragWidth &&
|
||||||
|
liveHeight === ds.preDragHeight
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateAnnotation(annotation.id, {
|
||||||
|
x: liveX,
|
||||||
|
y: liveY,
|
||||||
|
width: liveWidth,
|
||||||
|
height: liveHeight
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('annotation drag update failed', err);
|
||||||
|
liveX = ds.preDragX;
|
||||||
|
liveY = ds.preDragY;
|
||||||
|
liveWidth = ds.preDragWidth;
|
||||||
|
liveHeight = ds.preDragHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent): void {
|
||||||
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const STEP = event.shiftKey ? 0.05 : 0.005;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft') liveX = Math.max(0, liveX - STEP);
|
||||||
|
if (event.key === 'ArrowRight') liveX = Math.min(1 - liveWidth, liveX + STEP);
|
||||||
|
if (event.key === 'ArrowUp') liveY = Math.max(0, liveY - STEP);
|
||||||
|
if (event.key === 'ArrowDown') liveY = Math.min(1 - liveHeight, liveY + STEP);
|
||||||
|
|
||||||
|
if (keyDebounceTimer) clearTimeout(keyDebounceTimer);
|
||||||
|
keyDebounceTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await updateAnnotation(annotation.id, {
|
||||||
|
x: liveX,
|
||||||
|
y: liveY,
|
||||||
|
width: liveWidth,
|
||||||
|
height: liveHeight
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('annotation keyboard update failed', err);
|
||||||
|
liveX = annotation.x;
|
||||||
|
liveY = annotation.y;
|
||||||
|
liveWidth = annotation.width;
|
||||||
|
liveHeight = annotation.height;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionLabels: Record<HandleId, string> = {
|
||||||
|
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);
|
||||||
|
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
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div aria-live="polite" class="sr-only">
|
||||||
|
{m.annotation_edit_mode_active()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
bind:this={svgEl}
|
||||||
|
viewBox="0 0 {svgWidth} {svgHeight}"
|
||||||
|
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;"
|
||||||
|
onpointermove={handlePointerMove}
|
||||||
|
onpointerup={handlePointerUp}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
data-move-area
|
||||||
|
role="none"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width={svgWidth}
|
||||||
|
height={svgHeight}
|
||||||
|
fill="transparent"
|
||||||
|
style="cursor: move; pointer-events: all;"
|
||||||
|
onpointerdown={(e) => handlePointerDown(e, 'move')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if dragState || hasLiveChanges}
|
||||||
|
<rect
|
||||||
|
x={previewX}
|
||||||
|
y={previewY}
|
||||||
|
width={previewW}
|
||||||
|
height={previewH}
|
||||||
|
fill="none"
|
||||||
|
stroke="#002850"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-dasharray="4 3"
|
||||||
|
pointer-events="none"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Dashed selection border — signals the annotation is in edit mode -->
|
||||||
|
<rect
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width={svgWidth}
|
||||||
|
height={svgHeight}
|
||||||
|
fill="none"
|
||||||
|
stroke="#002850"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-dasharray="4 3"
|
||||||
|
opacity="0.6"
|
||||||
|
pointer-events="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#each handles as handle (handle.id)}
|
||||||
|
<g
|
||||||
|
data-handle={handle.id}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label={m.annotation_resize_handle({ direction: directionLabels[handle.id] })}
|
||||||
|
transform="translate({handle.cx}, {handle.cy})"
|
||||||
|
style="cursor: {handle.cursor}; pointer-events: all;"
|
||||||
|
onpointerdown={(e) => handlePointerDown(e, 'handle', handle.id)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<rect data-handle-hit x="-22" y="-22" width="44" height="44" fill="transparent" />
|
||||||
|
<path d={handle.path} fill="none" stroke="#002850" stroke-width="2" stroke-linecap="square" />
|
||||||
|
</g>
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
svg[role='application']:focus-visible {
|
||||||
|
outline: 2px solid #002850;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -107,6 +107,7 @@ const containerStyle = $derived(
|
|||||||
annotation={annotation}
|
annotation={annotation}
|
||||||
isHovered={hoveredId === annotation.id}
|
isHovered={hoveredId === annotation.id}
|
||||||
isActive={annotation.id === activeAnnotationId}
|
isActive={annotation.id === activeAnnotationId}
|
||||||
|
isResizable={canDraw && annotation.id === activeAnnotationId && !annotation.polygon}
|
||||||
faded={!dimmed && !!activeAnnotationId && annotation.id !== activeAnnotationId}
|
faded={!dimmed && !!activeAnnotationId && annotation.id !== activeAnnotationId}
|
||||||
dimmed={dimmed}
|
dimmed={dimmed}
|
||||||
blockNumber={blockNumbers[annotation.id]}
|
blockNumber={blockNumbers[annotation.id]}
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ const annotation: Annotation = {
|
|||||||
createdAt: '2026-01-01T00:00:00Z'
|
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('AnnotationLayer', () => {
|
||||||
describe('dimmed prop', () => {
|
describe('dimmed prop', () => {
|
||||||
it('should hide block number badges when dimmed is true', async () => {
|
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', () => {
|
describe('flashAnnotationId prop', () => {
|
||||||
it('should apply annotation-flash class when flashAnnotationId matches', async () => {
|
it('should apply annotation-flash class when flashAnnotationId matches', async () => {
|
||||||
render(AnnotationLayer, {
|
render(AnnotationLayer, {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Annotation } from '$lib/types';
|
import type { Annotation } from '$lib/types';
|
||||||
|
import AnnotationEditOverlay from './AnnotationEditOverlay.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
annotation,
|
annotation,
|
||||||
@@ -9,6 +10,7 @@ let {
|
|||||||
dimmed = false,
|
dimmed = false,
|
||||||
blockNumber = undefined,
|
blockNumber = undefined,
|
||||||
isFlashing = false,
|
isFlashing = false,
|
||||||
|
isResizable = false,
|
||||||
onclick,
|
onclick,
|
||||||
onpointerenter,
|
onpointerenter,
|
||||||
onpointerleave
|
onpointerleave
|
||||||
@@ -20,6 +22,7 @@ let {
|
|||||||
dimmed?: boolean;
|
dimmed?: boolean;
|
||||||
blockNumber?: number | undefined;
|
blockNumber?: number | undefined;
|
||||||
isFlashing?: boolean;
|
isFlashing?: boolean;
|
||||||
|
isResizable?: boolean;
|
||||||
onclick: () => void;
|
onclick: () => void;
|
||||||
onpointerenter: () => void;
|
onpointerenter: () => void;
|
||||||
onpointerleave: () => void;
|
onpointerleave: () => void;
|
||||||
@@ -109,6 +112,9 @@ let shapeStyle = $derived(
|
|||||||
{blockNumber}
|
{blockNumber}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if isResizable}
|
||||||
|
<AnnotationEditOverlay annotation={annotation} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
50
frontend/src/lib/components/AnnotationShape.svelte.test.ts
Normal file
50
frontend/src/lib/components/AnnotationShape.svelte.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import AnnotationShape from './AnnotationShape.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('AnnotationShape', () => {
|
||||||
|
describe('isResizable prop', () => {
|
||||||
|
it('does not render AnnotationEditOverlay when isResizable is false', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation,
|
||||||
|
isHovered: false,
|
||||||
|
isActive: false,
|
||||||
|
isResizable: false,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handles = document.querySelectorAll('[data-handle]');
|
||||||
|
expect(handles).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders AnnotationEditOverlay when isResizable is true', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation,
|
||||||
|
isHovered: false,
|
||||||
|
isActive: true,
|
||||||
|
isResizable: true,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handles = document.querySelectorAll('[data-handle]');
|
||||||
|
expect(handles).toHaveLength(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, setContext } from 'svelte';
|
||||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||||
import type { Annotation } from '$lib/types';
|
import type { Annotation } from '$lib/types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number };
|
type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number };
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ let pdfjsReady = $state(false);
|
|||||||
|
|
||||||
let annotations = $state<Annotation[]>([]);
|
let annotations = $state<Annotation[]>([]);
|
||||||
let showAnnotations = $state(true);
|
let showAnnotations = $state(true);
|
||||||
|
let annotationUpdateError = $state<string | null>(null);
|
||||||
|
|
||||||
const TRANSCRIPTION_COLOR = '#00C7B1';
|
const TRANSCRIPTION_COLOR = '#00C7B1';
|
||||||
|
|
||||||
@@ -186,6 +188,29 @@ async function loadAnnotations(docId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateAnnotation(
|
||||||
|
annotationId: string,
|
||||||
|
coords: { x: number; y: number; width: number; height: number }
|
||||||
|
) {
|
||||||
|
if (!documentId) return;
|
||||||
|
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(coords)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await parseBackendError(res);
|
||||||
|
const msg = getErrorMessage(err?.code ?? 'ANNOTATION_UPDATE_FAILED');
|
||||||
|
annotationUpdateError = msg;
|
||||||
|
setTimeout(() => (annotationUpdateError = null), 4000);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
const updated = await res.json();
|
||||||
|
annotations = annotations.map((a) => (a.id === annotationId ? updated : a));
|
||||||
|
}
|
||||||
|
|
||||||
|
setContext('annotationUpdate', updateAnnotation);
|
||||||
|
|
||||||
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
||||||
if (!documentId || !transcribeMode) return;
|
if (!documentId || !transcribeMode) return;
|
||||||
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
|
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
|
||||||
@@ -306,6 +331,26 @@ function zoomOut() {
|
|||||||
<span class="font-sans text-xs text-amber-300">{m.annotation_outdated_notice()}</span>
|
<span class="font-sans text-xs text-amber-300">{m.annotation_outdated_notice()}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if annotationUpdateError}
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center gap-2 border-b border-red-500/30 bg-red-500/10 px-4 py-2"
|
||||||
|
aria-live="assertive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 shrink-0 text-red-400"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-sans text-xs text-red-300">{annotationUpdateError}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<!-- Controls -->
|
<!-- Controls -->
|
||||||
<div
|
<div
|
||||||
class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2"
|
class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type ErrorCode =
|
|||||||
| 'IMPORT_ALREADY_RUNNING'
|
| 'IMPORT_ALREADY_RUNNING'
|
||||||
| 'INVALID_RESET_TOKEN'
|
| 'INVALID_RESET_TOKEN'
|
||||||
| 'ANNOTATION_NOT_FOUND'
|
| 'ANNOTATION_NOT_FOUND'
|
||||||
|
| 'ANNOTATION_UPDATE_FAILED'
|
||||||
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
|
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
|
||||||
| 'TRANSCRIPTION_BLOCK_CONFLICT'
|
| 'TRANSCRIPTION_BLOCK_CONFLICT'
|
||||||
| 'COMMENT_NOT_FOUND'
|
| 'COMMENT_NOT_FOUND'
|
||||||
@@ -81,6 +82,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_invalid_reset_token();
|
return m.error_invalid_reset_token();
|
||||||
case 'ANNOTATION_NOT_FOUND':
|
case 'ANNOTATION_NOT_FOUND':
|
||||||
return m.error_annotation_not_found();
|
return m.error_annotation_not_found();
|
||||||
|
case 'ANNOTATION_UPDATE_FAILED':
|
||||||
|
return m.error_annotation_update_failed();
|
||||||
case 'TRANSCRIPTION_BLOCK_NOT_FOUND':
|
case 'TRANSCRIPTION_BLOCK_NOT_FOUND':
|
||||||
return m.error_transcription_block_not_found();
|
return m.error_transcription_block_not_found();
|
||||||
case 'TRANSCRIPTION_BLOCK_CONFLICT':
|
case 'TRANSCRIPTION_BLOCK_CONFLICT':
|
||||||
|
|||||||
Reference in New Issue
Block a user