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). */
|
/** 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],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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