Merge branch 'feat/hold-to-record' into dev: hold the + button to record a thought
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ac2d9e1412
6 files changed
+649
-9
No files matched your search
@@ -68,3 +68,14 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
|
||||
- `index.html` enthält in `#root` einen reinen HTML-Platzhalter (vollflächig Blau, Logo in der Mitte, kein Text). React ersetzt ihn beim ersten Rendern, dazwischen blitzt nichts Weißes auf.
|
||||
- `meta theme-color` startet blau und wird von [`theme.ts`](../frontend/src/lib/theme.ts) nach dem Start und bei jedem Theme-Wechsel auf `--bg-app` des aktiven Themes gesetzt, damit die Statusleiste zum Header passt.
|
||||
|
||||
## +-Button: Tippen und Halten
|
||||
|
||||
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
|
||||
|
||||
- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
|
||||
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über die Web Speech API (`de-DE`, `continuous`, `interimResults`). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
|
||||
- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`).
|
||||
- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`).
|
||||
- **Fallback:** Unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
ThemeId,
|
||||
} from './lib/theme';
|
||||
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
|
||||
import { HoldToRecordFab } from './features/capture/HoldToRecordFab';
|
||||
import { CountBadge } from './components/CountBadge';
|
||||
import { TriageDeck } from './features/triage/TriageDeck';
|
||||
import { TasksView } from './features/triage/TasksView';
|
||||
@@ -1246,9 +1247,8 @@ export const App: React.FC = () => {
|
||||
|
||||
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<HoldToRecordFab
|
||||
onTap={() => {
|
||||
triggerHaptic(15);
|
||||
if (primaryAction) {
|
||||
primaryAction();
|
||||
@@ -1256,11 +1256,11 @@ export const App: React.FC = () => {
|
||||
openMobileTriageCapture();
|
||||
}
|
||||
}}
|
||||
className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer"
|
||||
title="Schnell erfassen (+)"
|
||||
>
|
||||
<Plus className="w-7 h-7 stroke-[2.5]" />
|
||||
</button>
|
||||
onTranscript={async (text) => {
|
||||
const thought = await api.captureThought(text, 'normal', 'voice', '', { target: 'inbox' });
|
||||
handleCaptured(thought, 'inbox');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Col 4: Aufgaben */}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Mic, Plus } from 'lucide-react';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
|
||||
export interface HoldToRecordFabProps {
|
||||
onTap: () => void;
|
||||
onTranscript: (text: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const HOLD_DELAY_MS = 350;
|
||||
const CANCEL_DRAG_PX = 60;
|
||||
const STOP_TIMEOUT_MS = 1500;
|
||||
const TOAST_DURATION_MS = 2500;
|
||||
|
||||
export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTranscript }) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isCancelZone, setIsCancelZone] = useState(false);
|
||||
const [liveTranscript, setLiveTranscript] = useState('');
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
const isPressedRef = useRef(false);
|
||||
const didEnterHoldRef = useRef(false);
|
||||
const fallbackHandledRef = useRef(false);
|
||||
const isCancelZoneRef = useRef(false);
|
||||
const abortedRef = useRef(false);
|
||||
const isStoppingRef = useRef(false);
|
||||
const hasEndedRef = useRef(false);
|
||||
const finalizedRef = useRef(false);
|
||||
const startYRef = useRef(0);
|
||||
const transcriptRef = useRef('');
|
||||
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const holdTimerRef = useRef<number | null>(null);
|
||||
const stopTimerRef = useRef<number | null>(null);
|
||||
const toastTimerRef = useRef<number | null>(null);
|
||||
|
||||
const clearHoldTimer = () => {
|
||||
if (holdTimerRef.current !== null) {
|
||||
window.clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearStopTimer = () => {
|
||||
if (stopTimerRef.current !== null) {
|
||||
window.clearTimeout(stopTimerRef.current);
|
||||
stopTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
if (toastTimerRef.current !== null) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
setToastMessage(msg);
|
||||
toastTimerRef.current = window.setTimeout(() => {
|
||||
setToastMessage(null);
|
||||
toastTimerRef.current = null;
|
||||
}, TOAST_DURATION_MS);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearHoldTimer();
|
||||
clearStopTimer();
|
||||
if (toastTimerRef.current !== null) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.abort?.();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const finalizeRecording = async () => {
|
||||
if (finalizedRef.current) return;
|
||||
finalizedRef.current = true;
|
||||
clearStopTimer();
|
||||
recognitionRef.current = null;
|
||||
|
||||
if (abortedRef.current) return;
|
||||
|
||||
const text = transcriptRef.current.trim();
|
||||
setLiveTranscript('');
|
||||
if (!text) {
|
||||
showToast('Nichts erkannt');
|
||||
return;
|
||||
}
|
||||
|
||||
triggerHaptic(25);
|
||||
showToast('Im Quick Dump gespeichert');
|
||||
try {
|
||||
await onTranscript(text);
|
||||
} catch {
|
||||
showToast('Fehler beim Speichern');
|
||||
}
|
||||
};
|
||||
|
||||
const startVoiceRecording = () => {
|
||||
const SpeechRecognition =
|
||||
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
|
||||
if (!SpeechRecognition) {
|
||||
fallbackHandledRef.current = true;
|
||||
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||
onTap();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = true;
|
||||
|
||||
transcriptRef.current = '';
|
||||
abortedRef.current = false;
|
||||
isStoppingRef.current = false;
|
||||
hasEndedRef.current = false;
|
||||
finalizedRef.current = false;
|
||||
isCancelZoneRef.current = false;
|
||||
|
||||
recognition.onresult = (event: any) => {
|
||||
let combined = '';
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
combined += event.results[i][0].transcript;
|
||||
}
|
||||
transcriptRef.current = combined;
|
||||
setLiveTranscript(combined);
|
||||
};
|
||||
|
||||
recognition.onerror = (event: any) => {
|
||||
const errCode = event?.error;
|
||||
if (errCode === 'not-allowed' || errCode === 'service-not-allowed') {
|
||||
abortedRef.current = true;
|
||||
clearStopTimer();
|
||||
setIsRecording(false);
|
||||
setIsCancelZone(false);
|
||||
setLiveTranscript('');
|
||||
if (!fallbackHandledRef.current) {
|
||||
fallbackHandledRef.current = true;
|
||||
showToast('Mikrofon nicht freigegeben');
|
||||
onTap();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isStoppingRef.current) {
|
||||
void finalizeRecording();
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
hasEndedRef.current = true;
|
||||
if (isStoppingRef.current) {
|
||||
void finalizeRecording();
|
||||
}
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
setLiveTranscript('');
|
||||
setIsCancelZone(false);
|
||||
setIsRecording(true);
|
||||
triggerHaptic([15, 60, 25]);
|
||||
recognition.start();
|
||||
} catch {
|
||||
fallbackHandledRef.current = true;
|
||||
setIsRecording(false);
|
||||
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||
onTap();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
|
||||
try {
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
// ignore if unsupported in environment
|
||||
}
|
||||
|
||||
clearHoldTimer();
|
||||
clearStopTimer();
|
||||
|
||||
isPressedRef.current = true;
|
||||
didEnterHoldRef.current = false;
|
||||
fallbackHandledRef.current = false;
|
||||
isCancelZoneRef.current = false;
|
||||
startYRef.current = e.clientY;
|
||||
|
||||
holdTimerRef.current = window.setTimeout(() => {
|
||||
holdTimerRef.current = null;
|
||||
if (!isPressedRef.current) return;
|
||||
didEnterHoldRef.current = true;
|
||||
startVoiceRecording();
|
||||
}, HOLD_DELAY_MS);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (!isPressedRef.current || !didEnterHoldRef.current || fallbackHandledRef.current) return;
|
||||
const deltaUp = startYRef.current - e.clientY;
|
||||
const inCancel = deltaUp > CANCEL_DRAG_PX;
|
||||
if (inCancel !== isCancelZoneRef.current) {
|
||||
isCancelZoneRef.current = inCancel;
|
||||
setIsCancelZone(inCancel);
|
||||
triggerHaptic(10);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!isPressedRef.current) return;
|
||||
isPressedRef.current = false;
|
||||
clearHoldTimer();
|
||||
|
||||
if (!didEnterHoldRef.current) {
|
||||
onTap();
|
||||
return;
|
||||
}
|
||||
|
||||
if (fallbackHandledRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRecording(false);
|
||||
setIsCancelZone(false);
|
||||
|
||||
if (isCancelZoneRef.current) {
|
||||
abortedRef.current = true;
|
||||
clearStopTimer();
|
||||
setLiveTranscript('');
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
recognitionRef.current?.abort?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
recognitionRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isStoppingRef.current = true;
|
||||
if (hasEndedRef.current || !recognitionRef.current) {
|
||||
void finalizeRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
stopTimerRef.current = window.setTimeout(() => {
|
||||
void finalizeRecording();
|
||||
}, STOP_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
recognitionRef.current.stop();
|
||||
} catch {
|
||||
void finalizeRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!isPressedRef.current) return;
|
||||
isPressedRef.current = false;
|
||||
clearHoldTimer();
|
||||
clearStopTimer();
|
||||
|
||||
if (didEnterHoldRef.current && !fallbackHandledRef.current) {
|
||||
abortedRef.current = true;
|
||||
setIsRecording(false);
|
||||
setIsCancelZone(false);
|
||||
setLiveTranscript('');
|
||||
try {
|
||||
recognitionRef.current?.abort?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
recognitionRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const displayedTranscript =
|
||||
liveTranscript.length > 180 ? `…${liveTranscript.slice(-180)}` : liveTranscript;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRecording && (
|
||||
<div
|
||||
className={
|
||||
isCancelZone
|
||||
? 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-status-err-border text-status-err shadow-panel pointer-events-none text-center'
|
||||
: 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-border text-main shadow-panel pointer-events-none text-center'
|
||||
}
|
||||
>
|
||||
{isCancelZone ? (
|
||||
<div className="text-xs font-semibold">🗑️ Zum Abbrechen loslassen</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold text-main">
|
||||
Ich höre zu… (loslassen speichert)
|
||||
</div>
|
||||
{displayedTranscript ? (
|
||||
<div className="text-xs text-muted break-words max-w-[calc(100vw-3rem)]">
|
||||
{displayedTranscript}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-faint">Nach oben ziehen zum Abbrechen</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRecording && toastMessage && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2 rounded-pill bg-panel-elevated border border-border text-main text-xs font-medium shadow-panel pointer-events-none text-center"
|
||||
>
|
||||
{toastMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className={
|
||||
isRecording
|
||||
? 'w-14 h-14 -mt-6 rounded-full bg-status-err text-on-accent shadow-lg flex items-center justify-center transition-transform border-4 border-panel cursor-pointer touch-none select-none animate-pulse'
|
||||
: 'w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer touch-none select-none'
|
||||
}
|
||||
title="Schnell erfassen (+)"
|
||||
aria-label="Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen"
|
||||
>
|
||||
{isRecording ? (
|
||||
<Mic className="w-7 h-7 stroke-[2.5]" />
|
||||
) : (
|
||||
<Plus className="w-7 h-7 stroke-[2.5]" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { HoldToRecordFab } from '../HoldToRecordFab';
|
||||
|
||||
class FakeSpeechRecognition {
|
||||
static instances: FakeSpeechRecognition[] = [];
|
||||
|
||||
lang = '';
|
||||
interimResults = false;
|
||||
continuous = false;
|
||||
started = false;
|
||||
stopped = false;
|
||||
aborted = false;
|
||||
|
||||
onresult: ((event: any) => void) | null = null;
|
||||
onerror: ((event: any) => void) | null = null;
|
||||
onend: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
FakeSpeechRecognition.instances.push(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
this.onend?.();
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
this.onend?.();
|
||||
}
|
||||
|
||||
emitResult(transcript: string) {
|
||||
this.onresult?.({
|
||||
results: [[{ transcript }]],
|
||||
});
|
||||
}
|
||||
|
||||
emitError(error: string) {
|
||||
this.onerror?.({ error });
|
||||
}
|
||||
}
|
||||
|
||||
describe('HoldToRecordFab', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
FakeSpeechRecognition.instances = [];
|
||||
(window as any).SpeechRecognition = FakeSpeechRecognition;
|
||||
delete (window as any).webkitSpeechRecognition;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete (window as any).SpeechRecognition;
|
||||
delete (window as any).webkitSpeechRecognition;
|
||||
});
|
||||
|
||||
it('calls onTap on a short tap (< 350 ms)', () => {
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn();
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||
|
||||
expect(onTap).toHaveBeenCalledTimes(1);
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
expect(FakeSpeechRecognition.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('starts recording on hold (>= 350 ms) and calls onTranscript with recognized text on release', async () => {
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
expect(FakeSpeechRecognition.instances).toHaveLength(1);
|
||||
const rec = FakeSpeechRecognition.instances[0];
|
||||
expect(rec.started).toBe(true);
|
||||
expect(rec.lang).toBe('de-DE');
|
||||
expect(rec.interimResults).toBe(true);
|
||||
expect(rec.continuous).toBe(true);
|
||||
expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
rec.emitResult('Milch und Kaffee einkaufen');
|
||||
});
|
||||
expect(screen.getByText('Milch und Kaffee einkaufen')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||
});
|
||||
|
||||
expect(rec.stopped).toBe(true);
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
expect(onTranscript).toHaveBeenCalledTimes(1);
|
||||
expect(onTranscript).toHaveBeenCalledWith('Milch und Kaffee einkaufen');
|
||||
expect(screen.getByRole('status').textContent).toContain('Im Quick Dump gespeichert');
|
||||
});
|
||||
|
||||
it('cancels recording when dragged up by more than 60 px and released', () => {
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn();
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
const rec = FakeSpeechRecognition.instances[0];
|
||||
act(() => {
|
||||
rec.emitResult('Verwerfen Test');
|
||||
});
|
||||
|
||||
// Drag up by 80px (> 60px threshold)
|
||||
fireEvent.pointerMove(btn, { clientY: 520 });
|
||||
expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
|
||||
|
||||
fireEvent.pointerUp(btn, { clientY: 520 });
|
||||
|
||||
expect(rec.aborted).toBe(true);
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resumes normal recording if dragged above 60 px and then pulled back down before release', async () => {
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
const rec = FakeSpeechRecognition.instances[0];
|
||||
act(() => {
|
||||
rec.emitResult('Doch behalten');
|
||||
});
|
||||
|
||||
// Drag up into cancel zone
|
||||
fireEvent.pointerMove(btn, { clientY: 510 });
|
||||
expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
|
||||
|
||||
// Drag back down below threshold
|
||||
fireEvent.pointerMove(btn, { clientY: 580 });
|
||||
expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.pointerUp(btn, { clientY: 580 });
|
||||
});
|
||||
|
||||
expect(rec.aborted).toBe(false);
|
||||
expect(onTranscript).toHaveBeenCalledWith('Doch behalten');
|
||||
});
|
||||
|
||||
it('shows "Nichts erkannt" and does not save when transcript is empty', async () => {
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn();
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||
});
|
||||
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('status').textContent).toContain('Nichts erkannt');
|
||||
});
|
||||
|
||||
it('falls back to onTap and shows message when microphone permission is denied', () => {
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn();
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
const rec = FakeSpeechRecognition.instances[0];
|
||||
act(() => {
|
||||
rec.emitError('not-allowed');
|
||||
});
|
||||
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||
|
||||
expect(onTap).toHaveBeenCalledTimes(1);
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('status').textContent).toContain('Mikrofon nicht freigegeben');
|
||||
});
|
||||
|
||||
it('falls back to onTap and shows a status message when SpeechRecognition is unsupported', () => {
|
||||
delete (window as any).SpeechRecognition;
|
||||
delete (window as any).webkitSpeechRecognition;
|
||||
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn();
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||
|
||||
expect(onTap).toHaveBeenCalledTimes(1);
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('status').textContent).toContain(
|
||||
'Spracheingabe wird von diesem Browser nicht unterstützt'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -408,7 +408,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
<strong className="text-main">Sofortiger Fokus:</strong> Drücke irgendwo in der App <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">C</kbd> oder <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">/</kbd> (sofern du nicht bereits in einem Textfeld tippst).
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Sprach-Diktat:</strong> Klicke auf das Mikrofon-Symbol (<Mic className="w-3.5 h-3.5 inline text-accent" />) für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag <code className="text-accent font-mono">#diktat</code> versehen.
|
||||
<strong className="text-main">Sprach-Diktat:</strong> Klicke auf das Mikrofon-Symbol (<Mic className="w-3.5 h-3.5 inline text-accent" />) für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag <code className="text-accent font-mono">#diktat</code> versehen. Auf dem Smartphone kannst du außerdem den runden <strong className="text-main">+</strong>-Button in der unteren Leiste gedrückt halten, um direkt einzusprechen – beim Loslassen landet der Text sofort im Posteingang (nach oben ziehen bricht ab).
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Ziel-Auswahl:</strong> Gedanken landen standardmäßig im Posteingang. Über die Optionen kannst du sie jedoch sofort als <strong className="text-main">Aufgabe</strong> (mit Priorität und Fälligkeit), <strong className="text-main">Listeneintrag</strong> (z.B. auf die Einkaufsliste) oder <strong className="text-main">neue Notiz</strong> routen.
|
||||
|
||||
@@ -4,6 +4,19 @@ import { cleanup } from '@testing-library/react';
|
||||
// jsdom has no layout engine, so scrolling is a no-op here
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
|
||||
if (typeof window !== 'undefined' && !window.PointerEvent) {
|
||||
class PolyfillPointerEvent extends MouseEvent {
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
constructor(type: string, params: PointerEventInit = {}) {
|
||||
super(type, params);
|
||||
this.pointerId = params.pointerId ?? 1;
|
||||
this.pointerType = params.pointerType ?? 'touch';
|
||||
}
|
||||
}
|
||||
(window as any).PointerEvent = PolyfillPointerEvent;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Reference in new issue
Block a user