task(CAD-14): phase 4c - legacy interaction engine DELETED (1173 lines), dispatcher is the single engine (native snap/ortho/pan/wheel/cursor), ToolState moved to ui.types
This commit is contained in:
@@ -6,7 +6,7 @@ import type {
|
|||||||
import type { CADElement, CADLayer, BlockDefinition, ImageElement } from './types/cad.types';
|
import type { CADElement, CADLayer, BlockDefinition, ImageElement } from './types/cad.types';
|
||||||
import { BlockService } from './services/blockService';
|
import { BlockService } from './services/blockService';
|
||||||
import { SeatingService } from './services/seatingService';
|
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 { GroupManager, type ElementGroup } from './tools/modification/GroupTool';
|
||||||
import Topbar from './components/Topbar';
|
import Topbar from './components/Topbar';
|
||||||
import RibbonBar from './components/RibbonBar';
|
import RibbonBar from './components/RibbonBar';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ToolType } from '../types/cad.types';
|
|||||||
import type { UserCursor } from '../crdt';
|
import type { UserCursor } from '../crdt';
|
||||||
import { RenderEngine } from '../canvas/RenderEngine';
|
import { RenderEngine } from '../canvas/RenderEngine';
|
||||||
import { ZoomPanController } from '../canvas/ZoomPanController';
|
import { ZoomPanController } from '../canvas/ZoomPanController';
|
||||||
import { InteractionEngine } from '../interaction';
|
|
||||||
import { SnapEngine } from '../canvas/SnapEngine';
|
import { SnapEngine } from '../canvas/SnapEngine';
|
||||||
import { SelectionEngine } from '../canvas/SelectionEngine';
|
import { SelectionEngine } from '../canvas/SelectionEngine';
|
||||||
import { SpatialIndex } from '../canvas/SpatialIndex';
|
import { SpatialIndex } from '../canvas/SpatialIndex';
|
||||||
@@ -34,7 +34,7 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const zoomPanRef = useRef<ZoomPanController | null>(null);
|
const zoomPanRef = useRef<ZoomPanController | null>(null);
|
||||||
const renderEngineRef = useRef<RenderEngine | null>(null);
|
const renderEngineRef = useRef<RenderEngine | null>(null);
|
||||||
const interactionRef = useRef<InteractionEngine | null>(null);
|
|
||||||
const dispatcherRef = useRef<InteractionDispatcher | null>(null);
|
const dispatcherRef = useRef<InteractionDispatcher | null>(null);
|
||||||
/** Task C2: PixiJS-Canvas-Ref für V2-Elemente-Overlay. */
|
/** Task C2: PixiJS-Canvas-Ref für V2-Elemente-Overlay. */
|
||||||
const pixiCanvasRef = useRef<HTMLCanvasElement>(null);
|
const pixiCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
@@ -50,6 +50,14 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
/** CAD-14: Frische Prop-Referenz für Selection-Änderungen aus V2-Werkzeugen. */
|
/** CAD-14: Frische Prop-Referenz für Selection-Änderungen aus V2-Werkzeugen. */
|
||||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||||
onSelectionChangeRef.current = 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<SpatialIndex | null>(null);
|
const spatialIndexRef = useRef<SpatialIndex | null>(null);
|
||||||
const layerManagerRef = useRef<LayerManager | null>(null);
|
const layerManagerRef = useRef<LayerManager | null>(null);
|
||||||
const selectionEngineRef = useRef<SelectionEngine | null>(null);
|
const selectionEngineRef = useRef<SelectionEngine | null>(null);
|
||||||
@@ -64,9 +72,6 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
const renderEngine = new RenderEngine(canvas, zoomPan, spatialIndex, layerManager);
|
const renderEngine = new RenderEngine(canvas, zoomPan, spatialIndex, layerManager);
|
||||||
const snapEngine = new SnapEngine();
|
const snapEngine = new SnapEngine();
|
||||||
const selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager);
|
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
|
// ── Task A2/A4.14: V2-Pfad ist seit Verifikation STANDARD — Opt-out via
|
||||||
// VITE_TOOL_DISPATCHER=legacy. Siehe kernel/featureFlags.ts.
|
// VITE_TOOL_DISPATCHER=legacy. Siehe kernel/featureFlags.ts.
|
||||||
@@ -165,6 +170,30 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
|
|
||||||
dispatcherRef.current = new InteractionDispatcher(canvas, {
|
dispatcherRef.current = new InteractionDispatcher(canvas, {
|
||||||
zoomPan,
|
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) => {
|
setStatus: (msg) => {
|
||||||
/* StatusBar-Bridge folgt in A4 */
|
/* StatusBar-Bridge folgt in A4 */
|
||||||
},
|
},
|
||||||
@@ -240,26 +269,11 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
|
|
||||||
zoomPanRef.current = zoomPan;
|
zoomPanRef.current = zoomPan;
|
||||||
renderEngineRef.current = renderEngine;
|
renderEngineRef.current = renderEngine;
|
||||||
interactionRef.current = interaction;
|
|
||||||
spatialIndexRef.current = spatialIndex;
|
spatialIndexRef.current = spatialIndex;
|
||||||
layerManagerRef.current = layerManager;
|
layerManagerRef.current = layerManager;
|
||||||
selectionEngineRef.current = selectionEngine;
|
selectionEngineRef.current = selectionEngine;
|
||||||
|
|
||||||
interaction.setCallbacks({
|
|
||||||
onElementCreated,
|
|
||||||
onElementsDeleted,
|
|
||||||
onElementsModified,
|
|
||||||
onCursorMoved,
|
|
||||||
onToolStateChanged,
|
|
||||||
onTextEdit,
|
|
||||||
onCommandTrigger,
|
|
||||||
onSelectionChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
interaction.attach();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
interaction.detach();
|
|
||||||
dispatcherRef.current?.destroy();
|
dispatcherRef.current?.destroy();
|
||||||
dispatcherRef.current = null;
|
dispatcherRef.current = null;
|
||||||
pixiRendererRef.current?.destroy(); // Task C2: WebGL-Kontext freigeben
|
pixiRendererRef.current?.destroy(); // Task C2: WebGL-Kontext freigeben
|
||||||
@@ -267,7 +281,6 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
setActiveDocument(null);
|
setActiveDocument(null);
|
||||||
zoomPanRef.current = null;
|
zoomPanRef.current = null;
|
||||||
renderEngineRef.current = null;
|
renderEngineRef.current = null;
|
||||||
interactionRef.current = null;
|
|
||||||
spatialIndexRef.current = null;
|
spatialIndexRef.current = null;
|
||||||
layerManagerRef.current = null;
|
layerManagerRef.current = null;
|
||||||
selectionEngineRef.current = null;
|
selectionEngineRef.current = null;
|
||||||
@@ -275,23 +288,6 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
// Resize canvas to fill container
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -320,13 +316,11 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sync elements to interaction engine + spatial index + render
|
// Sync elements to spatial index + render (CAD-14: keine Legacy-Engine)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interaction = interactionRef.current;
|
|
||||||
const spatialIndex = spatialIndexRef.current;
|
const spatialIndex = spatialIndexRef.current;
|
||||||
const renderEngine = renderEngineRef.current;
|
const renderEngine = renderEngineRef.current;
|
||||||
if (!interaction || !spatialIndex || !renderEngine) return;
|
if (!spatialIndex || !renderEngine) return;
|
||||||
interaction.setElements(elements);
|
|
||||||
spatialIndex.clear();
|
spatialIndex.clear();
|
||||||
spatialIndex.bulkInsert(elements);
|
spatialIndex.bulkInsert(elements);
|
||||||
renderEngine.setOverlayElements(elements);
|
renderEngine.setOverlayElements(elements);
|
||||||
@@ -391,21 +385,12 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [blocks]);
|
}, [blocks]);
|
||||||
|
|
||||||
// Sync groups to RenderEngine and InteractionEngine
|
// Sync groups to RenderEngine (CAD-14: keine Legacy-Engine mehr)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const renderEngine = renderEngineRef.current;
|
const renderEngine = renderEngineRef.current;
|
||||||
const interaction = interactionRef.current;
|
|
||||||
if (!renderEngine) return;
|
if (!renderEngine) return;
|
||||||
renderEngine.setGroups(groups ?? []);
|
renderEngine.setGroups(groups ?? []);
|
||||||
renderEngine.render();
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [groups]);
|
}, [groups]);
|
||||||
|
|
||||||
@@ -439,11 +424,10 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeLayerId, layers]);
|
}, [activeLayerId, layers]);
|
||||||
|
|
||||||
// Sync grid/snap/ortho toggles
|
// Sync grid/snap/ortho toggles (CAD-14: Dispatcher liest snap/ortho via Refs)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const renderEngine = renderEngineRef.current;
|
const renderEngine = renderEngineRef.current;
|
||||||
const interaction = interactionRef.current;
|
if (!renderEngine) return;
|
||||||
if (!renderEngine || !interaction) return;
|
|
||||||
renderEngine.setOptions({ showGrid: gridEnabled });
|
renderEngine.setOptions({ showGrid: gridEnabled });
|
||||||
renderEngine.setOptions({ showSnapPoints: snapEnabled });
|
renderEngine.setOptions({ showSnapPoints: snapEnabled });
|
||||||
renderEngine.setOptions({ showOrtho: orthoEnabled });
|
renderEngine.setOptions({ showOrtho: orthoEnabled });
|
||||||
@@ -462,26 +446,14 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
backgroundOpacity: bgConfig?.opacity ?? 0.5,
|
backgroundOpacity: bgConfig?.opacity ?? 0.5,
|
||||||
});
|
});
|
||||||
renderEngine.render();
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor, canvasBgColor, gridColor, rulerEnabled, bgConfig]);
|
}, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor, canvasBgColor, gridColor, rulerEnabled, bgConfig]);
|
||||||
|
|
||||||
// Sync active tool
|
// Sync active tool (CAD-14: ausschliesslich Dispatcher)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interaction = interactionRef.current;
|
// CAD-14 Phase 4c: Legacy-Engine entfernt — der Dispatcher ist die
|
||||||
if (!interaction) return;
|
// EINZIGE Interaktions-Engine fuer alle Werkzeuge.
|
||||||
// CAD-14: EXKLUSIVITAET — V2-Werkzeuge laufen AUSSCHLIESSLICH ueber den
|
dispatcherRef.current?.setActive(activeTool);
|
||||||
// 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);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTool]);
|
}, [activeTool]);
|
||||||
|
|
||||||
@@ -505,14 +477,6 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
return () => window.removeEventListener('keydown', onKey);
|
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
|
// Compute screen positions for remote cursors
|
||||||
const [cursorScreenPositions, setCursorScreenPositions] = useState<Array<{ cursor: UserCursor; sx: number; sy: number }>>([]);
|
const [cursorScreenPositions, setCursorScreenPositions] = useState<Array<{ cursor: UserCursor; sx: number; sy: number }>>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -572,9 +536,10 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleZoomFit = () => {
|
const handleZoomFit = () => {
|
||||||
const interaction = interactionRef.current;
|
// CAD-14 Phase 4c: zoomFit direkt ueber ZoomPan (V2-Dokument-Elemente)
|
||||||
if (interaction) {
|
const zoomPan = zoomPanRef.current;
|
||||||
interaction.zoomFit();
|
if (zoomPan) {
|
||||||
|
zoomPan.zoomFit(getActiveDocument()?.getAllElements() ?? elements);
|
||||||
}
|
}
|
||||||
onZoomFit();
|
onZoomFit();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import type { ZoomPanController } from '../canvas/ZoomPanController';
|
|||||||
|
|
||||||
export interface DispatcherDeps {
|
export interface DispatcherDeps {
|
||||||
zoomPan: ZoomPanController;
|
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). */
|
/** Liefert Optionsstände je ToolId (typisch: toolStore.optionValues). */
|
||||||
getOptions?(toolId: string): Record<string, unknown>;
|
getOptions?(toolId: string): Record<string, unknown>;
|
||||||
/** Statuszeile (typisch: StatusBar-Bridge). */
|
/** Statuszeile (typisch: StatusBar-Bridge). */
|
||||||
@@ -81,6 +89,9 @@ export class InteractionDispatcher {
|
|||||||
window.removeEventListener('pointermove', this.onMove);
|
window.removeEventListener('pointermove', this.onMove);
|
||||||
window.removeEventListener('pointerup', this.onUp);
|
window.removeEventListener('pointerup', this.onUp);
|
||||||
window.removeEventListener('keydown', this.onKey);
|
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;
|
this.bound = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +103,46 @@ export class InteractionDispatcher {
|
|||||||
window.addEventListener('pointermove', this.onMove);
|
window.addEventListener('pointermove', this.onMove);
|
||||||
window.addEventListener('pointerup', this.onUp);
|
window.addEventListener('pointerup', this.onUp);
|
||||||
window.addEventListener('keydown', this.onKey);
|
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;
|
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 {
|
private buildContext(): import('../plugins/types').FullToolContext {
|
||||||
const toolId = this.active?.manifest.id ?? '';
|
const toolId = this.active?.manifest.id ?? '';
|
||||||
return {
|
return {
|
||||||
@@ -112,26 +160,31 @@ export class InteractionDispatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onDown = (e: PointerEvent): void => {
|
private onDown = (e: PointerEvent): void => {
|
||||||
if (!this.active) return;
|
// CAD-14 Phase 4c: natives Pan bei Mittel-/Rechtsklick (auch ohne Tool)
|
||||||
// Rechtsklick = Abbruch wie ESC
|
if (e.button === 1 || e.button === 2) {
|
||||||
if (e.button === 2) {
|
this.panActive = true;
|
||||||
this.cancelActive();
|
this.lastPanScreen = { x: e.clientX, y: e.clientY };
|
||||||
return;
|
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.session.begin();
|
||||||
this.active.handlers.down?.(tpe, this.buildContext());
|
this.active.handlers.down?.(tpe, this.buildContext());
|
||||||
};
|
};
|
||||||
|
|
||||||
private onMove = (e: PointerEvent): void => {
|
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;
|
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());
|
this.active.handlers.move?.(tpe, this.buildContext());
|
||||||
};
|
};
|
||||||
|
|
||||||
private onUp = (e: PointerEvent): void => {
|
private onUp = (e: PointerEvent): void => {
|
||||||
if (!this.active || !this.session.isActive) return;
|
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 {
|
try {
|
||||||
this.active.handlers.up?.(tpe, this.buildContext());
|
this.active.handlers.up?.(tpe, this.buildContext());
|
||||||
} finally {
|
} finally {
|
||||||
@@ -149,6 +202,20 @@ export class InteractionDispatcher {
|
|||||||
if (consumed) e.preventDefault();
|
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 {
|
private cancelActive(): void {
|
||||||
if (!this.active) return;
|
if (!this.active) return;
|
||||||
this.active.handlers.cancel?.(this.buildContext());
|
this.active.handlers.cancel?.(this.buildContext());
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,16 @@
|
|||||||
*/
|
*/
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { CADElement, CADLayer, BlockDefinition, ToolType } from './cad.types';
|
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 { BackgroundConfig } from '../services/backgroundService';
|
||||||
import type { UserCursor } from '../crdt';
|
import type { UserCursor } from '../crdt';
|
||||||
import type { UnitType } from '../utils/format';
|
import type { UnitType } from '../utils/format';
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import type { ZoomPanController } from '../src/canvas/ZoomPanController';
|
|||||||
function makeFakeZoomPan(): ZoomPanController {
|
function makeFakeZoomPan(): ZoomPanController {
|
||||||
return {
|
return {
|
||||||
screenToWorld: (sx: number, sy: number) => ({ x: sx * 2, y: sy * 3 }),
|
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;
|
} as unknown as ZoomPanController;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,11 +63,12 @@ describe('InteractionDispatcher', () => {
|
|||||||
function setup(tool?: ToolExtensionV2) {
|
function setup(tool?: ToolExtensionV2) {
|
||||||
canvas = document.createElement('canvas');
|
canvas = document.createElement('canvas');
|
||||||
document.body.appendChild(canvas);
|
document.body.appendChild(canvas);
|
||||||
|
const zoomPan = makeFakeZoomPan();
|
||||||
const dispatcher = new InteractionDispatcher(canvas, {
|
const dispatcher = new InteractionDispatcher(canvas, {
|
||||||
zoomPan: makeFakeZoomPan(),
|
zoomPan,
|
||||||
toolLookup: tool ? () => tool : undefined,
|
toolLookup: tool ? () => tool : undefined,
|
||||||
});
|
});
|
||||||
return { dispatcher };
|
return { dispatcher, zoomPan };
|
||||||
}
|
}
|
||||||
|
|
||||||
it('routet down→move→up in Reihenfolge an das aktive Tool', () => {
|
it('routet down→move→up in Reihenfolge an das aktive Tool', () => {
|
||||||
@@ -103,13 +107,17 @@ describe('InteractionDispatcher', () => {
|
|||||||
expect(log).toEqual(['down', 'cancel']);
|
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 { tool, log } = makeRecordingTool();
|
||||||
const { dispatcher } = setup(tool);
|
const { dispatcher, zoomPan } = setup(tool);
|
||||||
dispatcher.setActive('fake');
|
dispatcher.setActive('fake');
|
||||||
|
|
||||||
fire(canvas, 'pointerdown', { button: 2 });
|
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', () => {
|
it('setActive(null) deaktiviert Routing komplett', () => {
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user