Domain: CardTypeSchema öffnet 'image-occlusion'. Neues Modul
@cards/domain/src/image-occlusion.ts mit MaskRegionSchema (zod-strict,
0..1-relative Coords + optionalem Label, max 100 Regionen),
parseMaskRegions (parse + sort-by-id), maskRegionCount, maskForSubIndex.
Field-Schema (cards.fields):
image_ref: string — media_files.id (Phase 9k Storage)
mask_regions: string — JSON-Array<MaskRegion>
note?: string — optionale Bildunterschrift
subIndexCount('image-occlusion') wirft analog zu cloze, weil die
Anzahl text-abhängig ist. Card-POST-Handler ruft maskRegionCount
und lehnt 422 ab, wenn das Mask-Array leer / kaputt ist (vor dem
Deck-Lookup).
UI-Komponenten:
- ImageOcclusionEditor: File-Picker → uploadMedia (Phase 9k),
SVG-Overlay über das Bild, Drag-to-create-Rectangle (mind. 2%
Größe, sonst Klick-Filter), Mask-Liste mit Label-Input und
Delete-Button. Pointer-Events für Touch-Mobile-Support.
- ImageOcclusionView (Study-Render): Bild + SVG-Overlay; aktive
Mask ist im Prompt opake schwarz, im Reveal transparent grün
mit Label-Text; andere Masken bleiben dezent gelb-durchsichtig
als Lern-Hinweis.
/cards/new + /cards/[id]/edit: Type-Picker um Image-Occlusion
erweitert, Branch-Logik schaltet auf den Editor um. canSave-
Validierung: imageRef gesetzt + mind. 1 Mask. /study/[deckId]
nutzt ImageOcclusionView statt Markdown-Render. /decks/[id]-Liste
zeigt "🖼 image-occlusion · <ref-prefix>" statt "(leer) → (leer)".
i18n DE/EN: type_image_occlusion, toast_image_occlusion,
image_occlusion-Namespace (image_label, draw_hint,
label_placeholder, delete_mask, no_image_selected, etc.).
Tests: 11 neue Domain-Tests für MaskRegion-Schema/Parse/Mapping
(66 Domain ges.), 3 neue API-Tests für 422-Branches und
Validation-vor-Deck-Lookup-Pfad (56 API ges.). 129 Tests grün
ges. (66 + 56 + 7), type-check 384 files 0 errors, prod-Build
sauber.
E2E-Smoke: Image-Occlusion-Card mit 2 Masken (image_ref auf das
Sprint-9k-Test-PNG) → API legt content_hash + 2 Reviews mit
sub_index 0+1 an, reviews/due returnt sie korrekt typisiert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
72 lines
1.9 KiB
Svelte
72 lines
1.9 KiB
Svelte
<!--
|
|
Image-Occlusion-Render im Study-View. Zeigt das Bild und überlagert
|
|
es mit den Mask-Regionen. Aktive Maske ist immer opake (Prompt:
|
|
Antwort versteckt; Reveal: aktive ist transparent grün als Bestätigung).
|
|
Andere Masken bleiben dezent durchsichtig — der User sieht sie als
|
|
Hinweis darauf, was es noch zu lernen gibt.
|
|
-->
|
|
<script lang="ts">
|
|
import { parseMaskRegions } from '@cards/domain';
|
|
import { API_BASE } from '$lib/api/client.ts';
|
|
|
|
let {
|
|
imageRef,
|
|
maskRegionsJson,
|
|
activeMaskId,
|
|
revealed,
|
|
}: {
|
|
imageRef: string;
|
|
maskRegionsJson: string;
|
|
activeMaskId: string | null;
|
|
revealed: boolean;
|
|
} = $props();
|
|
|
|
const masks = $derived(parseMaskRegions(maskRegionsJson));
|
|
const imageUrl = $derived(`${API_BASE}/api/v1/media/${imageRef}`);
|
|
|
|
function fillFor(maskId: string): string {
|
|
const isActive = maskId === activeMaskId;
|
|
if (isActive) {
|
|
return revealed ? 'rgba(34,197,94,0.55)' : 'rgba(20,20,30,0.95)';
|
|
}
|
|
// Andere Masken: leicht sichtbar als Lern-Hinweis.
|
|
return 'rgba(255,180,0,0.18)';
|
|
}
|
|
</script>
|
|
|
|
<div class="relative inline-block max-w-full">
|
|
<img src={imageUrl} alt="" class="block max-w-full" draggable="false" />
|
|
<svg
|
|
class="pointer-events-none absolute inset-0 h-full w-full"
|
|
viewBox="0 0 1 1"
|
|
preserveAspectRatio="none"
|
|
>
|
|
{#each masks as m (m.id)}
|
|
<rect
|
|
x={m.x}
|
|
y={m.y}
|
|
width={m.w}
|
|
height={m.h}
|
|
fill={fillFor(m.id)}
|
|
stroke={m.id === activeMaskId ? 'rgba(20,20,30,1)' : 'rgba(255,140,0,0.4)'}
|
|
stroke-width="0.004"
|
|
/>
|
|
{#if m.id === activeMaskId && revealed && m.label}
|
|
<text
|
|
x={m.x + m.w / 2}
|
|
y={m.y + m.h / 2}
|
|
text-anchor="middle"
|
|
dominant-baseline="middle"
|
|
fill="white"
|
|
font-size="0.04"
|
|
font-family="system-ui, sans-serif"
|
|
stroke="rgba(0,0,0,0.8)"
|
|
stroke-width="0.002"
|
|
paint-order="stroke"
|
|
>
|
|
{m.label}
|
|
</text>
|
|
{/if}
|
|
{/each}
|
|
</svg>
|
|
</div>
|