task(A3): pilot v2 tools select+line
This commit is contained in:
@@ -25,6 +25,10 @@ export interface DispatcherDeps {
|
||||
setPreview?(el: unknown | null): void;
|
||||
/** Tool-Nachschlag überschreibbar (Default: aktive Registry-Tools). */
|
||||
toolLookup?(toolId: string): ToolExtensionV2 | undefined;
|
||||
/** Dokument-Fassade für Tools (ab A3; lazy pro Event abgefragt). */
|
||||
getDoc?(): import('../kernel/document/CADDocument').CADDocument | undefined;
|
||||
/** Selektions-Bridge für Tools (ab A3; lazy pro Event abgefragt). */
|
||||
getSelectionBridge?(): import('../plugins/types').ToolSelectionBridge;
|
||||
}
|
||||
|
||||
export class InteractionDispatcher {
|
||||
@@ -91,12 +95,14 @@ export class InteractionDispatcher {
|
||||
this.bound = true;
|
||||
}
|
||||
|
||||
private buildContext(): ToolContext {
|
||||
private buildContext(): import('../plugins/types').FullToolContext {
|
||||
const toolId = this.active?.manifest.id ?? '';
|
||||
return {
|
||||
options: this.deps.getOptions?.(toolId) ?? {},
|
||||
setStatus: (msg) => this.setStatus(msg),
|
||||
setPreview: (el) => this.deps.setPreview?.(el),
|
||||
doc: this.deps.getDoc?.(),
|
||||
selection: this.deps.getSelectionBridge?.(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* core-drawing – Built-in V2-Plugin (Task A3 Pilot).
|
||||
*
|
||||
* Werkzeuge: line (Migrations-Pilot aus dem Legacy-Switch).
|
||||
* Elemente werden über CADDocument committed → kollaborativ korrektes Undo.
|
||||
*
|
||||
* Sitzungszustand (Startpunkt der aktuellen Linie) lebt als Modul-Member,
|
||||
* NICHT in options: Options sind geteilter UI-Store-Zustand.
|
||||
*/
|
||||
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
||||
import type { CADElement } from '../../../types/cad.types';
|
||||
import type { Pt } from '../../../tools/modification/geometry';
|
||||
|
||||
const uid = (prefix: string): string =>
|
||||
`${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
/** Startpunkt der laufenden Linie (nur innerhalb einer down→up-Sitzung gesetzt). */
|
||||
let lineStart: Pt | null = null;
|
||||
|
||||
function buildLine(start: Pt, end: Pt, ctx: FullToolContext): CADElement {
|
||||
const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0';
|
||||
const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0';
|
||||
return {
|
||||
id: uid('line'),
|
||||
type: 'line',
|
||||
layerId,
|
||||
x: (start.x + end.x) / 2,
|
||||
y: (start.y + end.y) / 2,
|
||||
width: Math.abs(end.x - start.x),
|
||||
height: Math.abs(end.y - start.y),
|
||||
properties: { x1: start.x, y1: start.y, x2: end.x, y2: end.y, stroke, strokeWidth: 1 },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* line-Werkzeug: down setzt Startpunkt, move zeigt Live-Vorschau,
|
||||
* up committet via doc.transact (1 Linie = 1 Undo-Schritt).
|
||||
*/
|
||||
export const lineTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'line',
|
||||
label: 'Linie',
|
||||
icon: '\u2571',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['basic'],
|
||||
description: 'Zeichnet eine Linie (Klick Start, Klick Ende)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
lineStart = e.world;
|
||||
ctx.setStatus(`Linie ab (${lineStart.x.toFixed(0)}, ${lineStart.y.toFixed(0)}) – Ende wählen`);
|
||||
},
|
||||
move(e, ctx) {
|
||||
if (!lineStart) return;
|
||||
ctx.setPreview?.(buildLine(lineStart, e.world, ctx));
|
||||
},
|
||||
up(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!lineStart || !c.doc) {
|
||||
lineStart = null;
|
||||
return;
|
||||
}
|
||||
const el = buildLine(lineStart, e.world, c);
|
||||
c.doc.transact(() => {
|
||||
// addElement öffnet inneres transact — Yjs verschachtelt sie → EIN Undo-Schritt
|
||||
c.doc!.addElement(el);
|
||||
});
|
||||
lineStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus(`Linie erstellt (${el.id})`);
|
||||
},
|
||||
cancel(ctx) {
|
||||
lineStart = null;
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Linie abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** V2-Plugin: Kern-Zeichenwerkzeuge (Pilot: nur line). */
|
||||
export const coreDrawingPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'core-drawing',
|
||||
name: 'Zeichnen (Kern)',
|
||||
version: '1.0.0',
|
||||
author: 'web-cad team',
|
||||
description: 'Grundlegende Zeichenwerkzeuge (V2-Pilot: Linie).',
|
||||
category: 'tools',
|
||||
enabledByDefault: true,
|
||||
},
|
||||
tools: [lineTool],
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* core-modify – Built-in V2-Plugin (Task A3 Pilot).
|
||||
*
|
||||
* Werkzeuge: select (Pilotportierung aus Legacy handleSelectDown).
|
||||
* Pilot-Scope: Klick-Auswahl inkl. Group-Bewusstheit via SelectionBridge;
|
||||
* Marquee/Box-Auswahl Migration folgt in A4 (Legacy bleibt parallel aktiv).
|
||||
*/
|
||||
import type { PluginV2, ToolExtensionV2 } from '../../types';
|
||||
|
||||
/**
|
||||
* select-Werkzeug: Klick wählt Element direkt (Shift=additiv, Ctrl=subtraktiv).
|
||||
*/
|
||||
export const selectTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'select',
|
||||
label: 'Auswahl',
|
||||
icon: '\u2196',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['basic'],
|
||||
description: 'Elemente anklicken zum Auswählen',
|
||||
},
|
||||
optionsSchema: [],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const bridge = (ctx as import('../../types').FullToolContext).selection;
|
||||
if (!bridge) return;
|
||||
|
||||
const additive = e.shift;
|
||||
const subtractive = e.ctrl;
|
||||
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
|
||||
|
||||
if (!hit) {
|
||||
// Leerer Klick: Selektion leeren (außer additiv)
|
||||
if (!additive && !subtractive) bridge.setIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let ids = new Set(bridge.getIds());
|
||||
if (subtractive) {
|
||||
ids.delete(hit.id);
|
||||
} else {
|
||||
ids.add(hit.id);
|
||||
if (!additive) ids = new Set([hit.id]); // ohne Shift: nur das getroffene
|
||||
}
|
||||
bridge.setIds(Array.from(ids));
|
||||
ctx.setStatus(`Selektion: ${ids.size} Element(e)`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** V2-Plugin: Kern-Bearbeitungswerkzeuge (Pilot: nur select). */
|
||||
export const coreModifyPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'core-modify',
|
||||
name: 'Bearbeiten (Kern)',
|
||||
version: '1.0.0',
|
||||
author: 'web-cad team',
|
||||
description: 'Auswahl und Bearbeitung (V2-Pilot: Selektion).',
|
||||
category: 'tools',
|
||||
enabledByDefault: true,
|
||||
},
|
||||
tools: [selectTool],
|
||||
};
|
||||
@@ -27,11 +27,18 @@ export type {
|
||||
|
||||
// Built-in plugins
|
||||
export { eventToolsPlugin } from './builtin/eventTools';
|
||||
export { coreDrawingPlugin } from './builtin/core-drawing';
|
||||
export { coreModifyPlugin } from './builtin/core-modify';
|
||||
|
||||
import { pluginRegistry } from './PluginRegistry';
|
||||
import { eventToolsPlugin } from './builtin/eventTools';
|
||||
import { coreDrawingPlugin } from './builtin/core-drawing';
|
||||
import { coreModifyPlugin } from './builtin/core-modify';
|
||||
|
||||
/** Register all built-in plugins */
|
||||
export function registerBuiltinPlugins() {
|
||||
pluginRegistry.register(eventToolsPlugin);
|
||||
// API v2 (Task A3):
|
||||
pluginRegistry.registerV2(coreDrawingPlugin);
|
||||
pluginRegistry.registerV2(coreModifyPlugin);
|
||||
}
|
||||
|
||||
@@ -153,6 +153,24 @@ export interface ToolContext {
|
||||
setPreview?(el: CADElement | null): void;
|
||||
}
|
||||
|
||||
/** Selektions-/Treffer-Bridge für Werkzeuge (ab A3, von CanvasArea injiziert). */
|
||||
export interface ToolSelectionBridge {
|
||||
/** Aktuell selektierte Element-IDs. */
|
||||
getIds(): string[];
|
||||
/** Setzt die Selektion vollständig. */
|
||||
setIds(ids: string[]): void;
|
||||
/** Element-Treffer an Weltposition (oder null). */
|
||||
hitTest(worldX: number, worldY: number, tolerancePx: number): CADElement | null;
|
||||
}
|
||||
|
||||
/** Erweiterter Kontext für Tools mit Dokument-/Selektionszugriff (A3). */
|
||||
export interface FullToolContext extends ToolContext {
|
||||
/** Dokumentzugriff (transact/add/update/delete). */
|
||||
doc?: import('../kernel/document/CADDocument').CADDocument;
|
||||
/** Selektion + HitTest-Bridge. */
|
||||
selection?: ToolSelectionBridge;
|
||||
}
|
||||
|
||||
/** V2-Werkzeug mit eigenen Pointer-Handlern (ersetzt langfristig ToolExtension). */
|
||||
export interface ToolExtensionV2 {
|
||||
manifest: ToolManifestPart & { description?: string };
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Pilot V2-Tools Tests (Task A3): line + select.
|
||||
* line: Handler-Sequenz gegen echtes CADDocument (In-Memory), Undo-Verhalten.
|
||||
* select: Bridge-basierte Auswahl mit Shift/Ctrl.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { lineTool, coreDrawingPlugin } from '../src/plugins/builtin/core-drawing';
|
||||
import { selectTool, coreModifyPlugin } from '../src/plugins/builtin/core-modify';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { ToolPointerEvent, FullToolContext } from '../src/plugins/types';
|
||||
import type { CADElement } from '../src/types/cad.types';
|
||||
|
||||
function pe(x: number, y: number, mods: Partial<Pick<ToolPointerEvent, 'shift' | 'ctrl'>> = {}): ToolPointerEvent {
|
||||
return {
|
||||
world: { x, y },
|
||||
screen: { x: x / 2, y: y / 2 },
|
||||
button: 0,
|
||||
shift: mods.shift ?? false,
|
||||
alt: false,
|
||||
ctrl: mods.ctrl ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(overrides: Partial<FullToolContext> = {}): FullToolContext & { previews: (CADElement | null)[]; statuses: string[] } {
|
||||
const previews: (CADElement | null)[] = [];
|
||||
const statuses: string[] = [];
|
||||
return {
|
||||
options: {},
|
||||
setStatus: (m) => statuses.push(m),
|
||||
setPreview: (el) => previews.push(el),
|
||||
previews,
|
||||
statuses,
|
||||
...overrides,
|
||||
} as FullToolContext & { previews: (CADElement | null)[]; statuses: string[] };
|
||||
}
|
||||
|
||||
function makeEl(id: string): CADElement {
|
||||
return { id, type: 'rect', layerId: 'l', x: 0, y: 0, width: 1, height: 1, properties: {} } as unknown as CADElement;
|
||||
}
|
||||
|
||||
describe('A3 pilot: line tool', () => {
|
||||
it('erzeugt EINE Linie per down→up; ein undo entfernt sie', () => {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
const ctx = makeCtx({ doc });
|
||||
|
||||
lineTool.handlers.down!(pe(0, 0), ctx);
|
||||
lineTool.handlers.move!(pe(100, 50), ctx);
|
||||
expect(ctx.previews.length).toBe(1); // Live-Vorschau war da
|
||||
lineTool.handlers.up!(pe(100, 50), ctx);
|
||||
|
||||
const all = doc.getAllElements();
|
||||
expect(all.length).toBe(1);
|
||||
expect(all[0].type).toBe('line');
|
||||
expect(all[0].properties.x1).toBe(0);
|
||||
expect(all[0].properties.y2).toBe(50);
|
||||
|
||||
// Kern-Akzeptanz aus ROADMAP: Ein Werkzeug-Klick = ein Undo-Schritt
|
||||
expect(doc.undo()).toBe(true);
|
||||
expect(doc.getAllElements().length).toBe(0);
|
||||
});
|
||||
|
||||
it('cancel verwirft die laufende Linie ohne Element', () => {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
const ctx = makeCtx({ doc });
|
||||
|
||||
lineTool.handlers.down!(pe(5, 5), ctx);
|
||||
lineTool.handlers.cancel!(ctx);
|
||||
lineTool.handlers.up!(pe(9, 9), ctx); // up nach cancel: kein Start mehr
|
||||
|
||||
expect(doc.getAllElements().length).toBe(0);
|
||||
});
|
||||
|
||||
it('Plugin-Manifest ist registrierbar und tagged basic', () => {
|
||||
expect(coreDrawingPlugin.manifest.enabledByDefault).toBe(true);
|
||||
const line = coreDrawingPlugin.tools?.find((t) => t.manifest.id === 'line');
|
||||
expect(line?.manifest.tags).toContain('basic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('A3 pilot: select tool', () => {
|
||||
function makeBridge(elements: CADElement[]) {
|
||||
let ids: string[] = [];
|
||||
return {
|
||||
bridge: {
|
||||
getIds: () => ids,
|
||||
setIds: (next: string[]) => {
|
||||
ids = next;
|
||||
},
|
||||
hitTest: (wx: number, wy: number, tol: number): CADElement | null =>
|
||||
elements.find((el) => Math.abs(el.x - wx) <= tol && Math.abs(el.y - wy) <= tol) ?? null,
|
||||
},
|
||||
getIds: () => ids,
|
||||
};
|
||||
}
|
||||
|
||||
it('wählt getroffenes Element ohne Modifier exklusiv', () => {
|
||||
const els = [makeEl('a'), makeEl('b')];
|
||||
els[0].x = 10; els[0].y = 10;
|
||||
els[1].x = 500; els[1].y = 500;
|
||||
const { bridge, getIds } = makeBridge(els);
|
||||
const ctx = makeCtx({ selection: bridge });
|
||||
|
||||
selectTool.handlers.down!(pe(10, 10), ctx);
|
||||
expect(getIds()).toEqual(['a']);
|
||||
|
||||
// Klick auf b ohne Shift ersetzt Selektion
|
||||
selectTool.handlers.down!(pe(500, 500), ctx);
|
||||
expect(getIds()).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('Shift addiert, Ctrl subtrahiert', () => {
|
||||
const els = [makeEl('a'), makeEl('b'), makeEl('c')];
|
||||
els[0].x = 10; els[0].y = 10;
|
||||
els[1].x = 50; els[1].y = 50;
|
||||
els[2].x = 90; els[2].y = 90;
|
||||
const { bridge, getIds } = makeBridge(els);
|
||||
const ctx = makeCtx({ selection: bridge });
|
||||
|
||||
selectTool.handlers.down!(pe(10, 10), ctx);
|
||||
expect(getIds()).toEqual(['a']);
|
||||
|
||||
selectTool.handlers.down!(pe(50, 50, { shift: true }), ctx);
|
||||
expect(getIds().sort()).toEqual(['a', 'b']);
|
||||
|
||||
selectTool.handlers.down!(pe(90, 90, { shift: true }), ctx);
|
||||
expect(getIds().sort()).toEqual(['a', 'b', 'c']);
|
||||
|
||||
selectTool.handlers.down!(pe(50, 50, { ctrl: true }), ctx);
|
||||
expect(getIds().sort()).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('Leerklick ohne Modifier leert die Selektion; Plugin tagged basic', () => {
|
||||
const els = [makeEl('a')];
|
||||
els[0].x = 10; els[0].y = 10;
|
||||
const { bridge, getIds } = makeBridge(els);
|
||||
const ctx = makeCtx({ selection: bridge });
|
||||
|
||||
selectTool.handlers.down!(pe(10, 10), ctx);
|
||||
expect(getIds()).toEqual(['a']);
|
||||
selectTool.handlers.down!(pe(999, 999), ctx);
|
||||
expect(getIds()).toEqual([]);
|
||||
|
||||
expect(coreModifyPlugin.manifest.enabledByDefault).toBe(true);
|
||||
const sel = coreModifyPlugin.tools?.find((t) => t.manifest.id === 'select');
|
||||
expect(sel?.manifest.tags).toContain('basic');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user