task(A2): tool dispatcher behind flag

This commit is contained in:
Agent Zero
2026-08-26 21:14:46 +02:00
parent 5d7fa23825
commit 4a7a8f912f
5 changed files with 396 additions and 0 deletions
+20
View File
@@ -12,6 +12,8 @@ import { SpatialIndex } from '../canvas/SpatialIndex';
import { LayerManager } from '../canvas/LayerManager';
import { GroupManager } from '../tools/modification/GroupTool';
import { formatCoordinate } from '../utils/format';
import { InteractionDispatcher } from '../interaction/dispatcher';
import { logger } from '../utils/logger';
const CanvasArea: React.FC<CanvasAreaProps> = ({
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
@@ -26,6 +28,7 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
const zoomPanRef = useRef<ZoomPanController | null>(null);
const renderEngineRef = useRef<RenderEngine | null>(null);
const interactionRef = useRef<InteractionEngine | null>(null);
const dispatcherRef = useRef<InteractionDispatcher | null>(null);
const spatialIndexRef = useRef<SpatialIndex | null>(null);
const layerManagerRef = useRef<LayerManager | null>(null);
const selectionEngineRef = useRef<SelectionEngine | null>(null);
@@ -44,6 +47,21 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
canvas, zoomPan, renderEngine, snapEngine, selectionEngine, spatialIndex, layerManager,
);
// ── Task A2: V2-Tool-Dispatcher hinter Feature-Flag ──
// Flag VITE_TOOL_DISPATCHER=v2 (in .env / Shell): aktiviert den neuen
// InteractionDispatcher parallel zur Legacy InteractionEngine.
// Tools mit handlers werden dann über die Plugin-API v2 geroutet.
if (import.meta.env.VITE_TOOL_DISPATCHER === 'v2') {
dispatcherRef.current = new InteractionDispatcher(canvas, {
zoomPan,
setStatus: (msg) => {
/* StatusBar-Bridge folgt in A3 */
},
setPreview: (el) => renderEngine.setPreviewElement(el as CADElement | null),
});
logger.info('InteractionDispatcher v2 aktiviert (VITE_TOOL_DISPATCHER=v2)');
}
zoomPanRef.current = zoomPan;
renderEngineRef.current = renderEngine;
interactionRef.current = interaction;
@@ -66,6 +84,8 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
return () => {
interaction.detach();
dispatcherRef.current?.destroy();
dispatcherRef.current = null;
zoomPanRef.current = null;
renderEngineRef.current = null;
interactionRef.current = null;
+151
View File
@@ -0,0 +1,151 @@
/**
* dispatcher.ts InteractionDispatcher (Task A2).
*
* Ersetzt langfristig den 1157-Zeilen-Switch der InteractionEngine:
* - Routet DOM-Pointer-/Key-Events an das AKTIVE V2-Werkzeug (ToolExtensionV2)
* - Normalisiert Events (pointer.ts) zu Weltkoordinaten
* - Führt eine ToolSession als Down/Up-Guard
* - Kein Werkzeugwissen! Alle Logik liegt in den Plugins.
*
* Aktivierung per Feature-Flag VITE_TOOL_DISPATCHER=v2 (CanvasArea, A2 Schritt 4).
*/
import type { ToolExtensionV2, ToolContext } from '../plugins/types';
import { pluginRegistry } from '../plugins/PluginRegistry';
import { toToolPointerEvent } from './pointer';
import { ToolSession } from './session';
import type { ZoomPanController } from '../canvas/ZoomPanController';
export interface DispatcherDeps {
zoomPan: ZoomPanController;
/** Liefert Optionsstände je ToolId (typisch: toolStore.optionValues). */
getOptions?(toolId: string): Record<string, unknown>;
/** Statuszeile (typisch: StatusBar-Bridge). */
setStatus?(msg: string): void;
/** Live-Vorschau-Ziel (typisch RenderEngine.setPreviewElement). */
setPreview?(el: unknown | null): void;
/** Tool-Nachschlag überschreibbar (Default: aktive Registry-Tools). */
toolLookup?(toolId: string): ToolExtensionV2 | undefined;
}
export class InteractionDispatcher {
private readonly canvas: HTMLCanvasElement;
private readonly deps: DispatcherDeps;
private readonly session = new ToolSession();
private active: ToolExtensionV2 | null = null;
private bound = false;
/** Zuletzt gesetzter Status-Text (für Tests/Debug). */
lastStatus = '';
constructor(canvas: HTMLCanvasElement, deps: DispatcherDeps) {
this.canvas = canvas;
this.deps = deps;
this.attach();
}
/** Setzt das aktive Werkzeug (null = deaktiviert Routing). */
setActive(toolId: string | null): void {
if (this.active && this.session.isActive) {
// Sitzung sauber abbrechen, bevor gewechselt wird
this.active.handlers.cancel?.(this.buildContext());
this.session.end();
}
if (!toolId) {
this.active = null;
return;
}
const lookup = this.deps.toolLookup ?? ((id: string) => pluginRegistry.getToolV2(id));
const tool = lookup(toolId);
if (!tool) {
console.warn(`[InteractionDispatcher] kein aktives V2-Tool '${toolId}'`);
this.active = null;
return;
}
this.active = tool;
this.setStatus(`${tool.manifest.label} aktiv`);
}
/** Aktives Werkzeug (oder null). */
getActive(): ToolExtensionV2 | null {
return this.active;
}
/** Entfernt alle Event-Listener (aufräumen beim Unmount). */
destroy(): void {
if (!this.bound) return;
this.canvas.removeEventListener('pointerdown', this.onDown);
window.removeEventListener('pointermove', this.onMove);
window.removeEventListener('pointerup', this.onUp);
window.removeEventListener('keydown', this.onKey);
this.bound = false;
}
// ── intern ───────────────────────────────────────────────
private attach(): void {
if (this.bound) return;
this.canvas.addEventListener('pointerdown', this.onDown);
window.addEventListener('pointermove', this.onMove);
window.addEventListener('pointerup', this.onUp);
window.addEventListener('keydown', this.onKey);
this.bound = true;
}
private buildContext(): ToolContext {
const toolId = this.active?.manifest.id ?? '';
return {
options: this.deps.getOptions?.(toolId) ?? {},
setStatus: (msg) => this.setStatus(msg),
setPreview: (el) => this.deps.setPreview?.(el),
};
}
private setStatus(msg: string): void {
this.lastStatus = msg;
this.deps.setStatus?.(msg);
}
private onDown = (e: PointerEvent): void => {
if (!this.active) return;
// Rechtsklick = Abbruch wie ESC
if (e.button === 2) {
this.cancelActive();
return;
}
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
this.session.begin();
this.active.handlers.down?.(tpe, this.buildContext());
};
private onMove = (e: PointerEvent): void => {
if (!this.active || !this.session.isActive) return;
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
this.active.handlers.move?.(tpe, this.buildContext());
};
private onUp = (e: PointerEvent): void => {
if (!this.active || !this.session.isActive) return;
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
try {
this.active.handlers.up?.(tpe, this.buildContext());
} finally {
this.session.end();
}
};
private onKey = (e: KeyboardEvent): void => {
if (!this.active) return;
if (e.key === 'Escape') {
this.cancelActive();
return;
}
const consumed = this.active.handlers.key?.(e, this.buildContext()) === true;
if (consumed) e.preventDefault();
};
private cancelActive(): void {
if (!this.active) return;
this.active.handlers.cancel?.(this.buildContext());
this.session.end();
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* pointer.ts DOM-PointerEvent → ToolPointerEvent Normalisierung (Task A2).
* Konvertiert Bildschirmkoordinaten in Weltkoordinaten über ZoomPanController.
*/
import type { ToolPointerEvent } from '../plugins/types';
import type { ZoomPanController } from '../canvas/ZoomPanController';
/**
* Normalisiert ein Pointer/Mouse-Event zu einem ToolPointerEvent.
* @param e DOM-Event (MouseEvent ist Obermenge von PointerEvent-Feldern)
* @param canvas Das Canvas-Element (für offset-Koordinaten)
* @param zoomPan aktiver Zoom/Pan-Controller
*/
export function toToolPointerEvent(
e: MouseEvent,
canvas: HTMLCanvasElement,
zoomPan: ZoomPanController,
): ToolPointerEvent {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const world = zoomPan.screenToWorld(sx, sy);
return {
world: { x: world.x, y: world.y },
screen: { x: sx, y: sy },
button: e.button,
shift: e.shiftKey,
alt: e.altKey,
ctrl: e.ctrlKey || e.metaKey,
};
}
/** Fangt weitere Pointer-Events auf dem Canvas bis zum release (Pen/Tablet- Komfort). */
export function capturePointer(canvas: HTMLCanvasElement, e: PointerEvent): void {
if (typeof canvas.setPointerCapture === 'function') {
try {
canvas.setPointerCapture(e.pointerId);
} catch {
/* pointerId evtl. schon ungültig — unkritisch */
}
}
}
/** Gibt einen vorherigen Pointer-Capture frei. */
export function releasePointer(canvas: HTMLCanvasElement, e: PointerEvent): void {
if (typeof canvas.releasePointerCapture === 'function') {
try {
canvas.releasePointerCapture(e.pointerId);
} catch {
/* ignorieren */
}
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* session.ts Minimaler Werkzeug-Sitzungszustand (Task A2).
*
* phase 'idle' : kein Ziehen aktiv, Tool wartet auf down
* phase 'active' : down ist erfolgt; Tool zieht/zeichnet bis up/cancel
*
* Der Dispatcher nutzt das, um move-Events nur während aktiver Sitzung an
* Handler durchzureichen (und als Guard gegen verlorene up-Events).
*/
export type ToolPhase = 'idle' | 'active';
export class ToolSession {
private current: ToolPhase = 'idle';
/** Ist eine Zeichensitzung aktiv (zwischen down und up)? */
get isActive(): boolean {
return this.current === 'active';
}
/** Aktuelle Phase. */
get phase(): ToolPhase {
return this.current;
}
/** Startet eine Sitzung (nach einem gültigen down). */
begin(): void {
this.current = 'active';
}
/** Beendet die Sitzung regulär (up) oder abgebrochen (cancel/ESC). */
end(): void {
this.current = 'idle';
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* InteractionDispatcher Tests (Task A2).
* Prüft Event-Routing gegen ein Fake-Werkzeug ohne echte Registry:
* Reihenfolge down→move→up, ESC/Rechtsklick cancel, move nur bei aktiver Session,
* unbekanntes Tool → kein Routing.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { InteractionDispatcher } from '../src/interaction/dispatcher';
import type { ToolExtensionV2 } from '../src/plugins/types';
import type { ZoomPanController } from '../src/canvas/ZoomPanController';
function makeFakeZoomPan(): ZoomPanController {
return {
screenToWorld: (sx: number, sy: number) => ({ x: sx * 2, y: sy * 3 }),
} as unknown as ZoomPanController;
}
function makeRecordingTool(id = 'fake'): { tool: ToolExtensionV2; log: string[] } {
const log: string[] = [];
const tool: ToolExtensionV2 = {
manifest: { id, label: 'Fake', icon: 'f', ribbonTab: 'tools' },
optionsSchema: [],
handlers: {
down: () => log.push('down'),
move: () => log.push('move'),
up: () => log.push('up'),
key: () => false,
cancel: () => log.push('cancel'),
},
};
return { tool, log };
}
function fire(canvas: HTMLCanvasElement, type: string, opts: Partial<PointerEventInit> = {}): PointerEvent {
const e = new PointerEvent(type, {
bubbles: true,
clientX: 100,
clientY: 50,
button: 0,
...opts,
});
if (type === 'pointerdown') canvas.dispatchEvent(e);
else window.dispatchEvent(e);
return e;
}
function fireKey(key: string): KeyboardEvent {
const e = new KeyboardEvent('keydown', { key, bubbles: true });
window.dispatchEvent(e);
return e;
}
describe('InteractionDispatcher', () => {
let canvas: HTMLCanvasElement;
afterEach(() => {
canvas?.remove();
});
function setup(tool?: ToolExtensionV2) {
canvas = document.createElement('canvas');
document.body.appendChild(canvas);
const dispatcher = new InteractionDispatcher(canvas, {
zoomPan: makeFakeZoomPan(),
toolLookup: tool ? () => tool : undefined,
});
return { dispatcher };
}
it('routet down→move→up in Reihenfolge an das aktive Tool', () => {
const { tool, log } = makeRecordingTool();
const { dispatcher } = setup(tool);
dispatcher.setActive('fake');
fire(canvas, 'pointerdown');
fire(window, 'pointermove');
fire(window, 'pointerup');
expect(log).toEqual(['down', 'move', 'up']);
expect(dispatcher.getActive()?.manifest.id).toBe('fake');
});
it('ignoriert move/up ohne vorheriges down (Session-Guard)', () => {
const { tool, log } = makeRecordingTool();
const { dispatcher } = setup(tool);
dispatcher.setActive('fake');
fire(window, 'pointermove'); // keine Session aktiv
fire(window, 'pointerup');
expect(log).toEqual([]);
});
it('ESC ruft cancel und beendet die Session', () => {
const { tool, log } = makeRecordingTool();
const { dispatcher } = setup(tool);
dispatcher.setActive('fake');
fire(canvas, 'pointerdown');
fireKey('Escape');
expect(log).toEqual(['down', 'cancel']);
fire(window, 'pointermove'); // Session beendet → kein move mehr
expect(log).toEqual(['down', 'cancel']);
});
it('Rechtsklick bricht ab, ohne down durchzureichen', () => {
const { tool, log } = makeRecordingTool();
const { dispatcher } = setup(tool);
dispatcher.setActive('fake');
fire(canvas, 'pointerdown', { button: 2 });
expect(log).toEqual(['cancel']);
});
it('setActive(null) deaktiviert Routing komplett', () => {
const { tool, log } = makeRecordingTool();
const { dispatcher } = setup(tool);
dispatcher.setActive('fake');
dispatcher.setActive(null);
fire(canvas, 'pointerdown');
fire(window, 'pointermove');
expect(log).toEqual([]);
});
it('liefert Weltkoordinaten aus zoomPan.screenToWorld', () => {
let received: { x: number; y: number } | null = null;
const tool: ToolExtensionV2 = {
manifest: { id: 'geo', label: 'G', icon: 'g', ribbonTab: 't' },
optionsSchema: [],
handlers: { down: (e) => (received = { ...e.world }) },
};
const { dispatcher } = setup(tool);
dispatcher.setActive('geo');
fire(canvas, 'pointerdown', { clientX: 100, clientY: 50 });
// fake screenToWorld: x*2, y*3 → (200, 150)
expect(received).toEqual({ x: 200, y: 150 });
});
});