feat(workspace): image blocks via /bild (M2)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m18s

- /bild (only in an empty bullet) turns the bullet into an inline picker:
  file, camera on phones, drag & drop, paste, or an https link the server
  copies. Esc/✕ restores the empty bullet; nothing is synced before the
  upload succeeded.
- Photos are shrunk to 2048 px and stored as WebP (JPEG fallback); the
  re-encoding drops EXIF data such as the location.
- Image block: loaded with the login as blob URL, click opens full
  screen, optional caption kept in the block content
  (![caption](media:id)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 23:14:08 +01:00
1 parent 177fa49363
commit e8fa14a4cb
8 files changed
+476 -4

No files matched your search

+7
View File
@@ -159,6 +159,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
'code',
'callout',
'divider',
'bild',
'image',
'foto',
'auto-formatting',
],
content: (
@@ -192,6 +195,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<span className="text-accent font-bold">—</span>
<span>/divider (Trennlinie)</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>/bild (Bild hochladen, fotografieren, einfügen oder per Link; nur im leeren Punkt)</span>
</div>
</div>
</div>
<div className="pt-2 border-t border-border">
@@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ImageOff, Loader2, X } from 'lucide-react';
import { mediaObjectURL, parseMediaContent } from '../../lib/media';
interface ImageBlockProps {
content: string;
canEdit: boolean;
onCaptionChange: (caption: string) => void;
}
// Image block: the picture (click = full screen) and an optional caption below it.
export const ImageBlock: React.FC<ImageBlockProps> = ({ content, canEdit, onCaptionChange }) => {
const ref = parseMediaContent(content);
const [src, setSrc] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [zoomed, setZoomed] = useState(false);
const [caption, setCaption] = useState(ref?.caption || '');
useEffect(() => {
setCaption(ref?.caption || '');
}, [ref?.caption]);
const mediaId = ref?.id;
useEffect(() => {
if (!mediaId) return;
let alive = true;
setFailed(false);
mediaObjectURL(mediaId)
.then((u) => alive && setSrc(u))
.catch(() => alive && setFailed(true));
return () => {
alive = false;
};
}, [mediaId]);
useEffect(() => {
if (!zoomed) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setZoomed(false);
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [zoomed]);
if (!ref) {
return <div className="text-xs text-status-err py-1">Bild-Verweis ist beschädigt: {content}</div>;
}
const commitCaption = () => {
if (caption.trim() !== ref.caption) onCaptionChange(caption.trim());
};
return (
<figure className="my-1 space-y-1">
{failed ? (
<div className="flex items-center gap-2 h-24 px-3 rounded-card border border-border bg-panel text-xs text-muted">
<ImageOff className="w-4 h-4" /> Bild nicht verfügbar (offline oder gelöscht)
</div>
) : src ? (
<button
type="button"
onClick={() => setZoomed(true)}
title="Vergrößern"
className="block max-w-full rounded-card overflow-hidden border border-border bg-panel cursor-zoom-in"
>
<img src={src} alt={ref.caption} className="block max-w-full max-h-[28rem] object-contain" />
</button>
) : (
<div className="flex items-center justify-center w-48 h-32 rounded-card border border-border bg-panel">
<Loader2 className="w-4 h-4 animate-spin text-muted" />
</div>
)}
{canEdit ? (
<input
type="text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
onBlur={commitCaption}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
placeholder="Beschriftung hinzufügen…"
className="w-full bg-transparent border-0 p-0 text-xs text-muted placeholder-faint focus:outline-none focus:ring-0"
/>
) : (
ref.caption && <figcaption className="text-xs text-muted">{ref.caption}</figcaption>
)}
{zoomed &&
src &&
createPortal(
<div
role="dialog"
aria-label={ref.caption || 'Bild'}
onClick={() => setZoomed(false)}
className="fixed inset-0 z-[100] bg-black/85 flex flex-col items-center justify-center p-4 cursor-zoom-out"
>
<button
type="button"
onClick={() => setZoomed(false)}
title="Schließen (Esc)"
className="absolute top-[calc(0.75rem+env(safe-area-inset-top))] right-3 w-11 h-11 flex items-center justify-center rounded-full bg-black/50 text-white"
>
<X className="w-5 h-5" />
</button>
<img src={src} alt={ref.caption} className="max-w-full max-h-[85vh] object-contain" />
{ref.caption && <p className="mt-3 text-sm text-white/90 text-center max-w-2xl">{ref.caption}</p>}
</div>,
document.body
)}
</figure>
);
};
@@ -0,0 +1,165 @@
import React, { useEffect, useRef, useState } from 'react';
import { Camera, ImagePlus, Link as LinkIcon, Loader2, X } from 'lucide-react';
import { copyExternalImage, prepareImage, UploadedMedia, uploadMedia } from '../../lib/media';
interface ImagePickerProps {
docId: string;
onDone: (media: UploadedMedia) => void;
onCancel: () => void;
}
// Inline box an empty bullet turns into after /bild: pick a file (or the camera on phones), drop or
// paste an image, or give a link the server copies. Nothing is synced until the upload succeeded.
export const ImagePicker: React.FC<ImagePickerProps> = ({ docId, onDone, onCancel }) => {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const [url, setUrl] = useState('');
const fileRef = useRef<HTMLInputElement>(null);
const cameraRef = useRef<HTMLInputElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
boxRef.current?.focus();
}, []);
const uploadFile = async (file: File | Blob | null | undefined) => {
if (!file || busy) return;
if (file.type && !file.type.startsWith('image/')) {
setError('Bitte eine Bilddatei wählen');
return;
}
setBusy(true);
setError(null);
try {
const { blob, name } = await prepareImage(file);
onDone(await uploadMedia(docId, blob, name));
} catch (err: any) {
setError(err?.message || 'Hochladen fehlgeschlagen');
setBusy(false);
}
};
const copyLink = async (e?: React.FormEvent) => {
e?.preventDefault();
const target = url.trim();
if (!target || busy) return;
setBusy(true);
setError(null);
try {
onDone(await copyExternalImage(docId, target));
} catch (err: any) {
setError(err?.message || 'Bild konnte nicht übernommen werden');
setBusy(false);
}
};
return (
<div
ref={boxRef}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
}}
onPaste={(e) => {
const item = Array.from(e.clipboardData.items).find((i) => i.type.startsWith('image/'));
if (item) {
e.preventDefault();
void uploadFile(item.getAsFile());
}
}}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
void uploadFile(e.dataTransfer.files?.[0]);
}}
className={
dragOver
? 'my-1 p-3 rounded-card border-2 border-dashed border-accent bg-accent-dim space-y-3 focus:outline-none'
: 'my-1 p-3 rounded-card border-2 border-dashed border-border bg-panel space-y-3 focus:outline-none'
}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-main flex items-center gap-1.5">
<ImagePlus className="w-4 h-4 text-accent" /> Bild einfügen
</span>
<button
type="button"
onClick={onCancel}
title="Abbrechen (Esc)"
className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-btn text-muted hover:text-main hover:bg-panel-elevated"
>
<X className="w-4 h-4" />
</button>
</div>
{busy ? (
<div className="flex items-center gap-2 text-xs text-muted py-3">
<Loader2 className="w-4 h-4 animate-spin text-accent" /> Wird hochgeladen…
</div>
) : (
<>
<div className="flex flex-col sm:flex-row gap-2">
<button
type="button"
onClick={() => fileRef.current?.click()}
className="btn-primary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5"
>
<ImagePlus className="w-4 h-4" /> Datei wählen
</button>
<button
type="button"
onClick={() => cameraRef.current?.click()}
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5 sm:hidden"
>
<Camera className="w-4 h-4" /> Foto aufnehmen
</button>
<span className="hidden sm:flex items-center text-[11px] text-faint">
oder hierher ziehen bzw. mit Strg+V einfügen
</span>
</div>
<form onSubmit={copyLink} className="flex gap-2">
<div className="relative flex-1 min-w-0">
<LinkIcon className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="url"
inputMode="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://… Bild-Link"
className="w-full h-11 sm:h-8 pl-8 pr-2 input-field text-xs"
/>
</div>
<button
type="submit"
disabled={!url.trim()}
className="btn-secondary h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40"
>
Übernehmen
</button>
</form>
</>
)}
{error && <p className="text-xs text-status-err">{error}</p>}
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => void uploadFile(e.target.files?.[0])} />
<input
ref={cameraRef}
type="file"
accept="image/*"
capture="environment"
hidden
onChange={(e) => void uploadFile(e.target.files?.[0])}
/>
</div>
);
};
@@ -20,10 +20,14 @@ import {
FileText,
Lock,
AlertTriangle,
Image as ImageIcon,
} 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 { mediaContent, parseMediaContent } from '../../lib/media';
import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api';
import { safeHref } from '../../lib/safeUrl';
@@ -33,6 +37,8 @@ export interface SlashCommand {
label: string;
desc: string;
icon: React.ReactNode;
/** Only offered in an empty bullet; the bullet turns into an inline picker first */
media?: boolean;
}
const SLASH_COMMANDS: SlashCommand[] = [
@@ -44,6 +50,7 @@ const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: <Code className="w-3.5 h-3.5" /> },
{ 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 },
];
export interface OutlinerBlockProps {
@@ -134,6 +141,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
// A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end
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 [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
@@ -329,10 +338,14 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (slashSearch === null) return [];
const q = slashSearch.toLowerCase();
const cmdQuery = q.startsWith('code ') ? 'code' : q;
// Media only in an otherwise empty bullet
const emptyBullet = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
return SLASH_COMMANDS.filter(
(c) => c.label.toLowerCase().includes(cmdQuery) || c.desc.toLowerCase().includes(cmdQuery)
(c) =>
(!c.media || emptyBullet) &&
(c.label.toLowerCase().includes(cmdQuery) || c.desc.toLowerCase().includes(cmdQuery))
);
}, [slashSearch]);
}, [slashSearch, localContent]);
const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null);
@@ -343,6 +356,14 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
setShowLinkModal(true);
return;
}
if (cmd.media) {
// The typed "/bild" must not stay in the synced text; the block type changes only after the upload
setLocalContent('');
localContentRef.current = '';
flushDebounce('');
setMediaPicker(cmd.type as 'image');
return;
}
const langOverride =
cmd.type === 'code' && codeLangMatch
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
@@ -814,7 +835,35 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* Main Block Content */}
<div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]">
{block.type === 'divider' ? (
{mediaPicker === 'image' ? (
<ImagePicker
docId={docId}
onDone={(media) => {
setMediaPicker(null);
const content = mediaContent('image', media.id);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content, { type: 'image' });
}}
onCancel={() => {
setMediaPicker(null);
requestAnimationFrame(() => textareaRef.current?.focus());
}}
/>
) : block.type === 'image' ? (
<ImageBlock
content={localContent}
canEdit={canEdit}
onCaptionChange={(caption) => {
const ref = parseMediaContent(localContent);
if (!ref) return;
const content = mediaContent('image', ref.id, caption);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content);
}}
/>
) : block.type === 'divider' ? (
<div className="py-2">
<hr className="border-t border-border" />
</div>