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/] } }, }, })