diff --git a/frontend/src/crdt/YjsDocument.ts b/frontend/src/crdt/YjsDocument.ts index fc726cd..cac81fb 100644 --- a/frontend/src/crdt/YjsDocument.ts +++ b/frontend/src/crdt/YjsDocument.ts @@ -6,6 +6,7 @@ import * as Y from 'yjs'; import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types'; import type { ElementGroup } from '../tools/modification/GroupTool'; import type { BackgroundConfig } from '../services/backgroundService'; +import type { LayoutDefinition } from '../types/cad.types'; export interface YjsDocumentData { elements: Y.Map; @@ -14,6 +15,7 @@ export interface YjsDocumentData { meta: Y.Map; groups: Y.Map; bgConfig: Y.Map; + layouts: Y.Map; // Task G1 } export class YjsDocument { @@ -29,6 +31,7 @@ export class YjsDocument { meta: this.doc.getMap('meta'), groups: this.doc.getMap('groups'), bgConfig: this.doc.getMap('bgConfig'), + layouts: this.doc.getMap('layouts'), }; } @@ -57,6 +60,11 @@ export class YjsDocument { return Array.from(this.data.bgConfig.values()); } + /** Get all layouts as a plain array (Task G1) */ + getLayouts(): LayoutDefinition[] { + return Array.from(this.data.layouts.values()); + } + /** Add or update a single element */ setElement(el: CADElement): void { this.doc.transact(() => { diff --git a/frontend/src/kernel/document/CADDocument.ts b/frontend/src/kernel/document/CADDocument.ts index 87964fa..63558ea 100644 --- a/frontend/src/kernel/document/CADDocument.ts +++ b/frontend/src/kernel/document/CADDocument.ts @@ -12,7 +12,7 @@ */ import * as Y from 'yjs'; import { YjsDocument } from '../../crdt/YjsDocument'; -import type { CADElement, CADLayer } from '../../types/cad.types'; +import type { CADElement, CADLayer, LayoutDefinition } from '../../types/cad.types'; export class CADDocument { private readonly ydoc: YjsDocument; @@ -22,10 +22,10 @@ export class CADDocument { 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. + // Blocks/Groups/Configs bewusst NICHT tracken; Layouts (G1) dazu seit Task G1. // 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], { + this.undoManager = new Y.UndoManager([this.ydoc.data.elements, this.ydoc.data.layers, this.ydoc.data.layouts], { captureTimeout: 0, }); } @@ -52,6 +52,53 @@ export class CADDocument { return Array.from(this.ydoc.data.layers.values()); } + // ── Layouts (Task G1) ──────────────────────────────────── + + /** Ein Layout per ID (oder undefined). */ + getLayout(id: string): LayoutDefinition | undefined { + return this.ydoc.data.layouts.get(id); + } + + /** Alle Layouts als Array. */ + getAllLayouts(): LayoutDefinition[] { + return Array.from(this.ydoc.data.layouts.values()); + } + + /** Fügt ein Layout hinzu (eigener Undo-Schritt). */ + addLayout(layout: LayoutDefinition): void { + this.transact(() => { + this.ydoc.data.layouts.set(layout.id, layout); + }); + } + + /** + * Aktualisiert ein Layout: Top-Level-Felder ersetzt, viewport SHALLOW gemergt + * (center/scale einzeln änderbar). @throws wenn Layout fehlt. + */ + updateLayout(id: string, patch: Partial>): void { + const existing = this.ydoc.data.layouts.get(id); + if (!existing) throw new Error(`updateLayout: Layout '${id}' nicht gefunden`); + const merged: LayoutDefinition = { + ...existing, + ...patch, + viewport: patch.viewport + ? { ...existing.viewport, ...patch.viewport, center: { ...existing.viewport.center, ...patch.viewport.center } } + : existing.viewport, + }; + this.transact(() => { + this.ydoc.data.layouts.set(id, merged); + }); + } + + /** Entfernt ein Layout (still bei unbekannter ID, eigener Undo-Schritt). */ + deleteLayout(id: string): void { + this.transact(() => { + if (this.ydoc.data.layouts.has(id)) { + this.ydoc.data.layouts.delete(id); + } + }); + } + // ── Schreiben ──────────────────────────────────────────── /** Fügt ein Element hinzu (einzelner Aufruf = eigener Undo-Schritt). */ diff --git a/frontend/src/types/cad.types.ts b/frontend/src/types/cad.types.ts index ebe8706..9e89b1b 100644 --- a/frontend/src/types/cad.types.ts +++ b/frontend/src/types/cad.types.ts @@ -72,6 +72,23 @@ export interface BlockDefinition { thumbnail?: string; } +/** + * Layout (Task G1): Papier-/Plot-Seite mit eigenem Viewport aufs Modell. + * Persistiert in der Yjs-Map 'layouts' (key = layoutId). + */ +export interface LayoutViewport { + center: { x: number; y: number }; + scale: number; +} + +export interface LayoutDefinition { + id: string; + name: string; + viewport: LayoutViewport; + /** Referenz auf den Titelblock-Block (frame block), optional. */ + frameBlockRef?: string; +} + export interface BoundingBox { minX: number; minY: number; diff --git a/frontend/tests/layouts.test.ts b/frontend/tests/layouts.test.ts new file mode 100644 index 0000000..892e450 --- /dev/null +++ b/frontend/tests/layouts.test.ts @@ -0,0 +1,87 @@ +/** + * Task G1 – Layout-Datenmodell: Yjs Map layoutId → LayoutDefinition. + * + * LayoutDefinition: { id, name, viewport: { center: {x,y}, scale }, + * frameBlockRef? }. CRUD über CADDocument, undo-getrackt, Persistence + * läuft automatisch über die Yjs-Doc-Serialisierung (drawing json). + */ +import { describe, it, expect } from 'vitest'; +import { CADDocument } from '../src/kernel/document/CADDocument'; +import { createInMemoryDoc } from './helpers/inMemoryDoc'; + +function mkDoc(): CADDocument { + const { ydoc } = createInMemoryDoc(); + return new CADDocument(ydoc); +} + +const LAYOUT = { + id: 'layout-1', + name: 'A4 Plan', + viewport: { center: { x: 100, y: 50 }, scale: 0.5 }, + frameBlockRef: 'frame-a4', +}; + +describe('G1: Layout-CRUD', () => { + it('addLayout + getLayout: Layout landet mit allen Feldern in der Map', () => { + const doc = mkDoc(); + doc.addLayout(LAYOUT); + const loaded = doc.getLayout('layout-1'); + expect(loaded).toBeDefined(); + expect(loaded!.name).toBe('A4 Plan'); + expect(loaded!.viewport.center.x).toBe(100); + expect(loaded!.viewport.center.y).toBe(50); + expect(loaded!.viewport.scale).toBe(0.5); + expect(loaded!.frameBlockRef).toBe('frame-a4'); + }); + + it('getAllLayouts liefert Array; leer ohne Layouts', () => { + const doc = mkDoc(); + expect(doc.getAllLayouts()).toHaveLength(0); + doc.addLayout(LAYOUT); + doc.addLayout({ ...LAYOUT, id: 'layout-2', name: 'A3 Plan' }); + const all = doc.getAllLayouts(); + expect(all).toHaveLength(2); + expect(all.map((l) => l.name)).toContain('A3 Plan'); + }); + + it('updateLayout ändert name und viewport shallow (id/struktur bleibt)', () => { + const doc = mkDoc(); + doc.addLayout(LAYOUT); + doc.updateLayout('layout-1', { name: 'A4 geändert', viewport: { center: { x: 0, y: 0 }, scale: 2 } }); + const loaded = doc.getLayout('layout-1'); + expect(loaded!.name).toBe('A4 geändert'); + expect(loaded!.viewport.scale).toBe(2); + expect(loaded!.frameBlockRef).toBe('frame-a4'); // unverändert erhalten + }); + + it('deleteLayout entfernt Layout still (unbekannte ID kein Fehler)', () => { + const doc = mkDoc(); + doc.addLayout(LAYOUT); + doc.deleteLayout('layout-1'); + expect(doc.getLayout('layout-1')).toBeUndefined(); + expect(() => doc.deleteLayout('gibts-nicht')).not.toThrow(); + }); +}); + +describe('G1: Undo-Integration', () => { + it('Undo entfernt hinzugefügtes Layout wieder', () => { + const doc = mkDoc(); + doc.addLayout(LAYOUT); + expect(doc.getLayout('layout-1')).toBeDefined(); + doc.undo(); + expect(doc.getLayout('layout-1')).toBeUndefined(); + doc.redo(); + expect(doc.getLayout('layout-1')).toBeDefined(); + }); + + it('Layout-Undo ist EIN Schritt (nicht mit Element-Transaktion vermischt)', () => { + const doc = mkDoc(); + doc.addLayout(LAYOUT); + doc.deleteLayout('layout-1'); + // Zwei Schritte: undo stellt Löschung wieder her + doc.undo(); + expect(doc.getLayout('layout-1')).toBeDefined(); + doc.undo(); + expect(doc.getLayout('layout-1')).toBeUndefined(); + }); +});