feat(workspace): dictation via /diktat (M4)
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:
sandrodachandClaude Opus 5.5 committed 2026-10-29 23:30:15 +01:00
1 parent 3f5e3b2ac1
commit 3f2bc96732
4 files changed
+131 -4

No files matched your search

@@ -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}