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
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user