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

+1
View File
@@ -11,6 +11,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- API: `POST /voice/transcribe`, `GET /admin/voice`; `/auth/status` meldet `voice.mode`. - API: `POST /voice/transcribe`, `GET /admin/voice`; `/auth/status` meldet `voice.mode`.
- Bilder in Notizen: `/bild` in einem leeren Punkt öffnet direkt im Punkt eine Auswahl – Datei, Kamera (Handy), Hineinziehen, Einfügen mit Strg+V oder Bild-Link (der Server übernimmt eine Kopie). Fotos werden vor dem Hochladen auf höchstens 2048 px verkleinert und als WebP gespeichert, Standortdaten entfallen dabei. Klick vergrößert, darunter eine optionale Beschriftung. Esc oder ✕ macht den Punkt wieder zum leeren Punkt. - Bilder in Notizen: `/bild` in einem leeren Punkt öffnet direkt im Punkt eine Auswahl – Datei, Kamera (Handy), Hineinziehen, Einfügen mit Strg+V oder Bild-Link (der Server übernimmt eine Kopie). Fotos werden vor dem Hochladen auf höchstens 2048 px verkleinert und als WebP gespeichert, Standortdaten entfallen dabei. Klick vergrößert, darunter eine optionale Beschriftung. Esc oder ✕ macht den Punkt wieder zum leeren Punkt.
- Sprachnotizen in Notizen: `/audio` in einem leeren Punkt zeigt eine Aufnahme-Blase – grüner Knopf startet, er wird rot und links laufen Live-Wellen mit der Zeit, ein Tipp speichert die Aufnahme als Audio-Player im Punkt. Alternativ eine Audiodatei hochladen. Mit eigener Spracherkennung macht „Transkribieren“ aus der Aufnahme die (durchsuchbare) Beschriftung. - Sprachnotizen in Notizen: `/audio` in einem leeren Punkt zeigt eine Aufnahme-Blase – grüner Knopf startet, er wird rot und links laufen Live-Wellen mit der Zeit, ein Tipp speichert die Aufnahme als Audio-Player im Punkt. Alternativ eine Audiodatei hochladen. Mit eigener Spracherkennung macht „Transkribieren“ aus der Aufnahme die (durchsuchbare) Beschriftung.
- Diktat in Notizen: `/diktat` in einem leeren Punkt hört sofort zu; der Punkt wird zu einer Fläche mit Live-Wellen und „Stopp“, ein Tipp setzt den erkannten Text in den Punkt. Nutzt die eingestellte Spracherkennung (eigener Server oder Browser) und wird ohne Spracherkennung nicht angeboten.
- Medienablage für Notizen (Grundlage für Bilder und Audio): Hochladen, Kopieren externer Bilder, Abruf nur mit Leserecht; Grenzen per `MEDIA_*`; nicht mehr verwendete Dateien werden automatisch entfernt. API: `POST /documents/{id}/media`, `POST /documents/{id}/media/fetch`, `GET /media/{id}`. - Medienablage für Notizen (Grundlage für Bilder und Audio): Hochladen, Kopieren externer Bilder, Abruf nur mit Leserecht; Grenzen per `MEDIA_*`; nicht mehr verwendete Dateien werden automatisch entfernt. API: `POST /documents/{id}/media`, `POST /documents/{id}/media/fetch`, `GET /media/{id}`.
- Code-Blöcke in Notizen: Sprachauswahl (rund 50 Sprachen, bei Bedarf nachgeladen), Farben beim Tippen im Stil von VS Code Dark+/Light+ für alle Themes, „Kopieren“, Tab/Shift+Tab rückt ein und aus. `/code yaml` oder ```` ```yaml ```` legt die Sprache direkt fest. - Code-Blöcke in Notizen: Sprachauswahl (rund 50 Sprachen, bei Bedarf nachgeladen), Farben beim Tippen im Stil von VS Code Dark+/Light+ für alle Themes, „Kopieren“, Tab/Shift+Tab rückt ein und aus. `/code yaml` oder ```` ```yaml ```` legt die Sprache direkt fest.
+5
View File
@@ -162,6 +162,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
'bild', 'bild',
'audio', 'audio',
'sprachnotiz', 'sprachnotiz',
'diktat',
'image', 'image',
'foto', 'foto',
'auto-formatting', 'auto-formatting',
@@ -205,6 +206,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<span className="text-accent font-bold">🎙️</span> <span className="text-accent font-bold">🎙️</span>
<span>/audio (Sprachnotiz aufnehmen: grüner Knopf startet, roter speichert; oder Audiodatei hochladen)</span> <span>/audio (Sprachnotiz aufnehmen: grüner Knopf startet, roter speichert; oder Audiodatei hochladen)</span>
</div> </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> </div>
<div className="pt-2 border-t border-border"> <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, AlertTriangle,
Image as ImageIcon, Image as ImageIcon,
Mic, Mic,
AudioLines,
} from 'lucide-react'; } from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types'; import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal'; import { LinkModal } from './LinkModal';
@@ -30,13 +31,15 @@ import { ImagePicker } from './ImagePicker';
import { ImageBlock } from './ImageBlock'; import { ImageBlock } from './ImageBlock';
import { AudioRecorderPicker } from './AudioRecorderPicker'; import { AudioRecorderPicker } from './AudioRecorderPicker';
import { AudioBlock } from './AudioBlock'; import { AudioBlock } from './AudioBlock';
import { DictationBlock } from './DictationBlock';
import { currentVoiceConfig } from '../../lib/voice';
import { mediaContent, parseMediaContent } from '../../lib/media'; import { mediaContent, parseMediaContent } from '../../lib/media';
import { renderInlineMarkdown } from '../../lib/markdown'; import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import { safeHref } from '../../lib/safeUrl'; import { safeHref } from '../../lib/safeUrl';
export interface SlashCommand { export interface SlashCommand {
type: BlockType | 'link'; type: BlockType | 'link' | 'dictation';
label: string; label: string;
desc: string; desc: string;
icon: React.ReactNode; 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: '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: '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: '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 { export interface OutlinerBlockProps {
@@ -146,7 +150,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const canFocus = canEdit || (!!lockedBy && !!canAppendBelow); const canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
const [slashSearch, setSlashSearch] = useState<string | null>(null); const [slashSearch, setSlashSearch] = useState<string | null>(null);
// Inline picker an empty bullet turns into after /bild (local only, nothing synced until done) // 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 [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false); const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false); const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
@@ -347,6 +351,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return SLASH_COMMANDS.filter( return SLASH_COMMANDS.filter(
(c) => (c) =>
(!c.media || emptyBullet) && (!c.media || emptyBullet) &&
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
(c.label.toLowerCase().includes(cmdQuery) || c.desc.toLowerCase().includes(cmdQuery)) (c.label.toLowerCase().includes(cmdQuery) || c.desc.toLowerCase().includes(cmdQuery))
); );
}, [slashSearch, localContent]); }, [slashSearch, localContent]);
@@ -365,7 +370,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
setLocalContent(''); setLocalContent('');
localContentRef.current = ''; localContentRef.current = '';
flushDebounce(''); flushDebounce('');
setMediaPicker(cmd.type as 'image' | 'audio'); setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation');
return; return;
} }
const langOverride = const langOverride =
@@ -376,7 +381,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
localContentRef.current = clean; localContentRef.current = clean;
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true; if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
flushDebounce(clean, { flushDebounce(clean, {
type: cmd.type, type: cmd.type as BlockType, // media commands returned above
...(langOverride !== undefined ? { lang: langOverride } : {}), ...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: cmd.type === 'todo' ? Boolean(block.checked) : false, checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
}); });
@@ -854,6 +859,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
requestAnimationFrame(() => textareaRef.current?.focus()); 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' ? ( ) : mediaPicker === 'audio' ? (
<AudioRecorderPicker <AudioRecorderPicker
docId={docId} docId={docId}