fix(drawing): Rückfrage nach dem Speichern, Vorschaubild in der Liste, eigener Dialog

Drei Punkte aus Marcs Test.

„Close without saving?" kam, obwohl gerade gespeichert war. Der Schalter
„geändert" wurde in onChange gesetzt – und das feuert bei jeder Kleinigkeit,
auch bei Mauszeiger, Auswahl und Bildlauf. Sekunden nach dem Speichern stand er
wieder auf offen. Jetzt zählt getSceneVersion den Inhalt statt der Bedienung.

Das Vorschaubild kam nicht in der Liste an. Der Editor lädt es nach dem
Speichern hoch, die Liste kannte aber nur die Antwort des Uploads – und die
entstand, bevor es das Bild gab. Ohne Neuladen blieb die Kachel leer. Sie wird
jetzt ein zweites Mal benachrichtigt.

Rückfragen laufen über den ConfirmDialog des Projekts statt über
window.confirm. Beim Schliessen über die Zurück-Geste gibt der Griff false
zurück, solange der Dialog steht: Sonst wäre der Verlaufseintrag schon
verbraucht, und ein „Cancel" liesse die nächste Zurück-Geste aus der Anwendung
führen.

Nebenbei: Die Beschriftung des Schliessen-Knopfes versprach „Esc". Den Griff
gab es nie – und er wäre falsch, weil das Zeichenbrett die Taste selbst
braucht (Abwählen, Werkzeug verlassen). Wer sie abfängt, schliesst die
Zeichnung, während jemand nur ein Rechteck abwählen wollte. Versprechen
entfernt statt Griff gebaut.

Geprüft: tsc sauber, 53 Vitest-Tests, Zeichnungs-Selbsttest 18/18.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-29 21:23:07 +02:00
1 parent dd68bc594d
commit cedcb1ba6f
2 files changed
+116 -23

No files matched your search

+82 -23
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Excalidraw, exportToBlob, restore, serializeAsJSON } from '@excalidraw/excalidraw'
import { Excalidraw, exportToBlob, getSceneVersion, 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 ConfirmDialog from './ConfirmDialog'
import { toast, fehlertext } from './Toasts'
/**
@@ -64,6 +65,24 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
const [fehler, setFehler] = useState('')
const [speichert, setSpeichert] = useState(false)
const [geaendert, setGeaendert] = useState(false)
/** Rückfragen als eigener Dialog statt window.confirm – siehe unten. */
const [frage, setFrage] = useState<null | {
titel: string; text: string; label: string; danger?: boolean; tun: () => void
}>(null)
/**
* Die Fassung der Zeichnung, wie sie zuletzt gespeichert wurde.
*
* **Nicht einfach ein Schalter „geändert".** `onChange` feuert bei jeder
* Kleinigkeit – Mauszeiger, Auswahl, Bildlauf –, nicht nur wenn sich etwas
* am Inhalt tut. Ein Schalter stand deshalb schon Sekunden nach dem
* Speichern wieder auf „geändert", und beim Schließen kam die Rückfrage,
* obwohl nichts offen war.
*
* `getSceneVersion` zählt den Inhalt, nicht die Bedienung. Stimmt die Zahl
* mit der gespeicherten überein, gibt es nichts zu sichern.
*/
const gespeicherteFassung = useRef<number | null>(null)
/** Die Fassung, auf der wir arbeiten. Grundlage für den Abgleich unten. */
const basis = useRef(file.version)
@@ -122,16 +141,21 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
}
}, [file.id, file.folder_id])
async function speichern() {
async function speichern(trotzFremderAenderung = false) {
if (!aktuell.current) return
setSpeichert(true)
try {
if (await pruefeFremdeAenderung()) {
const weiter = window.confirm(
'Someone else saved this drawing while you were working on it.\n\n' +
'Saving now replaces their version. The previous one stays in the ' +
'version history either way.\n\nSave anyway?')
if (!weiter) { setSpeichert(false); return }
if (!trotzFremderAenderung && await pruefeFremdeAenderung()) {
setSpeichert(false)
setFrage({
titel: 'Someone else saved this drawing',
text: 'Saving now replaces their version. The previous one stays in '
+ 'the version history either way.',
label: 'Save anyway',
danger: true,
tun: () => { setFrage(null); void speichern(true) },
})
return
}
// Ebenfalls über das Paket: `serializeAsJSON` schreibt nur die Felder,
@@ -144,10 +168,11 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
const blob = new File([inhalt], file.name, { type: 'application/json' })
const neu = await filesApi.upload(blob, file.folder_id ?? undefined)
basis.current = neu.version
if (aktuell.current) gespeicherteFassung.current = getSceneVersion(aktuell.current.elements as never)
setGeaendert(false)
onSaved?.(neu)
toast.info('Drawing saved')
void vorschaubild(neu.id)
void vorschaubild(neu)
} catch (err) {
toast.error(fehlertext(err, 'Could not save the drawing'))
} finally {
@@ -167,7 +192,7 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
* 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) {
async function vorschaubild(neu: FileItem) {
if (!aktuell.current) return
try {
const bild = await exportToBlob({
@@ -183,7 +208,11 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
return { width: Math.round(w * faktor), height: Math.round(h * faktor), scale: faktor }
},
})
await filesApi.setThumbnail(id, bild)
await filesApi.setThumbnail(neu.id, bild)
// **Der Liste Bescheid geben.** Sie kennt nur die Antwort des Uploads,
// und die entstand, bevor es das Bild gab – ohne diese Zeile zeigte die
// Kachel bis zum nächsten Neuladen nichts.
onSaved?.({ ...neu, has_thumbnail: true })
} catch {
/* Schönheitsfehler – die Zeichnung ist gespeichert. */
}
@@ -206,11 +235,26 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
useBackClose({
active: true,
onClose: () => {
if (geaendert && !window.confirm('Close without saving?')) return false
onClose()
if (!geaendert) { onClose(); return }
// `false` hält die Ansicht offen, solange der Dialog steht – sonst wäre
// der Verlaufseintrag schon verbraucht, und ein „Cancel" liesse die
// nächste Zurück-Geste aus der Anwendung führen.
frageSchliessen()
return false
},
})
/** Die eine Rückfrage vor dem Verwerfen – an drei Stellen dieselbe. */
function frageSchliessen() {
setFrage({
titel: 'Close without saving?',
text: 'Your changes to this drawing will be lost.',
label: 'Discard changes',
danger: true,
tun: () => { setFrage(null); onClose() },
})
}
return (
<div data-fullscreen="" style={{
position: 'fixed', inset: 'var(--honeybar-h) 0 0 0', zIndex: 120,
@@ -248,7 +292,7 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
<span style={{ flex: 1 }} />
<button
onClick={speichern}
onClick={() => void speichern()}
disabled={speichert || !geaendert}
title={geaendert ? 'Save (Ctrl+S)' : 'Nothing to save'}
style={{
@@ -264,11 +308,12 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
</button>
<button
onClick={() => {
if (geaendert && !window.confirm('Close without saving?')) return
onClose()
}}
title="Close (Esc)"
onClick={() => { if (geaendert) frageSchliessen(); else onClose() }}
// **Kein Escape.** Das Zeichenbrett braucht die Taste selbst – zum
// Abwählen und zum Verlassen eines Werkzeugs. Wer sie hier abfängt,
// schliesst die Zeichnung, während jemand nur ein Rechteck abwählen
// wollte. Hinaus geht es über diesen Knopf oder die Zurück-Geste.
title="Close"
style={{
padding: 6, borderRadius: 8, border: 'none', background: 'none',
color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex',
@@ -280,6 +325,17 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
</button>
</div>
{frage && (
<ConfirmDialog
title={frage.titel}
message={frage.text}
confirmLabel={frage.label}
danger={frage.danger}
onConfirm={frage.tun}
onCancel={() => setFrage(null)}
/>
)}
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{fehler && (
<div style={{
@@ -322,11 +378,14 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
},
}}
onChange={(elements, appState, files) => {
// **Nicht in den Zustand schreiben.** onChange feuert bei jeder
// Mausbewegung; ein setState hier würde das Zeichenbrett bei
// jedem Strich neu rendern.
// **Nicht bei jedem Aufruf in den Zustand schreiben.** onChange
// feuert bei jeder Mausbewegung; ein setState hier würde das
// Zeichenbrett bei jedem Strich neu rendern.
aktuell.current = { elements, appState: appState as unknown as Record<string, unknown>, files }
if (!geaendert) setGeaendert(true)
const fassung = getSceneVersion(elements)
if (gespeicherteFassung.current === null) gespeicherteFassung.current = fassung
const offen = fassung !== gespeicherteFassung.current
if (offen !== geaendert) setGeaendert(offen)
}}
/>
)}