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