diff --git a/frontend/src/interaction/dispatcher.ts b/frontend/src/interaction/dispatcher.ts index 5860f7a..1c3bc05 100644 --- a/frontend/src/interaction/dispatcher.ts +++ b/frontend/src/interaction/dispatcher.ts @@ -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?.(), }; } diff --git a/frontend/src/plugins/builtin/core-drawing/index.ts b/frontend/src/plugins/builtin/core-drawing/index.ts new file mode 100644 index 0000000..b94538d --- /dev/null +++ b/frontend/src/plugins/builtin/core-drawing/index.ts @@ -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], +}; diff --git a/frontend/src/plugins/builtin/core-modify/index.ts b/frontend/src/plugins/builtin/core-modify/index.ts new file mode 100644 index 0000000..126db53 --- /dev/null +++ b/frontend/src/plugins/builtin/core-modify/index.ts @@ -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], +}; diff --git a/frontend/src/plugins/index.ts b/frontend/src/plugins/index.ts index 31712a2..f5787b8 100644 --- a/frontend/src/plugins/index.ts +++ b/frontend/src/plugins/index.ts @@ -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); } diff --git a/frontend/src/plugins/types.ts b/frontend/src/plugins/types.ts index a692aa1..f6e7139 100644 --- a/frontend/src/plugins/types.ts +++ b/frontend/src/plugins/types.ts @@ -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 }; diff --git a/frontend/tests/pilotTools.test.ts b/frontend/tests/pilotTools.test.ts new file mode 100644 index 0000000..c4d9bb4 --- /dev/null +++ b/frontend/tests/pilotTools.test.ts @@ -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> = {}): 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 & { 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'); + }); +});