diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index 774bcda..df4c164 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -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 = ({ cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled, @@ -26,6 +28,7 @@ const CanvasArea: React.FC = ({ const zoomPanRef = useRef(null); const renderEngineRef = useRef(null); const interactionRef = useRef(null); + const dispatcherRef = useRef(null); const spatialIndexRef = useRef(null); const layerManagerRef = useRef(null); const selectionEngineRef = useRef(null); @@ -44,6 +47,21 @@ const CanvasArea: React.FC = ({ 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 = ({ return () => { interaction.detach(); + dispatcherRef.current?.destroy(); + dispatcherRef.current = null; zoomPanRef.current = null; renderEngineRef.current = null; interactionRef.current = null; diff --git a/frontend/src/interaction/dispatcher.ts b/frontend/src/interaction/dispatcher.ts new file mode 100644 index 0000000..5860f7a --- /dev/null +++ b/frontend/src/interaction/dispatcher.ts @@ -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; + /** 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(); + } +} diff --git a/frontend/src/interaction/pointer.ts b/frontend/src/interaction/pointer.ts new file mode 100644 index 0000000..a449008 --- /dev/null +++ b/frontend/src/interaction/pointer.ts @@ -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 */ + } + } +} diff --git a/frontend/src/interaction/session.ts b/frontend/src/interaction/session.ts new file mode 100644 index 0000000..d49b813 --- /dev/null +++ b/frontend/src/interaction/session.ts @@ -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'; + } +} diff --git a/frontend/tests/dispatcher.test.ts b/frontend/tests/dispatcher.test.ts new file mode 100644 index 0000000..49112bf --- /dev/null +++ b/frontend/tests/dispatcher.test.ts @@ -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 = {}): 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 }); + }); +});