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' ? (