Phase 9l: Image-Occlusion als 4. MVP-CardType
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>
This commit is contained in:
parent
c9eb0a6f80
commit
39b1791fb9
17 changed files with 682 additions and 34 deletions
216
apps/web/src/lib/components/ImageOcclusionEditor.svelte
Normal file
216
apps/web/src/lib/components/ImageOcclusionEditor.svelte
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<!--
|
||||
Image-Occlusion-Editor: Bild auswählen, mit Maus rechteckige Masken
|
||||
auf das Bild zeichnen, jede wird zu einem eigenen Review.
|
||||
|
||||
Modell: alle Coordinaten in 0..1 relativ zum Bild — der Renderer
|
||||
skaliert auf die tatsächliche Display-Größe. So überleben Masken
|
||||
auch Browser-Resizing und Mobile-Display.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { type MaskRegion, parseMaskRegions } from '@cards/domain';
|
||||
import { uploadMedia } from '$lib/api/media.ts';
|
||||
import { API_BASE } from '$lib/api/client.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
let {
|
||||
imageRef = $bindable(''),
|
||||
maskRegionsJson = $bindable('[]'),
|
||||
}: {
|
||||
imageRef: string;
|
||||
maskRegionsJson: string;
|
||||
} = $props();
|
||||
|
||||
let imgEl: HTMLImageElement | null = $state(null);
|
||||
let containerEl: HTMLDivElement | null = $state(null);
|
||||
let uploading = $state(false);
|
||||
let dragStart = $state<{ x: number; y: number } | null>(null);
|
||||
let dragCurrent = $state<{ x: number; y: number } | null>(null);
|
||||
|
||||
const masks = $derived(parseMaskRegions(maskRegionsJson));
|
||||
const imageUrl = $derived(imageRef ? `${API_BASE}/api/v1/media/${imageRef}` : '');
|
||||
|
||||
async function onFile(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
uploading = true;
|
||||
try {
|
||||
const r = await uploadMedia(file);
|
||||
if (r.kind !== 'image') {
|
||||
toasts.error(t('image_occlusion.not_an_image'));
|
||||
return;
|
||||
}
|
||||
imageRef = r.id;
|
||||
maskRegionsJson = '[]';
|
||||
} catch (err) {
|
||||
toasts.error(`${(err as Error).message}`);
|
||||
} finally {
|
||||
uploading = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function rel(e: MouseEvent | PointerEvent): { x: number; y: number } | null {
|
||||
if (!containerEl) return null;
|
||||
const rect = containerEl.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!imageRef) return;
|
||||
const p = rel(e);
|
||||
if (!p) return;
|
||||
dragStart = p;
|
||||
dragCurrent = p;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragStart) return;
|
||||
dragCurrent = rel(e);
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (!dragStart || !dragCurrent) {
|
||||
dragStart = null;
|
||||
dragCurrent = null;
|
||||
return;
|
||||
}
|
||||
const x = Math.min(dragStart.x, dragCurrent.x);
|
||||
const y = Math.min(dragStart.y, dragCurrent.y);
|
||||
const w = Math.abs(dragCurrent.x - dragStart.x);
|
||||
const h = Math.abs(dragCurrent.y - dragStart.y);
|
||||
|
||||
// Ignoriere zu kleine Dragger (Klick statt Drag).
|
||||
if (w >= 0.02 && h >= 0.02) {
|
||||
const id = `m${Date.now().toString(36)}`;
|
||||
const next: MaskRegion = { id, x, y, w, h };
|
||||
maskRegionsJson = JSON.stringify([...masks, next]);
|
||||
}
|
||||
dragStart = null;
|
||||
dragCurrent = null;
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function onPointerCancel(e: PointerEvent) {
|
||||
dragStart = null;
|
||||
dragCurrent = null;
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function deleteMask(id: string) {
|
||||
maskRegionsJson = JSON.stringify(masks.filter((m) => m.id !== id));
|
||||
}
|
||||
|
||||
function setLabel(id: string, label: string) {
|
||||
maskRegionsJson = JSON.stringify(
|
||||
masks.map((m) => (m.id === id ? { ...m, label: label || undefined } : m))
|
||||
);
|
||||
}
|
||||
|
||||
const dragRect = $derived.by(() => {
|
||||
if (!dragStart || !dragCurrent) return null;
|
||||
return {
|
||||
x: Math.min(dragStart.x, dragCurrent.x),
|
||||
y: Math.min(dragStart.y, dragCurrent.y),
|
||||
w: Math.abs(dragCurrent.x - dragStart.x),
|
||||
h: Math.abs(dragCurrent.y - dragStart.y),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{t('image_occlusion.image_label')}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="mt-1 block text-xs"
|
||||
disabled={uploading}
|
||||
onchange={onFile}
|
||||
/>
|
||||
</label>
|
||||
{#if uploading}
|
||||
<p class="text-xs text-[var(--color-muted)]">{t('image_occlusion.uploading')}</p>
|
||||
{/if}
|
||||
|
||||
{#if imageRef}
|
||||
<div
|
||||
bind:this={containerEl}
|
||||
class="relative inline-block max-w-full select-none touch-none"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerCancel}
|
||||
role="application"
|
||||
aria-label={t('image_occlusion.canvas_aria')}
|
||||
>
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
class="block max-w-full"
|
||||
draggable="false"
|
||||
/>
|
||||
<svg
|
||||
class="absolute inset-0 h-full w-full pointer-events-none"
|
||||
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="rgba(255,180,0,0.6)"
|
||||
stroke="rgba(255,140,0,1)"
|
||||
stroke-width="0.005"
|
||||
/>
|
||||
{/each}
|
||||
{#if dragRect}
|
||||
<rect
|
||||
x={dragRect.x}
|
||||
y={dragRect.y}
|
||||
width={dragRect.w}
|
||||
height={dragRect.h}
|
||||
fill="rgba(0,140,255,0.4)"
|
||||
stroke="rgba(0,100,200,1)"
|
||||
stroke-width="0.005"
|
||||
stroke-dasharray="0.01,0.005"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-[var(--color-muted)]">{t('image_occlusion.draw_hint')}</p>
|
||||
|
||||
{#if masks.length > 0}
|
||||
<ul class="space-y-2 text-sm">
|
||||
{#each masks as m, i (m.id)}
|
||||
<li class="flex items-center gap-3 rounded border border-[var(--color-border)] px-3 py-2">
|
||||
<span class="text-xs text-[var(--color-muted)] tabular-nums">{i + 1}</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('image_occlusion.label_placeholder')}
|
||||
value={m.label ?? ''}
|
||||
oninput={(e) => setLabel(m.id, (e.currentTarget as HTMLInputElement).value)}
|
||||
class="flex-1 rounded border bg-[var(--color-card)] border-[var(--color-border)] px-2 py-1 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => deleteMask(m.id)}
|
||||
class="text-xs text-[var(--color-muted)] hover:text-[var(--color-danger)]"
|
||||
aria-label={t('image_occlusion.delete_mask')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
72
apps/web/src/lib/components/ImageOcclusionView.svelte
Normal file
72
apps/web/src/lib/components/ImageOcclusionView.svelte
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<!--
|
||||
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>
|
||||
|
|
@ -87,6 +87,8 @@ export const de: TranslationNode = {
|
|||
toast_basic: 'Karte angelegt',
|
||||
toast_basic_reverse: '2 Reviews initialisiert (front→back, back→front)',
|
||||
toast_cloze: '{n} Reviews initialisiert (1 pro Cluster)',
|
||||
toast_image_occlusion: '{n} Reviews initialisiert (1 pro Maske)',
|
||||
type_image_occlusion: 'Image-Occlusion (Bild + N Masken)',
|
||||
decks_load_failed: 'Decks konnten nicht geladen werden: {msg}',
|
||||
},
|
||||
card_edit: {
|
||||
|
|
@ -221,4 +223,15 @@ export const de: TranslationNode = {
|
|||
notifications: 'Benachrichtigungen',
|
||||
language_switcher: 'Sprache wechseln',
|
||||
},
|
||||
image_occlusion: {
|
||||
image_label: 'Bild auswählen',
|
||||
uploading: 'Lade Bild hoch…',
|
||||
not_an_image: 'Datei ist kein Bild.',
|
||||
canvas_aria: 'Bild-Canvas — ziehe mit der Maus, um Masken anzulegen',
|
||||
draw_hint: 'Ziehe ein Rechteck auf dem Bild, um eine Maske anzulegen.',
|
||||
label_placeholder: 'Beschriftung (optional)',
|
||||
delete_mask: 'Maske löschen',
|
||||
no_image_selected: 'Wähle zuerst ein Bild aus.',
|
||||
no_masks: 'Lege mindestens eine Maske an.',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export const en: TranslationNode = {
|
|||
toast_basic: 'Card created',
|
||||
toast_basic_reverse: '2 reviews initialized (front→back, back→front)',
|
||||
toast_cloze: '{n} reviews initialized (1 per cluster)',
|
||||
toast_image_occlusion: '{n} reviews initialized (1 per mask)',
|
||||
type_image_occlusion: 'Image-Occlusion (image + N masks)',
|
||||
decks_load_failed: 'Could not load decks: {msg}',
|
||||
},
|
||||
card_edit: {
|
||||
|
|
@ -218,4 +220,15 @@ export const en: TranslationNode = {
|
|||
notifications: 'Notifications',
|
||||
language_switcher: 'Switch language',
|
||||
},
|
||||
image_occlusion: {
|
||||
image_label: 'Choose image',
|
||||
uploading: 'Uploading image…',
|
||||
not_an_image: 'File is not an image.',
|
||||
canvas_aria: 'Image canvas — drag to create masks',
|
||||
draw_hint: 'Drag a rectangle on the image to create a mask.',
|
||||
label_placeholder: 'Label (optional)',
|
||||
delete_mask: 'Delete mask',
|
||||
no_image_selected: 'Choose an image first.',
|
||||
no_masks: 'Create at least one mask.',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue