/** * 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; /** CAD-14 Phase 4c: Snap-Funktion (Weltkoordinaten) fuer Tool-Events. */ snap?(x: number, y: number): { x: number; y: number }; /** CAD-14 Phase 4c: Cursor-Meldung (auch ohne aktives Tool). */ onCursorMoved?(worldX: number, worldY: number): void; /** CAD-14 Phase 4c: Render-Anstoess nach Pan/Wheel (CanvasArea ruft renderEngine.render). */ requestRender?(): void; /** CAD-14 Phase 4c: Ortho-Anwendung gegen den letzten gesnappten Down-Punkt. */ ortho?(x: number, y: number, last: { x: number; y: number } | null): { x: number; y: number }; /** Liefert Optionsstände je ToolId (typisch: toolStore.optionValues). */ getOptions?(toolId: string): Record; /** 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; /** Dokument-Fassade für Tools (ab A3; lazy pro Event abgefragt). */ getDoc?(): import('../kernel/document/CADDocument').CADDocument | undefined; /** Selektions-Bridge für Tools (ab A3; lazy pro Event abgefragt). */ getSelectionBridge?(): import('../plugins/types').ToolSelectionBridge; } 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.canvas.removeEventListener('wheel', this.onWheelNative as EventListener); window.removeEventListener('pointermove', this.onPanMove); window.removeEventListener('pointerup', this.onPanEnd); 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); // CAD-14 Phase 4c: natives Pan + Wheel (ersetzt Legacy-Engine) this.canvas.addEventListener('wheel', this.onWheelNative as EventListener, { passive: false }); window.addEventListener('pointermove', this.onPanMove); window.addEventListener('pointerup', this.onPanEnd); this.bound = true; } // ── CAD-14 Phase 4c: natives Pan (Mittel-/Rechtsklick-Drag) ── private panActive = false; private lastPanScreen = { x: 0, y: 0 }; /** CAD-14 Phase 4c: Letzter gesnappter Down-Punkt (Ortho-Referenz). */ private lastSnappedDown: { x: number; y: number } | null = null; private onPanMove = (e: PointerEvent): void => { if (!this.panActive) return; const dx = e.clientX - this.lastPanScreen.x; const dy = e.clientY - this.lastPanScreen.y; this.lastPanScreen = { x: e.clientX, y: e.clientY }; this.deps.zoomPan.pan(dx, dy); this.requestRender(); }; private onPanEnd = (): void => { this.panActive = false; }; private onWheelNative = (e: WheelEvent): void => { e.preventDefault(); const factor = e.deltaY > 0 ? 0.9 : 1.1; const rect = this.canvas.getBoundingClientRect(); const cx = e.clientX - rect.left; const cy = e.clientY - rect.top; this.deps.zoomPan.zoomAt(cx, cy, factor); this.requestRender(); }; private requestRender(): void { this.deps.requestRender?.(); } private buildContext(): import('../plugins/types').FullToolContext { const toolId = this.active?.manifest.id ?? ''; return { options: this.deps.getOptions?.(toolId) ?? {}, setStatus: (msg) => this.setStatus(msg), setPreview: (el) => this.deps.setPreview?.(el), doc: this.deps.getDoc?.(), selection: this.deps.getSelectionBridge?.(), }; } private setStatus(msg: string): void { this.lastStatus = msg; this.deps.setStatus?.(msg); } private onDown = (e: PointerEvent): void => { // CAD-14 Phase 4c: natives Pan bei Mittel-/Rechtsklick (auch ohne Tool) if (e.button === 1 || e.button === 2) { this.panActive = true; this.lastPanScreen = { x: e.clientX, y: e.clientY }; return; } if (!this.active) return; const tpe = this.applySnapToEvent(toToolPointerEvent(e, this.canvas, this.deps.zoomPan)); this.lastSnappedDown = { x: tpe.world.x, y: tpe.world.y }; this.session.begin(); this.active.handlers.down?.(tpe, this.buildContext()); }; private onMove = (e: PointerEvent): void => { const tpeRaw = toToolPointerEvent(e, this.canvas, this.deps.zoomPan); // CAD-14 Phase 4c: Cursor-Meldung auch ohne aktives Tool (Statusleiste) this.deps.onCursorMoved?.(tpeRaw.world.x, tpeRaw.world.y); if (!this.active || !this.session.isActive) return; const tpe = this.applySnapToEvent(tpeRaw); this.active.handlers.move?.(tpe, this.buildContext()); }; private onUp = (e: PointerEvent): void => { if (!this.active || !this.session.isActive) return; const tpe = this.applySnapToEvent(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(); }; /** CAD-14 Phase 4c: wendet Snap aus Deps auf ein Tool-Pointer-Event an. */ private applySnapToEvent(tpe: import('../plugins/types').ToolPointerEvent): import('../plugins/types').ToolPointerEvent { if (!this.deps.snap && !this.deps.ortho) return tpe; let p = { x: tpe.world.x, y: tpe.world.y }; if (this.deps.snap) { const snapped = this.deps.snap(p.x, p.y); p = { x: snapped.x, y: snapped.y }; } if (this.deps.ortho) { p = this.deps.ortho(p.x, p.y, this.lastSnappedDown); } return { ...tpe, world: p }; } private cancelActive(): void { if (!this.active) return; this.active.handlers.cancel?.(this.buildContext()); this.session.end(); } }