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
@@ -9,6 +9,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
### Added
|
### Added
|
||||||
- Eigene Spracherkennung (optional): Mit `STT_URL` schickt MindSift Aufnahmen an einen selbst betriebenen Whisper-Server (OpenAI-kompatibel, z. B. speaches) statt an Google oder Microsoft. Einstellung nur per Umgebungsvariablen (`STT_*`) inklusive Grenzwerten; `docker-compose.yml` enthält den Dienst `whisper` als auskommentierten Block zum Einschalten, das Modell lädt MindSift beim ersten Start selbst. Administration → Server & Isolation zeigt den aktiven Modus und bietet eine Testaufnahme. In der Demo bleibt es bei der Browser-Erkennung.
|
- Eigene Spracherkennung (optional): Mit `STT_URL` schickt MindSift Aufnahmen an einen selbst betriebenen Whisper-Server (OpenAI-kompatibel, z. B. speaches) statt an Google oder Microsoft. Einstellung nur per Umgebungsvariablen (`STT_*`) inklusive Grenzwerten; `docker-compose.yml` enthält den Dienst `whisper` als auskommentierten Block zum Einschalten, das Modell lädt MindSift beim ersten Start selbst. Administration → Server & Isolation zeigt den aktiven Modus und bietet eine Testaufnahme. In der Demo bleibt es bei der Browser-Erkennung.
|
||||||
- API: `POST /voice/transcribe`, `GET /admin/voice`; `/auth/status` meldet `voice.mode`.
|
- 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.
|
||||||
- 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}`.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
'code',
|
'code',
|
||||||
'callout',
|
'callout',
|
||||||
'divider',
|
'divider',
|
||||||
|
'bild',
|
||||||
|
'image',
|
||||||
|
'foto',
|
||||||
'auto-formatting',
|
'auto-formatting',
|
||||||
],
|
],
|
||||||
content: (
|
content: (
|
||||||
@@ -192,6 +195,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
<span className="text-accent font-bold">—</span>
|
<span className="text-accent font-bold">—</span>
|
||||||
<span>/divider (Trennlinie)</span>
|
<span>/divider (Trennlinie)</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className="pt-2 border-t border-border">
|
<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,
|
FileText,
|
||||||
Lock,
|
Lock,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Image as ImageIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { LinkModal } from './LinkModal';
|
import { LinkModal } from './LinkModal';
|
||||||
import { CodeBlockEditor, resolveLanguageId, isUnambiguousLanguageShortcut } from './CodeBlockEditor';
|
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 { renderInlineMarkdown } from '../../lib/markdown';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import { safeHref } from '../../lib/safeUrl';
|
import { safeHref } from '../../lib/safeUrl';
|
||||||
@@ -33,6 +37,8 @@ export interface SlashCommand {
|
|||||||
label: string;
|
label: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
|
/** Only offered in an empty bullet; the bullet turns into an inline picker first */
|
||||||
|
media?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SLASH_COMMANDS: SlashCommand[] = [
|
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: '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: '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: '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 {
|
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
|
// 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 canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
|
||||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
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 [slashIndex, setSlashIndex] = useState(0);
|
||||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||||
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
|
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
|
||||||
@@ -329,10 +338,14 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
if (slashSearch === null) return [];
|
if (slashSearch === null) return [];
|
||||||
const q = slashSearch.toLowerCase();
|
const q = slashSearch.toLowerCase();
|
||||||
const cmdQuery = q.startsWith('code ') ? 'code' : q;
|
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(
|
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) => {
|
const applySlashCommand = (cmd: SlashCommand) => {
|
||||||
setSlashSearch(null);
|
setSlashSearch(null);
|
||||||
@@ -343,6 +356,14 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
setShowLinkModal(true);
|
setShowLinkModal(true);
|
||||||
return;
|
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 =
|
const langOverride =
|
||||||
cmd.type === 'code' && codeLangMatch
|
cmd.type === 'code' && codeLangMatch
|
||||||
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
|
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
|
||||||
@@ -814,7 +835,35 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
|
|
||||||
{/* Main Block Content */}
|
{/* Main Block Content */}
|
||||||
<div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]">
|
<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">
|
<div className="py-2">
|
||||||
<hr className="border-t border-border" />
|
<hr className="border-t border-border" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { mediaContent, parseMediaContent } from '../media';
|
||||||
|
|
||||||
|
describe('media content format', () => {
|
||||||
|
it('round-trips captions, also with brackets and backslashes', () => {
|
||||||
|
for (const caption of ['', 'Urlaub 2026', 'Plan [Entwurf]', 'C:\\Fotos\\x', 'zwei\nZeilen']) {
|
||||||
|
const content = mediaContent('image', 'med_0123abcd', caption);
|
||||||
|
expect(content.startsWith('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores anything that is not a media reference', () => {
|
||||||
|
expect(parseMediaContent('')).toBeNull();
|
||||||
|
expect(parseMediaContent('Text ')).toBeNull();
|
||||||
|
expect(parseMediaContent('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Images and audio in notes. A block points to its file through its content:
|
||||||
|
// image 
|
||||||
|
// audio [Beschriftung](media:med_…)
|
||||||
|
// so the reference travels with the normal note sync. Files are loaded with the login (fetch) and
|
||||||
|
// shown as blob: URLs; there are no public links.
|
||||||
|
import { getToken } from './api';
|
||||||
|
|
||||||
|
const MEDIA_RE = /^!?\[((?:[^\]\\]|\\.)*)\]\(media:(med_[0-9a-f]+)\)$/;
|
||||||
|
|
||||||
|
export interface MediaRef {
|
||||||
|
id: string;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMediaContent(content: string): MediaRef | null {
|
||||||
|
const m = MEDIA_RE.exec((content || '').trim());
|
||||||
|
if (!m) return null;
|
||||||
|
return { id: m[2], caption: m[1].replace(/\\(.)/g, '$1') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaContent(kind: 'image' | 'audio', id: string, caption = ''): string {
|
||||||
|
const safe = caption.replace(/[\r\n]+/g, ' ').replace(/([\\\]])/g, '\\$1');
|
||||||
|
return `${kind === 'image' ? '!' : ''}[${safe}](media:${id})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectURLs = new Map<string, Promise<string>>();
|
||||||
|
|
||||||
|
/** blob: URL of a file; loaded once per page. */
|
||||||
|
export function mediaObjectURL(id: string): Promise<string> {
|
||||||
|
let p = objectURLs.get(id);
|
||||||
|
if (!p) {
|
||||||
|
p = (async () => {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`/api/v1/media/${encodeURIComponent(id)}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(res.status === 404 ? 'Datei nicht gefunden' : `HTTP ${res.status}`);
|
||||||
|
return URL.createObjectURL(await res.blob());
|
||||||
|
})();
|
||||||
|
p.catch(() => objectURLs.delete(id)); // try again next time (e.g. was offline)
|
||||||
|
objectURLs.set(id, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadedMedia {
|
||||||
|
id: string;
|
||||||
|
kind: 'image' | 'audio';
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mediaRequest(path: string, init: RequestInit): Promise<UploadedMedia> {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: { ...(init.headers as Record<string, string>), ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || `Hochladen fehlgeschlagen (HTTP ${res.status})`);
|
||||||
|
return data as UploadedMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadMedia(docId: string, file: Blob, filename: string): Promise<UploadedMedia> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file, filename);
|
||||||
|
return mediaRequest(`/api/v1/documents/${encodeURIComponent(docId)}/media`, { method: 'POST', body: form });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets the server copy an external image (https). */
|
||||||
|
export function copyExternalImage(docId: string, url: string): Promise<UploadedMedia> {
|
||||||
|
return mediaRequest(`/api/v1/documents/${encodeURIComponent(docId)}/media/fetch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_EDGE = 2048;
|
||||||
|
|
||||||
|
function canvasBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
|
||||||
|
return new Promise((resolve) => canvas.toBlob((b) => resolve(b), type, quality));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shrinks photos to at most 2048 px and stores them as WebP (JPEG where the browser cannot write WebP).
|
||||||
|
* Re-encoding also drops EXIF data such as the location. GIFs stay as they are (animation); anything
|
||||||
|
* the browser cannot decode is sent unchanged and checked by the server.
|
||||||
|
*/
|
||||||
|
export async function prepareImage(file: Blob): Promise<{ blob: Blob; name: string }> {
|
||||||
|
if (file.type === 'image/gif' || typeof createImageBitmap === 'undefined') return { blob: file, name: 'bild' };
|
||||||
|
let bitmap: ImageBitmap;
|
||||||
|
try {
|
||||||
|
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' } as ImageBitmapOptions);
|
||||||
|
} catch {
|
||||||
|
return { blob: file, name: 'bild' };
|
||||||
|
}
|
||||||
|
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return { blob: file, name: 'bild' };
|
||||||
|
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||||
|
bitmap.close?.();
|
||||||
|
const webp = await canvasBlob(canvas, 'image/webp', 0.82);
|
||||||
|
if (webp && webp.type === 'image/webp') return { blob: webp, name: 'bild.webp' };
|
||||||
|
// No WebP encoder (older Safari): JPEG on white, since JPEG has no transparency
|
||||||
|
ctx.globalCompositeOperation = 'destination-over';
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const jpeg = await canvasBlob(canvas, 'image/jpeg', 0.85);
|
||||||
|
return jpeg ? { blob: jpeg, name: 'bild.jpg' } : { blob: file, name: 'bild' };
|
||||||
|
}
|
||||||
@@ -230,7 +230,7 @@ export const DATA_SECTIONS: { id: DataSection; label: string }[] = [
|
|||||||
{ id: 'themes', label: 'Themes' },
|
{ id: 'themes', label: 'Themes' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress' | 'image' | 'audio';
|
||||||
|
|
||||||
export interface DocumentBlock {
|
export interface DocumentBlock {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in new issue
Block a user