Some checks failed
CI / Unit & Component Tests (push) Failing after 3m11s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 3m4s
CI / Unit & Component Tests (pull_request) Failing after 3m7s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
Adds a trash icon button (44×44 px touch target) directly on each annotation shape in transcription mode so users can delete a block without navigating through the sidebar. Includes keyboard support (Delete key), confirm dialog via ConfirmService, prop-chain wiring through DocumentViewer → PdfViewer → AnnotationLayer → AnnotationShape, and orphaned-annotation fallback (calls DELETE /annotations/{id} when no block is linked). Backend security regression test added for deleteBlock 403 on READ_ALL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
195 lines
4.7 KiB
Svelte
195 lines
4.7 KiB
Svelte
<script lang="ts">
|
|
import type { Annotation } from '$lib/types';
|
|
import AnnotationEditOverlay from './AnnotationEditOverlay.svelte';
|
|
|
|
let {
|
|
annotation,
|
|
isHovered,
|
|
isActive,
|
|
faded = false,
|
|
dimmed = false,
|
|
blockNumber = undefined,
|
|
isFlashing = false,
|
|
isResizable = false,
|
|
showDelete = false,
|
|
onDeleteRequest,
|
|
onclick,
|
|
onpointerenter,
|
|
onpointerleave
|
|
}: {
|
|
annotation: Annotation;
|
|
isHovered: boolean;
|
|
isActive: boolean;
|
|
faded?: boolean;
|
|
dimmed?: boolean;
|
|
blockNumber?: number | undefined;
|
|
isFlashing?: boolean;
|
|
isResizable?: boolean;
|
|
showDelete?: boolean;
|
|
onDeleteRequest?: () => void;
|
|
onclick: () => void;
|
|
onpointerenter: () => void;
|
|
onpointerleave: () => void;
|
|
} = $props();
|
|
|
|
const deleteVisible = $derived(showDelete && (isHovered || isActive));
|
|
|
|
function hexToRgba(hex: string, alpha: number): string {
|
|
const r = parseInt(hex.slice(1, 3), 16);
|
|
const g = parseInt(hex.slice(3, 5), 16);
|
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
}
|
|
|
|
let clipPath = $derived.by(() => {
|
|
if (!annotation.polygon || annotation.polygon.length !== 4) return 'none';
|
|
const points = annotation.polygon
|
|
.map(([px, py]) => {
|
|
const cx = ((px - annotation.x) / annotation.width) * 100;
|
|
const cy = ((py - annotation.y) / annotation.height) * 100;
|
|
return `${cx}% ${cy}%`;
|
|
})
|
|
.join(', ');
|
|
return `polygon(${points})`;
|
|
});
|
|
|
|
let bgAlpha = $derived(dimmed ? 0.3 : isHovered || isActive ? 0.5 : 0.3);
|
|
|
|
let boxShadow = $derived.by(() => {
|
|
if (dimmed) return 'none';
|
|
if (isActive || isHovered) return `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}`;
|
|
return 'none';
|
|
});
|
|
|
|
let opacity = $derived(dimmed ? 1 : faded ? 0.3 : 1);
|
|
|
|
let shapeStyle = $derived(
|
|
`position: absolute;` +
|
|
` left: ${annotation.x * 100}%;` +
|
|
` top: ${annotation.y * 100}%;` +
|
|
` width: ${annotation.width * 100}%;` +
|
|
` height: ${annotation.height * 100}%;` +
|
|
` background-color: ${hexToRgba(annotation.color, bgAlpha)};` +
|
|
` box-shadow: ${boxShadow};` +
|
|
` opacity: ${opacity};` +
|
|
` pointer-events: auto;` +
|
|
` cursor: pointer;` +
|
|
` transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease;` +
|
|
(clipPath !== 'none' ? ` clip-path: ${clipPath};` : '')
|
|
);
|
|
</script>
|
|
|
|
<div
|
|
data-testid="annotation-{annotation.id}"
|
|
data-annotation
|
|
class:annotation-flash={isFlashing}
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Block anzeigen"
|
|
onclick={onclick}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') onclick();
|
|
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
|
|
}}
|
|
onpointerenter={onpointerenter}
|
|
onpointerleave={onpointerleave}
|
|
style={shapeStyle}
|
|
>
|
|
{#if !dimmed && blockNumber}
|
|
<div
|
|
style="
|
|
position: absolute;
|
|
top: -8px;
|
|
left: -8px;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
background-color: {annotation.color};
|
|
color: white;
|
|
font-size: 12px;
|
|
font-family: sans-serif;
|
|
font-weight: 700;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
pointer-events: none;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
"
|
|
>
|
|
{blockNumber}
|
|
</div>
|
|
{/if}
|
|
{#if deleteVisible}
|
|
<button
|
|
data-testid="annotation-delete-{annotation.id}"
|
|
type="button"
|
|
aria-label="Löschen"
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
onDeleteRequest?.();
|
|
}}
|
|
style="
|
|
position: absolute;
|
|
top: -8px;
|
|
right: -8px;
|
|
min-width: 44px;
|
|
min-height: 44px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 50%;
|
|
background-color: #fff;
|
|
border: 1px solid var(--color-error, #e53e3e);
|
|
color: var(--color-error, #e53e3e);
|
|
cursor: pointer;
|
|
pointer-events: auto;
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
|
z-index: 10;
|
|
"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
{#if isResizable}
|
|
<AnnotationEditOverlay annotation={annotation} />
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
@keyframes annotation-flash-anim {
|
|
0% {
|
|
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 80%, transparent);
|
|
outline-offset: 0px;
|
|
}
|
|
100% {
|
|
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 0%, transparent);
|
|
outline-offset: 2px;
|
|
}
|
|
}
|
|
|
|
.annotation-flash {
|
|
animation: annotation-flash-anim 1.5s ease-out;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.annotation-flash {
|
|
animation: none;
|
|
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 80%, transparent);
|
|
}
|
|
}
|
|
</style>
|