feat(workspace): voice notes via /audio (M3)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m10s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m10s
- /audio (only in an empty bullet) shows a recording bubble: green round button starts, it turns red with a live wave and timer on its left, a tap saves the recording as an inline audio player. An audio file can be uploaded instead; Esc/✕ restores the empty bullet. - Chrome's WebM recordings get their length measured, so the player shows the total time and seeking works. - With the own Whisper server "Transkribieren" turns the recording into the (searchable) caption. - Loaded files keep their Blob, as the CSP rightly blocks fetch() on blob: URLs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8fa14a4cb
commit
3f5e3b2ac1
6 files changed
+378
-9
No files matched your search
@@ -21,12 +21,15 @@ import {
|
||||
Lock,
|
||||
AlertTriangle,
|
||||
Image as ImageIcon,
|
||||
Mic,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { LinkModal } from './LinkModal';
|
||||
import { CodeBlockEditor, resolveLanguageId, isUnambiguousLanguageShortcut } from './CodeBlockEditor';
|
||||
import { ImagePicker } from './ImagePicker';
|
||||
import { ImageBlock } from './ImageBlock';
|
||||
import { AudioRecorderPicker } from './AudioRecorderPicker';
|
||||
import { AudioBlock } from './AudioBlock';
|
||||
import { mediaContent, parseMediaContent } from '../../lib/media';
|
||||
import { renderInlineMarkdown } from '../../lib/markdown';
|
||||
import { api } from '../../lib/api';
|
||||
@@ -51,6 +54,7 @@ const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{ type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: <MessageSquare 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: 'audio', label: '/audio', desc: 'Sprachnotiz aufnehmen oder Audiodatei hochladen', icon: <Mic className="w-3.5 h-3.5" />, media: true },
|
||||
];
|
||||
|
||||
export interface OutlinerBlockProps {
|
||||
@@ -142,7 +146,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' | null>(null);
|
||||
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | null>(null);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
|
||||
@@ -361,7 +365,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
setLocalContent('');
|
||||
localContentRef.current = '';
|
||||
flushDebounce('');
|
||||
setMediaPicker(cmd.type as 'image');
|
||||
setMediaPicker(cmd.type as 'image' | 'audio');
|
||||
return;
|
||||
}
|
||||
const langOverride =
|
||||
@@ -850,6 +854,34 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}}
|
||||
/>
|
||||
) : mediaPicker === 'audio' ? (
|
||||
<AudioRecorderPicker
|
||||
docId={docId}
|
||||
onDone={(media) => {
|
||||
setMediaPicker(null);
|
||||
const content = mediaContent('audio', media.id);
|
||||
setLocalContent(content);
|
||||
localContentRef.current = content;
|
||||
flushDebounce(content, { type: 'audio' });
|
||||
}}
|
||||
onCancel={() => {
|
||||
setMediaPicker(null);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}}
|
||||
/>
|
||||
) : block.type === 'audio' ? (
|
||||
<AudioBlock
|
||||
content={localContent}
|
||||
canEdit={canEdit}
|
||||
onCaptionChange={(caption) => {
|
||||
const ref = parseMediaContent(localContent);
|
||||
if (!ref) return;
|
||||
const content = mediaContent('audio', ref.id, caption);
|
||||
setLocalContent(content);
|
||||
localContentRef.current = content;
|
||||
flushDebounce(content);
|
||||
}}
|
||||
/>
|
||||
) : block.type === 'image' ? (
|
||||
<ImageBlock
|
||||
content={localContent}
|
||||
|
||||
Reference in new issue
Block a user