diff --git a/frontend/src/plugins/builtin/core-modify/index.ts b/frontend/src/plugins/builtin/core-modify/index.ts index 863ce8f..7255047 100644 --- a/frontend/src/plugins/builtin/core-modify/index.ts +++ b/frontend/src/plugins/builtin/core-modify/index.ts @@ -6,6 +6,7 @@ * Marquee/Box-Auswahl Migration folgt in A4 (Legacy bleibt parallel aktiv). */ import type { PluginV2, ToolExtensionV2 } from '../../types'; +import type { CADElement } from '../../../types/cad.types'; /** * select-Werkzeug: Klick wählt Element direkt (Shift=additiv, Ctrl=subtraktiv). @@ -85,16 +86,98 @@ export const hatchTool: ToolExtensionV2 = { }, }; -/** V2-Plugin: Kern-Bearbeitungswerkzeuge (Pilot: select; A4.5: hatch). */ +/** + * Move/Copy-Basispunkt (A4.6): erster down setzt ihn, zweiter down committet. + * Gemeinsam für beide Tools (klick-progressiv wie arc-Lektion). + */ +let modBasePoint: import('../../../tools/modification/geometry').Pt | null = null; + +/** + * Fabrik für move/copy-Werkzeuge (A4.6). + * Wirkt auf die AKTUELLE SELEKTION (SelectionBridge): + * - Klick 1: Basispunkt setzen + * - Klick 2: Commit — move: updateElement(x±dx,y±dy); copy: addElement der + * versetzten Kopien (neue IDs). Alles in EINER Transaktion = 1 Undo-Schritt. + * Legacy-Konvention: Versatzvektor = zweiterKlick − basis. + */ +function makeMoveCopyTool(mode: 'move' | 'copy'): ToolExtensionV2 { + const label = mode === 'move' ? 'Verschieben' : 'Kopieren'; + return { + manifest: { + id: mode, + label, + icon: mode === 'move' ? '\u27a4' : '\u29c9', + ribbonTab: 'canvas', + tags: ['basic'], + description: `${label}: Auswahl treffen, dann Basis→Ziel klicken`, + }, + optionsSchema: [], + handlers: { + down(e, ctx) { + const c = ctx as import('../../types').FullToolContext; + if (!c.doc || !c.selection) return; + + if (!modBasePoint) { + const ids = c.selection.getIds(); + if (ids.length === 0) { + // Komfort: nichts selektiert → hitTest und sofort auswählen + const hit = c.selection.hitTest(e.world.x, e.world.y, 5); + if (!hit) { + ctx.setStatus(`${label}: zuerst Element(e) auswählen`); + return; + } + c.selection.setIds([hit.id]); + } + modBasePoint = e.world; + ctx.setStatus(`${label}: Zielklick zum ${mode === 'move' ? 'Verschieben' : 'Kopieren'} (${c.selection.getIds().length} Elemente)`); + return; + } + + // Zweiter Klick → Commit + const dx = e.world.x - modBasePoint.x; + const dy = e.world.y - modBasePoint.y; + const ids = c.selection.getIds(); + c.doc.transact(() => { + for (const id of ids) { + const el = c.doc!.getElement(id); + if (!el) continue; + if (mode === 'move') { + c.doc!.updateElement(id, { x: el.x + dx, y: el.y + dy }); + } else { + const copy: CADElement = { + ...el, + id: `cp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}_${ids.indexOf(id)}`, + x: el.x + dx, + y: el.y + dy, + }; + c.doc!.addElement(copy); + } + } + }); + ctx.setStatus(`${label}: ${ids.length} Element(e), \u0394(${dx.toFixed(0)}, ${dy.toFixed(0)})`); + modBasePoint = null; + }, + cancel(ctx) { + modBasePoint = null; + ctx.setStatus(`${label} abgebrochen`); + }, + }, + }; +} + +export const moveTool: ToolExtensionV2 = makeMoveCopyTool('move'); +export const copyTool: ToolExtensionV2 = makeMoveCopyTool('copy'); + +/** V2-Plugin: Kern-Bearbeitungswerkzeuge (select, hatch; A4.6: move/copy). */ export const coreModifyPlugin: PluginV2 = { manifest: { id: 'core-modify', name: 'Bearbeiten (Kern)', - version: '1.1.0', + version: '1.2.0', author: 'web-cad team', - description: 'Auswahl und Bearbeitung (V2: Selektion, Schraffur).', + description: 'Auswahl, Schraffur, Verschieben, Kopieren (V2).', category: 'tools', enabledByDefault: true, }, - tools: [selectTool, hatchTool], + tools: [selectTool, hatchTool, moveTool, copyTool], }; diff --git a/frontend/tests/pilotTools.test.ts b/frontend/tests/pilotTools.test.ts index 52cf687..a0158a4 100644 --- a/frontend/tests/pilotTools.test.ts +++ b/frontend/tests/pilotTools.test.ts @@ -348,6 +348,91 @@ describe('A4.5 pilot: hatch tool', () => { }); }); +describe('A4.6 pilot: move/copy tools', () => { + it('move verschiebt selektierte Elemente in EINER Transaktion (undo entfernt Versatz)', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + doc.addElement(makeEl('m1')); + doc.addElement(makeEl('m2')); + let selIds = ['m1', 'm2']; + const bridge = { + getIds: () => selIds, + setIds: (ids: string[]) => { + selIds = ids; + }, + hitTest: () => null, + }; + const ctx = makeCtx({ doc, selection: bridge }); + const move = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'move')!; + const origM1x = doc.getElement('m1')!.x; + const origY = doc.getElement('m1')!.y; + + move.handlers.down!(pe(0, 0), ctx); // Basispunkt + move.handlers.down!(pe(50, -20), ctx); // Zielklick → Commit + + expect(doc.getElement('m1')!.x).toBe(origM1x + 50); + expect(doc.getElement('m2')!.y).toBe(origY - 20); + // EINE Transaktion für beide → ein undo stellt beide wieder her: + expect(doc.undo()).toBe(true); + expect(doc.getElement('m1')!.x).toBe(origM1x); + expect(doc.getElement('m2')!.y).toBe(origY); + }); + + it('copy erzeugt neue Elemente (IDs neu) ohne Original zu veraendern', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + doc.addElement(makeEl('o1')); + let selIds = ['o1']; + const bridge = { + getIds: () => selIds, + setIds: (ids: string[]) => { + selIds = ids; + }, + hitTest: () => null, + }; + const ctx = makeCtx({ doc, selection: bridge }); + const copy = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'copy')!; + + copy.handlers.down!(pe(0, 0), ctx); + copy.handlers.down!(pe(100, 100), ctx); + + const all = doc.getAllElements(); + expect(all.length).toBe(2); // Original + Kopie + expect(all[0].id).toBe('o1'); // Original unberührt + expect(all[0].x).not.toBe(all[1].x); // Kopie versetzt + expect(all[1].id).not.toBe('o1'); // neue ID + expect(all[1].type).toBe(all[0].type); // gleicher Typ + + expect(doc.undo()).toBe(true); + expect(doc.getAllElements().length).toBe(1); // nur Original bleibt + }); + + it('Komfort: bei leerer Selektion wird Element unter Klick auto-gewählt', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const target = makeEl('auto'); + target.x = 10; + target.y = 10; + doc.addElement(target); + let selIds2: string[] = []; + const bridge = { + getIds: () => selIds2, + setIds: (ids: string[]) => { + selIds2 = ids; + }, + hitTest: () => target, + }; + const ctx = makeCtx({ doc, selection: bridge }); + const move = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'move')!; + + move.handlers.down!(pe(10, 10), ctx); // Basis + Auto-Select + expect(selIds2).toEqual(['auto']); + move.handlers.down!(pe(30, 30), ctx); // Zielklick (Δ=+20) + + expect(doc.getElement('auto')!.x).toBe(30); // 10+Δ20 + }); +}); + describe('A3 pilot: select tool', () => { function makeBridge(elements: CADElement[]) { let ids: string[] = [];