feat(drawing): Vorschaubilder und ein Zeichenbrett, das zur Oberfläche passt
Vorschaubilder entstehen sonst serverseitig aus den hochgeladenen Bytes – bei
einer Zeichnung steht dort JSON, daraus wird ohne Browser kein Bild. Wer sie
gerade bearbeitet, hat aber genau den Browser, der sie ohnehin zeichnet.
Neu: POST /api/v1/files/{id}/thumbnail. Der Editor schickt nach dem Speichern
ein PNG hinterher.
Den Bytes wird dabei nicht geglaubt: Sie werden nicht abgelegt, sondern durch
denselben Weg geschickt wie jedes andere Vorschaubild – dekodieren,
verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
übliche Grösse; nichts anderes kann im thumbs-Bereich landen. Dazu Schreibrecht
auf die Datei: Wer sie nicht ändern darf, bestimmt auch nicht, wie sie in der
Liste aussieht.
Das Hochladen läuft nach dem Speichern und ohne darauf zu warten. Ein
fehlgeschlagenes Vorschaubild ist ein Schönheitsfehler, kein Grund, das
Speichern scheitern zu lassen – und bleibt deshalb stumm.
Politur am Zeichenbrett: dunkles Thema (ein weisses Brett in dunkler Oberfläche
war der auffälligste Bruch), „Unsaved" als Marke statt eines Punktes hinter dem
Namen, Strg+S, Dateisymbol und Name ohne Endung.
„Open" und „Save to…" sind aus. Beide gehen am Browser vorbei: „Open" ersetzte
den Inhalt dieser Zeichnung durch eine fremde Datei, „Save to…" schriebe eine
zweite Fassung in den Download-Ordner, die niemand wiederfindet.
Selbsttest 12 → 18 Prüfungen, darunter die beiden Ablehnungen (kein Bild → 400,
fremder Typ → 415).
Geprüft: go build/vet sauber, alle Go-Pakete grün, 261 Prüfungen der acht
Selbsttest-Suiten grün, 53 Vitest-Tests, tsc sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2e1eaf9cbf
commit
dd68bc594d
6 files changed
+277
-3
No files matched your search
@@ -671,6 +671,27 @@ export const filesApi = {
|
||||
* Gemessen: dreimal dieselbe Datei hochgeladen ergibt dreimal dieselbe ID
|
||||
* mit den Fassungen 1, 2, 3.
|
||||
*/
|
||||
/**
|
||||
* Ein Vorschaubild setzen, das nur der Browser erzeugen kann.
|
||||
*
|
||||
* Der Server rechnet Vorschaubilder sonst selbst aus den hochgeladenen
|
||||
* Bytes. Bei einer Zeichnung steht dort JSON – daraus wird ohne Browser kein
|
||||
* Bild. Wer sie gerade bearbeitet, hat den Browser aber.
|
||||
*
|
||||
* Der Server legt die Bytes **nicht** ab, sondern schickt sie durch
|
||||
* denselben Weg wie jedes andere Vorschaubild (dekodieren, verkleinern, neu
|
||||
* kodieren). Was hier hochgeht, ist also ein Vorschlag, kein Befehl.
|
||||
*/
|
||||
setThumbnail: async (id: string, bild: Blob) => {
|
||||
const res = await fetch(`/api/v1/files/${id}/thumbnail`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': bild.type || 'image/png', 'X-CSRF-Token': csrfToken },
|
||||
body: bild,
|
||||
})
|
||||
if (!res.ok) throw new APIError(res.status, 'thumbnail upload failed')
|
||||
},
|
||||
|
||||
thumbnail: (id: string, version?: number | string) =>
|
||||
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Excalidraw, restore, serializeAsJSON } from '@excalidraw/excalidraw'
|
||||
import { Excalidraw, exportToBlob, restore, serializeAsJSON } from '@excalidraw/excalidraw'
|
||||
import '@excalidraw/excalidraw/index.css'
|
||||
import { filesApi, type FileItem } from '../api/client'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
import { FileTypeIcon } from './icons'
|
||||
import { toast, fehlertext } from './Toasts'
|
||||
|
||||
/**
|
||||
@@ -146,6 +147,7 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
|
||||
setGeaendert(false)
|
||||
onSaved?.(neu)
|
||||
toast.info('Drawing saved')
|
||||
void vorschaubild(neu.id)
|
||||
} catch (err) {
|
||||
toast.error(fehlertext(err, 'Could not save the drawing'))
|
||||
} finally {
|
||||
@@ -153,6 +155,52 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Vorschaubild erzeugen und hochladen.
|
||||
*
|
||||
* Der Server kann das nicht: In der Datei steht JSON, und daraus wird ohne
|
||||
* Browser kein Bild. Hier ist der Browser, der die Zeichnung ohnehin gerade
|
||||
* gezeichnet hat.
|
||||
*
|
||||
* **Nach** dem Speichern und ohne darauf zu warten: Ein fehlgeschlagenes
|
||||
* Vorschaubild ist ein Schönheitsfehler, kein Grund, das Speichern
|
||||
* scheitern zu lassen. Es bleibt deshalb auch stumm – die Zeichnung selbst
|
||||
* ist heil, und eine rote Meldung dafür wäre verwirrend.
|
||||
*/
|
||||
async function vorschaubild(id: string) {
|
||||
if (!aktuell.current) return
|
||||
try {
|
||||
const bild = await exportToBlob({
|
||||
elements: aktuell.current.elements as never,
|
||||
appState: { ...(aktuell.current.appState as object), exportBackground: true } as never,
|
||||
files: aktuell.current.files as never,
|
||||
mimeType: 'image/png',
|
||||
exportPadding: 16,
|
||||
// Der Server verkleinert ohnehin auf Kachelgröße. Grösser hochzuladen
|
||||
// kostet nur Bandbreite.
|
||||
getDimensions: (w: number, h: number) => {
|
||||
const faktor = Math.min(1, 512 / Math.max(w, h))
|
||||
return { width: Math.round(w * faktor), height: Math.round(h * faktor), scale: faktor }
|
||||
},
|
||||
})
|
||||
await filesApi.setThumbnail(id, bild)
|
||||
} catch {
|
||||
/* Schönheitsfehler – die Zeichnung ist gespeichert. */
|
||||
}
|
||||
}
|
||||
|
||||
// Strg+S / Cmd+S, wie überall sonst auch.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
if (geaendert && !speichert) void speichern()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
})
|
||||
|
||||
// Die Zurück-Geste schließt das Zeichenbrett. Bei ungespeicherten Änderungen
|
||||
// erst fragen – `false` hält die Ansicht offen (siehe useBackClose).
|
||||
useBackClose({
|
||||
@@ -174,22 +222,42 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<span style={{ display: 'flex', flexShrink: 0 }}>
|
||||
<FileTypeIcon mime={file.mime_type} name={file.name} size={18} />
|
||||
</span>
|
||||
|
||||
<span style={{
|
||||
flex: 1, minWidth: 0, fontSize: 14, color: 'var(--color-text)',
|
||||
minWidth: 0, fontSize: 14, fontWeight: 500, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{file.name}{geaendert ? ' •' : ''}
|
||||
{file.name.replace(/\.excalidraw$/i, '')}
|
||||
</span>
|
||||
|
||||
{/* Ein Punkt hinter dem Namen ist leicht zu übersehen – und das
|
||||
Übersehene ist hier die ungespeicherte Arbeit. */}
|
||||
{geaendert && (
|
||||
<span style={{
|
||||
flexShrink: 0, padding: '2px 8px', borderRadius: 999,
|
||||
background: 'rgba(var(--color-primary-rgb), 0.18)',
|
||||
color: 'var(--color-primary)', fontSize: 11, fontWeight: 600,
|
||||
}}>
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{ flex: 1 }} />
|
||||
|
||||
<button
|
||||
onClick={speichern}
|
||||
disabled={speichert || !geaendert}
|
||||
title={geaendert ? 'Save (Ctrl+S)' : 'Nothing to save'}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 8, border: 'none',
|
||||
background: geaendert ? 'var(--color-primary)' : 'var(--color-surface-2)',
|
||||
color: geaendert ? 'var(--color-primary-fg)' : 'var(--color-text-muted)',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
cursor: speichert || !geaendert ? 'default' : 'pointer',
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
>
|
||||
{speichert ? 'Saving…' : 'Save'}
|
||||
@@ -236,6 +304,23 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
|
||||
{szene && (
|
||||
<Excalidraw
|
||||
initialData={szene}
|
||||
// Die Oberfläche ist dunkel; ein weisses Zeichenbrett darin war
|
||||
// der auffälligste Bruch. Umschalten kann man es im Menü – für
|
||||
// einen Ausdruck ist Hell die bessere Wahl.
|
||||
theme="dark"
|
||||
name={file.name.replace(/\.excalidraw$/i, '')}
|
||||
autoFocus
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
// **Aus.** Beide laden oder speichern eine Datei am Browser
|
||||
// vorbei: „Open" ersetzte den Inhalt dieser Zeichnung durch
|
||||
// eine fremde, „Save to…" schriebe eine zweite Fassung in den
|
||||
// Download-Ordner, die niemand mehr wiederfindet. In Aether
|
||||
// gehört die Datei dorthin, wo sie liegt.
|
||||
loadScene: false,
|
||||
saveToActiveFile: false,
|
||||
},
|
||||
}}
|
||||
onChange={(elements, appState, files) => {
|
||||
// **Nicht in den Zustand schreiben.** onChange feuert bei jeder
|
||||
// Mausbewegung; ein setState hier würde das Zeichenbrett bei
|
||||
|
||||
Reference in new issue
Block a user