diff --git a/CHANGELOG.md b/CHANGELOG.md index 359def9..1a2015d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ### 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. - 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}`. - 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. diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index e969824..2902c0a 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -159,6 +159,9 @@ export const HelpView: React.FC = ({ onBack }) => { 'code', 'callout', 'divider', + 'bild', + 'image', + 'foto', 'auto-formatting', ], content: ( @@ -192,6 +195,10 @@ export const HelpView: React.FC = ({ onBack }) => { — /divider (Trennlinie) +
+ 🖼️ + /bild (Bild hochladen, fotografieren, einfügen oder per Link; nur im leeren Punkt) +
diff --git a/frontend/src/features/workspace/ImageBlock.tsx b/frontend/src/features/workspace/ImageBlock.tsx new file mode 100644 index 0000000..0f3c409 --- /dev/null +++ b/frontend/src/features/workspace/ImageBlock.tsx @@ -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 = ({ content, canEdit, onCaptionChange }) => { + const ref = parseMediaContent(content); + const [src, setSrc] = useState(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
Bild-Verweis ist beschädigt: {content}
; + } + + const commitCaption = () => { + if (caption.trim() !== ref.caption) onCaptionChange(caption.trim()); + }; + + return ( +
+ {failed ? ( +
+ Bild nicht verfügbar (offline oder gelöscht) +
+ ) : src ? ( + + ) : ( +
+ +
+ )} + + {canEdit ? ( + 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 &&
{ref.caption}
+ )} + + {zoomed && + src && + createPortal( +
setZoomed(false)} + className="fixed inset-0 z-[100] bg-black/85 flex flex-col items-center justify-center p-4 cursor-zoom-out" + > + + {ref.caption} + {ref.caption &&

{ref.caption}

} +
, + document.body + )} +
+ ); +}; diff --git a/frontend/src/features/workspace/ImagePicker.tsx b/frontend/src/features/workspace/ImagePicker.tsx new file mode 100644 index 0000000..a3dc88a --- /dev/null +++ b/frontend/src/features/workspace/ImagePicker.tsx @@ -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 = ({ docId, onDone, onCancel }) => { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [dragOver, setDragOver] = useState(false); + const [url, setUrl] = useState(''); + const fileRef = useRef(null); + const cameraRef = useRef(null); + const boxRef = useRef(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 ( +
{ + 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' + } + > +
+ + Bild einfügen + + +
+ + {busy ? ( +
+ Wird hochgeladen… +
+ ) : ( + <> +
+ + + + oder hierher ziehen bzw. mit Strg+V einfügen + +
+
+
+ + setUrl(e.target.value)} + placeholder="https://… Bild-Link" + className="w-full h-11 sm:h-8 pl-8 pr-2 input-field text-xs" + /> +
+ +
+ + )} + + {error &&

{error}

} + + void uploadFile(e.target.files?.[0])} /> + void uploadFile(e.target.files?.[0])} + /> +
+ ); +}; diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index ce9056b..6279722 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -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: }, { type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: }, { type: 'divider', label: '/divider', desc: 'Trennlinie', icon: }, + { type: 'image', label: '/bild', desc: 'Bild hochladen, fotografieren oder verlinken (image)', icon: , media: true }, ]; export interface OutlinerBlockProps { @@ -134,6 +141,8 @@ export const OutlinerBlock: React.FC = ({ // 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(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(false); const [showTodoMenu, setShowTodoMenu] = useState(false); @@ -329,10 +338,14 @@ export const OutlinerBlock: React.FC = ({ 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 = ({ 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 = ({ {/* Main Block Content */}
- {block.type === 'divider' ? ( + {mediaPicker === 'image' ? ( + { + 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' ? ( + { + const ref = parseMediaContent(localContent); + if (!ref) return; + const content = mediaContent('image', ref.id, caption); + setLocalContent(content); + localContentRef.current = content; + flushDebounce(content); + }} + /> + ) : block.type === 'divider' ? (

diff --git a/frontend/src/lib/__tests__/media.test.ts b/frontend/src/lib/__tests__/media.test.ts new file mode 100644 index 0000000..44c5b93 --- /dev/null +++ b/frontend/src/lib/__tests__/media.test.ts @@ -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('![')).toBe(true); + expect(parseMediaContent(content)).toEqual({ id: 'med_0123abcd', caption: caption.replace(/\n/g, ' ') }); + } + expect(mediaContent('audio', 'med_ff', 'Sprachnotiz')).toBe('[Sprachnotiz](media:med_ff)'); + }); + + it('ignores anything that is not a media reference', () => { + expect(parseMediaContent('![x](https://example.com/a.png)')).toBeNull(); + expect(parseMediaContent('Text ![x](media:med_aa)')).toBeNull(); + expect(parseMediaContent('![x](media:../etc/passwd)')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/media.ts b/frontend/src/lib/media.ts new file mode 100644 index 0000000..a59b0e5 --- /dev/null +++ b/frontend/src/lib/media.ts @@ -0,0 +1,115 @@ +// Images and audio in notes. A block points to its file through its content: +// image ![Beschriftung](media:med_…) +// 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>(); + +/** blob: URL of a file; loaded once per page. */ +export function mediaObjectURL(id: string): Promise { + 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 { + const token = getToken(); + const res = await fetch(path, { + ...init, + headers: { ...(init.headers as Record), ...(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 { + 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 { + 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 { + 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' }; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index c5b13c6..a787954 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -230,7 +230,7 @@ export const DATA_SECTIONS: { id: DataSection; label: string }[] = [ { 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 { id: string;