task(A4.11): documentService singleton + global v2 undo/redo shortcuts

This commit is contained in:
Agent Zero
2026-08-27 07:14:41 +02:00
parent e76b3efe20
commit 25e721d3d3
3 changed files with 123 additions and 0 deletions
+24
View File
@@ -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<CanvasAreaProps> = ({
@@ -62,6 +63,8 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
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<CanvasAreaProps> = ({
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<CanvasAreaProps> = ({
// 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;
@@ -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<Listener>();
/** 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;
}
+56
View File
@@ -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);
});
});