task(A2): tool dispatcher behind flag
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user