From 3f2bc967328d50c2dd8a046e99c9024fe571443e Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 23:30:15 +0100 Subject: [PATCH] feat(workspace): dictation via /diktat (M4) /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 --- CHANGELOG.md | 1 + frontend/src/features/help/HelpView.tsx | 5 + .../src/features/workspace/DictationBlock.tsx | 97 +++++++++++++++++++ .../src/features/workspace/OutlinerBlock.tsx | 32 +++++- 4 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 frontend/src/features/workspace/DictationBlock.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 92a4f0d..83890dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. - 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. +- 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}`. - 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. diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index b85dbc0..9132b94 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -162,6 +162,7 @@ export const HelpView: React.FC = ({ onBack }) => { 'bild', 'audio', 'sprachnotiz', + 'diktat', 'image', 'foto', 'auto-formatting', @@ -205,6 +206,10 @@ export const HelpView: React.FC = ({ onBack }) => { 🎙️ /audio (Sprachnotiz aufnehmen: grüner Knopf startet, roter speichert; oder Audiodatei hochladen) +
+ 🗣️ + /diktat (sprechen statt tippen: startet sofort, Tipp auf „Stopp“ setzt den Text in den Punkt) +
diff --git a/frontend/src/features/workspace/DictationBlock.tsx b/frontend/src/features/workspace/DictationBlock.tsx new file mode 100644 index 0000000..8322208 --- /dev/null +++ b/frontend/src/features/workspace/DictationBlock.tsx @@ -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 = ({ onDone, onCancel }) => { + const [interim, setInterim] = useState(''); + const [error, setError] = useState(null); + const doneRef = useRef(false); + const buttonRef = useRef(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 ( + + ); +}; diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index f556ecd..f4a1650 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -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: }, { type: 'image', label: '/bild', desc: 'Bild hochladen, fotografieren oder verlinken (image)', icon: , media: true }, { type: 'audio', label: '/audio', desc: 'Sprachnotiz aufnehmen oder Audiodatei hochladen', icon: , media: true }, + { type: 'dictation', label: '/diktat', desc: 'Sprechen statt tippen: Text direkt in den Punkt', icon: , media: true }, ]; export interface OutlinerBlockProps { @@ -146,7 +150,7 @@ export const OutlinerBlock: React.FC = ({ const canFocus = canEdit || (!!lockedBy && !!canAppendBelow); const [slashSearch, setSlashSearch] = useState(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(false); const [showTodoMenu, setShowTodoMenu] = useState(false); @@ -347,6 +351,7 @@ export const OutlinerBlock: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ requestAnimationFrame(() => textareaRef.current?.focus()); }} /> + ) : mediaPicker === 'dictation' ? ( + { + 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' ? (