fix(drawing): Zeichnung liess sich nicht wieder oeffnen

Anlegen und bearbeiten ging, beim zweiten Öffnen blieb das Zeichenbrett leer.

Die erste Fassung schrieb den appState roh mit JSON.stringify heraus. Darin
steckt `collaborators`, und das ist eine Map. JSON kennt keine Maps; es wurde
`{}` daraus. Beim Laden bekam Excalidraw ein leeres Objekt, wo es eine Map
erwartet, und brach ab. Beim ersten Öffnen fiel das nicht auf – da war noch
nichts gespeichert.

Jetzt über die Werkzeuge des Pakets: serializeAsJSON schreibt nur, was eine
Zeichnung ausmacht, restore setzt beim Laden zusammen, was fehlt. Selbst zu
prüfen hiesse, Excalidraws Feldliste nachzubauen und bei jeder Fassung
nachzuziehen.

Eine Sache erledigt restore allerdings nicht – nachgemessen, nicht angenommen:
Ein bereits verunglücktes collaborators: {} reicht es unverändert durch; die
Map entsteht nur, wenn das Feld ganz fehlt. Neue Dateien haben es nicht mehr,
aber die aus jener Stunde wären dauerhaft kaputt geblieben. Deshalb wird es
beim Laden zusätzlich entfernt – Marcs bereits angelegte Zeichnungen öffnen
damit wieder.

Fünf Tests in drawing.test.ts, darunter einer, der festhält, dass restore
allein nicht reicht. Zwei Stolpersteine dabei, in vite.config.ts vermerkt:
Excalidraw importiert roughjs/bin/rough ohne Endung (Nodes Auflösung verlangt
sie, der Bündler nicht) und fasst beim Laden ein Canvas an, das jsdom nicht
hat.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-29 19:55:46 +02:00
1 parent 6fc157bc07
commit 2e1eaf9cbf
4 files changed
+166 -21

No files matched your search

+32
View File
@@ -1634,9 +1634,41 @@ ist, muss sie koennen, was Dateien koennen.
- [x] Zeichnung im Gruppenordner
- [x] Excalifont wird vom eigenen Server ausgeliefert
### Beim zweiten Oeffnen blieb es leer (29.09.2026)
Eine Zeichnung liess sich anlegen und bearbeiten – **beim zweiten Oeffnen**
blieb das Zeichenbrett leer.
Ursache: Die erste Fassung schrieb den `appState` roh mit `JSON.stringify`
heraus. Darin steckt `collaborators`, und das ist eine `Map`. JSON kennt keine
Maps; es wurde `{}` daraus. Beim Laden bekam Excalidraw ein leeres Objekt, wo
es eine Map erwartet, und brach ab. Beim **ersten** Oeffnen fiel es nicht auf –
da war noch nichts gespeichert.
Behoben mit den Werkzeugen des Pakets: `serializeAsJSON` schreibt nur, was eine
Zeichnung ausmacht, `restore` setzt beim Laden zusammen, was fehlt. Selbst zu
pruefen hiesse, Excalidraws Feldliste nachzubauen und bei jeder Fassung
nachzuziehen.
**Eine Sache erledigt `restore` allerdings nicht** – nachgemessen, nicht
angenommen: Ein bereits verungluecktes `collaborators: {}` reicht es
**unveraendert durch**; die Map entsteht nur, wenn das Feld ganz fehlt. Neue
Dateien haben es nicht mehr, aber die aus jener Stunde waeren dauerhaft kaputt
gewesen. Deshalb wird es beim Laden zusaetzlich entfernt.
- [x] Fuenf Tests in `drawing.test.ts` (jsdom), darunter einer, der genau
festhaelt, dass `restore` allein nicht reicht.
> **Zwei Stolpersteine beim Testen**, beide in `vite.config.ts` vermerkt:
> Excalidraw importiert `roughjs/bin/rough` ohne Endung – Nodes Aufloesung
> verlangt sie, der Buendler nicht. Deshalb laeuft es unter
> `server.deps.inline`. Und es fasst beim Laden ein Canvas an, das jsdom nicht
> hat; ein Platzhalter im Test genuegt.
### Von Hand zu pruefen
- [ ] `+ Drawing` legt eine Zeichnung an und oeffnet sie 🔎
- [ ] Zeichnung schliessen und **erneut oeffnen** -> der Inhalt ist da 🔎
- [ ] Zeichnen, Rechteck, Text darin, Pfeil andocken – Block verschieben, der
Pfeil folgt (das ist der Mindmap-Fall)
- [ ] Die Schrift sieht handgezeichnet aus, nicht nach Arial
+30 -21
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Excalidraw } from '@excalidraw/excalidraw'
import { Excalidraw, restore, serializeAsJSON } from '@excalidraw/excalidraw'
import '@excalidraw/excalidraw/index.css'
import { filesApi, type FileItem } from '../api/client'
import { useBackClose } from '../hooks/useBackClose'
@@ -46,14 +46,7 @@ if (typeof window !== 'undefined') {
/** Was in einer leeren Zeichnung steht. */
export function leereZeichnung(): string {
return JSON.stringify({
type: 'excalidraw',
version: 2,
source: 'aether',
elements: [],
appState: {},
files: {},
})
return serializeAsJSON([], {}, {}, 'local')
}
export const drawingExtension = '.excalidraw'
@@ -66,7 +59,7 @@ interface Props {
}
export default function DrawingEditor({ file, onClose, onSaved }: Props) {
const [szene, setSzene] = useState<{ elements: unknown[]; appState: Record<string, unknown>; files: unknown } | null>(null)
const [szene, setSzene] = useState<ReturnType<typeof restore> | null>(null)
const [fehler, setFehler] = useState('')
const [speichert, setSpeichert] = useState(false)
const [geaendert, setGeaendert] = useState(false)
@@ -83,8 +76,25 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
.then(text => {
if (!lebt) return
// Eine frisch angelegte Zeichnung ist leer – das ist kein Fehler.
const d = text.trim() ? JSON.parse(text) : JSON.parse(leereZeichnung())
setSzene({ elements: d.elements ?? [], appState: d.appState ?? {}, files: d.files ?? {} })
const d = JSON.parse(text.trim() || leereZeichnung())
// **Über `restore`, nicht roh hineinreichen.** Der `appState` enthält
// Dinge, die kein JSON überlebt – `collaborators` ist eine Map und
// kommt als `{}` zurück; Excalidraw ruft darauf `forEach` und bricht
// ab. Das Zeichenbrett blieb dann beim **zweiten** Öffnen leer, beim
// ersten nicht, weil da noch nichts gespeichert war.
//
// `restore` säubert und ergänzt Fehlendes. Es ist der Weg, den
// Excalidraw dafür vorsieht – selbst zu prüfen hiesse, seine
// Feldliste nachzubauen und bei jeder Fassung nachzuziehen.
//
// **Eine Sache erledigt `restore` allerdings nicht** (nachgemessen):
// Ein bereits verunglücktes `collaborators: {}` reicht es unverändert
// durch – die Map entsteht nur, wenn das Feld ganz fehlt. Neue
// Dateien haben es nicht mehr, weil `serializeAsJSON` es gar nicht
// erst schreibt. Die paar, die die erste Fassung angelegt hat, wären
// sonst dauerhaft kaputt. Also weg damit, bevor `restore` läuft.
if (d?.appState) delete d.appState.collaborators
setSzene(restore(d, null, null))
})
.catch(err => { if (lebt) setFehler(fehlertext(err, 'Could not open this drawing')) })
return () => { lebt = false }
@@ -123,14 +133,13 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
if (!weiter) { setSpeichert(false); return }
}
const inhalt = JSON.stringify({
type: 'excalidraw',
version: 2,
source: 'aether',
elements: aktuell.current.elements,
appState: aktuell.current.appState,
files: aktuell.current.files,
})
// Ebenfalls über das Paket: `serializeAsJSON` schreibt nur die Felder,
// die eine Zeichnung ausmachen – nicht den Laufzeitzustand.
const inhalt = serializeAsJSON(
aktuell.current.elements as never,
aktuell.current.appState as never,
aktuell.current.files as never,
'local')
const blob = new File([inhalt], file.name, { type: 'application/json' })
const neu = await filesApi.upload(blob, file.folder_id ?? undefined)
basis.current = neu.version
@@ -226,7 +235,7 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
{szene && (
<Excalidraw
initialData={{ elements: szene.elements as never, appState: szene.appState as never, files: szene.files as never }}
initialData={szene}
onChange={(elements, appState, files) => {
// **Nicht in den Zustand schreiben.** onChange feuert bei jeder
// Mausbewegung; ein setState hier würde das Zeichenbrett bei
+99
View File
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
// jsdom hat kein echtes Canvas. Excalidraw fasst es beim Laden an, obwohl die
// beiden geprueften Funktionen es nicht brauchen – ein Platzhalter genuegt.
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
HTMLCanvasElement.prototype.getContext = (() => ({
measureText: () => ({ width: 0 }), fillText: () => {}, clearRect: () => {},
save: () => {}, restore: () => {}, translate: () => {}, scale: () => {},
beginPath: () => {}, closePath: () => {}, fill: () => {}, stroke: () => {},
setTransform: () => {}, drawImage: () => {}, getImageData: () => ({ data: [] }),
})) as never
const { restore, serializeAsJSON } = await import('@excalidraw/excalidraw')
/**
* Der Fehler, den dieser Test festhält, ist echt passiert (29.09.2026):
* Eine Zeichnung liess sich anlegen und bearbeiten – aber **beim zweiten
* Öffnen** blieb das Zeichenbrett leer.
*
* Ursache: Die erste Fassung schrieb den `appState` roh mit `JSON.stringify`
* heraus. Darin steckt `collaborators`, und das ist eine `Map`. JSON kennt
* keine Maps; es wurde `{}` daraus. Beim Laden bekam Excalidraw ein leeres
* Objekt, wo es eine Map erwartet, rief eine Map-Methode darauf und brach ab.
*
* Beim **ersten** Öffnen fiel das nicht auf, weil da noch nichts gespeichert
* war – genau die Sorte Fehler, die man nur findet, wenn man zweimal hinsieht.
*
* Behoben mit den Werkzeugen, die das Paket dafür mitbringt:
* `serializeAsJSON` schreibt nur, was eine Zeichnung ausmacht, und `restore`
* setzt beim Laden wieder zusammen, was fehlt. Selbst zu prüfen hiesse, die
* Feldliste von Excalidraw nachzubauen und bei jeder Fassung nachzuziehen.
*/
describe('Zeichnungen: Speichern und wieder Öffnen', () => {
const beispiel = [{
id: 'a1', type: 'rectangle', x: 10, y: 20, width: 100, height: 50,
angle: 0, strokeColor: '#000', backgroundColor: 'transparent',
fillStyle: 'solid', strokeWidth: 1, strokeStyle: 'solid', roughness: 1,
opacity: 100, seed: 1, version: 1, versionNonce: 1, isDeleted: false,
groupIds: [], frameId: null, roundness: null, boundElements: null,
updatedAt: 1, link: null, locked: false, index: 'a0',
}]
it('schreibt den Laufzeitzustand gar nicht erst mit', () => {
// `collaborators` ist eine Map und hat in einer Datei nichts zu suchen.
const json = serializeAsJSON(
beispiel as never,
{ collaborators: new Map(), viewBackgroundColor: '#ffffff' } as never,
{}, 'local')
const d = JSON.parse(json)
expect(d.type).toBe('excalidraw')
expect(d.elements).toHaveLength(1)
expect('collaborators' in d.appState).toBe(false)
// Was zur Zeichnung gehört, bleibt erhalten.
expect(d.appState.viewBackgroundColor).toBe('#ffffff')
})
it('überlebt den Weg durch die Datei und zurück', () => {
const json = serializeAsJSON(beispiel as never, { viewBackgroundColor: '#fafafa' } as never, {}, 'local')
const zurueck = restore(JSON.parse(json), null, null)
expect(zurueck.elements).toHaveLength(1)
expect(zurueck.elements[0].id).toBe('a1')
expect(zurueck.elements[0].width).toBe(100)
expect(zurueck.appState.viewBackgroundColor).toBe('#fafafa')
})
it('restore allein repariert ein verunglücktes collaborators NICHT', () => {
// Nachgemessen, nicht angenommen: `restore` erzeugt die Map nur, wenn das
// Feld **fehlt**. Ein bereits vorhandenes `{}` reicht es durch.
const kaputt = {
type: 'excalidraw', version: 2, source: 'aether',
elements: beispiel, appState: { collaborators: {} }, files: {},
}
const zurueck = restore(kaputt as never, null, null)
expect(zurueck.appState.collaborators instanceof Map).toBe(false)
})
it('deshalb wird das Feld vorher entfernt – so wie im Editor', () => {
// Genau das, was die alte Fassung in die Datei geschrieben hat. Die paar
// Dateien aus jener Stunde sollen sich wieder öffnen lassen.
const kaputt: { appState: Record<string, unknown>; elements: unknown[]; files: unknown } = {
elements: beispiel, appState: { collaborators: {} }, files: {},
}
delete kaputt.appState.collaborators
const zurueck = restore(kaputt as never, null, null)
expect(zurueck.appState.collaborators instanceof Map).toBe(true)
expect(zurueck.elements).toHaveLength(1)
})
it('kommt mit einer leeren Datei zurecht', () => {
// Eine frisch angelegte Zeichnung hat noch keinen Inhalt.
const leer = restore(JSON.parse(serializeAsJSON([], {}, {}, 'local')), null, null)
expect(leer.elements).toHaveLength(0)
expect(leer.appState.collaborators instanceof Map).toBe(true)
})
})
+5
View File
@@ -181,5 +181,10 @@ export default defineConfig({
environment: 'node',
globals: true,
setupFiles: './src/test-setup.ts',
// Excalidraw durch die Vite-Verarbeitung schicken statt es Node
// aufloesen zu lassen: Es importiert `roughjs/bin/rough` ohne Endung, und
// Nodes ESM-Aufloesung verlangt sie. Der Buendler kennt diese Schreibweise,
// Vitest im Normalbetrieb nicht.
server: { deps: { inline: [/@excalidraw/] } },
},
})