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:
@@ -46,6 +46,9 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
/** 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<SpatialIndex | null>(null);
|
||||
const layerManagerRef = useRef<LayerManager | null>(null);
|
||||
const selectionEngineRef = useRef<SelectionEngine | null>(null);
|
||||
@@ -170,6 +173,9 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
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),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user