From 25e721d3d3b93de4c70684edeecaa2a4f35748ea Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 27 Aug 2026 07:14:41 +0200 Subject: [PATCH] task(A4.11): documentService singleton + global v2 undo/redo shortcuts --- frontend/src/components/CanvasArea.tsx | 24 ++++++++ .../src/kernel/document/documentService.ts | 43 ++++++++++++++ frontend/tests/documentService.test.ts | 56 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 frontend/src/kernel/document/documentService.ts create mode 100644 frontend/tests/documentService.test.ts diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index f4cdbff..b7f7980 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -16,6 +16,7 @@ import { InteractionDispatcher } from '../interaction/dispatcher'; import { logger } from '../utils/logger'; import { CADDocument } from '../kernel/document/CADDocument'; import { YjsDocument } from '../crdt/YjsDocument'; +import { setActiveDocument, getActiveDocument } from '../kernel/document/documentService'; import { pluginRegistry } from '../plugins'; const CanvasArea: React.FC = ({ @@ -62,6 +63,8 @@ const CanvasArea: React.FC = ({ color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null, } as never); const v2Doc = new CADDocument(ydoc); + // App-Level-Zugriffspunkt für HistoryPanel/Tastenkürzel (Task A4.11): + setActiveDocument(v2Doc); // Committete V2-Elemente fließen über den etablierten Kanal in UI/State: v2Doc.onChanged(() => { for (const el of v2Doc.getAllElements()) { @@ -112,6 +115,7 @@ const CanvasArea: React.FC = ({ interaction.detach(); dispatcherRef.current?.destroy(); dispatcherRef.current = null; + setActiveDocument(null); zoomPanRef.current = null; renderEngineRef.current = null; interactionRef.current = null; @@ -283,6 +287,26 @@ const CanvasArea: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTool]); + // Task A4.11: Globale Undo/Redo-Shortcuts wirken nur auf das V2-Dokument, + // wenn es registriert ist (= Flag aktiv). Legacy-History bleibt unberührt. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!(e.ctrlKey || e.metaKey)) return; + const doc = getActiveDocument(); + if (!doc) return; // Flag aus → Legacy-Verhalten unangetastet + const key = e.key.toLowerCase(); + if (key === 'z' && !e.shiftKey) { + e.preventDefault(); + if (doc.undo()) logger.info('V2 undo'); + } else if (key === 'y' || (key === 'z' && e.shiftKey)) { + e.preventDefault(); + if (doc.redo()) logger.info('V2 redo'); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + // Sync selected template to interaction engine useEffect(() => { const interaction = interactionRef.current; diff --git a/frontend/src/kernel/document/documentService.ts b/frontend/src/kernel/document/documentService.ts new file mode 100644 index 0000000..5c9971c --- /dev/null +++ b/frontend/src/kernel/document/documentService.ts @@ -0,0 +1,43 @@ +/** + * documentService – Prozessweiter Zugriffspunkt auf das aktive CADDocument. + * + * Warum: CanvasArea besitzt die V2-Dokumentinstanz (Task A4.1), aber App-Level + * UI (HistoryPanel, Tastenkürzel Strg+Z/Y) benötigt Zugriff OHNE prop- + * threading durch die God-Component. Dieser Service ist die bewusste, + * dokumentierte Ausnahme von der Reinheitsregel — nur EINE Instanz aktiv. + * + * Setzen/Entfernen übernimmt CanvasArea im Mount-/Cleanup-Zyklus. + */ +import type { CADDocument } from './CADDocument'; + +type Listener = () => void; + +let activeDoc: CADDocument | null = null; +const listeners = new Set(); + +/** Registriert das aktive Dokument (CanvasArea-Mount). */ +export function setActiveDocument(doc: CADDocument | null): void { + activeDoc = doc; + listeners.forEach((l) => l()); +} + +/** Aktives Dokument oder null (z.B. wenn Flag aus / Canvas unmounted). */ +export function getActiveDocument(): CADDocument | null { + return activeDoc; +} + +/** Änderungsabonnement (für React-Hooks). Liefert Unsubscribe. */ +export function onActiveDocumentChanged(cb: Listener): () => void { + listeners.add(cb); + return () => listeners.delete(cb); +} + +/** Bequemlichkeit: globaler Undo am aktiven Dokument. false wenn kein Doc/Stack leer. */ +export function undoActive(): boolean { + return activeDoc?.undo() ?? false; +} + +/** Bequemlichkeit: globales Redo am aktiven Dokument. */ +export function redoActive(): boolean { + return activeDoc?.redo() ?? false; +} diff --git a/frontend/tests/documentService.test.ts b/frontend/tests/documentService.test.ts new file mode 100644 index 0000000..2f4995c --- /dev/null +++ b/frontend/tests/documentService.test.ts @@ -0,0 +1,56 @@ +/** + * documentService Tests (Task A4.11). + * Prüft Registrierung/Löschung des aktiven Dokuments und die + * Undo/Redo-Delegation an CADDocument. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { setActiveDocument, getActiveDocument, undoActive, redoActive } from '../src/kernel/document/documentService'; +import { CADDocument } from '../src/kernel/document/CADDocument'; +import { createInMemoryDoc } from './helpers/inMemoryDoc'; +import type { CADElement } from '../src/types/cad.types'; + +function makeEl(id: string): CADElement { + return { + id, + type: 'rect', + layerId: 'l', + x: 0, + y: 0, + width: 1, + height: 1, + properties: {}, + } as unknown as CADElement; +} + +afterEach(() => { + setActiveDocument(null); +}); + +describe('documentService', () => { + it('registriert und entregistriert das aktive Dokument', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + setActiveDocument(doc); + expect(getActiveDocument()).toBe(doc); + setActiveDocument(null); + expect(getActiveDocument()).toBeNull(); + }); + + it('undoActive delegiert an das registrierte Dokument', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + doc.addElement(makeEl('x1')); + setActiveDocument(doc); + + expect(undoActive()).toBe(true); // Element wird entfernt + expect(doc.getAllElements().length).toBe(0); + expect(redoActive()).toBe(true); // wiederhergestellt + expect(doc.getAllElements().length).toBe(1); + }); + + it('undoActive liefert false ohne aktives Dokument', () => { + setActiveDocument(null); + expect(undoActive()).toBe(false); + expect(redoActive()).toBe(false); + }); +});