feat(cards): typing Card-Type mit Fuzzy-Match

- 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 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 15:23:58 +02:00
parent 1212b62613
commit 0791436107
6 changed files with 354 additions and 1 deletions

View file

@ -0,0 +1,278 @@
<script lang="ts">
import { checkTypingAnswer, type TypingMatchResult } from '@cards/domain';
import type { Rating } from '@cards/domain';
let {
promptHtml,
answer,
aliases,
answerHtml,
ongrade,
}: {
promptHtml: string;
answer: string;
aliases?: string;
answerHtml: string;
ongrade: (r: Rating) => void;
} = $props();
let input = $state('');
let submitted = $state(false);
let result = $state<TypingMatchResult | null>(null);
let inputEl = $state<HTMLInputElement | null>(null);
$effect(() => {
if (inputEl && !submitted) inputEl.focus();
});
function submit() {
if (submitted || !input.trim()) return;
result = checkTypingAnswer(input, answer, aliases);
submitted = true;
}
function handleKey(e: KeyboardEvent) {
if (!submitted) {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
submit();
}
return;
}
// Nach Submit: Keyboard-Shortcuts für Grade
if (result === 'correct') {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
ongrade('good');
}
} else if (result === 'close') {
if (e.key === '1') { e.stopPropagation(); ongrade('again'); }
else if (e.key === '3' || e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
ongrade('good');
}
} else if (result === 'wrong') {
if (e.key === '1') { e.stopPropagation(); ongrade('again'); }
else if (e.key === '2') { e.stopPropagation(); ongrade('hard'); }
else if (e.key === '3' || e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
ongrade('good');
}
else if (e.key === '4') { e.stopPropagation(); ongrade('easy'); }
}
}
</script>
<svelte:window onkeydown={handleKey} />
<div class="typing-view">
<div class="prose">{@html promptHtml}</div>
{#if !submitted}
<div class="input-row">
<input
bind:this={inputEl}
bind:value={input}
class="typing-input"
type="text"
placeholder="Antwort eingeben …"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck={false}
/>
<button class="submit-btn" onclick={submit} disabled={!input.trim()} aria-label="Antwort prüfen">
</button>
</div>
{:else}
<div class="result-row">
<span class="badge badge-{result}">
{#if result === 'correct'}✓ Richtig
{:else if result === 'close'}≈ Fast
{:else}✗ Falsch{/if}
</span>
<span class="your-input">{input}"</span>
</div>
<hr class="divider" />
<div class="prose answer">{@html answerHtml}</div>
<div class="grade-row grade-row-{result}">
{#if result === 'correct'}
<button class="grade-btn primary" onclick={() => ongrade('good')}>
Weiter <kbd>Space</kbd>
</button>
{:else if result === 'close'}
<button class="grade-btn" onclick={() => ongrade('again')}>
Nochmal <kbd>1</kbd>
</button>
<button class="grade-btn primary" onclick={() => ongrade('good')}>
War richtig <kbd>3</kbd>
</button>
{:else}
<button class="grade-btn danger" onclick={() => ongrade('again')}>
Wieder <kbd>1</kbd>
</button>
<button class="grade-btn" onclick={() => ongrade('hard')}>
Schwer <kbd>2</kbd>
</button>
<button class="grade-btn primary" onclick={() => ongrade('good')}>
Gut <kbd>3</kbd>
</button>
<button class="grade-btn success" onclick={() => ongrade('easy')}>
Leicht <kbd>4</kbd>
</button>
{/if}
</div>
{/if}
</div>
<style>
.typing-view {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.prose {
font-size: 1.125rem;
line-height: 1.55;
color: hsl(var(--color-foreground));
}
.prose :global(p) { margin: 0 0 0.875em; }
.prose :global(p:last-child) { margin-bottom: 0; }
.prose :global(h1) {
font-size: 4rem;
font-weight: 700;
text-align: center;
margin: 0;
line-height: 1;
letter-spacing: -0.02em;
}
.prose.answer { color: hsl(var(--color-foreground)); }
.input-row {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
.typing-input {
flex: 1;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 1rem;
outline: none;
transition: border-color 0.15s ease;
}
.typing-input:focus {
border-color: hsl(var(--color-primary));
}
.submit-btn {
padding: 0 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 1.125rem;
cursor: pointer;
transition: background-color 0.15s ease;
}
.submit-btn:hover:not(:disabled) {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.submit-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.result-row {
display: flex;
align-items: center;
gap: 0.625rem;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.625rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
}
.badge-correct { background: hsl(var(--color-success) / 0.15); color: hsl(var(--color-success)); }
.badge-close { background: hsl(40 80% 50% / 0.15); color: hsl(40 80% 40%); }
.badge-wrong { background: hsl(var(--color-error) / 0.15); color: hsl(var(--color-error)); }
.your-input {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.divider {
border: none;
border-top: 1px solid hsl(var(--color-border));
margin: 0;
}
.grade-row {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.grade-row-wrong {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.grade-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.625rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.15s ease;
}
.grade-btn:hover { background: hsl(var(--color-surface-hover)); }
.grade-btn.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.grade-btn.primary:hover { background: hsl(var(--color-primary) / 0.9); }
.grade-btn.danger { border-color: hsl(var(--color-error) / 0.4); }
.grade-btn.danger:hover { background: hsl(var(--color-error) / 0.08); }
.grade-btn.success { border-color: hsl(var(--color-success) / 0.4); }
.grade-btn.success:hover { background: hsl(var(--color-success) / 0.08); }
.grade-btn kbd {
font-size: 0.6875rem;
color: inherit;
opacity: 0.6;
font-family: inherit;
}
.grade-btn.primary kbd { opacity: 0.7; }
</style>

View file

@ -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<string, string>;
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}
<TypingView
promptHtml={promptHtml}
answer={typingData.answer}
aliases={typingData.aliases}
answerHtml={answerHtml}
ongrade={grade}
/>
{:else}
{#if isAudioFront && audioFrontData}
<AudioFrontView
@ -246,7 +267,7 @@
</CardSurface>
</div>
<div class="action-bar">
<div class="action-bar" class:invisible={isTyping}>
<!-- grade-row immer im Flow → setzt die Höhe der action-bar -->
<div
class="grade-row"

View file

@ -149,6 +149,8 @@ export function subIndexCount(type: string): number {
);
case 'audio-front':
return 1;
case 'typing':
return 1;
case 'multiple-choice':
return 1;
case 'cloze':

View file

@ -13,3 +13,4 @@ export * from './protocol/index.ts';
export * from './cloze.ts';
export * from './content-hash.ts';
export * from './image-occlusion.ts';
export * from './typing.ts';

View file

@ -11,6 +11,7 @@ export const CardTypeSchema = z.enum([
'cloze',
'image-occlusion',
'audio-front',
'typing',
]);
export type CardType = z.infer<typeof CardTypeSchema>;
@ -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] ?? [];

View file

@ -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];
}