MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
Hold-to-record opened the sheet on pointerup; the click the browser sends right after landed on the new sheet's backdrop and closed it at once. Lists and notes have no such backdrop and kept working. A short tap now opens on the click itself (as before), and after a hold that click is ignored. Tests send the real pointerup + click sequence. Version 0.5.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
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);
|
||
// A press that turned into a hold must not also count as a tap when the browser sends its click
|
||
const suppressClickRef = 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;
|
||
}
|
||
|
||
try {
|
||
await onTranscript(text);
|
||
triggerHaptic(25);
|
||
showToast('Im Quick Dump gespeichert');
|
||
} 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;
|
||
suppressClickRef.current = false;
|
||
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;
|
||
suppressClickRef.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) {
|
||
// A short tap opens on the click that follows. Opening here already would let that click land
|
||
// on the freshly opened sheet's backdrop and close it again at once.
|
||
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 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()}
|
||
// Taps, keyboard, screen readers and switch control all end in a click; after a hold it is ignored
|
||
onClick={() => {
|
||
if (suppressClickRef.current) {
|
||
suppressClickRef.current = false;
|
||
return;
|
||
}
|
||
onTap();
|
||
}}
|
||
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>
|
||
</>
|
||
);
|
||
};
|