feat(capture): hold mobile + button to record voice thoughts directly to inbox

This commit is contained in:
sandrodach committed 2026-10-29 14:27:42 +01:00
1 parent 6d6a35d28d
commit eabb018c64
6 files changed
+649 -9

No files matched your search

+11
View File
@@ -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. - `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. - `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.
+8 -8
View File
@@ -44,6 +44,7 @@ import {
ThemeId, ThemeId,
} from './lib/theme'; } from './lib/theme';
import { InstantCaptureBar } from './features/capture/InstantCaptureBar'; import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
import { HoldToRecordFab } from './features/capture/HoldToRecordFab';
import { CountBadge } from './components/CountBadge'; import { CountBadge } from './components/CountBadge';
import { TriageDeck } from './features/triage/TriageDeck'; import { TriageDeck } from './features/triage/TriageDeck';
import { TasksView } from './features/triage/TasksView'; import { TasksView } from './features/triage/TasksView';
@@ -1174,9 +1175,8 @@ export const App: React.FC = () => {
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */} {/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<button <HoldToRecordFab
type="button" onTap={() => {
onClick={() => {
triggerHaptic(15); triggerHaptic(15);
if (primaryAction) { if (primaryAction) {
primaryAction(); primaryAction();
@@ -1184,11 +1184,11 @@ export const App: React.FC = () => {
openMobileTriageCapture(); 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" onTranscript={async (text) => {
title="Schnell erfassen (+)" const thought = await api.captureThought(text, 'normal', 'voice', '', { target: 'inbox' });
> handleCaptured(thought, 'inbox');
<Plus className="w-7 h-7 stroke-[2.5]" /> }}
</button> />
</div> </div>
{/* Col 4: Aufgaben */} {/* 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'
);
});
});
+1 -1
View File
@@ -413,7 +413,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). <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>
<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>
<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. <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.
+13
View File
@@ -4,6 +4,19 @@ import { cleanup } from '@testing-library/react';
// jsdom has no layout engine, so scrolling is a no-op here // jsdom has no layout engine, so scrolling is a no-op here
Element.prototype.scrollIntoView = () => {}; 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(() => { afterEach(() => {
cleanup(); cleanup();
}); });