Files
web-cad/frontend/src/interaction/dispatcher.ts
T

225 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<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;
/** 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();
}
}