From 07914361078d849605d779b98e9eddb646437e61 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 10 May 2026 15:23:58 +0200 Subject: [PATCH] feat(cards): typing Card-Type mit Fuzzy-Match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - typing.ts: checkTypingAnswer (exact / close / wrong) + Levenshtein- Impl; close = Distanz ≤ max(1, floor(len * 0.2)); Alias-Support via komma-separiertes aliases-Feld - CardTypeSchema: 'typing' ergänzt; validateFieldsForType: front+answer required - subIndexCount: 'typing' → 1 - TypingView.svelte: Input-Feld + Submit + Result-Badge + Antwort-Markdown + kontext-spezifische Grade-Buttons (correct: Weiter; close: Nochmal/War richtig; wrong: volle 4 Buttons); svelte:window für Keyboard - Study-Page: TypingView eingebunden, Action-Bar bei typing ausgeblendet, onKey delegiert zu TypingView Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/lib/components/TypingView.svelte | 278 ++++++++++++++++++ .../src/routes/study/[deckId]/+page.svelte | 23 +- packages/cards-domain/src/fsrs.ts | 2 + packages/cards-domain/src/index.ts | 1 + packages/cards-domain/src/schemas/card.ts | 2 + packages/cards-domain/src/typing.ts | 49 +++ 6 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/components/TypingView.svelte create mode 100644 packages/cards-domain/src/typing.ts diff --git a/apps/web/src/lib/components/TypingView.svelte b/apps/web/src/lib/components/TypingView.svelte new file mode 100644 index 0000000..333014d --- /dev/null +++ b/apps/web/src/lib/components/TypingView.svelte @@ -0,0 +1,278 @@ + + + + +
+
{@html promptHtml}
+ + {#if !submitted} +
+ + +
+ {:else} +
+ + {#if result === 'correct'}✓ Richtig + {:else if result === 'close'}≈ Fast + {:else}✗ Falsch{/if} + + „{input}" +
+ +
+
{@html answerHtml}
+ +
+ {#if result === 'correct'} + + {:else if result === 'close'} + + + {:else} + + + + + {/if} +
+ {/if} +
+ + diff --git a/apps/web/src/routes/study/[deckId]/+page.svelte b/apps/web/src/routes/study/[deckId]/+page.svelte index 607e430..233ba17 100644 --- a/apps/web/src/routes/study/[deckId]/+page.svelte +++ b/apps/web/src/routes/study/[deckId]/+page.svelte @@ -17,6 +17,7 @@ import { t } from '$lib/i18n/index.svelte.ts'; import ImageOcclusionView from '$lib/components/ImageOcclusionView.svelte'; import AudioFrontView from '$lib/components/AudioFrontView.svelte'; + import TypingView from '$lib/components/TypingView.svelte'; import CardSurface from '$lib/components/CardSurface.svelte'; const deckId = $derived(page.params.deckId ?? ''); @@ -94,6 +95,17 @@ }; }); + const isTyping = $derived(current?.card?.type === 'typing'); + const typingData = $derived.by(() => { + const c = current; + if (!c?.card || c.card.type !== 'typing') return null; + const fields = c.card.fields as Record; + return { + answer: fields.answer ?? '', + aliases: fields.aliases || undefined, + }; + }); + const isAudioFront = $derived(current?.card?.type === 'audio-front'); const audioFrontData = $derived.by(() => { const c = current; @@ -137,6 +149,7 @@ const target = e.target as HTMLElement | null; if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return; if (busy || isDone) return; + if (isTyping) return; // TypingView übernimmt per svelte:window if (!revealed) { if (e.key === ' ' || e.key === 'Enter') { @@ -228,6 +241,14 @@ activeMaskId={imageOcclusionData.activeMaskId} {revealed} /> + {:else if isTyping && typingData} + {:else} {#if isAudioFront && audioFrontData} -
+
; @@ -48,6 +49,7 @@ export function validateFieldsForType( 'type-in': ['question', 'expected'], 'image-occlusion': ['image_ref', 'mask_regions'], 'audio-front': ['audio_ref', 'back'], + typing: ['front', 'answer'], 'multiple-choice': ['question', 'options', 'correct_index'], }; const need = required[type] ?? []; diff --git a/packages/cards-domain/src/typing.ts b/packages/cards-domain/src/typing.ts new file mode 100644 index 0000000..bcfcb24 --- /dev/null +++ b/packages/cards-domain/src/typing.ts @@ -0,0 +1,49 @@ +export type TypingMatchResult = 'correct' | 'close' | 'wrong'; + +/** + * Vergleicht eine getippte Antwort mit der erwarteten Antwort. + * Normalisiert: lowercase, trim, NFD-Diakritika-Stripping. + * "close" = Levenshtein-Distanz ≤ max(1, floor(answer.length * 0.2)). + */ +export function checkTypingAnswer( + input: string, + answer: string, + aliases?: string, +): TypingMatchResult { + const norm = (s: string) => + s + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/\p{Mn}/gu, ''); + + const normInput = norm(input); + const candidates = [answer, ...(aliases ? aliases.split(',') : [])] + .map(norm) + .filter((s) => s.length > 0); + + if (candidates.some((c) => c === normInput)) return 'correct'; + + const shortestLen = Math.min(...candidates.map((c) => c.length)); + const threshold = Math.max(1, Math.floor(shortestLen * 0.2)); + if (candidates.some((c) => levenshtein(normInput, c) <= threshold)) return 'close'; + + return 'wrong'; +} + +function levenshtein(a: string, b: string): number { + const m = a.length; + const n = b.length; + const row = Array.from({ length: n + 1 }, (_, j) => j); + for (let i = 1; i <= m; i++) { + let prev = row[0]; + row[0] = i; + for (let j = 1; j <= n; j++) { + const tmp = row[j]; + row[j] = + a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, row[j], row[j - 1]); + prev = tmp; + } + } + return row[n]; +}