From 2e1eaf9cbf3c48d71d7d963aaeb239483f65d622 Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 29 Sep 2026 19:55:46 +0200 Subject: [PATCH] fix(drawing): Zeichnung liess sich nicht wieder oeffnen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- TESTING.md | 32 ++++++++ frontend/src/components/DrawingEditor.tsx | 51 +++++++----- frontend/src/components/drawing.test.ts | 99 +++++++++++++++++++++++ frontend/vite.config.ts | 5 ++ 4 files changed, 166 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/drawing.test.ts diff --git a/TESTING.md b/TESTING.md index df33ffb..66f1ee0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 diff --git a/frontend/src/components/DrawingEditor.tsx b/frontend/src/components/DrawingEditor.tsx index bb486be..d46f1ae 100644 --- a/frontend/src/components/DrawingEditor.tsx +++ b/frontend/src/components/DrawingEditor.tsx @@ -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; files: unknown } | null>(null) + const [szene, setSzene] = useState | 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 && ( { // **Nicht in den Zustand schreiben.** onChange feuert bei jeder // Mausbewegung; ein setState hier würde das Zeichenbrett bei diff --git a/frontend/src/components/drawing.test.ts b/frontend/src/components/drawing.test.ts new file mode 100644 index 0000000..ccd7ff1 --- /dev/null +++ b/frontend/src/components/drawing.test.ts @@ -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; 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) + }) +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9c3e727..768db83 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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/] } }, }, })