task(CAD-14): phase 3 - V2 select tool on v2 doc (click via bridge hitTest, shift-additive, box-select by center-in-bbox, onChange app channel via ref), bridge onChange wired

This commit is contained in:
Agent Zero
2026-09-16 22:03:15 +02:00
parent 180aa9b03a
commit 1d129042d4
4 changed files with 197 additions and 5 deletions
+6
View File
@@ -46,6 +46,9 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
/** CAD-14: Frische Prop-Referenz gegen Stale-Closure (Dispatcher/onChanged sind einmalig registriert). */ /** CAD-14: Frische Prop-Referenz gegen Stale-Closure (Dispatcher/onChanged sind einmalig registriert). */
const onElementCreatedRef = useRef(onElementCreated); const onElementCreatedRef = useRef(onElementCreated);
onElementCreatedRef.current = onElementCreated; onElementCreatedRef.current = onElementCreated;
/** CAD-14: Frische Prop-Referenz für Selection-Änderungen aus V2-Werkzeugen. */
const onSelectionChangeRef = useRef(onSelectionChange);
onSelectionChangeRef.current = onSelectionChange;
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);
@@ -170,6 +173,9 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
getIds: () => Array.from(selectionEngine.getSelectedIds()), getIds: () => Array.from(selectionEngine.getSelectedIds()),
setIds: (ids) => selectionEngine.selectByIds(ids, false), setIds: (ids) => selectionEngine.selectByIds(ids, false),
hitTest: (wx, wy, tol) => renderEngine.hitTest(wx, wy, tol), hitTest: (wx, wy, tol) => renderEngine.hitTest(wx, wy, tol),
// CAD-14: App-Kanal fuer V2-Werkzeuge (selectTool) — via Ref gegen
// Stale-Closure, derselbe Kanal wie die Legacy-Engine nutzt.
onChange: (ids) => onSelectionChangeRef.current?.(ids),
}), }),
}); });
@@ -1022,15 +1022,99 @@ export const mlineTool: ToolExtensionV2 = {
}, },
}; };
let boxStart: Pt | null = null;
export const selectTool: ToolExtensionV2 = {
manifest: {
id: 'select',
label: 'Auswahl',
icon: '\u2196',
ribbonTab: 'canvas',
tags: ['basic'],
description: 'Auswahl: Klick wählt, Drag = Box-Select, Shift additiv (CAD-14: direkt auf V2-Dokument)',
},
optionsSchema: [],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
const sel = c.selection;
const hit = sel?.hitTest(e.world.x, e.world.y, 5) ?? null;
if (hit) {
if (e.shift) {
// additiv: bestehende IDs + neue ID
const current = sel?.getIds() ?? [];
const next = current.includes(hit.id) ? current.filter((id) => id !== hit.id) : [...current, hit.id];
sel?.setIds(next);
} else {
sel?.setIds([hit.id]);
}
sel?.onChange?.(sel?.getIds());
c.setStatus(`Ausgewählt: ${hit.id}`);
boxStart = null;
} else {
if (!e.shift) sel?.setIds([]);
sel?.onChange?.(sel?.getIds() ?? []);
boxStart = e.world;
c.setStatus('Box-Select: aufziehen');
}
},
move(e, ctx) {
if (!boxStart) return;
const c = ctx as FullToolContext;
// Live-Vorschau der Box als gestricheltes Rechteck
ctx.setPreview?.({
id: '__boxselect__', type: 'rect', layerId: '',
x: (boxStart.x + e.world.x) / 2,
y: (boxStart.y + e.world.y) / 2,
width: Math.abs(e.world.x - boxStart.x),
height: Math.abs(e.world.y - boxStart.y),
properties: { stroke: '#00aaff', strokeWidth: 1, visible: true },
} as unknown as CADElement);
},
up(e, ctx) {
const c = ctx as FullToolContext;
if (!boxStart) return;
const start = boxStart;
boxStart = null;
ctx.setPreview?.(null);
// War es ein Drag (Box) oder nur ein Klick?
const dx = Math.abs(e.world.x - start.x);
const dy = Math.abs(e.world.y - start.y);
if (dx < 3 && dy < 3) return; // Klick bereits in down behandelt
const minX = Math.min(start.x, e.world.x);
const maxX = Math.max(start.x, e.world.x);
const minY = Math.min(start.y, e.world.y);
const maxY = Math.max(start.y, e.world.y);
const all = c.doc?.getAllElements() ?? [];
const hits = all.filter((el) => el.x >= minX && el.x <= maxX && el.y >= minY && el.y <= maxY).map((el) => el.id);
const sel = c.selection;
if (e.shift) {
const current = sel?.getIds() ?? [];
const merged = Array.from(new Set([...current, ...hits]));
sel?.setIds(merged);
} else {
sel?.setIds(hits);
}
sel?.onChange?.(sel?.getIds() ?? []);
c.setStatus(`Box-Select: ${hits.length} Element(e)`);
},
cancel(ctx) {
boxStart = null;
ctx.setPreview?.(null);
ctx.setStatus('Auswahl abgebrochen');
},
},
};
export const coreDrawingPlugin: PluginV2 = { export const coreDrawingPlugin: PluginV2 = {
manifest: { manifest: {
id: 'core-drawing', id: 'core-drawing',
name: 'Zeichnen (Kern)', name: 'Zeichnen (Kern)',
version: '1.5.0', version: '1.6.0',
author: 'web-cad team', author: 'web-cad team',
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.', description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud, Auswahl.',
category: 'tools', category: 'tools',
enabledByDefault: true, enabledByDefault: true,
}, },
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool, mlineTool], tools: [selectTool, lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool, mlineTool],
}; };
+4 -2
View File
@@ -157,10 +157,12 @@ export interface ToolContext {
export interface ToolSelectionBridge { export interface ToolSelectionBridge {
/** Aktuell selektierte Element-IDs. */ /** Aktuell selektierte Element-IDs. */
getIds(): string[]; getIds(): string[];
/** Setzt die Selektion vollständig. */ /** Setzt die Selektion vollständig (additive = zur bestehenden hinzufügen). */
setIds(ids: string[]): void; setIds(ids: string[], additive?: boolean): void;
/** Element-Treffer an Weltposition (oder null). */ /** Element-Treffer an Weltposition (oder null). */
hitTest(worldX: number, worldY: number, tolerancePx: number): CADElement | null; hitTest(worldX: number, worldY: number, tolerancePx: number): CADElement | null;
/** CAD-14: Wird bei jeder Selektionsänderung durch V2-Werkzeuge aufgerufen. */
onChange?(ids: string[]): void;
} }
/** Erweiterter Kontext für Tools mit Dokument-/Selektionszugriff (A3). */ /** Erweiterter Kontext für Tools mit Dokument-/Selektionszugriff (A3). */
+100
View File
@@ -0,0 +1,100 @@
/**
* CAD-14 Phase 3: selectTool V2 — Auswahl direkt auf dem V2-Dokument.
* Klick = hitTest via SelectionBridge, Leerklick leert, Shift additiv,
* Drag = Box-Select (Elemente mit Zentrum in BBox).
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { selectTool } from '../src/plugins/builtin/core-drawing';
import { CADDocument } from '../src/kernel/document/CADDocument';
import { YjsDocument } from '../src/crdt/YjsDocument';
import type { CADElement, Pt } from '../src/types/cad.types';
function pe(x: number, y: number, shift = false): { world: Pt; shift: boolean } {
return { world: { x, y }, shift } as never;
}
function makeCtx(ids: string[], hit: CADElement | null) {
const selected: string[][] = [];
const onChangeCalls: string[][] = [];
const realDoc = new CADDocument(new YjsDocument());
let currentIds = [...ids];
const ctx: any = {
options: {},
selection: {
getIds: () => currentIds,
setIds: (next: string[], additive?: boolean) => {
currentIds = additive ? [...currentIds, ...next] : next;
selected.push(currentIds);
},
hitTest: (_x: number, _y: number, _tol: number) => hit,
onChange: (ids2: string[]) => onChangeCalls.push(ids2),
},
doc: realDoc,
setStatus: () => {},
setPreview: () => {},
};
return { ctx, selected, onChangeCalls, realDoc, getCurrentIds: () => currentIds };
}
function lineEl(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
return {
id, type: 'line', layerId: 'l', x: (x1 + x2) / 2, y: (y1 + y2) / 2,
width: Math.abs(x2 - x1), height: Math.abs(y2 - y1),
properties: { x1, y1, x2, y2 },
} as unknown as CADElement;
}
describe('CAD-14 Phase 3: selectTool V2', () => {
beforeEach(() => {
selectTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
});
it('Manifest: id=select, basic-tag (immer sichtbar)', () => {
expect(selectTool.manifest.id).toBe('select');
expect((selectTool.manifest.tags ?? []).includes('basic')).toBe(true);
});
it('Klick auf Element: hitTest-Treffer wird selektiert (setIds mit ID)', () => {
const el = lineEl('e1', 0, 0, 100, 100);
const { ctx, selected } = makeCtx([], el);
selectTool.handlers.down!(pe(50, 50), ctx);
expect(selected.length).toBe(1);
expect(selected[0]).toEqual(['e1']);
});
it('Leerklick (hitTest null): Auswahl geleert (setIds mit [])', () => {
const { ctx, selected } = makeCtx(['e9'], null);
selectTool.handlers.down!(pe(500, 500), ctx);
expect(selected.length).toBe(1);
expect(selected[0]).toEqual([]);
});
it('Shift-Klick additiv: setIds mit additive=true', () => {
const el = lineEl('e1', 0, 0, 100, 100);
const { ctx, selected } = makeCtx(['e9'], el);
selectTool.handlers.down!(pe(50, 50, true), ctx);
expect(selected.length).toBe(1);
// additive-Variante: bestaehige IDs + neue ID
expect(selected[0]).toEqual(['e9', 'e1']);
});
it('Box-Select: Element mit Zentrum in BBox wird selektiert', () => {
const { ctx, realDoc, getCurrentIds } = makeCtx([], null);
const el = lineEl('e1', 0, 0, 100, 100);
realDoc.addElement(el);
// down in leerer Gegend startet Box (Leerklick leert zuerst — korrekt),
// move zeigt Vorschau, up schliesst BBox um Element.
selectTool.handlers.down!(pe(-10, -10), ctx);
selectTool.handlers.move!(pe(200, 200), ctx);
selectTool.handlers.up!(pe(200, 200), ctx);
expect(getCurrentIds()).toEqual(['e1']);
});
it('onChange feuert nach Klick-Auswahl (App-Kanal)', () => {
const el = lineEl('e1', 0, 0, 100, 100);
const { ctx, onChangeCalls } = makeCtx([], el);
selectTool.handlers.down!(pe(50, 50), ctx);
expect(onChangeCalls.length).toBe(1);
expect(onChangeCalls[0]).toEqual(['e1']);
});
});