From 1d129042d45f1808bd91a901ef4b7a628ef85ea1 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 16 Sep 2026 22:03:15 +0200 Subject: [PATCH] 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 --- frontend/src/components/CanvasArea.tsx | 6 ++ .../src/plugins/builtin/core-drawing/index.ts | 90 +++++++++++++++- frontend/src/plugins/types.ts | 6 +- frontend/tests/selectToolV2.test.ts | 100 ++++++++++++++++++ 4 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 frontend/tests/selectToolV2.test.ts diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index e82be09..8589b56 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -46,6 +46,9 @@ const CanvasArea: React.FC = ({ /** CAD-14: Frische Prop-Referenz gegen Stale-Closure (Dispatcher/onChanged sind einmalig registriert). */ const onElementCreatedRef = useRef(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(null); const layerManagerRef = useRef(null); const selectionEngineRef = useRef(null); @@ -170,6 +173,9 @@ const CanvasArea: React.FC = ({ getIds: () => Array.from(selectionEngine.getSelectedIds()), setIds: (ids) => selectionEngine.selectByIds(ids, false), 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), }), }); diff --git a/frontend/src/plugins/builtin/core-drawing/index.ts b/frontend/src/plugins/builtin/core-drawing/index.ts index 2c48c5b..1245b81 100644 --- a/frontend/src/plugins/builtin/core-drawing/index.ts +++ b/frontend/src/plugins/builtin/core-drawing/index.ts @@ -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 = { manifest: { id: 'core-drawing', name: 'Zeichnen (Kern)', - version: '1.5.0', + version: '1.6.0', 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', 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], }; diff --git a/frontend/src/plugins/types.ts b/frontend/src/plugins/types.ts index 8deb69c..ca9091b 100644 --- a/frontend/src/plugins/types.ts +++ b/frontend/src/plugins/types.ts @@ -157,10 +157,12 @@ export interface ToolContext { export interface ToolSelectionBridge { /** Aktuell selektierte Element-IDs. */ getIds(): string[]; - /** Setzt die Selektion vollständig. */ - setIds(ids: string[]): void; + /** Setzt die Selektion vollständig (additive = zur bestehenden hinzufügen). */ + setIds(ids: string[], additive?: boolean): void; /** Element-Treffer an Weltposition (oder 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). */ diff --git a/frontend/tests/selectToolV2.test.ts b/frontend/tests/selectToolV2.test.ts new file mode 100644 index 0000000..8986944 --- /dev/null +++ b/frontend/tests/selectToolV2.test.ts @@ -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']); + }); +});