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
|
|
@ -5,6 +5,7 @@ import {
|
||||||
CardCreateSchema,
|
CardCreateSchema,
|
||||||
CardUpdateSchema,
|
CardUpdateSchema,
|
||||||
cardContentHash,
|
cardContentHash,
|
||||||
|
maskRegionCount,
|
||||||
newReview,
|
newReview,
|
||||||
subIndexCount,
|
subIndexCount,
|
||||||
subIndexCountForCloze,
|
subIndexCountForCloze,
|
||||||
|
|
@ -40,9 +41,9 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }>
|
||||||
}
|
}
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
|
|
||||||
// Cloze: Sub-Index-Anzahl hängt vom Cluster-Markup im Text ab.
|
// Text-abhängige Sub-Index-Counts (Cloze, Image-Occlusion) vor
|
||||||
// Eine Cloze-Karte ohne `{{cN::…}}` ist sinnlos — vor dem Deck-Lookup
|
// dem Deck-Lookup auflösen — Validation-Errors bleiben 422 statt
|
||||||
// ablehnen, damit Validation-Errors konsistent 422 statt 404 sind.
|
// versehentlich auf 404 zu fallen.
|
||||||
let count: number;
|
let count: number;
|
||||||
if (parsed.data.type === 'cloze') {
|
if (parsed.data.type === 'cloze') {
|
||||||
count = subIndexCountForCloze(parsed.data.fields.text ?? '');
|
count = subIndexCountForCloze(parsed.data.fields.text ?? '');
|
||||||
|
|
@ -52,6 +53,17 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }>
|
||||||
422
|
422
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (parsed.data.type === 'image-occlusion') {
|
||||||
|
count = maskRegionCount(parsed.data.fields.mask_regions ?? '');
|
||||||
|
if (count === 0) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: 'invalid_input',
|
||||||
|
issues: ['image-occlusion.mask_regions must be a JSON array with >=1 valid region'],
|
||||||
|
},
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
count = subIndexCount(parsed.data.type);
|
count = subIndexCount(parsed.data.type);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,13 +68,64 @@ describe('cardsRouter — Input-Validation', () => {
|
||||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
deck_id: 'd-1',
|
deck_id: 'd-1',
|
||||||
type: 'image-occlusion',
|
type: 'audio',
|
||||||
fields: { image_ref: 'x', mask_regions: 'y' },
|
fields: { audio_ref: 'x' },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(422);
|
expect(res.status).toBe(422);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST mit image-occlusion ohne mask_regions ist 422', async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
const res = await app.request('/api/v1/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
deck_id: 'd-1',
|
||||||
|
type: 'image-occlusion',
|
||||||
|
fields: { image_ref: 'm1' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST mit image-occlusion mit kaputtem mask_regions ist 422', async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
const res = await app.request('/api/v1/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
deck_id: 'd-1',
|
||||||
|
type: 'image-occlusion',
|
||||||
|
fields: { image_ref: 'm1', mask_regions: 'not json' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
const body = (await res.json()) as { issues: string[] };
|
||||||
|
expect(body.issues[0]).toMatch(/mask_regions/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST mit gültiger image-occlusion erreicht Deck-Lookup (404 bei stub)', async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
const res = await app.request('/api/v1/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
deck_id: 'd-1',
|
||||||
|
type: 'image-occlusion',
|
||||||
|
fields: {
|
||||||
|
image_ref: 'm1',
|
||||||
|
mask_regions: JSON.stringify([
|
||||||
|
{ id: 'r1', x: 0.1, y: 0.1, w: 0.1, h: 0.1 },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = (await res.json()) as { error: string };
|
||||||
|
expect(body.error).toBe('deck_not_found');
|
||||||
|
});
|
||||||
|
|
||||||
it('POST mit cloze-Card ohne text-Feld ist 422', async () => {
|
it('POST mit cloze-Card ohne text-Feld ist 422', async () => {
|
||||||
const { app } = buildApp();
|
const { app } = buildApp();
|
||||||
const res = await app.request('/api/v1/cards', {
|
const res = await app.request('/api/v1/cards', {
|
||||||
|
|
|
||||||
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: 'Karte angelegt',
|
||||||
toast_basic_reverse: '2 Reviews initialisiert (front→back, back→front)',
|
toast_basic_reverse: '2 Reviews initialisiert (front→back, back→front)',
|
||||||
toast_cloze: '{n} Reviews initialisiert (1 pro Cluster)',
|
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}',
|
decks_load_failed: 'Decks konnten nicht geladen werden: {msg}',
|
||||||
},
|
},
|
||||||
card_edit: {
|
card_edit: {
|
||||||
|
|
@ -221,4 +223,15 @@ export const de: TranslationNode = {
|
||||||
notifications: 'Benachrichtigungen',
|
notifications: 'Benachrichtigungen',
|
||||||
language_switcher: 'Sprache wechseln',
|
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: 'Card created',
|
||||||
toast_basic_reverse: '2 reviews initialized (front→back, back→front)',
|
toast_basic_reverse: '2 reviews initialized (front→back, back→front)',
|
||||||
toast_cloze: '{n} reviews initialized (1 per cluster)',
|
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}',
|
decks_load_failed: 'Could not load decks: {msg}',
|
||||||
},
|
},
|
||||||
card_edit: {
|
card_edit: {
|
||||||
|
|
@ -218,4 +220,15 @@ export const en: TranslationNode = {
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
language_switcher: 'Switch language',
|
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.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import {
|
import {
|
||||||
extractClusterIds,
|
extractClusterIds,
|
||||||
|
maskRegionCount,
|
||||||
renderClozePrompt,
|
renderClozePrompt,
|
||||||
type Card,
|
type Card,
|
||||||
type CardType,
|
type CardType,
|
||||||
|
|
@ -13,6 +14,7 @@
|
||||||
import { renderMarkdown } from '$lib/markdown.ts';
|
import { renderMarkdown } from '$lib/markdown.ts';
|
||||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||||
import { t } from '$lib/i18n/index.svelte.ts';
|
import { t } from '$lib/i18n/index.svelte.ts';
|
||||||
|
import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte';
|
||||||
|
|
||||||
let card = $state<Card | null>(null);
|
let card = $state<Card | null>(null);
|
||||||
let cardType = $state<CardType>('basic');
|
let cardType = $state<CardType>('basic');
|
||||||
|
|
@ -20,6 +22,8 @@
|
||||||
let back = $state('');
|
let back = $state('');
|
||||||
let text = $state('');
|
let text = $state('');
|
||||||
let extra = $state('');
|
let extra = $state('');
|
||||||
|
let imageRef = $state('');
|
||||||
|
let maskRegionsJson = $state('[]');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
@ -48,6 +52,9 @@
|
||||||
if (c.type === 'cloze') {
|
if (c.type === 'cloze') {
|
||||||
text = fields.text ?? '';
|
text = fields.text ?? '';
|
||||||
extra = fields.extra ?? '';
|
extra = fields.extra ?? '';
|
||||||
|
} else if (c.type === 'image-occlusion') {
|
||||||
|
imageRef = fields.image_ref ?? '';
|
||||||
|
maskRegionsJson = fields.mask_regions ?? '[]';
|
||||||
} else {
|
} else {
|
||||||
front = fields.front ?? '';
|
front = fields.front ?? '';
|
||||||
back = fields.back ?? '';
|
back = fields.back ?? '';
|
||||||
|
|
@ -59,11 +66,16 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const maskCount = $derived(maskRegionCount(maskRegionsJson));
|
||||||
|
|
||||||
const canSave = $derived.by(() => {
|
const canSave = $derived.by(() => {
|
||||||
if (saving) return false;
|
if (saving) return false;
|
||||||
if (cardType === 'cloze') {
|
if (cardType === 'cloze') {
|
||||||
return text.trim().length > 0 && clusterIds.length > 0;
|
return text.trim().length > 0 && clusterIds.length > 0;
|
||||||
}
|
}
|
||||||
|
if (cardType === 'image-occlusion') {
|
||||||
|
return imageRef.length > 0 && maskCount > 0;
|
||||||
|
}
|
||||||
return front.trim().length > 0 && back.trim().length > 0;
|
return front.trim().length > 0 && back.trim().length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -72,12 +84,16 @@
|
||||||
if (!card || !canSave) return;
|
if (!card || !canSave) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
const fields: Record<string, string> =
|
let fields: Record<string, string>;
|
||||||
cardType === 'cloze'
|
if (cardType === 'cloze') {
|
||||||
? extra.trim()
|
fields = extra.trim()
|
||||||
? { text: text.trim(), extra: extra.trim() }
|
? { text: text.trim(), extra: extra.trim() }
|
||||||
: { text: text.trim() }
|
: { text: text.trim() };
|
||||||
: { front: front.trim(), back: back.trim() };
|
} else if (cardType === 'image-occlusion') {
|
||||||
|
fields = { image_ref: imageRef, mask_regions: maskRegionsJson };
|
||||||
|
} else {
|
||||||
|
fields = { front: front.trim(), back: back.trim() };
|
||||||
|
}
|
||||||
const updated = await updateCard(card.id, { fields });
|
const updated = await updateCard(card.id, { fields });
|
||||||
toasts.success(t('card_edit.updated'));
|
toasts.success(t('card_edit.updated'));
|
||||||
goto(`/decks/${updated.deck_id}`);
|
goto(`/decks/${updated.deck_id}`);
|
||||||
|
|
@ -123,7 +139,9 @@
|
||||||
<p class="mt-1 text-xs text-[var(--color-muted)]">{t('card_edit.type_locked_help')}</p>
|
<p class="mt-1 text-xs text-[var(--color-muted)]">{t('card_edit.type_locked_help')}</p>
|
||||||
|
|
||||||
<form class="mt-6 space-y-5" onsubmit={onSubmit}>
|
<form class="mt-6 space-y-5" onsubmit={onSubmit}>
|
||||||
{#if cardType === 'cloze'}
|
{#if cardType === 'image-occlusion'}
|
||||||
|
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
|
||||||
|
{:else if cardType === 'cloze'}
|
||||||
<div>
|
<div>
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="text-sm font-medium">{t('card_new.cloze_text_label')}</span>
|
<span class="text-sm font-medium">{t('card_new.cloze_text_label')}</span>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import {
|
import {
|
||||||
extractClusterIds,
|
extractClusterIds,
|
||||||
|
maskRegionCount,
|
||||||
renderClozePrompt,
|
renderClozePrompt,
|
||||||
type CardType,
|
type CardType,
|
||||||
} from '@cards/domain';
|
} from '@cards/domain';
|
||||||
|
|
@ -13,6 +14,7 @@
|
||||||
import { renderMarkdown } from '$lib/markdown.ts';
|
import { renderMarkdown } from '$lib/markdown.ts';
|
||||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||||
import { t } from '$lib/i18n/index.svelte.ts';
|
import { t } from '$lib/i18n/index.svelte.ts';
|
||||||
|
import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte';
|
||||||
|
|
||||||
type DeckLite = { id: string; name: string };
|
type DeckLite = { id: string; name: string };
|
||||||
|
|
||||||
|
|
@ -23,6 +25,8 @@
|
||||||
let back = $state('');
|
let back = $state('');
|
||||||
let text = $state('');
|
let text = $state('');
|
||||||
let extra = $state('');
|
let extra = $state('');
|
||||||
|
let imageRef = $state('');
|
||||||
|
let maskRegionsJson = $state('[]');
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
const frontHtml = $derived(renderMarkdown(front));
|
const frontHtml = $derived(renderMarkdown(front));
|
||||||
|
|
@ -57,11 +61,16 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const maskCount = $derived(maskRegionCount(maskRegionsJson));
|
||||||
|
|
||||||
const canSave = $derived.by(() => {
|
const canSave = $derived.by(() => {
|
||||||
if (saving || !deckId) return false;
|
if (saving || !deckId) return false;
|
||||||
if (cardType === 'cloze') {
|
if (cardType === 'cloze') {
|
||||||
return text.trim().length > 0 && clusterIds.length > 0;
|
return text.trim().length > 0 && clusterIds.length > 0;
|
||||||
}
|
}
|
||||||
|
if (cardType === 'image-occlusion') {
|
||||||
|
return imageRef.length > 0 && maskCount > 0;
|
||||||
|
}
|
||||||
return front.trim().length > 0 && back.trim().length > 0;
|
return front.trim().length > 0 && back.trim().length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -70,16 +79,22 @@
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
const fields: Record<string, string> =
|
let fields: Record<string, string>;
|
||||||
cardType === 'cloze'
|
if (cardType === 'cloze') {
|
||||||
? extra.trim()
|
fields = extra.trim()
|
||||||
? { text: text.trim(), extra: extra.trim() }
|
? { text: text.trim(), extra: extra.trim() }
|
||||||
: { text: text.trim() }
|
: { text: text.trim() };
|
||||||
: { front: front.trim(), back: back.trim() };
|
} else if (cardType === 'image-occlusion') {
|
||||||
|
fields = { image_ref: imageRef, mask_regions: maskRegionsJson };
|
||||||
|
} else {
|
||||||
|
fields = { front: front.trim(), back: back.trim() };
|
||||||
|
}
|
||||||
const card = await createCard({ deck_id: deckId, type: cardType, fields });
|
const card = await createCard({ deck_id: deckId, type: cardType, fields });
|
||||||
const msg =
|
const msg =
|
||||||
cardType === 'cloze'
|
cardType === 'cloze'
|
||||||
? t('card_new.toast_cloze', { n: clusterIds.length })
|
? t('card_new.toast_cloze', { n: clusterIds.length })
|
||||||
|
: cardType === 'image-occlusion'
|
||||||
|
? t('card_new.toast_image_occlusion', { n: maskCount })
|
||||||
: cardType === 'basic-reverse'
|
: cardType === 'basic-reverse'
|
||||||
? t('card_new.toast_basic_reverse')
|
? t('card_new.toast_basic_reverse')
|
||||||
: t('card_new.toast_basic');
|
: t('card_new.toast_basic');
|
||||||
|
|
@ -121,11 +136,14 @@
|
||||||
<option value="basic">{t('card_new.type_basic')}</option>
|
<option value="basic">{t('card_new.type_basic')}</option>
|
||||||
<option value="basic-reverse">{t('card_new.type_basic_reverse')}</option>
|
<option value="basic-reverse">{t('card_new.type_basic_reverse')}</option>
|
||||||
<option value="cloze">{t('card_new.type_cloze')}</option>
|
<option value="cloze">{t('card_new.type_cloze')}</option>
|
||||||
|
<option value="image-occlusion">{t('card_new.type_image_occlusion')}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if cardType === 'cloze'}
|
{#if cardType === 'image-occlusion'}
|
||||||
|
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
|
||||||
|
{:else if cardType === 'cloze'}
|
||||||
<div>
|
<div>
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="text-sm font-medium">{t('card_new.cloze_text_label')}</span>
|
<span class="text-sm font-medium">{t('card_new.cloze_text_label')}</span>
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,13 @@
|
||||||
<p class="mt-1 truncate text-sm">
|
<p class="mt-1 truncate text-sm">
|
||||||
{#if card.type === 'cloze'}
|
{#if card.type === 'cloze'}
|
||||||
<span class="font-medium">{card.fields.text ?? t('common.empty')}</span>
|
<span class="font-medium">{card.fields.text ?? t('common.empty')}</span>
|
||||||
|
{:else if card.type === 'image-occlusion'}
|
||||||
|
<span class="font-medium">🖼 image-occlusion</span>
|
||||||
|
<span class="text-[var(--color-muted)]">
|
||||||
|
· {card.fields.image_ref
|
||||||
|
? card.fields.image_ref.slice(0, 12)
|
||||||
|
: t('common.empty')}
|
||||||
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="font-medium">{card.fields.front ?? t('common.empty')}</span>
|
<span class="font-medium">{card.fields.front ?? t('common.empty')}</span>
|
||||||
<span class="text-[var(--color-muted)]"> → {card.fields.back ?? t('common.empty')}</span>
|
<span class="text-[var(--color-muted)]"> → {card.fields.back ?? t('common.empty')}</span>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import {
|
import {
|
||||||
clusterIdForSubIndex,
|
clusterIdForSubIndex,
|
||||||
|
maskForSubIndex,
|
||||||
renderClozePrompt,
|
renderClozePrompt,
|
||||||
renderClozeAnswer,
|
renderClozeAnswer,
|
||||||
type Rating,
|
type Rating,
|
||||||
|
|
@ -14,6 +15,7 @@
|
||||||
import { renderMarkdown } from '$lib/markdown.ts';
|
import { renderMarkdown } from '$lib/markdown.ts';
|
||||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||||
import { t } from '$lib/i18n/index.svelte.ts';
|
import { t } from '$lib/i18n/index.svelte.ts';
|
||||||
|
import ImageOcclusionView from '$lib/components/ImageOcclusionView.svelte';
|
||||||
|
|
||||||
const deckId = $derived(page.params.deckId ?? '');
|
const deckId = $derived(page.params.deckId ?? '');
|
||||||
|
|
||||||
|
|
@ -73,6 +75,19 @@
|
||||||
const promptHtml = $derived(renderMarkdown(promptMarkdown));
|
const promptHtml = $derived(renderMarkdown(promptMarkdown));
|
||||||
const answerHtml = $derived(renderMarkdown(answerMarkdown));
|
const answerHtml = $derived(renderMarkdown(answerMarkdown));
|
||||||
|
|
||||||
|
const isImageOcclusion = $derived(current?.card?.type === 'image-occlusion');
|
||||||
|
const imageOcclusionData = $derived.by(() => {
|
||||||
|
const c = current;
|
||||||
|
if (!c?.card || c.card.type !== 'image-occlusion') return null;
|
||||||
|
const fields = c.card.fields as Record<string, string>;
|
||||||
|
const mask = maskForSubIndex(fields.mask_regions ?? '', c.sub_index);
|
||||||
|
return {
|
||||||
|
imageRef: fields.image_ref ?? '',
|
||||||
|
maskRegionsJson: fields.mask_regions ?? '[]',
|
||||||
|
activeMaskId: mask?.id ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!devUser.id) {
|
if (!devUser.id) {
|
||||||
goto('/');
|
goto('/');
|
||||||
|
|
@ -183,12 +198,21 @@
|
||||||
<h2 id="study-prompt-heading" class="sr-only">
|
<h2 id="study-prompt-heading" class="sr-only">
|
||||||
{revealed ? t('card_new.preview_label') : t('study_session.reveal')}
|
{revealed ? t('card_new.preview_label') : t('study_session.reveal')}
|
||||||
</h2>
|
</h2>
|
||||||
|
{#if isImageOcclusion && imageOcclusionData}
|
||||||
|
<ImageOcclusionView
|
||||||
|
imageRef={imageOcclusionData.imageRef}
|
||||||
|
maskRegionsJson={imageOcclusionData.maskRegionsJson}
|
||||||
|
activeMaskId={imageOcclusionData.activeMaskId}
|
||||||
|
{revealed}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<div class="prose prose-lg max-w-none">{@html promptHtml}</div>
|
<div class="prose prose-lg max-w-none">{@html promptHtml}</div>
|
||||||
|
|
||||||
{#if revealed}
|
{#if revealed}
|
||||||
<hr class="my-6 border-[var(--color-border)]" />
|
<hr class="my-6 border-[var(--color-border)]" />
|
||||||
<div class="prose prose-lg max-w-none">{@html answerHtml}</div>
|
<div class="prose prose-lg max-w-none">{@html answerHtml}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
{#if !revealed}
|
{#if !revealed}
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,9 @@ export function subIndexCount(type: string): number {
|
||||||
case 'type-in':
|
case 'type-in':
|
||||||
return 1;
|
return 1;
|
||||||
case 'image-occlusion':
|
case 'image-occlusion':
|
||||||
return 1; // pro Mask-Region in Phase 8+ angepasst
|
throw new Error(
|
||||||
|
'subIndexCount("image-occlusion") not supported — use maskRegionCount(fields.mask_regions) from @cards/domain'
|
||||||
|
);
|
||||||
case 'audio':
|
case 'audio':
|
||||||
return 1;
|
return 1;
|
||||||
case 'multiple-choice':
|
case 'multiple-choice':
|
||||||
|
|
|
||||||
61
packages/cards-domain/src/image-occlusion.ts
Normal file
61
packages/cards-domain/src/image-occlusion.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* Image-Occlusion: Bild + N rechteckige Masken. Jede Maske wird zu
|
||||||
|
* einem eigenen Review (sub_index = sortierter Index der Mask-ID),
|
||||||
|
* im Prompt wird die aktive Maske als opakes Rechteck überlagert,
|
||||||
|
* andere Masken bleiben transparent.
|
||||||
|
*
|
||||||
|
* Field-Schema (in cards.fields):
|
||||||
|
* image_ref: string — media_files.id (Phase 9k Storage)
|
||||||
|
* mask_regions: string — JSON-Array von MaskRegion (siehe unten)
|
||||||
|
* note?: string — optionale Bildunterschrift / Lerntipp
|
||||||
|
*
|
||||||
|
* Coordinaten: 0..1 relativ zur Bildgröße, damit das Rendering
|
||||||
|
* unabhängig vom Display-Skalierungsfaktor funktioniert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const MaskRegionSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
x: z.number().min(0).max(1),
|
||||||
|
y: z.number().min(0).max(1),
|
||||||
|
w: z.number().min(0).max(1),
|
||||||
|
h: z.number().min(0).max(1),
|
||||||
|
label: z.string().max(200).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export type MaskRegion = z.infer<typeof MaskRegionSchema>;
|
||||||
|
|
||||||
|
export const MaskRegionsSchema = z.array(MaskRegionSchema).min(1).max(100);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest das `mask_regions`-Feld (JSON-String) und liefert die Liste,
|
||||||
|
* sortiert nach Mask-ID. Bei Parse- oder Schema-Fehler: leere Liste.
|
||||||
|
* Caller muss den 0-Fall als Validation-Error behandeln (analog
|
||||||
|
* Cloze ohne Cluster).
|
||||||
|
*/
|
||||||
|
export function parseMaskRegions(maskRegionsJson: string): MaskRegion[] {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(maskRegionsJson);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const result = MaskRegionsSchema.safeParse(parsed);
|
||||||
|
if (!result.success) return [];
|
||||||
|
return [...result.data].sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anzahl Mask-Regionen → Anzahl Reviews. */
|
||||||
|
export function maskRegionCount(maskRegionsJson: string): number {
|
||||||
|
return parseMaskRegions(maskRegionsJson).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping Sub-Index → Mask-Region. Sub-Index 0 = lexikographisch
|
||||||
|
* niedrigste Mask-ID, etc.
|
||||||
|
*/
|
||||||
|
export function maskForSubIndex(maskRegionsJson: string, subIndex: number): MaskRegion | null {
|
||||||
|
return parseMaskRegions(maskRegionsJson)[subIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
@ -12,3 +12,4 @@ export * from './fsrs.ts';
|
||||||
export * from './protocol/index.ts';
|
export * from './protocol/index.ts';
|
||||||
export * from './cloze.ts';
|
export * from './cloze.ts';
|
||||||
export * from './content-hash.ts';
|
export * from './content-hash.ts';
|
||||||
|
export * from './image-occlusion.ts';
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MVP-CardType-Set. Cloze in Phase 8 (Anki-Import) ergänzt; weitere
|
* MVP-CardType-Set. Cloze in Phase 8 (Anki-Import) ergänzt,
|
||||||
* Erweiterung (type-in, image-occlusion, audio, multiple-choice)
|
* image-occlusion in Phase 9l. Weitere Erweiterung (type-in, audio,
|
||||||
* vorbereitet im CardTypeFutureSchema.
|
* multiple-choice) bleibt im CardTypeFutureSchema vorbereitet.
|
||||||
*/
|
*/
|
||||||
export const CardTypeSchema = z.enum(['basic', 'basic-reverse', 'cloze']);
|
export const CardTypeSchema = z.enum([
|
||||||
|
'basic',
|
||||||
|
'basic-reverse',
|
||||||
|
'cloze',
|
||||||
|
'image-occlusion',
|
||||||
|
]);
|
||||||
export type CardType = z.infer<typeof CardTypeSchema>;
|
export type CardType = z.infer<typeof CardTypeSchema>;
|
||||||
|
|
||||||
/** Future-Set für Schema-Migration-Vorbereitung. */
|
/** Future-Set für Schema-Migration-Vorbereitung. */
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ describe('subIndexCount', () => {
|
||||||
it('cloze wirft — Caller muss subIndexCountForCloze nutzen', () => {
|
it('cloze wirft — Caller muss subIndexCountForCloze nutzen', () => {
|
||||||
expect(() => subIndexCount('cloze')).toThrow(/subIndexCountForCloze/);
|
expect(() => subIndexCount('cloze')).toThrow(/subIndexCountForCloze/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('image-occlusion wirft — Caller muss maskRegionCount nutzen', () => {
|
||||||
|
expect(() => subIndexCount('image-occlusion')).toThrow(/maskRegionCount/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildScheduler', () => {
|
describe('buildScheduler', () => {
|
||||||
|
|
|
||||||
122
packages/cards-domain/tests/image-occlusion.test.ts
Normal file
122
packages/cards-domain/tests/image-occlusion.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MaskRegionSchema,
|
||||||
|
MaskRegionsSchema,
|
||||||
|
parseMaskRegions,
|
||||||
|
maskRegionCount,
|
||||||
|
maskForSubIndex,
|
||||||
|
} from '../src/image-occlusion.ts';
|
||||||
|
|
||||||
|
describe('MaskRegionSchema', () => {
|
||||||
|
it('akzeptiert valide Region', () => {
|
||||||
|
const r = MaskRegionSchema.safeParse({
|
||||||
|
id: 'm1',
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.2,
|
||||||
|
w: 0.3,
|
||||||
|
h: 0.1,
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('akzeptiert mit Label', () => {
|
||||||
|
const r = MaskRegionSchema.safeParse({
|
||||||
|
id: 'm1',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: 1,
|
||||||
|
h: 1,
|
||||||
|
label: 'Hippocampus',
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt Coordinaten außerhalb 0..1 ab', () => {
|
||||||
|
expect(MaskRegionSchema.safeParse({ id: 'm', x: 1.5, y: 0, w: 0.1, h: 0.1 }).success).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(MaskRegionSchema.safeParse({ id: 'm', x: 0, y: 0, w: -0.1, h: 0.1 }).success).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt extra Felder ab (strict)', () => {
|
||||||
|
const r = MaskRegionSchema.safeParse({
|
||||||
|
id: 'm1',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: 0.1,
|
||||||
|
h: 0.1,
|
||||||
|
malicious: 'x',
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MaskRegionsSchema', () => {
|
||||||
|
it('verlangt mindestens eine Region', () => {
|
||||||
|
expect(MaskRegionsSchema.safeParse([]).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('akzeptiert mehrere Regionen', () => {
|
||||||
|
const r = MaskRegionsSchema.safeParse([
|
||||||
|
{ id: 'm1', x: 0, y: 0, w: 0.1, h: 0.1 },
|
||||||
|
{ id: 'm2', x: 0.5, y: 0.5, w: 0.1, h: 0.1 },
|
||||||
|
]);
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cap bei 100 Regionen', () => {
|
||||||
|
const tooMany = Array.from({ length: 101 }, (_, i) => ({
|
||||||
|
id: `m${i}`,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: 0.01,
|
||||||
|
h: 0.01,
|
||||||
|
}));
|
||||||
|
expect(MaskRegionsSchema.safeParse(tooMany).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseMaskRegions', () => {
|
||||||
|
it('parst und sortiert nach ID', () => {
|
||||||
|
const json = JSON.stringify([
|
||||||
|
{ id: 'm3', x: 0, y: 0, w: 0.1, h: 0.1 },
|
||||||
|
{ id: 'm1', x: 0, y: 0, w: 0.1, h: 0.1 },
|
||||||
|
{ id: 'm2', x: 0, y: 0, w: 0.1, h: 0.1 },
|
||||||
|
]);
|
||||||
|
const out = parseMaskRegions(json);
|
||||||
|
expect(out.map((r) => r.id)).toEqual(['m1', 'm2', 'm3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert leere Liste bei kaputtem JSON', () => {
|
||||||
|
expect(parseMaskRegions('not json')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert leere Liste bei Schema-Mismatch', () => {
|
||||||
|
expect(parseMaskRegions(JSON.stringify([{ x: 0, y: 0, w: 0.1, h: 0.1 }]))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maskRegionCount + maskForSubIndex', () => {
|
||||||
|
const json = JSON.stringify([
|
||||||
|
{ id: 'm2', x: 0.1, y: 0.1, w: 0.2, h: 0.2 },
|
||||||
|
{ id: 'm1', x: 0.3, y: 0.3, w: 0.2, h: 0.2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('zählt Regionen', () => {
|
||||||
|
expect(maskRegionCount(json)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mapt subIndex auf sortierte Mask', () => {
|
||||||
|
expect(maskForSubIndex(json, 0)?.id).toBe('m1');
|
||||||
|
expect(maskForSubIndex(json, 1)?.id).toBe('m2');
|
||||||
|
expect(maskForSubIndex(json, 2)).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returnt 0 / null bei kaputtem JSON', () => {
|
||||||
|
expect(maskRegionCount('garbage')).toBe(0);
|
||||||
|
expect(maskForSubIndex('garbage', 0)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -15,10 +15,10 @@ describe('CardTypeSchema', () => {
|
||||||
expect(() => CardTypeSchema.parse('basic')).not.toThrow();
|
expect(() => CardTypeSchema.parse('basic')).not.toThrow();
|
||||||
expect(() => CardTypeSchema.parse('basic-reverse')).not.toThrow();
|
expect(() => CardTypeSchema.parse('basic-reverse')).not.toThrow();
|
||||||
expect(() => CardTypeSchema.parse('cloze')).not.toThrow();
|
expect(() => CardTypeSchema.parse('cloze')).not.toThrow();
|
||||||
|
expect(() => CardTypeSchema.parse('image-occlusion')).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects future types not yet in MVP schema', () => {
|
it('rejects future types not yet in MVP schema', () => {
|
||||||
expect(() => CardTypeSchema.parse('image-occlusion')).toThrow();
|
|
||||||
expect(() => CardTypeSchema.parse('type-in')).toThrow();
|
expect(() => CardTypeSchema.parse('type-in')).toThrow();
|
||||||
expect(() => CardTypeSchema.parse('audio')).toThrow();
|
expect(() => CardTypeSchema.parse('audio')).toThrow();
|
||||||
expect(() => CardTypeSchema.parse('multiple-choice')).toThrow();
|
expect(() => CardTypeSchema.parse('multiple-choice')).toThrow();
|
||||||
|
|
@ -68,11 +68,20 @@ describe('CardCreateSchema', () => {
|
||||||
expect(r.success).toBe(true);
|
expect(r.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unknown type via CardTypeSchema', () => {
|
it('accepts an image-occlusion card with image_ref + mask_regions', () => {
|
||||||
const r = CardCreateSchema.safeParse({
|
const r = CardCreateSchema.safeParse({
|
||||||
deck_id: 'd-1',
|
deck_id: 'd-1',
|
||||||
type: 'image-occlusion',
|
type: 'image-occlusion',
|
||||||
fields: { image_ref: 'x', mask_regions: 'y' },
|
fields: { image_ref: 'media-id', mask_regions: '[]' },
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown type via CardTypeSchema', () => {
|
||||||
|
const r = CardCreateSchema.safeParse({
|
||||||
|
deck_id: 'd-1',
|
||||||
|
type: 'audio',
|
||||||
|
fields: { audio_ref: 'x' },
|
||||||
});
|
});
|
||||||
expect(r.success).toBe(false);
|
expect(r.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue