2026-08-26 21:14:46 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
2026-09-18 08:58:02 +02:00
|
|
|
|
/** 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 };
|
2026-08-26 21:14:46 +02:00
|
|
|
|
/** 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;
|
2026-08-26 21:30:54 +02:00
|
|
|
|
/** 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;
|
2026-08-26 21:14:46 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-09-18 08:58:02 +02:00
|
|
|
|
this.canvas.removeEventListener('wheel', this.onWheelNative as EventListener);
|
|
|
|
|
|
window.removeEventListener('pointermove', this.onPanMove);
|
|
|
|
|
|
window.removeEventListener('pointerup', this.onPanEnd);
|
2026-08-26 21:14:46 +02:00
|
|
|
|
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);
|
2026-09-18 08:58:02 +02:00
|
|
|
|
// 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);
|
2026-08-26 21:14:46 +02:00
|
|
|
|
this.bound = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-18 08:58:02 +02:00
|
|
|
|
// ── 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?.();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-26 21:30:54 +02:00
|
|
|
|
private buildContext(): import('../plugins/types').FullToolContext {
|
2026-08-26 21:14:46 +02:00
|
|
|
|
const toolId = this.active?.manifest.id ?? '';
|
|
|
|
|
|
return {
|
|
|
|
|
|
options: this.deps.getOptions?.(toolId) ?? {},
|
|
|
|
|
|
setStatus: (msg) => this.setStatus(msg),
|
|
|
|
|
|
setPreview: (el) => this.deps.setPreview?.(el),
|
2026-08-26 21:30:54 +02:00
|
|
|
|
doc: this.deps.getDoc?.(),
|
|
|
|
|
|
selection: this.deps.getSelectionBridge?.(),
|
2026-08-26 21:14:46 +02:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private setStatus(msg: string): void {
|
|
|
|
|
|
this.lastStatus = msg;
|
|
|
|
|
|
this.deps.setStatus?.(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private onDown = (e: PointerEvent): void => {
|
2026-09-18 08:58:02 +02:00
|
|
|
|
// 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 };
|
2026-08-26 21:14:46 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-18 08:58:02 +02:00
|
|
|
|
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 };
|
2026-08-26 21:14:46 +02:00
|
|
|
|
this.session.begin();
|
|
|
|
|
|
this.active.handlers.down?.(tpe, this.buildContext());
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
private onMove = (e: PointerEvent): void => {
|
2026-09-18 08:58:02 +02:00
|
|
|
|
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);
|
2026-08-26 21:14:46 +02:00
|
|
|
|
if (!this.active || !this.session.isActive) return;
|
2026-09-18 08:58:02 +02:00
|
|
|
|
const tpe = this.applySnapToEvent(tpeRaw);
|
2026-08-26 21:14:46 +02:00
|
|
|
|
this.active.handlers.move?.(tpe, this.buildContext());
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
private onUp = (e: PointerEvent): void => {
|
|
|
|
|
|
if (!this.active || !this.session.isActive) return;
|
2026-09-18 08:58:02 +02:00
|
|
|
|
const tpe = this.applySnapToEvent(toToolPointerEvent(e, this.canvas, this.deps.zoomPan));
|
2026-08-26 21:14:46 +02:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-18 08:58:02 +02:00
|
|
|
|
/** 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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-26 21:14:46 +02:00
|
|
|
|
private cancelActive(): void {
|
|
|
|
|
|
if (!this.active) return;
|
|
|
|
|
|
this.active.handlers.cancel?.(this.buildContext());
|
|
|
|
|
|
this.session.end();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|