feat(annotations): allow user to resize and move annotation boxes after drawing #233

Closed
opened 2026-04-13 22:42:58 +02:00 by marcel · 10 comments
Owner

Problem

After drawing a rectangular annotation on the PDF viewer, users have no way to adjust its position or size. If the box is slightly off — too wide, shifted, or misaligned with the handwriting — the only option is to delete it and redraw from scratch. This is especially frustrating when fine-tuning regions for guided OCR.

Proposed Solution

When a rectangle annotation is selected in transcription mode, show interactive resize handles and allow the whole box to be dragged to a new position.

UX Design

Edit mode is active when: annotation is selected + transcribeMode is on + annotation has no polygon (i.e. user-drawn, not OCR-placed).

8 resize handles appear at corners and edge midpoints (10×10 px squares, white fill, 2px colored border — matching the annotation color).

Interaction:

  • Drag a corner handle → resize in that direction (nwse-resize / nesw-resize cursor)
  • Drag an edge midpoint → resize along one axis (ns-resize / ew-resize cursor)
  • Drag the annotation interior → move the whole box (move cursor)
  • All changes are optimistic (live visual update) and persisted on pointerup via PATCH /api/documents/{id}/annotations/{annotationId}
  • Minimum size: 1% × 1% (normalized), matching the draw threshold

Polygon (OCR) annotations show NO handles — they remain click-only.

Backend Changes

  • UpdateAnnotationDTO (new): { x, y, width, height } — all optional doubles
  • AnnotationService.updateAnnotation() — load, verify ownership, update fields
  • AnnotationController — add PATCH /api/documents/{docId}/annotations/{annotationId} with @RequirePermission(WRITE_ALL)

Frontend Changes

File Change
AnnotationShape.svelte Add resize/move handles + pointer event logic; canEdit prop
AnnotationLayer.svelte Pass onAnnotationUpdate + canEdit down
PdfViewer.svelte Forward onAnnotationUpdate callback
+page.svelte (document) Implement handleAnnotationUpdate()PATCH request

Acceptance Criteria

  • Draw a box, click it in transcription mode → 8 handles appear
  • Drag a corner → box resizes live; PATCH fires on release
  • Drag the interior → box moves; PATCH fires on release
  • OCR annotation (has polygon) → no handles shown
  • Outside transcription mode → no handles shown
  • Backend rejects update from non-owner (403)
  • ./mvnw test passes including new AnnotationService update tests
## Problem After drawing a rectangular annotation on the PDF viewer, users have no way to adjust its position or size. If the box is slightly off — too wide, shifted, or misaligned with the handwriting — the only option is to delete it and redraw from scratch. This is especially frustrating when fine-tuning regions for guided OCR. ## Proposed Solution When a rectangle annotation is selected in transcription mode, show interactive resize handles and allow the whole box to be dragged to a new position. ### UX Design **Edit mode** is active when: annotation is selected + `transcribeMode` is on + annotation has no polygon (i.e. user-drawn, not OCR-placed). **8 resize handles** appear at corners and edge midpoints (10×10 px squares, white fill, 2px colored border — matching the annotation color). **Interaction:** - Drag a **corner handle** → resize in that direction (`nwse-resize` / `nesw-resize` cursor) - Drag an **edge midpoint** → resize along one axis (`ns-resize` / `ew-resize` cursor) - Drag the **annotation interior** → move the whole box (`move` cursor) - All changes are **optimistic** (live visual update) and persisted on `pointerup` via `PATCH /api/documents/{id}/annotations/{annotationId}` - Minimum size: 1% × 1% (normalized), matching the draw threshold **Polygon (OCR) annotations** show NO handles — they remain click-only. ## Backend Changes - **`UpdateAnnotationDTO`** (new): `{ x, y, width, height }` — all optional doubles - **`AnnotationService.updateAnnotation()`** — load, verify ownership, update fields - **`AnnotationController`** — add `PATCH /api/documents/{docId}/annotations/{annotationId}` with `@RequirePermission(WRITE_ALL)` ## Frontend Changes | File | Change | |---|---| | `AnnotationShape.svelte` | Add resize/move handles + pointer event logic; `canEdit` prop | | `AnnotationLayer.svelte` | Pass `onAnnotationUpdate` + `canEdit` down | | `PdfViewer.svelte` | Forward `onAnnotationUpdate` callback | | `+page.svelte` (document) | Implement `handleAnnotationUpdate()` → `PATCH` request | ## Acceptance Criteria - [ ] Draw a box, click it in transcription mode → 8 handles appear - [ ] Drag a corner → box resizes live; PATCH fires on release - [ ] Drag the interior → box moves; PATCH fires on release - [ ] OCR annotation (has polygon) → no handles shown - [ ] Outside transcription mode → no handles shown - [ ] Backend rejects update from non-owner (403) - [ ] `./mvnw test` passes including new `AnnotationService` update tests
marcel added the featureui labels 2026-04-13 22:43:02 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Questions & Observations

  • Component size risk: AnnotationShape.svelte is taking on resize handle rendering, 8 separate pointer event handlers, cursor logic, drag state, and PATCH triggering. That's easily 150+ lines. Will it still have a name that means one thing, or does it become a "ResizeableDraggableAnnotationShapeWithHandlesAndPatchManager"? Worth deciding up front whether to split ResizeHandle.svelte or AnnotationEditOverlay.svelte as a sibling.

  • Callback chain depth: onAnnotationUpdate is threaded through AnnotationShape → AnnotationLayer → PdfViewer → +page.svelte. That's four hops for a callback. At what point does a Svelte context (setContext/getContext) or a small local $state rune on the page become cleaner than prop-drilling four levels?

  • Naming: canEdit as a prop name is generic. Since this specific condition is "selected AND transcribeMode AND no polygon", something like isResizable or editModeActive carries intent more directly. The name should answer "why is this prop here?" not just "what does it enable?"

  • Rapid drag → PATCH storm: The spec says PATCH fires on pointerup, which is correct. But what about a user who clicks, micro-drags (less than 1px of real movement), and releases? Should we skip the PATCH if the position didn't actually change? Otherwise every accidental click on a selected annotation fires a no-op PATCH.

  • Rollback on PATCH failure: Optimistic updates are listed, but there's no mention of what happens when the PATCH returns a 4xx or 5xx. The box has already moved visually. Does it snap back? Does an error appear? This needs a defined path.

  • Keyboard users: The issue lists only pointer interactions. Is keyboard-based resize/move explicitly out of scope for this issue, or just forgotten? Worth a conscious decision either way.

Suggestions

  • Define the minimum component size threshold before implementation starts — prevents a refactor mid-PR.
  • Add a guard: if newX === annotation.x && newY === annotation.y && newWidth === annotation.width && newHeight === annotation.height, skip the PATCH entirely.
  • Name the failing AnnotationService update tests before writing any production code — they'll drive the service API design cleanly.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Questions & Observations - **Component size risk**: `AnnotationShape.svelte` is taking on resize handle rendering, 8 separate pointer event handlers, cursor logic, drag state, and PATCH triggering. That's easily 150+ lines. Will it still have a name that means one thing, or does it become a "ResizeableDraggableAnnotationShapeWithHandlesAndPatchManager"? Worth deciding up front whether to split `ResizeHandle.svelte` or `AnnotationEditOverlay.svelte` as a sibling. - **Callback chain depth**: `onAnnotationUpdate` is threaded through `AnnotationShape → AnnotationLayer → PdfViewer → +page.svelte`. That's four hops for a callback. At what point does a Svelte context (`setContext`/`getContext`) or a small local `$state` rune on the page become cleaner than prop-drilling four levels? - **Naming**: `canEdit` as a prop name is generic. Since this specific condition is "selected AND transcribeMode AND no polygon", something like `isResizable` or `editModeActive` carries intent more directly. The name should answer "why is this prop here?" not just "what does it enable?" - **Rapid drag → PATCH storm**: The spec says PATCH fires on `pointerup`, which is correct. But what about a user who clicks, micro-drags (less than 1px of real movement), and releases? Should we skip the PATCH if the position didn't actually change? Otherwise every accidental click on a selected annotation fires a no-op PATCH. - **Rollback on PATCH failure**: Optimistic updates are listed, but there's no mention of what happens when the PATCH returns a 4xx or 5xx. The box has already moved visually. Does it snap back? Does an error appear? This needs a defined path. - **Keyboard users**: The issue lists only pointer interactions. Is keyboard-based resize/move explicitly out of scope for this issue, or just forgotten? Worth a conscious decision either way. ### Suggestions - Define the minimum component size threshold before implementation starts — prevents a refactor mid-PR. - Add a guard: if `newX === annotation.x && newY === annotation.y && newWidth === annotation.width && newHeight === annotation.height`, skip the PATCH entirely. - Name the failing `AnnotationService` update tests before writing any production code — they'll drive the service API design cleanly.
Author
Owner

🏗️ Markus Keller — Application Architect

Questions & Observations

  • DB-layer bounds enforcement: UpdateAnnotationDTO has x, y, width, height as optional doubles. The issue mentions a minimum size of 1%×1% (0.01 normalized), but I see no mention of a CHECK constraint in the database to enforce valid ranges (0.0–1.0 for all four fields). Application-layer validation has race-condition and bypass risks. A migration adding CHECK (x >= 0 AND x <= 1 AND y >= 0 AND y <= 1 AND width >= 0.01 AND height >= 0.01) would be the right safety net.

  • Optimistic update without a PATCH failure path is an architectural gap: The spec describes optimistic UI updates but is silent on failure recovery. This isn't just a UI detail — it's a data consistency question. If the PATCH fails (network drop, 403, 500), the frontend has a stale visual state that doesn't match the backend. The architecture for this feature needs a defined rollback: store the pre-drag state locally, restore it on error.

  • Is PATCH the right verb here? Yes — the endpoint accepts a partial update of position/size fields. That's semantically correct. Just noting it was a deliberate choice, not a default.

  • Prop drilling vs. context: Four-level callback threading (AnnotationShape → AnnotationLayer → PdfViewer → +page.svelte) for a single write operation looks like a candidate for Svelte context. The document detail page already manages a lot of local state. At what point does the annotation update context belong to a page-level store rather than a prop chain?

  • No schema migration needed?: The issue lists no migration, which makes sense if the annotations table already has x, y, width, height columns. Worth confirming — if those columns don't already exist, this is missing a Flyway step.

Suggestions

  • Add a Flyway migration with a CHECK constraint bounding normalized coordinates to [0, 1] and minimum size >= 0.01. This is free integrity enforcement that no application bug can bypass.
  • Define the failure rollback before starting implementation — it will affect the state shape in AnnotationShape.svelte.
  • Document the "edit mode" condition in a $derived with an intent-revealing name (isInResizeMode) rather than composing the boolean inline at the call site.
## 🏗️ Markus Keller — Application Architect ### Questions & Observations - **DB-layer bounds enforcement**: `UpdateAnnotationDTO` has `x, y, width, height` as optional doubles. The issue mentions a minimum size of 1%×1% (0.01 normalized), but I see no mention of a CHECK constraint in the database to enforce valid ranges (0.0–1.0 for all four fields). Application-layer validation has race-condition and bypass risks. A migration adding `CHECK (x >= 0 AND x <= 1 AND y >= 0 AND y <= 1 AND width >= 0.01 AND height >= 0.01)` would be the right safety net. - **Optimistic update without a PATCH failure path is an architectural gap**: The spec describes optimistic UI updates but is silent on failure recovery. This isn't just a UI detail — it's a data consistency question. If the PATCH fails (network drop, 403, 500), the frontend has a stale visual state that doesn't match the backend. The architecture for this feature needs a defined rollback: store the pre-drag state locally, restore it on error. - **Is PATCH the right verb here?** Yes — the endpoint accepts a partial update of position/size fields. That's semantically correct. Just noting it was a deliberate choice, not a default. - **Prop drilling vs. context**: Four-level callback threading (`AnnotationShape → AnnotationLayer → PdfViewer → +page.svelte`) for a single write operation looks like a candidate for Svelte context. The document detail page already manages a lot of local state. At what point does the annotation update context belong to a page-level store rather than a prop chain? - **No schema migration needed?**: The issue lists no migration, which makes sense if the `annotations` table already has `x, y, width, height` columns. Worth confirming — if those columns don't already exist, this is missing a Flyway step. ### Suggestions - Add a Flyway migration with a `CHECK` constraint bounding normalized coordinates to `[0, 1]` and minimum size `>= 0.01`. This is free integrity enforcement that no application bug can bypass. - Define the failure rollback before starting implementation — it will affect the state shape in `AnnotationShape.svelte`. - Document the "edit mode" condition in a `$derived` with an intent-revealing name (`isInResizeMode`) rather than composing the boolean inline at the call site.
Author
Owner

🔍 Sara Holt — QA Engineer

Questions & Observations

Acceptance criteria gaps:

  • AC#3 says "drag the interior → box moves; PATCH fires on release." What's the expected behavior if the PATCH fails? The ACs only test the happy path. I'd want to see: "if PATCH fails, the box reverts to its pre-drag position and an error is shown."
  • AC#7 (backend rejects non-owner) — "non-owner" needs precise definition. Does this mean the annotation was created by a different user? Or that the caller lacks WRITE_ALL? These are two different access checks and need two separate test cases.
  • Missing edge case: what happens when the annotation is dragged to the document boundary (x=0, y=0, or x+width≥1)? Does it clamp? Does the PATCH reject it? Is the visual clamped but the stored value is the raw drag position?

Test strategy questions:

  • Pointer event drag simulation in Playwright is straightforward with page.mouse.move() + down/up, but pointer capture (setPointerCapture) can interfere with drag behavior in some browsers. Has this been tested on Firefox as well as Chrome?
  • Touch devices: the spec says "pointer events" which covers touch via pointerdown/pointermove/pointerup. But are there specific touch UX concerns (e.g. long-press to enter edit mode vs. tap to select)? This affects what E2E tests need to cover.
  • For AnnotationService.updateAnnotation() — I'd want tests for: update with valid bounds, update with x out of range, update with zero-size dimensions, update for annotation that belongs to a different document, update with a non-existent annotation ID.

CI impact:

  • Backend: new AnnotationService unit tests (Mockito) + one @WebMvcTest for the PATCH endpoint. That's fast and shouldn't add >5s to CI.
  • Frontend: Playwright drag test could be flaky if the PDF viewer doesn't have predictable element positions. Pin the test to a small known PDF fixture if one doesn't exist already.

Suggestions

  • Add two additional ACs: (1) "PATCH failure → box reverts + error message shown" and (2) "dragging to boundary clamps to valid range."
  • Write a @WebMvcTest for PATCH /api/documents/{docId}/annotations/{annotationId} covering: 200 (valid), 403 (wrong user), 404 (unknown annotation), 400 (out-of-bounds coordinates).
  • Use a factory method makeAnnotation(UUID docId, double x, double y, double w, double h) to avoid repeated builder chains across tests.
## 🔍 Sara Holt — QA Engineer ### Questions & Observations **Acceptance criteria gaps:** - AC#3 says "drag the interior → box moves; PATCH fires on release." What's the expected behavior if the PATCH fails? The ACs only test the happy path. I'd want to see: "if PATCH fails, the box reverts to its pre-drag position and an error is shown." - AC#7 (backend rejects non-owner) — "non-owner" needs precise definition. Does this mean the annotation was created by a different user? Or that the caller lacks `WRITE_ALL`? These are two different access checks and need two separate test cases. - Missing edge case: what happens when the annotation is dragged to the document boundary (x=0, y=0, or x+width≥1)? Does it clamp? Does the PATCH reject it? Is the visual clamped but the stored value is the raw drag position? **Test strategy questions:** - Pointer event drag simulation in Playwright is straightforward with `page.mouse.move()` + `down/up`, but pointer capture (`setPointerCapture`) can interfere with drag behavior in some browsers. Has this been tested on Firefox as well as Chrome? - Touch devices: the spec says "pointer events" which covers touch via `pointerdown/pointermove/pointerup`. But are there specific touch UX concerns (e.g. long-press to enter edit mode vs. tap to select)? This affects what E2E tests need to cover. - For `AnnotationService.updateAnnotation()` — I'd want tests for: update with valid bounds, update with x out of range, update with zero-size dimensions, update for annotation that belongs to a different document, update with a non-existent annotation ID. **CI impact:** - Backend: new `AnnotationService` unit tests (Mockito) + one `@WebMvcTest` for the PATCH endpoint. That's fast and shouldn't add >5s to CI. - Frontend: Playwright drag test could be flaky if the PDF viewer doesn't have predictable element positions. Pin the test to a small known PDF fixture if one doesn't exist already. ### Suggestions - Add two additional ACs: (1) "PATCH failure → box reverts + error message shown" and (2) "dragging to boundary clamps to valid range." - Write a `@WebMvcTest` for `PATCH /api/documents/{docId}/annotations/{annotationId}` covering: 200 (valid), 403 (wrong user), 404 (unknown annotation), 400 (out-of-bounds coordinates). - Use a factory method `makeAnnotation(UUID docId, double x, double y, double w, double h)` to avoid repeated builder chains across tests.
Author
Owner

🔐 Nora Steiner — Security Engineer

Questions & Observations

IDOR risk on the PATCH endpoint:

PATCH /api/documents/{docId}/annotations/{annotationId} — the issue says "verify ownership" in AnnotationService.updateAnnotation(), but doesn't specify what exactly is verified. There are two distinct checks needed:

  1. User authorization: does the caller have WRITE_ALL? Covered by @RequirePermission(WRITE_ALL).
  2. Object-level authorization: does annotationId actually belong to docId? This is the IDOR check. If the service loads the annotation by annotationId alone without also filtering by docId, an attacker can craft a request like PATCH /api/documents/docA/annotations/annotationFromDocB and move someone else's annotation.

The fix is a single JOIN: annotationRepository.findByIdAndDocumentId(annotationId, docId), throwing notFound if the result is empty. This prevents cross-document annotation manipulation without leaking whether the annotation exists.

Input validation — floating point edge cases:

UpdateAnnotationDTO carries Double fields (x, y, width, height). What happens if the client sends NaN, Infinity, or -Infinity? Jackson will deserialize these as valid Java doubles by default. The service should reject them explicitly before persisting. A @DecimalMin / @DecimalMax bean validation constraint on the DTO fields handles this cleanly:

@DecimalMin("0.0") @DecimalMax("1.0") Double x;
@DecimalMin("0.0") @DecimalMax("1.0") Double y;
@DecimalMin("0.01") @DecimalMax("1.0") Double width;
@DecimalMin("0.01") @DecimalMax("1.0") Double height;

NaN fails @DecimalMin because comparisons with NaN are always false.

No new attack surface otherwise:

  • @RequirePermission(WRITE_ALL) on the PATCH endpoint: correct.
  • PATCH is idempotent and scoped — no mass-update risk.
  • No file content involved — no injection or upload risk here.
  • Optimistic updates are client-side only; the server is authoritative.

Suggestions

  • Implement the IDOR check explicitly: load annotation by (annotationId, docId) pair, not by ID alone.
  • Add @Valid to the controller parameter and bean validation constraints to the DTO for all four coordinate fields.
  • Add a security regression test: PATCH /api/documents/{docA}/annotations/{annotationFromDocB} should return 404, not 200.
## 🔐 Nora Steiner — Security Engineer ### Questions & Observations **IDOR risk on the PATCH endpoint:** `PATCH /api/documents/{docId}/annotations/{annotationId}` — the issue says "verify ownership" in `AnnotationService.updateAnnotation()`, but doesn't specify what exactly is verified. There are two distinct checks needed: 1. **User authorization**: does the caller have `WRITE_ALL`? ✅ Covered by `@RequirePermission(WRITE_ALL)`. 2. **Object-level authorization**: does `annotationId` actually belong to `docId`? This is the IDOR check. If the service loads the annotation by `annotationId` alone without also filtering by `docId`, an attacker can craft a request like `PATCH /api/documents/docA/annotations/annotationFromDocB` and move someone else's annotation. The fix is a single JOIN: `annotationRepository.findByIdAndDocumentId(annotationId, docId)`, throwing `notFound` if the result is empty. This prevents cross-document annotation manipulation without leaking whether the annotation exists. **Input validation — floating point edge cases:** `UpdateAnnotationDTO` carries `Double` fields (`x, y, width, height`). What happens if the client sends `NaN`, `Infinity`, or `-Infinity`? Jackson will deserialize these as valid Java doubles by default. The service should reject them explicitly before persisting. A `@DecimalMin` / `@DecimalMax` bean validation constraint on the DTO fields handles this cleanly: ```java @DecimalMin("0.0") @DecimalMax("1.0") Double x; @DecimalMin("0.0") @DecimalMax("1.0") Double y; @DecimalMin("0.01") @DecimalMax("1.0") Double width; @DecimalMin("0.01") @DecimalMax("1.0") Double height; ``` `NaN` fails `@DecimalMin` because comparisons with NaN are always false. **No new attack surface otherwise:** - `@RequirePermission(WRITE_ALL)` on the PATCH endpoint: correct. - PATCH is idempotent and scoped — no mass-update risk. - No file content involved — no injection or upload risk here. - Optimistic updates are client-side only; the server is authoritative. ### Suggestions - Implement the IDOR check explicitly: load annotation by `(annotationId, docId)` pair, not by ID alone. - Add `@Valid` to the controller parameter and bean validation constraints to the DTO for all four coordinate fields. - Add a security regression test: `PATCH /api/documents/{docA}/annotations/{annotationFromDocB}` should return 404, not 200.
Author
Owner

🎨 Leonie Voss — UI/UX & Accessibility

Questions & Observations

Handle size is too small for the target audience:

The spec says 10×10 px squares. That fails WCAG 2.2 Target Size (Minimum) — which requires 24×24 px, and our senior audience (60+) needs at minimum 44×44 px. On a tablet with a finger, a 10px hit target is essentially unreachable. I'd recommend 16px visual square with a 44px invisible pointer event area (padding or a transparent <rect> overlay):

<!-- visible: 16px square, hit area: 44px via invisible wrapper -->
<g class="handle-group" style="cursor: nwse-resize;">
  <rect x={cx-22} y={cy-22} width={44} height={44} fill="transparent" />
  <rect x={cx-8} y={cy-8} width={16} height={16} fill="white" stroke={color} stroke-width={2} />
</g>

Keyboard accessibility is absent:

The entire interaction (drag handles, drag interior) is pointer-only. A user who navigates by keyboard cannot resize or move annotations at all. For a minimum viable accessible solution, a selected annotation in edit mode could expose four arrow-key nudge behaviors (e.g. ArrowLeft/Right/Up/Down with Shift for larger steps). This also makes the feature usable for users with fine motor impairments. Consider opening a follow-up issue explicitly scoped to keyboard resize if this iteration is pointer-only.

Screen reader discoverability:

When the 8 handles appear, screen reader users get no announcement. An aria-live="polite" region saying something like "Annotation selected. Drag handles visible." would close this gap without disrupting non-AT users.

PATCH failure feedback:

The spec mentions optimistic updates but no feedback on failure. If the drag snaps back, the user needs to understand why. An aria-live error message ("Could not save annotation position") ensures screen reader users aren't left in an invisible rollback state.

Contrast on handles:

"White fill, 2px colored border matching the annotation color" — annotation colors aren't specified in the issue. If a light-colored annotation is used on a white or light background, the handle border may fail WCAG AA contrast (4.5:1). Consider always using brand-navy as the handle border regardless of annotation color for guaranteed contrast.

Suggestions

  • Expand handle hit areas to 44×44 px using a transparent SVG overlay rect.
  • File a follow-up issue for keyboard-driven resize/move (arrow keys) — document the intentional gap.
  • Add an aria-live announcement when edit mode activates: "Annotation selected — resize handles visible."
  • Use brand-navy (#002850) as the fixed handle border color for contrast reliability.
## 🎨 Leonie Voss — UI/UX & Accessibility ### Questions & Observations **Handle size is too small for the target audience:** The spec says 10×10 px squares. That fails WCAG 2.2 Target Size (Minimum) — which requires 24×24 px, and our senior audience (60+) needs at minimum 44×44 px. On a tablet with a finger, a 10px hit target is essentially unreachable. I'd recommend 16px visual square with a 44px invisible pointer event area (padding or a transparent `<rect>` overlay): ```svelte <!-- visible: 16px square, hit area: 44px via invisible wrapper --> <g class="handle-group" style="cursor: nwse-resize;"> <rect x={cx-22} y={cy-22} width={44} height={44} fill="transparent" /> <rect x={cx-8} y={cy-8} width={16} height={16} fill="white" stroke={color} stroke-width={2} /> </g> ``` **Keyboard accessibility is absent:** The entire interaction (drag handles, drag interior) is pointer-only. A user who navigates by keyboard cannot resize or move annotations at all. For a minimum viable accessible solution, a selected annotation in edit mode could expose four arrow-key nudge behaviors (e.g. `ArrowLeft/Right/Up/Down` with `Shift` for larger steps). This also makes the feature usable for users with fine motor impairments. Consider opening a follow-up issue explicitly scoped to keyboard resize if this iteration is pointer-only. **Screen reader discoverability:** When the 8 handles appear, screen reader users get no announcement. An `aria-live="polite"` region saying something like "Annotation selected. Drag handles visible." would close this gap without disrupting non-AT users. **PATCH failure feedback:** The spec mentions optimistic updates but no feedback on failure. If the drag snaps back, the user needs to understand *why*. An `aria-live` error message ("Could not save annotation position") ensures screen reader users aren't left in an invisible rollback state. **Contrast on handles:** "White fill, 2px colored border matching the annotation color" — annotation colors aren't specified in the issue. If a light-colored annotation is used on a white or light background, the handle border may fail WCAG AA contrast (4.5:1). Consider always using `brand-navy` as the handle border regardless of annotation color for guaranteed contrast. ### Suggestions - Expand handle hit areas to 44×44 px using a transparent SVG overlay rect. - File a follow-up issue for keyboard-driven resize/move (arrow keys) — document the intentional gap. - Add an `aria-live` announcement when edit mode activates: "Annotation selected — resize handles visible." - Use `brand-navy` (`#002850`) as the fixed handle border color for contrast reliability.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Questions & Observations

No new infrastructure — good. This feature adds a PATCH endpoint and frontend pointer event logic. No new services, no new containers, no migration to deploy separately. Clean footprint.

PATCH request volume under heavy use:

The spec correctly fires PATCH on pointerup rather than on each pointermove — that's the right call and prevents a request storm. Even so, worth confirming: a user doing 50 drag operations in a session fires 50 PATCHes. At 5 concurrent users that's 250 annotation updates per session. That's well within the CX32's capacity, but worth adding a metric or counter if annotation usage grows.

No new Flyway migration listed:

The issue mentions no schema change. That's fine if x, y, width, height already exist in the annotations table. If they don't, the deployment will fail on startup when Flyway runs and finds nothing to alter. Double-check this before merging — a missing migration is the easiest production incident to trigger and the easiest to catch in review.

Observability:

The PATCH endpoint will appear in Spring Boot access logs automatically. If you want to track annotation edit frequency (useful for knowing how often users actually need to adjust boxes), a single Micrometer counter in AnnotationService.updateAnnotation() takes one line:

meterRegistry.counter("annotations.updated").increment();

Not blocking, but cheap to add while the method is being written.

E2E test infrastructure consideration:

If Playwright drag tests are added (which they should be), they'll need a real PDF document loaded in the test browser. Make sure the E2E Docker Compose setup has a fixture document with at least one annotation pre-seeded via the test database setup — otherwise the drag test creates its own annotation first and the test scope widens.

Suggestions

  • Confirm x, y, width, height columns already exist in the annotations table before the PR is opened. If not, write the migration.
  • Add the Micrometer counter in AnnotationService while you're touching the method — one line, zero operational cost.
  • Ensure the E2E test fixture database seeds a document with an annotation so Playwright drag tests have a known starting state.
## ⚙️ Tobias Wendt — DevOps & Platform Engineer ### Questions & Observations **No new infrastructure — good.** This feature adds a PATCH endpoint and frontend pointer event logic. No new services, no new containers, no migration to deploy separately. Clean footprint. **PATCH request volume under heavy use:** The spec correctly fires PATCH on `pointerup` rather than on each `pointermove` — that's the right call and prevents a request storm. Even so, worth confirming: a user doing 50 drag operations in a session fires 50 PATCHes. At 5 concurrent users that's 250 annotation updates per session. That's well within the CX32's capacity, but worth adding a metric or counter if annotation usage grows. **No new Flyway migration listed:** The issue mentions no schema change. That's fine if `x, y, width, height` already exist in the `annotations` table. If they don't, the deployment will fail on startup when Flyway runs and finds nothing to alter. Double-check this before merging — a missing migration is the easiest production incident to trigger and the easiest to catch in review. **Observability:** The PATCH endpoint will appear in Spring Boot access logs automatically. If you want to track annotation edit frequency (useful for knowing how often users actually need to adjust boxes), a single Micrometer counter in `AnnotationService.updateAnnotation()` takes one line: ```java meterRegistry.counter("annotations.updated").increment(); ``` Not blocking, but cheap to add while the method is being written. **E2E test infrastructure consideration:** If Playwright drag tests are added (which they should be), they'll need a real PDF document loaded in the test browser. Make sure the E2E Docker Compose setup has a fixture document with at least one annotation pre-seeded via the test database setup — otherwise the drag test creates its own annotation first and the test scope widens. ### Suggestions - Confirm `x, y, width, height` columns already exist in the `annotations` table before the PR is opened. If not, write the migration. - Add the Micrometer counter in `AnnotationService` while you're touching the method — one line, zero operational cost. - Ensure the E2E test fixture database seeds a document with an annotation so Playwright drag tests have a known starting state.
Author
Owner

👨‍💻 Felix Brandt — Developer Discussion Summary

Worked through 6 open items before implementation starts. All resolved.


1. PATCH failure rollback

Decision: Snap back + show error message. AnnotationEditOverlay captures a preDragState snapshot at pointerdown. If the PATCH returns non-ok, restore from snapshot and surface an error via the existing getErrorMessage() pipeline (with aria-live so screen readers catch it).

2. Component split boundary

Decision: Extract AnnotationEditOverlay.svelte for handles and drag state only. AnnotationShape.svelte keeps the box render and conditionally includes the overlay when isResizable is true. Zero render code duplication — the overlay is purely additive (handles only, no box).

3. Callback chain → Svelte context

Decision: Checked the code. PdfViewer already owns annotations state, documentId, and loadAnnotations() — the page doesn't manage annotation data at all. The PATCH therefore lives in PdfViewer, not +page.svelte. PdfViewer exposes the update function via setContext; AnnotationEditOverlay calls it directly via getContext. No prop threading through AnnotationLayer or AnnotationShape.

4. Prop naming

Decision: isResizable — reads the intent directly (selected AND transcribeMode AND no polygon).

5. No-op PATCH guard

Decision: Before firing the PATCH, compare new coordinates against the pointerdown snapshot. If unchanged (micro-drag or accidental click), skip the PATCH entirely.

6. Keyboard + touch scope (was missed in spec)

Decision:

  • Touch: Pointer events cover touch natively. Add touch-action: none on AnnotationEditOverlay + event.isPrimary guard to ignore multi-finger gestures. Handle hit areas 44px from the start (required for touch, not optional).
  • Keyboard: Arrow keys move the whole box. PATCH fires ~300ms after the last keypress (debounced). Keyboard resize is explicitly out of scope for this issue — follow-up ticket needed.
  • The acceptance criteria in the issue should be updated to include keyboard move and touch support.

Overall the spec is solid. The main structural insight from the code review: PdfViewer is the right home for annotation update logic — it already owns everything needed. The context pattern keeps intermediary components clean.

## 👨‍💻 Felix Brandt — Developer Discussion Summary Worked through 6 open items before implementation starts. All resolved. --- ### 1. PATCH failure rollback **Decision:** Snap back + show error message. `AnnotationEditOverlay` captures a `preDragState` snapshot at `pointerdown`. If the PATCH returns non-ok, restore from snapshot and surface an error via the existing `getErrorMessage()` pipeline (with `aria-live` so screen readers catch it). ### 2. Component split boundary **Decision:** Extract `AnnotationEditOverlay.svelte` for handles and drag state only. `AnnotationShape.svelte` keeps the box render and conditionally includes the overlay when `isResizable` is true. Zero render code duplication — the overlay is purely additive (handles only, no box). ### 3. Callback chain → Svelte context **Decision:** Checked the code. `PdfViewer` already owns `annotations` state, `documentId`, and `loadAnnotations()` — the page doesn't manage annotation data at all. The PATCH therefore lives in `PdfViewer`, not `+page.svelte`. `PdfViewer` exposes the update function via `setContext`; `AnnotationEditOverlay` calls it directly via `getContext`. No prop threading through `AnnotationLayer` or `AnnotationShape`. ### 4. Prop naming **Decision:** `isResizable` — reads the intent directly (selected AND transcribeMode AND no polygon). ### 5. No-op PATCH guard **Decision:** Before firing the PATCH, compare new coordinates against the `pointerdown` snapshot. If unchanged (micro-drag or accidental click), skip the PATCH entirely. ### 6. Keyboard + touch scope (was missed in spec) **Decision:** - **Touch:** Pointer events cover touch natively. Add `touch-action: none` on `AnnotationEditOverlay` + `event.isPrimary` guard to ignore multi-finger gestures. Handle hit areas 44px from the start (required for touch, not optional). - **Keyboard:** Arrow keys move the whole box. PATCH fires ~300ms after the last keypress (debounced). Keyboard resize is explicitly out of scope for this issue — follow-up ticket needed. - The acceptance criteria in the issue should be updated to include keyboard move and touch support. --- Overall the spec is solid. The main structural insight from the code review: `PdfViewer` is the right home for annotation update logic — it already owns everything needed. The context pattern keeps intermediary components clean.
Author
Owner

🎨 Leonie Voss — UI/UX Discussion Summary

Worked through 6 open UI/UX and accessibility items. All resolved.


1. Handle border color

Decision: brand-navy (#002850) fixed for all handle borders, regardless of annotation color. The teal annotation color (#00C7B1) on white gives ~2.8:1 — fails WCAG AA. Navy gives 14.5:1 and visually distinguishes handles (controls) from the box itself.

2. Discoverability

Decision: pointer cursor on hover for resizable annotations in transcription mode. No tooltip or highlight needed. The primary flow is draw → immediately correct — the user just drew the box, so discoverability is naturally high. Correcting others' boxes is not the primary use case.

3. Arrow key step size

Decision: 0.005 normalized (0.5% of document dimensions) per keypress. Shift+Arrow = 0.05 (5%) for coarser moves.

4. Error message placement

Decision: Inline message near the PDF viewer on PATCH failure. Transient (~4s), dismissible, wired to the existing aria-live pattern. Not a toast — the user's eyes are on the annotation, not the corner of the screen.

5. Drag boundary visual feedback

Decision: Silent clamping at document edges. No visual indicator needed — the primary flow doesn't involve extreme drags, and the added complexity isn't justified.

6. aria-live on edit mode activation

Decision: Yes — aria-live="polite" announcement when handles appear (e.g. "Annotation selected — resize handles visible"). Requires a new Paraglide translation key in de.json, en.json, and es.json.


The primary flow insight shaped most decisions here: draw → immediately correct a small miss. That context kept the design appropriately simple — no tooltips, no boundary indicators, pointer cursor only.

## 🎨 Leonie Voss — UI/UX Discussion Summary Worked through 6 open UI/UX and accessibility items. All resolved. --- ### 1. Handle border color **Decision:** `brand-navy` (`#002850`) fixed for all handle borders, regardless of annotation color. The teal annotation color (`#00C7B1`) on white gives ~2.8:1 — fails WCAG AA. Navy gives 14.5:1 and visually distinguishes handles (controls) from the box itself. ### 2. Discoverability **Decision:** `pointer` cursor on hover for resizable annotations in transcription mode. No tooltip or highlight needed. The primary flow is draw → immediately correct — the user just drew the box, so discoverability is naturally high. Correcting others' boxes is not the primary use case. ### 3. Arrow key step size **Decision:** 0.005 normalized (0.5% of document dimensions) per keypress. `Shift+Arrow` = 0.05 (5%) for coarser moves. ### 4. Error message placement **Decision:** Inline message near the PDF viewer on PATCH failure. Transient (~4s), dismissible, wired to the existing `aria-live` pattern. Not a toast — the user's eyes are on the annotation, not the corner of the screen. ### 5. Drag boundary visual feedback **Decision:** Silent clamping at document edges. No visual indicator needed — the primary flow doesn't involve extreme drags, and the added complexity isn't justified. ### 6. `aria-live` on edit mode activation **Decision:** Yes — `aria-live="polite"` announcement when handles appear (e.g. "Annotation selected — resize handles visible"). Requires a new Paraglide translation key in `de.json`, `en.json`, and `es.json`. --- The primary flow insight shaped most decisions here: draw → immediately correct a small miss. That context kept the design appropriately simple — no tooltips, no boundary indicators, pointer cursor only.
Author
Owner

🏗️ Markus Keller — Architecture Discussion Summary

Worked through 3 open backend/schema items. All resolved.


1. DB CHECK constraint

Decision: Add chk_annotation_bounds as part of this issue's migration (V33). Enforces normalized coordinate ranges at the database layer — no application bug or raw API call can persist invalid data.

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
    );

Consistent with the V23 precedent (chk_annotation_polygon_quad).

2. Schema confirmation

Decision: Confirmed by reading V10. x, y, width, height are already DOUBLE PRECISION NOT NULL in document_annotations. No new columns needed — only the CHECK constraint from item 1.

3. Error code for PATCH failure

Decision: ANNOTATION_UPDATE_FAILED — new value in ErrorCode.java, new case in errors.ts, translation key in de.json, en.json, es.json. Maps to "Could not save annotation position" (or equivalent per locale).


The backend scope for this issue is well-contained: one new DTO, one new service method, one new endpoint, and one migration (CHECK constraint only). No structural schema changes needed.

## 🏗️ Markus Keller — Architecture Discussion Summary Worked through 3 open backend/schema items. All resolved. --- ### 1. DB CHECK constraint **Decision:** Add `chk_annotation_bounds` as part of this issue's migration (V33). Enforces normalized coordinate ranges at the database layer — no application bug or raw API call can persist invalid data. ```sql 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 ); ``` Consistent with the V23 precedent (`chk_annotation_polygon_quad`). ### 2. Schema confirmation **Decision:** Confirmed by reading V10. `x`, `y`, `width`, `height` are already `DOUBLE PRECISION NOT NULL` in `document_annotations`. No new columns needed — only the CHECK constraint from item 1. ### 3. Error code for PATCH failure **Decision:** `ANNOTATION_UPDATE_FAILED` — new value in `ErrorCode.java`, new `case` in `errors.ts`, translation key in `de.json`, `en.json`, `es.json`. Maps to "Could not save annotation position" (or equivalent per locale). --- The backend scope for this issue is well-contained: one new DTO, one new service method, one new endpoint, and one migration (CHECK constraint only). No structural schema changes needed.
Author
Owner

Implementation complete — branch feat/issue-233-annotation-resize-move

All 20 planned tasks done. Every test is green.


Commits

Hash Description
f76a6c0 migration(annotations): add chk_annotation_bounds CHECK constraint (V33)
26c7181 feat(annotations): add ANNOTATION_UPDATE_FAILED error code
1558881 feat(annotations): add updateAnnotation service method with partial-update DTO
ff231db feat(annotations): add PATCH endpoint for annotation resize/move
953cb2c feat(i18n): add ANNOTATION_UPDATE_FAILED error code and annotation_edit_mode_active translation
f5362a5 feat(annotations): add AnnotationEditOverlay component with resize handles and drag
3b756cd feat(annotations): add isResizable prop to AnnotationShape to render edit overlay
3fb32ea feat(annotations): pass isResizable to AnnotationShape based on selection + transcribeMode
c610a3c feat(annotations): wire updateAnnotation context and error display into PdfViewer
e7f88a4 fix(annotations): use pixel-space viewBox so handles stay square on non-square annotations

What was built

Backend:

  • V33__add_annotation_bounds_constraint.sql — DB-level CHECK constraint (x/y ∈ [0,1], width/height ∈ [0.01,1])
  • UpdateAnnotationDTO — partial update DTO with @DecimalMin/@DecimalMax bean validation on all 4 fields
  • AnnotationService.updateAnnotation() — loads by (annotationId, docId) pair (IDOR guard via existing findByIdAndDocumentId), applies only non-null fields
  • PATCH /api/documents/{docId}/annotations/{annotationId}@RequirePermission({ANNOTATE_ALL, WRITE_ALL}), @Valid body
  • ANNOTATION_UPDATE_FAILED error code

Frontend:

  • AnnotationEditOverlay.svelte — SVG overlay with 8 resize handles (44px hit areas, 16px visible squares, navy border), full-box move area, drag with optimistic preview, PATCH-on-release, rollback on error, arrow-key nudge (0.005/0.05 normalized, 300ms debounce), aria-live announcement, touch-action: none
  • AnnotationShape.svelte — new isResizable prop conditionally renders the overlay
  • AnnotationLayer.svelte — computes isResizable = canDraw && id === activeAnnotationId && !polygon per annotation
  • PdfViewer.sveltesetContext('annotationUpdate', updateAnnotation), 4s auto-dismiss inline error banner with aria-live="assertive"

Key correctness properties:

  • OCR annotations (has polygon) → no handles, no resize possible
  • Non-transcribe mode → no handles
  • PATCH only fires if coordinates actually changed (no-op guard)
  • PATCH failure → box snaps back + error shown near PDF viewer
  • Handles are always physically 16×16px regardless of annotation aspect ratio (ResizeObserver-based viewBox)

Test coverage: 891 backend tests + 718 frontend tests, all green.

## ✅ Implementation complete — branch `feat/issue-233-annotation-resize-move` All 20 planned tasks done. Every test is green. --- ### Commits | Hash | Description | |---|---| | `f76a6c0` | migration(annotations): add chk_annotation_bounds CHECK constraint (V33) | | `26c7181` | feat(annotations): add ANNOTATION_UPDATE_FAILED error code | | `1558881` | feat(annotations): add updateAnnotation service method with partial-update DTO | | `ff231db` | feat(annotations): add PATCH endpoint for annotation resize/move | | `953cb2c` | feat(i18n): add ANNOTATION_UPDATE_FAILED error code and annotation_edit_mode_active translation | | `f5362a5` | feat(annotations): add AnnotationEditOverlay component with resize handles and drag | | `3b756cd` | feat(annotations): add isResizable prop to AnnotationShape to render edit overlay | | `3fb32ea` | feat(annotations): pass isResizable to AnnotationShape based on selection + transcribeMode | | `c610a3c` | feat(annotations): wire updateAnnotation context and error display into PdfViewer | | `e7f88a4` | fix(annotations): use pixel-space viewBox so handles stay square on non-square annotations | --- ### What was built **Backend:** - `V33__add_annotation_bounds_constraint.sql` — DB-level CHECK constraint (`x/y ∈ [0,1]`, `width/height ∈ [0.01,1]`) - `UpdateAnnotationDTO` — partial update DTO with `@DecimalMin`/`@DecimalMax` bean validation on all 4 fields - `AnnotationService.updateAnnotation()` — loads by `(annotationId, docId)` pair (IDOR guard via existing `findByIdAndDocumentId`), applies only non-null fields - `PATCH /api/documents/{docId}/annotations/{annotationId}` — `@RequirePermission({ANNOTATE_ALL, WRITE_ALL})`, `@Valid` body - `ANNOTATION_UPDATE_FAILED` error code **Frontend:** - `AnnotationEditOverlay.svelte` — SVG overlay with 8 resize handles (44px hit areas, 16px visible squares, navy border), full-box move area, drag with optimistic preview, PATCH-on-release, rollback on error, arrow-key nudge (0.005/0.05 normalized, 300ms debounce), `aria-live` announcement, `touch-action: none` - `AnnotationShape.svelte` — new `isResizable` prop conditionally renders the overlay - `AnnotationLayer.svelte` — computes `isResizable = canDraw && id === activeAnnotationId && !polygon` per annotation - `PdfViewer.svelte` — `setContext('annotationUpdate', updateAnnotation)`, 4s auto-dismiss inline error banner with `aria-live="assertive"` **Key correctness properties:** - OCR annotations (has polygon) → no handles, no resize possible ✅ - Non-transcribe mode → no handles ✅ - PATCH only fires if coordinates actually changed (no-op guard) ✅ - PATCH failure → box snaps back + error shown near PDF viewer ✅ - Handles are always physically 16×16px regardless of annotation aspect ratio (ResizeObserver-based viewBox) ✅ **Test coverage:** 891 backend tests + 718 frontend tests, all green.
Sign in to join this conversation.
No Label feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#233