feat(workspace): dictation via /diktat (M4)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
/diktat (only in an empty bullet, only with voice input available) listens right away; the bullet becomes one button with the live wave across it and a translucent "Stopp" on top. A tap ends it and the recognised text lands in the bullet as plain text. Uses the configured speech recognition (own Whisper server or the browser); Esc cancels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3f5e3b2ac1
commit
3f2bc96732
4 files changed
+131
-4
No files matched your search
@@ -162,6 +162,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
'bild',
|
||||
'audio',
|
||||
'sprachnotiz',
|
||||
'diktat',
|
||||
'image',
|
||||
'foto',
|
||||
'auto-formatting',
|
||||
@@ -205,6 +206,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
<span className="text-accent font-bold">🎙️</span>
|
||||
<span>/audio (Sprachnotiz aufnehmen: grüner Knopf startet, roter speichert; oder Audiodatei hochladen)</span>
|
||||
</div>
|
||||
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
|
||||
<span className="text-accent font-bold">🗣️</span>
|
||||
<span>/diktat (sprechen statt tippen: startet sofort, Tipp auf „Stopp“ setzt den Text in den Punkt)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-border">
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Square } from 'lucide-react';
|
||||
import { useVoiceInput } from '../../lib/useVoiceInput';
|
||||
import { VoiceWave } from '../../components/VoiceWave';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface DictationBlockProps {
|
||||
onDone: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// What an empty bullet turns into after /diktat: listening starts right away, the whole bullet is one
|
||||
// button with the live wave and a translucent "Stopp" on top. A tap ends it; the text lands in the
|
||||
// bullet as plain text.
|
||||
export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel }) => {
|
||||
const [interim, setInterim] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const doneRef = useRef(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const voice = useVoiceInput({
|
||||
continuous: true,
|
||||
onText: (text, final) => {
|
||||
if (!final) {
|
||||
setInterim(text);
|
||||
return;
|
||||
}
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
if (text.trim()) onDone(text.trim());
|
||||
else onCancel();
|
||||
},
|
||||
onError: (message) => {
|
||||
setError(message);
|
||||
window.setTimeout(() => {
|
||||
if (!doneRef.current) {
|
||||
doneRef.current = true;
|
||||
onCancel();
|
||||
}
|
||||
}, 2500);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
triggerHaptic([15, 60, 25]);
|
||||
void voice.start();
|
||||
buttonRef.current?.focus();
|
||||
// start once when the bullet turns into the dictation
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const transcribing = voice.state === 'transcribing';
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (voice.state === 'listening') {
|
||||
triggerHaptic(20);
|
||||
void voice.stop();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
doneRef.current = true;
|
||||
voice.cancel();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
disabled={transcribing || !!error}
|
||||
aria-label="Diktat beenden"
|
||||
className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 rounded-card border border-accent bg-accent-dim overflow-hidden flex items-center justify-center cursor-pointer disabled:cursor-default focus:outline-none"
|
||||
>
|
||||
{/* Live wave across the whole bullet */}
|
||||
<VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-3 inset-y-2 justify-between h-auto text-accent opacity-60" />
|
||||
{interim && !error && (
|
||||
<span className="absolute inset-x-3 bottom-1 text-[11px] text-main truncate text-left pointer-events-none">{interim}</span>
|
||||
)}
|
||||
{/* Floating stop label on top */}
|
||||
<span className="relative z-10 flex items-center gap-1.5 px-3 py-1 rounded-pill bg-panel/70 text-main text-xs font-semibold backdrop-blur-sm">
|
||||
{error ? (
|
||||
<span className="text-status-err font-normal">{error}</span>
|
||||
) : transcribing ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Wird erkannt…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Square className="w-3 h-3 fill-current" /> Stopp
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AlertTriangle,
|
||||
Image as ImageIcon,
|
||||
Mic,
|
||||
AudioLines,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { LinkModal } from './LinkModal';
|
||||
@@ -30,13 +31,15 @@ import { ImagePicker } from './ImagePicker';
|
||||
import { ImageBlock } from './ImageBlock';
|
||||
import { AudioRecorderPicker } from './AudioRecorderPicker';
|
||||
import { AudioBlock } from './AudioBlock';
|
||||
import { DictationBlock } from './DictationBlock';
|
||||
import { currentVoiceConfig } from '../../lib/voice';
|
||||
import { mediaContent, parseMediaContent } from '../../lib/media';
|
||||
import { renderInlineMarkdown } from '../../lib/markdown';
|
||||
import { api } from '../../lib/api';
|
||||
import { safeHref } from '../../lib/safeUrl';
|
||||
|
||||
export interface SlashCommand {
|
||||
type: BlockType | 'link';
|
||||
type: BlockType | 'link' | 'dictation';
|
||||
label: string;
|
||||
desc: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -55,6 +58,7 @@ const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{ type: 'divider', label: '/divider', desc: 'Trennlinie', icon: <Minus className="w-3.5 h-3.5" /> },
|
||||
{ type: 'image', label: '/bild', desc: 'Bild hochladen, fotografieren oder verlinken (image)', icon: <ImageIcon className="w-3.5 h-3.5" />, media: true },
|
||||
{ type: 'audio', label: '/audio', desc: 'Sprachnotiz aufnehmen oder Audiodatei hochladen', icon: <Mic className="w-3.5 h-3.5" />, media: true },
|
||||
{ type: 'dictation', label: '/diktat', desc: 'Sprechen statt tippen: Text direkt in den Punkt', icon: <AudioLines className="w-3.5 h-3.5" />, media: true },
|
||||
];
|
||||
|
||||
export interface OutlinerBlockProps {
|
||||
@@ -146,7 +150,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
const canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
|
||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||
// Inline picker an empty bullet turns into after /bild (local only, nothing synced until done)
|
||||
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | null>(null);
|
||||
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
|
||||
@@ -347,6 +351,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
return SLASH_COMMANDS.filter(
|
||||
(c) =>
|
||||
(!c.media || emptyBullet) &&
|
||||
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
|
||||
(c.label.toLowerCase().includes(cmdQuery) || c.desc.toLowerCase().includes(cmdQuery))
|
||||
);
|
||||
}, [slashSearch, localContent]);
|
||||
@@ -365,7 +370,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
setLocalContent('');
|
||||
localContentRef.current = '';
|
||||
flushDebounce('');
|
||||
setMediaPicker(cmd.type as 'image' | 'audio');
|
||||
setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation');
|
||||
return;
|
||||
}
|
||||
const langOverride =
|
||||
@@ -376,7 +381,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
localContentRef.current = clean;
|
||||
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
|
||||
flushDebounce(clean, {
|
||||
type: cmd.type,
|
||||
type: cmd.type as BlockType, // media commands returned above
|
||||
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
||||
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
|
||||
});
|
||||
@@ -854,6 +859,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}}
|
||||
/>
|
||||
) : mediaPicker === 'dictation' ? (
|
||||
<DictationBlock
|
||||
onDone={(text) => {
|
||||
setMediaPicker(null);
|
||||
setLocalContent(text);
|
||||
localContentRef.current = text;
|
||||
flushDebounce(text);
|
||||
requestAnimationFrame(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
setMediaPicker(null);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}}
|
||||
/>
|
||||
) : mediaPicker === 'audio' ? (
|
||||
<AudioRecorderPicker
|
||||
docId={docId}
|
||||
|
||||
Reference in new issue
Block a user