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

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