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:
parent
1212b62613
commit
0791436107
6 changed files with 354 additions and 1 deletions
278
apps/web/src/lib/components/TypingView.svelte
Normal file
278
apps/web/src/lib/components/TypingView.svelte
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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] ?? [];
|
||||
|
|
|
|||
49
packages/cards-domain/src/typing.ts
Normal file
49
packages/cards-domain/src/typing.ts
Normal 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];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue