diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d914fa2..fc8f0b9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,7 +6,7 @@ import type { import type { CADElement, CADLayer, BlockDefinition, ImageElement } from './types/cad.types'; import { BlockService } from './services/blockService'; import { SeatingService } from './services/seatingService'; -import type { ToolState } from './interaction'; +import type { ToolState } from './types/ui.types'; import { GroupManager, type ElementGroup } from './tools/modification/GroupTool'; import Topbar from './components/Topbar'; import RibbonBar from './components/RibbonBar'; diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index 6a1bb50..2d41177 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -5,7 +5,7 @@ import type { ToolType } from '../types/cad.types'; import type { UserCursor } from '../crdt'; import { RenderEngine } from '../canvas/RenderEngine'; import { ZoomPanController } from '../canvas/ZoomPanController'; -import { InteractionEngine } from '../interaction'; + import { SnapEngine } from '../canvas/SnapEngine'; import { SelectionEngine } from '../canvas/SelectionEngine'; import { SpatialIndex } from '../canvas/SpatialIndex'; @@ -34,7 +34,7 @@ const CanvasArea: React.FC = ({ const canvasRef = useRef(null); const zoomPanRef = useRef(null); const renderEngineRef = useRef(null); - const interactionRef = useRef(null); + const dispatcherRef = useRef(null); /** Task C2: PixiJS-Canvas-Ref für V2-Elemente-Overlay. */ const pixiCanvasRef = useRef(null); @@ -50,6 +50,14 @@ const CanvasArea: React.FC = ({ /** CAD-14: Frische Prop-Referenz für Selection-Änderungen aus V2-Werkzeugen. */ const onSelectionChangeRef = useRef(onSelectionChange); onSelectionChangeRef.current = onSelectionChange; + /** CAD-14 Phase 4c: Frische Cursor-Meldung (Dispatcher-nativ, kein Legacy-Callback). */ + const onCursorMovedRef = useRef(onCursorMoved); + onCursorMovedRef.current = onCursorMoved; + /** CAD-14 Phase 4c: Snap/Ortho-Zustände stale-sicher für den Dispatcher. */ + const snapEnabledRef = useRef(snapEnabled); + snapEnabledRef.current = snapEnabled; + const orthoRef = useRef(orthoEnabled); + orthoRef.current = orthoEnabled; const spatialIndexRef = useRef(null); const layerManagerRef = useRef(null); const selectionEngineRef = useRef(null); @@ -64,9 +72,6 @@ const CanvasArea: React.FC = ({ const renderEngine = new RenderEngine(canvas, zoomPan, spatialIndex, layerManager); const snapEngine = new SnapEngine(); const selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager); - const interaction = new InteractionEngine( - canvas, zoomPan, renderEngine, snapEngine, selectionEngine, spatialIndex, layerManager, - ); // ── Task A2/A4.14: V2-Pfad ist seit Verifikation STANDARD — Opt-out via // VITE_TOOL_DISPATCHER=legacy. Siehe kernel/featureFlags.ts. @@ -165,6 +170,30 @@ const CanvasArea: React.FC = ({ dispatcherRef.current = new InteractionDispatcher(canvas, { zoomPan, + // CAD-14 Phase 4c: Snap aus der bestehenden SnapEngine (grid-snaps), + // Ortho via Ref (stale-sicher). + snap: (x: number, y: number) => { + if (!snapEnabledRef.current) return { x, y }; + const result = snapEngine.snap(x, y); + if (result.point) { + renderEngine.setSnapPoints(result.preview); + renderEngine.setActiveSnapPoint(result.point); + return { x: result.point.x, y: result.point.y }; + } + renderEngine.setSnapPoints([]); + renderEngine.setActiveSnapPoint(null); + return { x, y }; + }, + onCursorMoved: (wx: number, wy: number) => onCursorMovedRef.current?.(wx, wy), + // CAD-14 Phase 4c: Ortho-Clamp gegen den letzten gesnappten + // Down-Punkt (Legacy-Konvention) — Referenz verwaltet der Dispatcher. + ortho: (x: number, y: number, last: { x: number; y: number } | null) => { + if (!orthoRef.current || !last) return { x, y }; + const dx = Math.abs(x - last.x); + const dy = Math.abs(y - last.y); + return dx >= dy ? { x, y: last.y } : { x: last.x, y }; + }, + requestRender: () => renderEngine.render(), setStatus: (msg) => { /* StatusBar-Bridge folgt in A4 */ }, @@ -240,26 +269,11 @@ const CanvasArea: React.FC = ({ zoomPanRef.current = zoomPan; renderEngineRef.current = renderEngine; - interactionRef.current = interaction; spatialIndexRef.current = spatialIndex; layerManagerRef.current = layerManager; selectionEngineRef.current = selectionEngine; - interaction.setCallbacks({ - onElementCreated, - onElementsDeleted, - onElementsModified, - onCursorMoved, - onToolStateChanged, - onTextEdit, - onCommandTrigger, - onSelectionChange, - }); - - interaction.attach(); - return () => { - interaction.detach(); dispatcherRef.current?.destroy(); dispatcherRef.current = null; pixiRendererRef.current?.destroy(); // Task C2: WebGL-Kontext freigeben @@ -267,7 +281,6 @@ const CanvasArea: React.FC = ({ setActiveDocument(null); zoomPanRef.current = null; renderEngineRef.current = null; - interactionRef.current = null; spatialIndexRef.current = null; layerManagerRef.current = null; selectionEngineRef.current = null; @@ -275,23 +288,6 @@ const CanvasArea: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Update callbacks when they change (avoids stale closures) - useEffect(() => { - const interaction = interactionRef.current; - if (!interaction) return; - interaction.setCallbacks({ - onElementCreated, - onElementsDeleted, - onElementsModified, - onCursorMoved, - onToolStateChanged, - onTextEdit, - onCommandTrigger, - onSelectionChange, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onTextEdit, onCommandTrigger, onSelectionChange]); - // Resize canvas to fill container useEffect(() => { const canvas = canvasRef.current; @@ -320,13 +316,11 @@ const CanvasArea: React.FC = ({ }; }, []); - // Sync elements to interaction engine + spatial index + render + // Sync elements to spatial index + render (CAD-14: keine Legacy-Engine) useEffect(() => { - const interaction = interactionRef.current; const spatialIndex = spatialIndexRef.current; const renderEngine = renderEngineRef.current; - if (!interaction || !spatialIndex || !renderEngine) return; - interaction.setElements(elements); + if (!spatialIndex || !renderEngine) return; spatialIndex.clear(); spatialIndex.bulkInsert(elements); renderEngine.setOverlayElements(elements); @@ -391,21 +385,12 @@ const CanvasArea: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [blocks]); - // Sync groups to RenderEngine and InteractionEngine + // Sync groups to RenderEngine (CAD-14: keine Legacy-Engine mehr) useEffect(() => { const renderEngine = renderEngineRef.current; - const interaction = interactionRef.current; if (!renderEngine) return; renderEngine.setGroups(groups ?? []); renderEngine.render(); - // InteractionEngine uses GroupManager from App — we pass it via a simple adapter - if (interaction && groups) { - const gm = new GroupManager(); - gm.fromJSON(groups); - interaction.setGroupManager(gm); - } else if (interaction) { - interaction.setGroupManager(null); - } // eslint-disable-next-line react-hooks/exhaustive-deps }, [groups]); @@ -439,11 +424,10 @@ const CanvasArea: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeLayerId, layers]); - // Sync grid/snap/ortho toggles + // Sync grid/snap/ortho toggles (CAD-14: Dispatcher liest snap/ortho via Refs) useEffect(() => { const renderEngine = renderEngineRef.current; - const interaction = interactionRef.current; - if (!renderEngine || !interaction) return; + if (!renderEngine) return; renderEngine.setOptions({ showGrid: gridEnabled }); renderEngine.setOptions({ showSnapPoints: snapEnabled }); renderEngine.setOptions({ showOrtho: orthoEnabled }); @@ -462,26 +446,14 @@ const CanvasArea: React.FC = ({ backgroundOpacity: bgConfig?.opacity ?? 0.5, }); renderEngine.render(); - interaction.setSnapEnabled(snapEnabled); - interaction.setOrthoEnabled(orthoEnabled); - interaction.setPolarEnabled(polarEnabled); - interaction.setUnits(unit, scaleFactor); - interaction.setGridSize(gridSize); - renderEngine.render(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor, canvasBgColor, gridColor, rulerEnabled, bgConfig]); - // Sync active tool + // Sync active tool (CAD-14: ausschliesslich Dispatcher) useEffect(() => { - const interaction = interactionRef.current; - if (!interaction) return; - // CAD-14: EXKLUSIVITAET — V2-Werkzeuge laufen AUSSCHLIESSLICH ueber den - // InteractionDispatcher. Die Legacy-Engine bekommt sie nicht mehr - // (vorher hatten BEIDE dasselbe Tool aktiv => jeder Klick erzeugte - // mehrfach Elemente). Nur Tools ohne V2-Entsprechung laufen legacy. - const isV2 = !!pluginRegistry.getToolV2(activeTool); - interaction.setTool(isV2 ? null : (activeTool as ToolType)); - dispatcherRef.current?.setActive(isV2 ? activeTool : null); + // CAD-14 Phase 4c: Legacy-Engine entfernt — der Dispatcher ist die + // EINZIGE Interaktions-Engine fuer alle Werkzeuge. + dispatcherRef.current?.setActive(activeTool); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTool]); @@ -505,14 +477,6 @@ const CanvasArea: React.FC = ({ return () => window.removeEventListener('keydown', onKey); }, []); - // Sync selected template to interaction engine - useEffect(() => { - const interaction = interactionRef.current; - if (!interaction) return; - interaction.setSelectedTemplate(selectedTemplate ?? null); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedTemplate]); - // Compute screen positions for remote cursors const [cursorScreenPositions, setCursorScreenPositions] = useState>([]); useEffect(() => { @@ -572,9 +536,10 @@ const CanvasArea: React.FC = ({ }; const handleZoomFit = () => { - const interaction = interactionRef.current; - if (interaction) { - interaction.zoomFit(); + // CAD-14 Phase 4c: zoomFit direkt ueber ZoomPan (V2-Dokument-Elemente) + const zoomPan = zoomPanRef.current; + if (zoomPan) { + zoomPan.zoomFit(getActiveDocument()?.getAllElements() ?? elements); } onZoomFit(); }; diff --git a/frontend/src/interaction/dispatcher.ts b/frontend/src/interaction/dispatcher.ts index 1c3bc05..32cce13 100644 --- a/frontend/src/interaction/dispatcher.ts +++ b/frontend/src/interaction/dispatcher.ts @@ -17,6 +17,14 @@ 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). */ @@ -81,6 +89,9 @@ export class InteractionDispatcher { 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; } @@ -92,9 +103,46 @@ export class InteractionDispatcher { 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 { @@ -112,26 +160,31 @@ export class InteractionDispatcher { } private onDown = (e: PointerEvent): void => { - if (!this.active) return; - // Rechtsklick = Abbruch wie ESC - if (e.button === 2) { - this.cancelActive(); + // 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; } - const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan); + 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 = toToolPointerEvent(e, this.canvas, this.deps.zoomPan); + 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 = toToolPointerEvent(e, this.canvas, this.deps.zoomPan); + const tpe = this.applySnapToEvent(toToolPointerEvent(e, this.canvas, this.deps.zoomPan)); try { this.active.handlers.up?.(tpe, this.buildContext()); } finally { @@ -149,6 +202,20 @@ export class InteractionDispatcher { 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()); diff --git a/frontend/src/interaction/index.ts b/frontend/src/interaction/index.ts deleted file mode 100644 index 43e8470..0000000 --- a/frontend/src/interaction/index.ts +++ /dev/null @@ -1,1173 +0,0 @@ -import type { CADElement, ElementType, ToolType } from '../types/cad.types'; -import { ZoomPanController } from '../canvas/ZoomPanController'; -import { RenderEngine } from '../canvas/RenderEngine'; -import { SnapEngine } from '../canvas/SnapEngine'; -import { SelectionEngine } from '../canvas/SelectionEngine'; -import { SpatialIndex } from '../canvas/SpatialIndex'; -import { LayerManager } from '../canvas/LayerManager'; -import { GroupManager } from '../tools/modification/GroupTool'; -import { moveElement, rotateElement, scaleElement, mirrorElement, offsetElement, trimElement, extendElement, filletElements, distance, angleBetween } from '../tools/modification/geometry'; -import { SeatingService, SEATING_TEMPLATES } from '../services/seatingService'; -import { DimensionService } from '../services/dimensionService'; -import { formatDistance, type UnitType } from '../utils/format'; - -export type ToolPhase = 'idle' | 'drawing' | 'modifying' | 'panning' | 'selecting'; - -export interface ToolState { - activeTool: ToolType | null; - phase: ToolPhase; - points: Array<{ x: number; y: number }>; // accumulated world points for current operation - previewElement: CADElement | null; // temporary element being drawn - ortho: boolean; - snapEnabled: boolean; -} - -export interface InteractionConfig { - orthoKey: string; // F8 - snapToggleKey: string; // F3 - escapeKey: string; - deleteKey: string; - selectAllKey: string; - undoKey: string; - redoKey: string; -} - -export class InteractionEngine { - private canvas: HTMLCanvasElement; - private zoomPan: ZoomPanController; - private renderEngine: RenderEngine; - private snapEngine: SnapEngine; - private selectionEngine: SelectionEngine; - private spatialIndex: SpatialIndex; - private layerManager: LayerManager; - private state: ToolState; - private config: InteractionConfig; - private selectedTemplate: string | null = null; - private allElements: CADElement[] = []; - private groupManager: GroupManager | null = null; - private isMouseDown = false; - private isRightDown = false; - private lastMouseScreen = { x: 0, y: 0 }; - private eventListeners: Array<{ type: string; fn: EventListener }> = []; - private onElementCreated?: (el: CADElement) => void; - private onElementsDeleted?: (ids: string[]) => void; - private onElementsModified?: (els: CADElement[]) => void; - private onToolStateChanged?: (state: ToolState) => void; - private onCursorMoved?: (worldX: number, worldY: number) => void; - private onCommandTrigger?: (cmd: string) => void; - private onTextEdit?: (el: CADElement) => void; - private onSelectionChange?: (selectedIds: string[]) => void; - private animationFrame: number | null = null; - private boundKeyDown: EventListener; - private boundMouseMove: EventListener; - private boundMouseUp: EventListener; - private boundPointerCancel: EventListener; - private unit: UnitType = 'mm'; - private scaleFactor: number = 1; - - constructor( - canvas: HTMLCanvasElement, - zoomPan: ZoomPanController, - renderEngine: RenderEngine, - snapEngine: SnapEngine, - selectionEngine: SelectionEngine, - spatialIndex: SpatialIndex, - layerManager: LayerManager, - ) { - this.canvas = canvas; - this.zoomPan = zoomPan; - this.renderEngine = renderEngine; - this.snapEngine = snapEngine; - this.selectionEngine = selectionEngine; - this.spatialIndex = spatialIndex; - this.layerManager = layerManager; - this.state = { - activeTool: 'select', - phase: 'idle', - points: [], - previewElement: null, - ortho: false, - snapEnabled: true, - }; - this.config = { - orthoKey: 'F8', - snapToggleKey: 'F3', - escapeKey: 'Escape', - deleteKey: 'Delete', - selectAllKey: 'a', - undoKey: 'z', - redoKey: 'y', - }; - this.boundKeyDown = this.onKeyDown.bind(this) as EventListener; - this.boundMouseMove = this.onMouseMove.bind(this) as EventListener; - this.boundMouseUp = this.onMouseUp.bind(this) as EventListener; - this.boundPointerCancel = this.onMouseUp.bind(this) as EventListener; - } - - setElements(elements: CADElement[]): void { - this.allElements = elements; - this.snapEngine.setElements(elements); - } - - setGroupManager(gm: GroupManager | null): void { - this.groupManager = gm; - } - - setSnapEnabled(enabled: boolean): void { - this.state.snapEnabled = enabled; - } - - setOrthoEnabled(enabled: boolean): void { - this.state.ortho = enabled; - } - - setUnits(unit: UnitType, scaleFactor: number): void { - this.unit = unit; - this.scaleFactor = scaleFactor; - } - - setGridSize(gridSize: number): void { - this.snapEngine.setConfig({ gridSpacing: gridSize }); - } - - setPolarEnabled(enabled: boolean): void { - this.snapEngine.setConfig({ polarEnabled: enabled }); - } - - setSelectedTemplate(templateName: string | null): void { - this.selectedTemplate = templateName; - } - - setTool(tool: ToolType | null): void { - this.state.activeTool = tool; - this.state.phase = 'idle'; - this.state.points = []; - this.state.previewElement = null; - const drawingTools: ToolType[] = ['line', 'rect', 'circle', 'arc', 'polyline', 'polygon', 'revcloud', 'text', 'dimension', 'leader', 'chair', 'seating-row', 'seating-block', 'table', 'stage', 'seating-template', 'hatch']; - if (tool && drawingTools.includes(tool)) { - this.selectionEngine.clearSelection(); - this.emitSelectionChange(); - } - this.notifyToolStateChanged(); - this.requestRender(); - } - - getToolState(): ToolState { - return { ...this.state, points: [...this.state.points] }; - } - - setCallbacks(callbacks: { - onElementCreated?: (el: CADElement) => void; - onElementsDeleted?: (ids: string[]) => void; - onElementsModified?: (els: CADElement[]) => void; - onToolStateChanged?: (state: ToolState) => void; - onCursorMoved?: (worldX: number, worldY: number) => void; - onCommandTrigger?: (cmd: string) => void; - onTextEdit?: (el: CADElement) => void; - onSelectionChange?: (selectedIds: string[]) => void; - }): void { - this.onElementCreated = callbacks.onElementCreated; - this.onElementsDeleted = callbacks.onElementsDeleted; - this.onElementsModified = callbacks.onElementsModified; - this.onToolStateChanged = callbacks.onToolStateChanged; - this.onCursorMoved = callbacks.onCursorMoved; - this.onCommandTrigger = callbacks.onCommandTrigger; - this.onTextEdit = callbacks.onTextEdit; - this.onSelectionChange = callbacks.onSelectionChange; - } - - private emitSelectionChange(): void { - if (this.onSelectionChange) { - this.onSelectionChange(Array.from(this.selectionEngine.getSelectedIds())); - } - } - - attach(): void { - this.addListener('pointerdown', this.onMouseDown.bind(this) as EventListener); - document.addEventListener('pointermove', this.boundMouseMove); - document.addEventListener('pointerup', this.boundMouseUp); - document.addEventListener('pointercancel', this.boundPointerCancel); - this.addListener('dblclick', this.onDoubleClick.bind(this) as EventListener); - this.addListener('wheel', this.onWheel.bind(this) as EventListener, { passive: false } as AddEventListenerOptions); - this.addListener('contextmenu', this.onContextMenu.bind(this) as EventListener); - document.addEventListener('keydown', this.boundKeyDown); - } - - detach(): void { - for (const { type, fn } of this.eventListeners) { - this.canvas.removeEventListener(type, fn); - } - this.eventListeners = []; - document.removeEventListener('pointermove', this.boundMouseMove); - document.removeEventListener('pointerup', this.boundMouseUp); - document.removeEventListener('pointercancel', this.boundPointerCancel); - document.removeEventListener('keydown', this.boundKeyDown); - if (this.animationFrame !== null) { - cancelAnimationFrame(this.animationFrame); - this.animationFrame = null; - } - } - - private addListener(type: string, fn: EventListener, options?: AddEventListenerOptions): void { - this.canvas.addEventListener(type, fn, options); - this.eventListeners.push({ type, fn }); - } - - private getWorldCoords(e: MouseEvent): { x: number; y: number } { - return this.zoomPan.screenToWorld(e.clientX, e.clientY); - } - - private applySnap(x: number, y: number): { x: number; y: number } { - if (!this.state.snapEnabled) return { x, y }; - // Pass last point as reference for polar tracking - const refPoint = this.state.points.length > 0 ? this.state.points[this.state.points.length - 1] : undefined; - const result = this.snapEngine.snap(x, y, refPoint); - if (result.point) { - this.renderEngine.setSnapPoints(result.preview); - this.renderEngine.setActiveSnapPoint(result.point); - return { x: result.point.x, y: result.point.y }; - } - this.renderEngine.setSnapPoints([]); - this.renderEngine.setActiveSnapPoint(null); - return { x, y }; - } - - private applyOrtho(x: number, y: number): { x: number; y: number } { - if (!this.state.ortho || this.state.points.length === 0) return { x, y }; - const last = this.state.points[this.state.points.length - 1]; - const dx = x - last.x; - const dy = y - last.y; - if (Math.abs(dx) > Math.abs(dy)) { - return { x, y: last.y }; - } else { - return { x: last.x, y }; - } - } - - private onMouseDown(e: MouseEvent): void { - try { this.canvas.setPointerCapture((e as any).pointerId); } catch {} - this.isMouseDown = true; - this.lastMouseScreen = { x: e.clientX, y: e.clientY }; - - if (e.button === 2) { - this.isRightDown = true; - this.state.phase = 'panning'; - return; - } - - if (e.button === 1 || this.state.activeTool === 'pan') { - this.state.phase = 'panning'; - return; - } - - const raw = this.getWorldCoords(e); - const snapped = this.applySnap(raw.x, raw.y); - const final = this.applyOrtho(snapped.x, snapped.y); - - switch (this.state.activeTool) { - case 'select': - this.handleSelectDown(e, raw); - break; - case 'line': - case 'polyline': - case 'rect': - case 'circle': - case 'arc': - case 'polygon': - case 'text': - case 'dimension': - case 'leader': - this.handleDrawDown(final); - break; - case 'revcloud': - this.handleDrawDown(final); - break; - case 'chair': - case 'seating-row': - case 'seating-block': - case 'table': - case 'stage': - case 'seating-template': - this.handlePlaceDown(final); - break; - case 'hatch': - this.handleHatchDown(final); - break; - case 'measure': - this.handleMeasureDown(final); - break; - case 'move': - case 'copy': - case 'rotate': - case 'scale': - case 'mirror': - case 'trim': - case 'extend': - case 'fillet': - case 'offset': - this.handleModifyDown(final); - break; - case 'delete': - this.handleDeleteDown(raw); - break; - default: - break; - } - this.requestRender(); - } - - private onMouseMove(e: MouseEvent): void { - if (this.state.phase === 'idle' && !this.isMouseDown) { - const rect = this.canvas.getBoundingClientRect(); - if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) return; - } - this.lastMouseScreen = { x: e.clientX, y: e.clientY }; - const raw = this.getWorldCoords(e); - this.onCursorMoved?.(raw.x, raw.y); - - if (this.state.phase === 'panning') { - const dx = e.movementX; - const dy = e.movementY; - this.zoomPan.pan(dx, dy); - this.requestRender(); - return; - } - - const snapped = this.applySnap(raw.x, raw.y); - const final = this.applyOrtho(snapped.x, snapped.y); - - // Hover detection for select tool - if (this.state.activeTool === 'select' && this.state.phase === 'idle') { - const hit = this.renderEngine.hitTest(raw.x, raw.y, 5); - this.selectionEngine.setHover(hit?.id ?? null); - } - - // Update preview during drawing - if (this.state.phase === 'drawing' && this.state.points.length > 0) { - this.updatePreview(final); - } - - // Update preview during modification - if (this.state.phase === 'modifying' && this.state.points.length > 0) { - this.updateModifyPreview(final); - } - - // Update box selection - if (this.selectionEngine.isBoxSelecting()) { - this.selectionEngine.updateBoxSelect(raw.x, raw.y, this.allElements); - } - - this.requestRender(); - } - - private onMouseUp(e: MouseEvent): void { - try { this.canvas.releasePointerCapture((e as any).pointerId); } catch {} - this.isMouseDown = false; - - if (e.button === 2 || (e.button === 1 && this.state.phase === 'panning')) { - this.isRightDown = false; - this.state.phase = this.state.activeTool === 'pan' ? 'idle' : 'idle'; - this.requestRender(); - return; - } - - if (this.state.phase === 'panning') { - this.state.phase = 'idle'; - this.requestRender(); - return; - } - - // Move/Copy/Rotate/Scale: confirm on mouseUp (press-drag-release) - if (this.state.phase === 'modifying' && this.state.points.length >= 1) { - const tool = this.state.activeTool; - if (tool === 'move' || tool === 'copy' || tool === 'rotate' || tool === 'scale') { - const raw = this.getWorldCoords(e); - const snapped = this.applySnap(raw.x, raw.y); - const final = this.applyOrtho(snapped.x, snapped.y); - this.confirmModify(final); - return; - } - } - - if (this.state.activeTool === 'select' && this.selectionEngine.isBoxSelecting()) { - if (this.selectionEngine.hasBoxMoved()) { - this.selectionEngine.finishBoxSelect(this.allElements); - this.emitSelectionChange(); - } else { - // No drag movement — cancel box select (it was just a click, already handled by clickSelect) - this.selectionEngine.cancelBoxSelect(); - } - this.requestRender(); - return; - } - - // Click-drag: confirm draw on mouseup for 2-point tools - if (this.state.phase === 'drawing' && this.state.points.length >= 1) { - const raw = this.getWorldCoords(e); - const snapped = this.applySnap(raw.x, raw.y); - const final = this.applyOrtho(snapped.x, snapped.y); - const tool = this.state.activeTool; - // For 2-point tools: use mouseup position as second point - if (tool === 'line' || tool === 'rect' || tool === 'circle' || tool === 'dimension' || tool === 'leader' || tool === 'arc' || tool === 'polygon' || tool === 'revcloud') { - this.state.points.push(final); - this.confirmDraw(); - return; - } - } - } - - private onWheel(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.zoomPan.zoomAt(cx, cy, factor); - this.requestRender(); - } - - private onContextMenu(e: MouseEvent): void { - e.preventDefault(); - } - - private onDoubleClick(e: MouseEvent): void { - e.preventDefault(); - if ( - (this.state.activeTool === 'polyline' || this.state.activeTool === 'polygon' || this.state.activeTool === 'revcloud') && - this.state.phase === 'drawing' && - this.state.points.length >= 2 - ) { - this.confirmDraw(); - } - } - - private onKeyDown(e: KeyboardEvent): void { - // Tool-independent keys - if (e.key === this.config.escapeKey) { - this.cancelCurrentOperation(); - return; - } - if (e.key === this.config.orthoKey) { - this.state.ortho = !this.state.ortho; - this.renderEngine.setOptions({ showOrtho: this.state.ortho }); - this.notifyToolStateChanged(); - this.requestRender(); - return; - } - if (e.key === this.config.snapToggleKey) { - this.state.snapEnabled = !this.state.snapEnabled; - this.renderEngine.setOptions({ showSnapPoints: this.state.snapEnabled }); - this.notifyToolStateChanged(); - this.requestRender(); - return; - } - if (e.key === this.config.deleteKey && this.state.activeTool === 'select') { - const ids = Array.from(this.selectionEngine.getSelectedIds()); - if (ids.length > 0) { - this.onElementsDeleted?.(ids); - } - return; - } - - // G = Group selected elements - if (e.key === 'g' && !e.ctrlKey && !e.metaKey && this.state.activeTool === 'select') { - const ids = Array.from(this.selectionEngine.getSelectedIds()); - if (ids.length >= 2) { - this.onCommandTrigger?.('group'); - } - return; - } - - // Ctrl+G = Ungroup - if ((e.ctrlKey || e.metaKey) && e.key === 'g' && this.state.activeTool === 'select') { - e.preventDefault(); - this.onCommandTrigger?.('ungroup'); - return; - } - - // Ctrl+A = select all - if ((e.ctrlKey || e.metaKey) && e.key === this.config.selectAllKey) { - e.preventDefault(); - this.selectionEngine.selectAll(this.allElements); - this.emitSelectionChange(); - this.requestRender(); - return; - } - - // Ctrl+Z = undo (delegated to app) - if ((e.ctrlKey || e.metaKey) && e.key === this.config.undoKey) { - e.preventDefault(); - this.onCommandTrigger?.('undo'); - return; - } - - // Ctrl+Y = redo - if ((e.ctrlKey || e.metaKey) && e.key === this.config.redoKey) { - e.preventDefault(); - this.onCommandTrigger?.('redo'); - return; - } - - // Enter = confirm current operation - if (e.key === 'Enter' && this.state.phase === 'drawing') { - this.confirmDraw(); - return; - } - - // Enter = confirm modification with current cursor position - if (e.key === 'Enter' && this.state.phase === 'modifying') { - const world = this.zoomPan.screenToWorld(this.lastMouseScreen.x, this.lastMouseScreen.y); - this.confirmModify(world); - return; - } - - // Space = pan toggle (hold) - if (e.key === ' ' && this.state.activeTool !== 'pan') { - e.preventDefault(); - // Temporary pan — could implement hold-to-pan - return; - } - } - - // --- Tool handlers --- - - private handleSelectDown(e: MouseEvent, world: { x: number; y: number }): void { - const additive = e.shiftKey; - const subtractive = e.ctrlKey || e.metaKey; - this.selectionEngine.setOptions({ additive, subtractive }); - - if (!additive && !subtractive) { - // Hit test first: if an object is clicked, select it directly - const hit = this.renderEngine.hitTest(world.x, world.y, 5); - if (hit) { - // Group-aware click selection: if clicked element is in a group, select all group members - if (this.groupManager) { - const groupElements = this.groupManager.getGroupElements(hit.id); - if (groupElements.length > 1) { - this.selectionEngine.selectByIds(groupElements, false); - this.emitSelectionChange(); - return; - } - } - this.selectionEngine.clickSelect(world.x, world.y, this.allElements); - this.emitSelectionChange(); - } else { - // No object hit: start potential box select for drag - this.selectionEngine.startBoxSelect(world.x, world.y); - } - } else { - // Group-aware click selection: if clicked element is in a group, select all group members - const hit = this.renderEngine.hitTest(world.x, world.y, 5); - if (hit && this.groupManager) { - const groupElements = this.groupManager.getGroupElements(hit.id); - if (groupElements.length > 1) { - if (subtractive) { - // Remove all group elements from selection - const currentIds = Array.from(this.selectionEngine.getSelectedIds()); - const filtered = currentIds.filter(id => !groupElements.includes(id)); - this.selectionEngine.selectByIds(filtered, false); - } else { - this.selectionEngine.selectByIds(groupElements, !additive ? false : true); - } - this.emitSelectionChange(); - return; - } - } - this.selectionEngine.clickSelect(world.x, world.y, this.allElements); - this.emitSelectionChange(); - } - } - - private handleDrawDown(pt: { x: number; y: number }): void { - // Clear any previous points when starting a new draw operation - if (this.state.points.length === 0) { - this.state.phase = 'drawing'; - } - this.state.points.push(pt); - - // Arc: auto-confirm after 3 points (center, start, end) - if (this.state.activeTool === 'arc' && this.state.points.length >= 3) { - this.confirmDraw(); - return; - } - - // Text: single click placement, then prompt for text content - if (this.state.activeTool === 'text') { - this.updatePreview(pt); - this.confirmDraw(); - return; - } - - // Line, rect, circle, dimension, leader: wait for mouseup (click-drag behavior) - // Points are collected on mousedown, confirmed on mouseup - - this.updatePreview(pt); - this.notifyToolStateChanged(); - } - - private handlePlaceDown(pt: { x: number; y: number }): void { - const tool = this.state.activeTool; - const layerId = this.layerManager.getActiveLayerId(); - const seating = new SeatingService(); - - switch (tool) { - case 'chair': { - const el = seating.createChair(pt.x, pt.y, layerId); - this.onElementCreated?.(el); - break; - } - case 'seating-row': { - const els = seating.createSeatingRow(pt.x, pt.y, layerId); - for (const el of els) this.onElementCreated?.(el); - this.onCommandTrigger?.(`Reihe mit ${els.length} Stühlen erstellt`); - break; - } - case 'seating-block': { - const els = seating.createSeatingBlock(pt.x, pt.y, layerId); - for (const el of els) this.onElementCreated?.(el); - this.onCommandTrigger?.(`Block mit ${els.length} Stühlen erstellt`); - break; - } - case 'table': { - const el = seating.createTable(pt.x, pt.y, layerId); - this.onElementCreated?.(el); - break; - } - case 'stage': { - const el = seating.createStage(pt.x, pt.y, layerId); - this.onElementCreated?.(el); - break; - } - case 'seating-template': { - if (this.selectedTemplate) { - const els = seating.createFromTemplate(this.selectedTemplate, pt.x, pt.y, layerId); - for (const el of els) this.onElementCreated?.(el); - this.onCommandTrigger?.(`Vorlage "${this.selectedTemplate}" platziert: ${els.length} Elemente`); - } else { - this.onCommandTrigger?.('Keine Vorlage ausgewählt – bitte in der Seitenleiste wählen'); - } - break; - } - default: - break; - } - this.requestRender(); - } - - private handleHatchDown(pt: { x: number; y: number }): void { - // Hatch: select a closed boundary element, then apply hatch pattern - const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); - if (hit && (hit.type === 'rect' || hit.type === 'circle' || hit.type === 'polygon')) { - const updated: CADElement = { - ...hit, - properties: { ...hit.properties, hatch: true, hatchPattern: 'lines', hatchSpacing: 8 }, - }; - this.onElementsModified?.([updated]); - } - this.requestRender(); - } - - private handleMeasureDown(pt: { x: number; y: number }): void { - // Measure: two clicks, display distance - if (this.state.points.length === 0) { - this.state.points.push(pt); - this.state.phase = 'drawing'; - this.onCommandTrigger?.('measure: click end point'); - } else { - const first = this.state.points[0]; - const rawDist = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2); - const angle = (Math.atan2(pt.y - first.y, pt.x - first.x) * 180) / Math.PI; - const distStr = formatDistance(rawDist, this.unit, this.scaleFactor); - this.onCommandTrigger?.(`distance: ${distStr} angle: ${angle.toFixed(1)}°`); - this.state.points = []; - this.state.phase = 'idle'; - this.requestRender(); - } - } - - private modifySelected: CADElement[] = []; - private modifyBoundary: CADElement | null = null; - private modifyFilletRadius = 10; - - private handleModifyDown(pt: { x: number; y: number }): void { - const tool = this.state.activeTool; - - // Trim/Extend/Fillet: first click selects boundary element, not from selection - if (tool === 'trim' || tool === 'extend') { - if (this.state.points.length === 0) { - // First click: select boundary element - const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); - if (hit) { - this.modifyBoundary = hit; - this.state.phase = 'modifying'; - this.state.points.push(pt); - this.onCommandTrigger?.(`${tool}: select element to ${tool === 'trim' ? 'trim' : 'extend'}`); - } - return; - } - // Second click: select element to trim/extend - const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); - if (hit && this.modifyBoundary) { - const fn = tool === 'trim' ? trimElement : extendElement; - const result = fn(hit, this.modifyBoundary); - if (result) { - this.onElementsModified?.([result]); - } - this.resetModify(); - } - return; - } - - if (tool === 'fillet') { - if (this.state.points.length === 0) { - // First click: select first element - const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); - if (hit) { - this.modifySelected = [hit]; - this.state.phase = 'modifying'; - this.state.points.push(pt); - this.onCommandTrigger?.('fillet: select second element'); - } - return; - } - if (this.state.points.length === 1) { - // Second click: select second element - const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); - if (hit && this.modifySelected.length === 1) { - const result = filletElements(this.modifySelected[0], hit, this.modifyFilletRadius); - if (result) { - this.onElementsModified?.(result); - } - this.resetModify(); - } - return; - } - return; - } - - if (tool === 'offset') { - if (this.state.points.length === 0) { - // First click: select element to offset - const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); - if (hit) { - this.modifySelected = [hit]; - this.state.phase = 'modifying'; - this.state.points.push(pt); - this.onCommandTrigger?.('offset: click to set direction and distance'); - } - return; - } - // Second click: direction + distance - if (this.modifySelected.length === 1) { - const first = this.state.points[0]; - const dist = distance(first, pt); - const base = this.modifySelected[0]; - // Seitigkeit bestimmen: Kreuzprodukt der Linienrichtung mit Klickvektor. - // cross > 0 → Klick links der Laufrichtung (p1→p2) → side=+1, sonst -1. - let side: 1 | -1 = 1; - if (base.type === 'line' && base.properties.x1 !== undefined && base.properties.y1 !== undefined) { - const p1x = base.properties.x1 as number; - const p1y = base.properties.y1 as number; - const p2x = base.properties.x2 as number; - const p2y = base.properties.y2 as number; - const cross = (p2x - p1x) * (pt.y - p1y) - (p2y - p1y) * (pt.x - p1x); - side = cross >= 0 ? 1 : -1; - } else if ((base.type === 'circle' || base.type === 'arc') && base.properties.radius !== undefined) { - // Kreis/Bogen: Klick außerhalb → vergrößern (+1), innerhalb → verkleinern (-1) - const clickDist = Math.hypot(pt.x - base.x, pt.y - base.y); - side = clickDist >= base.properties.radius ? 1 : -1; - } - const offsetted = offsetElement(base, dist, side); - // Create as new element (copy) - this.onElementCreated?.({ ...offsetted, id: this.generateId() }); - this.resetModify(); - } - return; - } - - // Move/Copy/Rotate/Scale: press-drag-release (kein click-click) - if (tool === 'move' || tool === 'copy' || tool === 'rotate' || tool === 'scale') { - if (this.state.phase === 'modifying' && this.state.points.length >= 1) { - // Zweiter Klick — sollte nicht passieren bei drag-release, aber falls doch: bestätigen - this.confirmModify(pt); - return; - } - const selected = this.selectionEngine.getSelectedElements(this.allElements); - if (selected.length === 0) { - this.onCommandTrigger?.('select-first'); - return; - } - this.modifySelected = selected; - this.state.phase = 'modifying'; - this.state.points.push(pt); // Start-Punkt für drag - this.notifyToolStateChanged(); - return; - } - - // Mirror: click-click (zwei Punkte für die Spiegelungsachse) - if (this.state.phase === 'modifying' && this.state.points.length >= 1) { - this.confirmModify(pt); - return; - } - - const selected = this.selectionEngine.getSelectedElements(this.allElements); - if (selected.length === 0) { - this.onCommandTrigger?.('select-first'); - return; - } - - this.modifySelected = selected; - this.state.phase = 'modifying'; - this.state.points.push(pt); - this.notifyToolStateChanged(); - } - - private updateModifyPreview(pt: { x: number; y: number }): void { - if (this.modifySelected.length === 0 || this.state.points.length === 0) return; - const tool = this.state.activeTool; - const base = this.state.points[0]; - - if (tool === 'move' || tool === 'copy') { - const dx = pt.x - base.x; - const dy = pt.y - base.y; - // Show preview of first selected element moved - this.state.previewElement = moveElement(this.modifySelected[0], dx, dy); - } else if (tool === 'rotate') { - const objCenter = { x: this.modifySelected[0].x, y: this.modifySelected[0].y }; - const startAngle = angleBetween(objCenter, base); - const currentAngle = angleBetween(objCenter, pt); - const rotationDelta = currentAngle - startAngle; - this.state.previewElement = rotateElement(this.modifySelected[0], objCenter.x, objCenter.y, rotationDelta); - } else if (tool === 'scale') { - const objCenter = { x: this.modifySelected[0].x, y: this.modifySelected[0].y }; - const startDist = distance(objCenter, base); - const currentDist = distance(objCenter, pt); - const factor = startDist > 0 ? currentDist / startDist : 1; - this.state.previewElement = scaleElement(this.modifySelected[0], objCenter.x, objCenter.y, factor, factor); - } else if (tool === 'mirror') { - // Mirror preview: axis from base point to current mouse position - this.state.previewElement = mirrorElement(this.modifySelected[0], base.x, base.y, pt.x, pt.y); - } - } - - private confirmModify(pt: { x: number; y: number }): void { - if (this.modifySelected.length === 0 || this.state.points.length === 0) return; - const tool = this.state.activeTool; - const base = this.state.points[0]; - - if (tool === 'move') { - const dx = pt.x - base.x; - const dy = pt.y - base.y; - // Group-aware: expand modifySelected to include group members - let toMove = [...this.modifySelected]; - if (this.groupManager) { - const additionalGroupEls: CADElement[] = []; - for (const el of this.modifySelected) { - const groupEls = this.groupManager.getGroupElements(el.id); - for (const gid of groupEls) { - if (!toMove.some(e => e.id === gid)) { - const found = this.allElements.find(e => e.id === gid); - if (found) additionalGroupEls.push(found); - } - } - } - toMove = [...toMove, ...additionalGroupEls]; - } - const modified = toMove.map(el => moveElement(el, dx, dy)); - this.onElementsModified?.(modified); - } else if (tool === 'copy') { - const dx = pt.x - base.x; - const dy = pt.y - base.y; - for (const el of this.modifySelected) { - const copy = moveElement(el, dx, dy); - this.onElementCreated?.({ ...copy, id: this.generateId() }); - } - } else if (tool === 'rotate') { - const objCenter = { x: this.modifySelected[0].x, y: this.modifySelected[0].y }; - const startAngle = angleBetween(objCenter, base); - const endAngle = angleBetween(objCenter, pt); - const rotationDelta = endAngle - startAngle; - const modified = this.modifySelected.map(el => rotateElement(el, objCenter.x, objCenter.y, rotationDelta)); - this.onElementsModified?.(modified); - } else if (tool === 'scale') { - const objCenter = { x: this.modifySelected[0].x, y: this.modifySelected[0].y }; - const startDist = distance(objCenter, base); - const endDist = distance(objCenter, pt); - const factor = startDist > 0 ? endDist / startDist : 1; - const modified = this.modifySelected.map(el => scaleElement(el, objCenter.x, objCenter.y, factor, factor)); - this.onElementsModified?.(modified); - } else if (tool === 'mirror') { - // Mirror axis: base point (first click) to current point (second click) - const modified = this.modifySelected.map(el => mirrorElement(el, base.x, base.y, pt.x, pt.y)); - this.onElementsModified?.(modified); - } - - this.resetModify(); - } - - private resetModify(): void { - this.modifySelected = []; - this.modifyBoundary = null; - this.state.points = []; - this.state.previewElement = null; - this.state.phase = 'idle'; - this.notifyToolStateChanged(); - this.requestRender(); - } - - private handleDeleteDown(world: { x: number; y: number }): void { - const hit = this.renderEngine.hitTest(world.x, world.y, 5); - if (hit) { - this.onElementsDeleted?.([hit.id]); - } - } - - private updatePreview(pt: { x: number; y: number }): void { - if (this.state.points.length === 0) return; - const layerId = this.layerManager.getActiveLayerId(); - const first = this.state.points[0]; - const tool = this.state.activeTool; - - switch (tool) { - case 'line': - this.state.previewElement = { - id: '__preview__', - type: 'line', - layerId, - x: (first.x + pt.x) / 2, - y: (first.y + pt.y) / 2, - width: Math.abs(pt.x - first.x), - height: Math.abs(pt.y - first.y), - properties: { x1: first.x, y1: first.y, x2: pt.x, y2: pt.y }, - }; - break; - case 'rect': { - const minX = Math.min(first.x, pt.x); - const minY = Math.min(first.y, pt.y); - const w = Math.abs(pt.x - first.x); - const h = Math.abs(pt.y - first.y); - this.state.previewElement = { - id: '__preview__', - type: 'rect', - layerId, - x: minX + w / 2, - y: minY + h / 2, - width: w, - height: h, - properties: {}, - }; - break; - } - case 'circle': { - const r = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2); - this.state.previewElement = { - id: '__preview__', - type: 'circle', - layerId, - x: first.x, - y: first.y, - width: r * 2, - height: r * 2, - properties: { radius: r }, - }; - break; - } - case 'arc': { - // 3-point arc: click 1 = center, click 2 = start point (radius + startAngle), click 3 = end point (endAngle) - const pts = this.state.points; - if (pts.length === 1) { - // After center placed, show preview circle as radius guide - const r = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2); - this.state.previewElement = { - id: '__preview__', - type: 'arc', - layerId, - x: first.x, - y: first.y, - width: r * 2, - height: r * 2, - properties: { radius: r, startAngle: 0, endAngle: 360 }, - }; - } else if (pts.length >= 2) { - const center = pts[0]; - const start = pts[1]; - const radius = Math.sqrt((start.x - center.x) ** 2 + (start.y - center.y) ** 2); - const startAngle = (Math.atan2(start.y - center.y, start.x - center.x) * 180) / Math.PI; - const endAngle = (Math.atan2(pt.y - center.y, pt.x - center.x) * 180) / Math.PI; - this.state.previewElement = { - id: '__preview__', - type: 'arc', - layerId, - x: center.x, - y: center.y, - width: radius * 2, - height: radius * 2, - properties: { radius, startAngle, endAngle }, - }; - } - break; - } - case 'polyline': - case 'polygon': { - const pts = [...this.state.points, pt]; - const xs = pts.map(p => p.x); - const ys = pts.map(p => p.y); - this.state.previewElement = { - id: '__preview__', - type: tool, - layerId, - x: (Math.min(...xs) + Math.max(...xs)) / 2, - y: (Math.min(...ys) + Math.max(...ys)) / 2, - width: Math.max(...xs) - Math.min(...xs), - height: Math.max(...ys) - Math.min(...ys), - properties: { points: pts }, - }; - break; - } - case 'text': - // Text placement — single point, then user types - this.state.previewElement = { - id: '__preview__', - type: 'text', - layerId, - x: pt.x, - y: pt.y, - width: 100, - height: 20, - properties: { text: '', fontSize: 12 }, - }; - break; - case 'dimension': { - const dist = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2); - this.state.previewElement = { - id: '__preview__', - type: 'dimension', - layerId, - x: (first.x + pt.x) / 2, - y: (first.y + pt.y) / 2, - width: dist, - height: 20, - properties: { x1: first.x, y1: first.y, x2: pt.x, y2: pt.y }, - }; - break; - } - case 'leader': { - this.state.previewElement = { - id: '__preview__', - type: 'leader', - layerId, - x: pt.x, y: pt.y, - width: Math.abs(pt.x - first.x), - height: Math.abs(pt.y - first.y), - properties: { x1: first.x, y1: first.y, x2: pt.x, y2: pt.y, text: '', fontSize: 12, stroke: '#e0e0e0', strokeWidth: 1 }, - }; - break; - } - case 'revcloud': { - const pts = [...this.state.points, pt]; - const xs = pts.map(p => p.x); - const ys = pts.map(p => p.y); - this.state.previewElement = { - id: '__preview__', - type: 'revcloud', - layerId, - x: (Math.min(...xs) + Math.max(...xs)) / 2, - y: (Math.min(...ys) + Math.max(...ys)) / 2, - width: Math.max(...xs) - Math.min(...xs), - height: Math.max(...ys) - Math.min(...ys), - properties: { points: pts, arcHeight: 8, fill: 'none', stroke: '#e0e0e0', strokeWidth: 1.5 }, - }; - break; - } - case 'measure': { - // Show a dashed line preview from first point to cursor - this.state.previewElement = { - id: '__preview__', - type: 'line', - layerId, - x: (first.x + pt.x) / 2, - y: (first.y + pt.y) / 2, - width: Math.abs(pt.x - first.x), - height: Math.abs(pt.y - first.y), - properties: { x1: first.x, y1: first.y, x2: pt.x, y2: pt.y }, - }; - break; - } - default: - break; - } - } - - private confirmDraw(): void { - if (!this.state.previewElement) return; - const layerId = this.layerManager.getActiveLayerId(); - const dimService = new DimensionService(); - let el: CADElement; - - // Use DimensionService for leader and revcloud creation - if (this.state.activeTool === 'leader' && this.state.points.length >= 2) { - const p1 = this.state.points[0]; - const p2 = this.state.points[1]; - el = dimService.createLeader(p1.x, p1.y, p2.x, p2.y, layerId, { text: 'Hinweis' }); - } else if (this.state.activeTool === 'revcloud' && this.state.points.length >= 2) { - el = dimService.createRevCloud(this.state.points, layerId); - } else { - el = { ...this.state.previewElement, id: this.generateId(), layerId: layerId }; - } - - this.onElementCreated?.(el); - - // Text: trigger edit callback after placement - if (el.type === 'text') { - this.onTextEdit?.(el); - } - - // Leader: trigger edit callback for text input - if (el.type === 'leader') { - this.onTextEdit?.(el); - } - - this.state.points = []; - this.state.previewElement = null; - this.state.phase = 'idle'; - this.notifyToolStateChanged(); - this.requestRender(); - } - - private cancelCurrentOperation(): void { - this.state.points = []; - this.state.previewElement = null; - this.state.phase = 'idle'; - this.selectionEngine.cancelBoxSelect(); - this.notifyToolStateChanged(); - this.requestRender(); - } - - private notifyToolStateChanged(): void { - this.onToolStateChanged?.(this.getToolState()); - } - - private requestRender(): void { - if (this.animationFrame !== null) return; - this.animationFrame = requestAnimationFrame(() => { - this.animationFrame = null; - this.renderEngine.setPreviewElement(this.state.previewElement); - this.renderEngine.render(); - }); - } - - private generateId(): string { - return `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; - } - - // Public method to trigger zoom fit - zoomFit(): void { - this.zoomPan.zoomFit(this.allElements); - this.requestRender(); - } - - // Get current cursor world position - getCursorWorld(): { x: number; y: number } { - return this.zoomPan.screenToWorld(this.lastMouseScreen.x, this.lastMouseScreen.y); - } -} diff --git a/frontend/src/types/ui.types.ts b/frontend/src/types/ui.types.ts index bedcca3..6d592c4 100644 --- a/frontend/src/types/ui.types.ts +++ b/frontend/src/types/ui.types.ts @@ -3,7 +3,16 @@ */ import type { ReactNode } from 'react'; import type { CADElement, CADLayer, BlockDefinition, ToolType } from './cad.types'; -import type { ToolState } from '../interaction'; +/** CAD-14 Phase 4c: ToolState/ToolPhase (ehem. interaction/index) — V2-nativ. */ +export type ToolPhase = 'idle' | 'drawing' | 'modifying' | 'panning' | 'selecting'; +export interface ToolState { + activeTool: string | null; + phase: ToolPhase; + points: Array<{ x: number; y: number }>; + previewElement: import('./cad.types').CADElement | null; + ortho: boolean; + snapEnabled: boolean; +} import type { BackgroundConfig } from '../services/backgroundService'; import type { UserCursor } from '../crdt'; import type { UnitType } from '../utils/format'; diff --git a/frontend/tests/dispatcher.test.ts b/frontend/tests/dispatcher.test.ts index 49112bf..95ade0e 100644 --- a/frontend/tests/dispatcher.test.ts +++ b/frontend/tests/dispatcher.test.ts @@ -12,6 +12,9 @@ import type { ZoomPanController } from '../src/canvas/ZoomPanController'; function makeFakeZoomPan(): ZoomPanController { return { screenToWorld: (sx: number, sy: number) => ({ x: sx * 2, y: sy * 3 }), + // CAD-14 Phase 4c: natives Pan/Wheel im Dispatcher (spies für Assertions) + pan: vi.fn(), + zoomAt: vi.fn(), } as unknown as ZoomPanController; } @@ -60,11 +63,12 @@ describe('InteractionDispatcher', () => { function setup(tool?: ToolExtensionV2) { canvas = document.createElement('canvas'); document.body.appendChild(canvas); + const zoomPan = makeFakeZoomPan(); const dispatcher = new InteractionDispatcher(canvas, { - zoomPan: makeFakeZoomPan(), + zoomPan, toolLookup: tool ? () => tool : undefined, }); - return { dispatcher }; + return { dispatcher, zoomPan }; } it('routet down→move→up in Reihenfolge an das aktive Tool', () => { @@ -103,13 +107,17 @@ describe('InteractionDispatcher', () => { expect(log).toEqual(['down', 'cancel']); }); - it('Rechtsklick bricht ab, ohne down durchzureichen', () => { + it('Rechtsklick startet natives Pan (kein Tool-Event)', () => { const { tool, log } = makeRecordingTool(); - const { dispatcher } = setup(tool); + const { dispatcher, zoomPan } = setup(tool); dispatcher.setActive('fake'); fire(canvas, 'pointerdown', { button: 2 }); - expect(log).toEqual(['cancel']); + fire(window, 'pointermove', { clientX: 120, clientY: 60 }); + fire(window, 'pointerup', { button: 2, clientX: 120, clientY: 60 }); + // CAD-14 Phase 4c: Rechtsklick = natives Pan, Tool bleibt unberührt + expect(log).toEqual([]); + expect(zoomPan.pan).toHaveBeenCalled(); }); it('setActive(null) deaktiviert Routing komplett', () => { diff --git a/frontend/tests/dispatcherNative.test.ts b/frontend/tests/dispatcherNative.test.ts new file mode 100644 index 0000000..43100f1 --- /dev/null +++ b/frontend/tests/dispatcherNative.test.ts @@ -0,0 +1,88 @@ +/** + * CAD-14 Phase 4c: Dispatcher-native Pan/Wheel/Cursor/Snap. + * Ersetzt die Legacy-InteractionEngine vollstaendig: + * - Snap/Ortho werden auf Tool-Pointer-Events angewandt (falls Deps gesetzt) + * - Pan bei Mittel-/Rechtsklick-Drag (auch ohne aktives Tool) + * - Wheel-Zoom am Cursor + * - Cursor-Meldung bei jedem Move (auch ohne Tool) + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { InteractionDispatcher } from '../src/interaction/dispatcher'; +import type { ToolExtensionV2 } from '../src/plugins/types'; + +function makeCanvas(): HTMLCanvasElement { + const c = document.createElement('canvas'); + c.getBoundingClientRect = () => ({ left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600, x: 0, y: 0, toJSON: () => ({}) } as DOMRect); + return c; +} + +const zoomPan = { + screenToWorld: (sx: number, sy: number) => ({ x: sx, y: sy }), + pan: vi.fn(), + zoomAt: vi.fn(), +}; + +function lineTool(): ToolExtensionV2 { + return { + manifest: { id: 'line', label: 'Linie', icon: 'L', ribbonTab: 'canvas', tags: ['basic'] }, + optionsSchema: [], + handlers: { + down: vi.fn(), + move: vi.fn(), + up: vi.fn(), + }, + }; +} + +function fireMouse(canvas: HTMLCanvasElement, type: string, opts: { button?: number; x?: number; y?: number } = {}) { + const e = new MouseEvent(type, { button: opts.button ?? 0, clientX: opts.x ?? 0, clientY: opts.y ?? 0, bubbles: true }); + canvas.dispatchEvent(e); +} + +describe('CAD-14 Phase 4c: dispatcher native pan/wheel/cursor', () => { + let canvas: HTMLCanvasElement; + let onCursor: ReturnType; + + beforeEach(() => { + canvas = makeCanvas(); + onCursor = vi.fn(); + }); + + it('Pan: Mittelklick-Drag verschiebt via zoomPan.pan (auch ohne Tool)', () => { + const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never, onCursorMoved: onCursor }); + canvas.dispatchEvent(new MouseEvent('pointerdown', { button: 1, clientX: 100, clientY: 100 })); + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 140, clientY: 120 })); + window.dispatchEvent(new MouseEvent('pointerup', { button: 1, clientX: 140, clientY: 120 })); + expect(zoomPan.pan).toHaveBeenCalled(); + d.destroy(); + }); + + it('Wheel: zoomAt am Cursor mit Faktor', () => { + const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never }); + canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, clientX: 300, clientY: 200, cancelable: true })); + expect(zoomPan.zoomAt).toHaveBeenCalled(); + d.destroy(); + }); + + it('Cursor-Meldung: onCursorMoved feuert bei pointermove ohne aktives Tool', () => { + const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never, onCursorMoved: onCursor }); + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 50, clientY: 60 })); + expect(onCursor).toHaveBeenCalledWith(50, 60); + d.destroy(); + }); + + it('Snap: down/up Positionen werden gesnappt (snap-Deps)', () => { + const snap = vi.fn((x: number, y: number) => ({ x: Math.round(x / 10) * 10, y: Math.round(y / 10) * 10 })); + const tool = lineTool(); + const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never, snap, toolLookup: () => tool } as never); + d.setActive('line'); + expect(d.getActive()).toBe(tool); + canvas.dispatchEvent(new MouseEvent('pointerdown', { button: 0, clientX: 12, clientY: 12 })); + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 35, clientY: 35 })); + window.dispatchEvent(new MouseEvent('pointerup', { button: 0, clientX: 35, clientY: 35 })); + expect(tool.handlers.down).toHaveBeenCalled(); + expect((tool.handlers.down as any).mock.calls[0][0].world).toEqual({ x: 10, y: 10 }); + expect((tool.handlers.up as any).mock.calls[0][0].world).toEqual({ x: 40, y: 40 }); + d.destroy(); + }); +});