task(A2b): CADDocument facade with transactions and undo
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* CADDocument – Fassade über YjsDocument (Task A2b).
|
||||
*
|
||||
* - Elementar-CRUD auf CADElement/CADLayer
|
||||
* - `transact(fn)`: Bündelt Änderungen zu EINEM Undo-Schritt
|
||||
* (kollaborativ korrekt via Y.UndoManager)
|
||||
* - `undo()/redo()` wrappen den Manager; HistoryPanel liest später daraus (A4)
|
||||
* - Keine Tool-Logik! Nur Dokument-Operationen.
|
||||
*
|
||||
* Nutzung künftiger V2-Werkzeuge (ab A3):
|
||||
* doc.transact(() => doc.addElement(line)); // 1 Klick = 1 Undo-Schritt
|
||||
*/
|
||||
import * as Y from 'yjs';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
import type { CADElement, CADLayer } from '../../types/cad.types';
|
||||
|
||||
export class CADDocument {
|
||||
private readonly ydoc: YjsDocument;
|
||||
private readonly undoManager: Y.UndoManager;
|
||||
|
||||
/** @param ydoc Netzwerkfrei erstellbar (createInMemoryDoc) oder CRDT-verbunden. */
|
||||
constructor(ydoc: YjsDocument) {
|
||||
this.ydoc = ydoc;
|
||||
// Trackt alle Operationen auf Elements + Layers → gemeinsame Undo-Historie.
|
||||
// Blocks/Groups/Configs bewusst NICHT tracken: Werkzeuge ändern primär Elemente/Layer.
|
||||
// captureTimeout 0: KEIN automatisches Mergen zeitnaher Transaktionen —
|
||||
// jede explizite transact() bleibt EIGENER Undo-Schritt (deterministisch für Tools).
|
||||
this.undoManager = new Y.UndoManager([this.ydoc.data.elements, this.ydoc.data.layers], {
|
||||
captureTimeout: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lesen ────────────────────────────────────────────────
|
||||
|
||||
/** Ein Element per ID (oder undefined). */
|
||||
getElement(id: string): CADElement | undefined {
|
||||
return this.ydoc.data.elements.get(id);
|
||||
}
|
||||
|
||||
/** Alle Elemente als Array. */
|
||||
getAllElements(): CADElement[] {
|
||||
return Array.from(this.ydoc.data.elements.values());
|
||||
}
|
||||
|
||||
/** Ein Layer per ID (oder undefined). */
|
||||
getLayer(id: string): CADLayer | undefined {
|
||||
return this.ydoc.data.layers.get(id);
|
||||
}
|
||||
|
||||
/** Alle Layer als Array. */
|
||||
getAllLayers(): CADLayer[] {
|
||||
return Array.from(this.ydoc.data.layers.values());
|
||||
}
|
||||
|
||||
// ── Schreiben ────────────────────────────────────────────
|
||||
|
||||
/** Fügt ein Element hinzu (einzelner Aufruf = eigener Undo-Schritt). */
|
||||
addElement(el: CADElement): void {
|
||||
this.transact(() => {
|
||||
this.ydoc.data.elements.set(el.id, el);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert ein Element: Top-Level-Felder werden ersetzt,
|
||||
* `patch.properties` wird SHALLOW in bestehende Properties gemergt
|
||||
* (Details wie fill/rotation einzeln änderbar, ohne den Rest zu verlieren).
|
||||
* @throws wenn Element nicht existiert
|
||||
*/
|
||||
updateElement(id: string, patch: Partial<CADElement>): void {
|
||||
const existing = this.ydoc.data.elements.get(id);
|
||||
if (!existing) throw new Error(`updateElement: Element '${id}' nicht gefunden`);
|
||||
const { properties, ...rest } = patch;
|
||||
const merged: CADElement = {
|
||||
...existing,
|
||||
...rest,
|
||||
properties: properties ? { ...existing.properties, ...properties } : existing.properties,
|
||||
};
|
||||
this.transact(() => {
|
||||
this.ydoc.data.elements.set(id, merged);
|
||||
});
|
||||
}
|
||||
|
||||
/** Entfernt mehrere Elemente in EINER Transaktion. Ignoriert unbekannte IDs still. */
|
||||
deleteElements(ids: string[]): void {
|
||||
this.transact(() => {
|
||||
for (const id of ids) {
|
||||
if (this.ydoc.data.elements.has(id)) {
|
||||
this.ydoc.data.elements.delete(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Transaktion & Undo ───────────────────────────────────
|
||||
|
||||
/** Führt fn aus; ALLE darin getätigten Y-Änderungen werden EIN Undo-Schritt. */
|
||||
transact(fn: () => void): void {
|
||||
this.ydoc.doc.transact(fn);
|
||||
}
|
||||
|
||||
/** Macht den letzten Schritt rückgängig. false wenn nichts da. */
|
||||
undo(): boolean {
|
||||
if (!this.canUndo()) return false;
|
||||
this.undoManager.undo();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Stellt einen Rückgängig-Schritt wieder her. false wenn nichts da. */
|
||||
redo(): boolean {
|
||||
if (!this.canRedo()) return false;
|
||||
this.undoManager.redo();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Gibt es etwas zum Rückgängigmachen? */
|
||||
canUndo(): boolean {
|
||||
return this.undoManager.undoStack.length > 0;
|
||||
}
|
||||
|
||||
/** Gibt es etwas zum Wiederherstellen? */
|
||||
canRedo(): boolean {
|
||||
return this.undoManager.redoStack.length > 0;
|
||||
}
|
||||
|
||||
/** Exponiert den Manager (HistoryPanel liest Stacklängen für die UI, A4). */
|
||||
getUndoManager(): Y.UndoManager {
|
||||
return this.undoManager;
|
||||
}
|
||||
|
||||
// ── Subscriptions ────────────────────────────────────────
|
||||
|
||||
/** Benachrichtigung bei JEDEM Doc-Update (inkl. Remote über CRDT). */
|
||||
onChanged(cb: () => void): () => void {
|
||||
return this.ydoc.onChange(cb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* CADDocument Tests (Task A2b).
|
||||
* Kern-Verhalten: CRUD, Shallow-Merge, Transaktion = 1 Undo-Schritt,
|
||||
* undo/redo-Stacks, deleteElements selektiv.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { CADElement } from '../src/types/cad.types';
|
||||
|
||||
function makeEl(id: string, props: Record<string, unknown> = {}): CADElement {
|
||||
return {
|
||||
id,
|
||||
type: 'rect',
|
||||
layerId: 'layer-1',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40,
|
||||
properties: props,
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
function makeDoc(): { doc: CADDocument } {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
return { doc: new CADDocument(ydoc) };
|
||||
}
|
||||
|
||||
describe('CADDocument', () => {
|
||||
it('a) addElement → getAllElements enthaelt es', () => {
|
||||
const { doc } = makeDoc();
|
||||
doc.addElement(makeEl('e1'));
|
||||
const all = doc.getAllElements();
|
||||
expect(all.length).toBe(1);
|
||||
expect(all[0].id).toBe('e1');
|
||||
expect(doc.getElement('e1')).toBeDefined();
|
||||
expect(doc.getElement('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('b) transact um zwei addElements = EIN Undo-Schritt entfernt BEIDE', () => {
|
||||
const { doc } = makeDoc();
|
||||
doc.transact(() => {
|
||||
doc.addElement(makeEl('a'));
|
||||
doc.addElement(makeEl('b'));
|
||||
});
|
||||
expect(doc.getAllElements().length).toBe(2);
|
||||
expect(doc.canUndo()).toBe(true);
|
||||
|
||||
expect(doc.undo()).toBe(true);
|
||||
expect(doc.getAllElements().length).toBe(0); // beide weg mit einem undo!
|
||||
expect(doc.canUndo()).toBe(false);
|
||||
});
|
||||
|
||||
it('c) redo stellt beide Elemente wieder her', () => {
|
||||
const { doc } = makeDoc();
|
||||
doc.transact(() => {
|
||||
doc.addElement(makeEl('a'));
|
||||
doc.addElement(makeEl('b'));
|
||||
});
|
||||
doc.undo();
|
||||
expect(doc.getAllElements().length).toBe(0);
|
||||
|
||||
expect(doc.canRedo()).toBe(true);
|
||||
expect(doc.redo()).toBe(true);
|
||||
expect(doc.getAllElements().length).toBe(2);
|
||||
expect(doc.getElement('a')).toBeDefined();
|
||||
expect(doc.getElement('b')).toBeDefined();
|
||||
});
|
||||
|
||||
it('d) updateElement merged properties SHALLOW (bestehende Keys bleiben)', () => {
|
||||
const { doc } = makeDoc();
|
||||
doc.addElement(makeEl('m', { fill: 'red', rotation: 45, stroke: 'black' }));
|
||||
|
||||
doc.updateElement('m', { properties: { fill: 'blue' } });
|
||||
const el = doc.getElement('m')!;
|
||||
expect(el.properties.fill).toBe('blue'); // ueberschrieben
|
||||
expect(el.properties.rotation).toBe(45); // erhalten
|
||||
expect(el.properties.stroke).toBe('black'); // erhalten
|
||||
});
|
||||
|
||||
it('d2) updateElement kann Top-Level-Felder setzen und wirft bei fehlender ID', () => {
|
||||
const { doc } = makeDoc();
|
||||
doc.addElement(makeEl('m'));
|
||||
doc.updateElement('m', { x: 999 });
|
||||
expect(doc.getElement('m')!.x).toBe(999);
|
||||
expect(() => doc.updateElement('ghost', { x: 1 })).toThrow(/nicht gefunden/);
|
||||
});
|
||||
|
||||
it('e) deleteElements entfernt nur die genannten IDs', () => {
|
||||
const { doc } = makeDoc();
|
||||
doc.transact(() => {
|
||||
doc.addElement(makeEl('keep1'));
|
||||
doc.addElement(makeEl('del1'));
|
||||
doc.addElement(makeEl('del2'));
|
||||
doc.addElement(makeEl('keep2'));
|
||||
});
|
||||
|
||||
doc.deleteElements(['del1', 'del2', 'unbekannt']); // unbekannte still ignorieren
|
||||
const ids = doc.getAllElements().map((e) => e.id).sort();
|
||||
expect(ids).toEqual(['keep1', 'keep2']);
|
||||
});
|
||||
|
||||
it('undo nach deleteElements stellt geloeschte Elemente wieder her', () => {
|
||||
const { doc } = makeDoc();
|
||||
doc.addElement(makeEl('x1'));
|
||||
doc.addElement(makeEl('x2'));
|
||||
|
||||
doc.deleteElements(['x1']);
|
||||
expect(doc.getAllElements().length).toBe(1);
|
||||
|
||||
doc.undo(); // Delete rueckgaengig
|
||||
expect(doc.getAllElements().length).toBe(2);
|
||||
expect(doc.getElement('x1')).toBeDefined();
|
||||
});
|
||||
|
||||
it('undo/redo liefern false auf leeren Stacks', () => {
|
||||
const { doc } = makeDoc();
|
||||
expect(doc.undo()).toBe(false);
|
||||
expect(doc.redo()).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user