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:
1 parent
dd68bc594d
commit
cedcb1ba6f
2 files changed
+116
-23
No files matched your search
@@ -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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user