diff --git a/TESTING.md b/TESTING.md index 37de9d1..cf02b9d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1685,6 +1685,40 @@ Excalidraw ist MIT. draw.io waere die Alternative (Apache-2.0), zielt aber auf Flussdiagramme und UML statt auf freies Zeichnen – und bringt eine deutlich schwerere Oberflaeche mit. +### Drei Nachbesserungen aus dem Testen (29.09.2026) + +**„Close without saving?" kam, obwohl gerade gespeichert war.** Der Schalter +„geaendert" 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 zaehlt `getSceneVersion` den **Inhalt** statt der Bedienung. Stimmt die +Zahl mit der zuletzt gespeicherten ueberein, gibt es nichts zu sichern. + +**Das Vorschaubild kam nicht in der Liste an.** Der Editor laedt 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. + +**Rueckfragen als eigener Dialog.** Vorher `window.confirm` – ein +Browser-Fenster mitten in einer Oberflaeche, die sonst ihre eigene Sprache +spricht. Jetzt derselbe `ConfirmDialog` wie ueberall. + +> Beim Schliessen ueber die Zurueck-Geste gibt der Griff `false` zurueck, +> solange der Dialog steht. Sonst waere der Verlaufseintrag schon verbraucht, +> und ein „Cancel" liesse die naechste Zurueck-Geste aus der Anwendung fuehren. + +**Kein Escape zum Schliessen** – die Beschriftung versprach es, es gab ihn nie, +und er waere falsch: Das Zeichenbrett braucht die Taste selbst (Abwaehlen, +Werkzeug verlassen). Wer sie abfaengt, schliesst die Zeichnung, waehrend jemand +nur ein Rechteck abwaehlen wollte. Das Versprechen ist raus. + +- [ ] Speichern, dann schliessen -> **keine** Rueckfrage 🔎 +- [ ] Etwas zeichnen, dann schliessen -> Rueckfrage im Aether-Dialog +- [ ] Im Dialog „Cancel" -> Zeichenbrett bleibt, Zurueck-Geste greift weiter +- [ ] Speichern -> Kachel zeigt das Bild **ohne** Neuladen 🔎 +- [ ] Nur etwas auswaehlen oder scrollen -> „Unsaved" erscheint **nicht** + ### Beim zweiten Oeffnen blieb es leer (29.09.2026) Eine Zeichnung liess sich anlegen und bearbeiten – **beim zweiten Oeffnen** diff --git a/frontend/src/components/DrawingEditor.tsx b/frontend/src/components/DrawingEditor.tsx index f58d19e..48cd70b 100644 --- a/frontend/src/components/DrawingEditor.tsx +++ b/frontend/src/components/DrawingEditor.tsx @@ -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 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(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 (
+ {frage && ( + setFrage(null)} + /> + )} +
{fehler && (
{ - // **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, 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) }} /> )}