feat(workspace): image blocks via /bild (M2)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m18s
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 (). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user